使用开源 Photon + tileserver-gl 搭建属于自己的离线地图
需求很简单,系统里面有个定位打卡选择,类似于钉钉的考勤距离选择,选择多少米区域内属于合法打卡距离。系统目前都是对接的高德地图或者百度地图的 API 直接使用,包括个人早期官方申请的开发 key 现在额度都收紧了,明确说明不能进行商用,如果要用得付费 5w 一年 10 个 key。但是对于客户来说一年花5w 去专门买个 key 不一定愿意支付,政国企更加不能接受了,如果是行业敏感还只能纯内网离线部署费用甚至更高。
其实不需要太精确的位置,只要能够把经纬度逆向显示大概街道信息,轮廓展示出来,支持模糊搜索基础功能就行了。做地图只有利用公开数据做,本文主要记录研究一套可用的方案笔记备忘。
网上方案很多,但是为了轻量方案最终选择了Photon 用来经纬度逆向名称,tileserver-gl 用来地图展示,前期可以在 OpenStreetMap 数据上只下载本市省区域进行小规模验证,后期可以使用类似于 geoserver 来进行自定义标注发布地图。
本文使用到的环境均为一台 Ubuntu 24.04.2 LTS 机器进行完整验证。
地址逆解析 Photon 使用
开源项目地址:https://github.com/komoot/photon
数据下载
下载OpenStreetMap数据地址:https://download1.graphhopper.com/public/
选择下载 asia - china 数据即可,总大小 500M 以内,文件名类似于:photon-dump-china-1.0-latest.jsonl.zst
数据导入
下载Photon地址:https://github.com/komoot/photon/releases,直接是一个 jar 包(photon-1.1.0.jar),需要 java21+环境
然后执行导入:zstd --stdout -d photon-dump-*.jsonl.zst | java -jar photon.jar import -import-file -,根据服务器性能导入时间不固定,一般就几分钟,内置会启动一个嵌入服务器OpenSearch,类似于 ES 的搜索引擎。如果你在 2c2g 的小配置机器上导入记得调整一下参数:
比如我最终使用的命令:zstd --stdout -d photon-dump-*.jsonl.zst | java -Xmx1500m -Xms1500m -jar photon-1.0.1.jar import -j 1 -import-file -
启动服务
导入没问题直接启动服务:最终会监听在 2322 端口
nohup java -jar photon-1.0.1.jar serve -listen-ip 0.0.0.0 &
API 测试
可以测试 geo 转换位置 api:http://服务器 IP:2322/reverse?lon=116.40&lat=39.91
官方提供的所有可用 API 可查阅:https://github.com/komoot/photon/blob/master/docs/api-v1.md
/api:用于搜索(通过名称和地址查找地点)使用纬度和经度参数来设置搜索的焦点点,优先选择结果,q 参数包含要搜索的词。除非包含/排除参数进行筛选,否则 q 参数是强制的。例如:http://localhost:2322/api?q=berlin&lon=10&lat=52&zoom=12&location_bias_scale=0.1
/structured:结构化搜索的工作原理类似于前向搜索,但查询词是拆分的 向上划分成地址部分。有时在地理编码时,这样能获得更有针对性的结果 地址。支持以下查询参数: 国家代码 , 州 , 县 , 市 , 邮编 、 区 (如城市区或郊区)、 户牌号 , 街道 。国家代码必须是有效的 ISO 3166-1 alpha-2 代码。比如:http://localhost:2322/structured?city=berlin&street=Unter%20den%20Linden&housenumber=2
/reverse: 用于反向地理编码(查找给定坐标处的物体),必填的纬度和经度参数描述了查找位置描述的坐标。可选的半径参数可用于指定一个以公里为单位的值,以便在内反转地理编码。该值必须在 0 到 5000 公里之间。例如:http://localhost:2322/reverse?lon=10&lat=52&radius=10&lang=it
/status:作为服务器健康检查,返回包含数据状态和最后更新日期的 JSON 文档。(这是数据的来源日期,而非导入 Photon 的时间。),例如:http://localhost:2322/status
离线地图展示 tileserver-gl 使用
类似的产品很多,但是基本上都比较重,所以使用了 gl,官方提供镜像,后期落地减少折腾。
开源项目地址:https://github.com/maptiler/tileserver-gl
数据下载
下载OpenStreetMap 对应地图数据:https://download.geofabrik.de/,这个可以只下载省的数据更加小了,比如四川省的。https://download.geofabrik.de/asia/china/sichuan.html,文件是:sichuan-latest.osm.pbf
数据格式转换
tileserver-gl 不能直接展示.pbf 文件还需要转换工具转换一下:这里采用官方提供的tilemaker 进行转换。
开源项目地址:https://github.com/systemed/tilemaker/,官方提供 docker 镜像直接一键转换,就不折腾环境了。
需要在开源项目仓库先下载config-openmaptiles.json、process-openmaptiles.lua这 2 个文件,在开源仓库代码里的/resources 中。
可以根据自己的需要调整一下展示,比如我们要展示中而不是英文等等,这里不细讲官方文档很详细。我这里只调了一下显示语言以及重写显示信息。
config-openmaptiles.json 这个文件不做变化保持默认,process-openmaptiles.lua 脚本前几行进行调整如下:
-- Data processing based on openmaptiles.org schema
-- https://openmaptiles.org/schema/
-- Copyright (c) 2016, KlokanTech.com & OpenMapTiles contributors.
-- Used under CC-BY 4.0
--------
-- Alter these lines to control which languages are written for place/streetnames
--
-- Preferred language can be (for example) "en" for English, "de" for German, or nil to use OSM's name tag:
preferred_language = nil
-- This is written into the following vector tile attribute (usually "name:latin"):
preferred_language_attribute = "name"
-- If OSM's name tag differs, then write it into this attribute (usually "name_int"):
default_language_attribute = "name_int"
-- Also write these languages if they differ - for example, { "de", "fr" }
additional_languages = {"zh","en" }
--------然后执行转换,转换前先看下我的目录结构:主要看 pbf 目录下的文件放置:
.
├── data
│ ├── china-260409.osm.mbtiles
│ ├── fonts
│ │ ├── klokantech-gl-fonts-master
│ │ ├── KlokanTech Noto Sans Bold
│ │ ├── KlokanTech Noto Sans CJK Bold
│ │ ├── KlokanTech Noto Sans CJK Regular
│ │ ├── KlokanTech Noto Sans Italic
│ │ ├── KlokanTech Noto Sans Regular
│ │ └── README.md
│ └── sichuan.osm.mbtiles
└── pbf
├── china-260409.osm.pbf
├── config
│ ├── config-openmaptiles.json
│ └── process-openmaptiles.lua
├── sichuan-260408.osm.pbf
└── sichuan.osm.mbtiles然后执行转换命令:
docker run -it --rm -v ./pbf:/data ghcr.1ms.run/systemed/tilemaker:master --input /data/china-260409.osm.pbf --output /data/china-260409.osm.mbtiles --config /data/config/config-openmaptiles.json --process /data/config/process-openmaptiles.lua
意思是转换china-260409.osm.pbf 文件到 china-260409.osm.mbtiles,使用自定义的配置文件,最终生产的china-260409.osm.mbtiles 就是tileserver-gl 可以直接使用的数据格式。
字体下载(可选)
若需要替换默认的字体,可以使用任意字体,建议使用非商业可用的字体,我在找了网上推荐的字体:https://github.com/klokantech/klokantech-gl-fonts/tree/master,随机选择一个下载下来备用,当然你也可以直接使用 windows 下 fonts 目录下的字体。但是这些字体最终转换成.pbf格式才行,如果是在开源项目下载直接是 pbf 文件了。
字体转换依旧采用开源项目:https://github.com/maplibre/font-maker,可以直接访问在线的上传转换:https://maplibre.org/font-maker/
或者使用这个开源项目转换:https://github.com/openmaptiles/fonts
安装tileserver-gl
开源项目地址:https://github.com/maptiler/tileserver-gl
docker-compose.yml 编排文件:
services:
tileserver-gl:
image: maptiler/tileserver-gl:v5.6.0
container_name: tileserver-gl
environment:
- PUBLIC_URL=https://xxxx:8111
ports:
- "8111:8080"
volumes:
- ./data:/data
restart: unless-stoppedPUBLIC_URL环境变量表示对外访问的地址,官方文档提供很多参数可以看下,比如允许哪些地址跨域等等。
data 目录下就是放的我们的数据格式转换步骤里的结果文件:china-260409.osm.mbtiles,目录结构如下:
.
├── data
│ ├── china-260409.osm.mbtiles
│ ├── fonts
│ │ ├── klokantech-gl-fonts-master
│ │ ├── KlokanTech Noto Sans Bold
│ │ ├── KlokanTech Noto Sans CJK Bold
│ │ ├── KlokanTech Noto Sans CJK Regular
│ │ ├── KlokanTech Noto Sans Italic
│ │ ├── KlokanTech Noto Sans Regular
│ │ └── README.md
│ └── sichuan.osm.mbtiles
└── docker-compose.yml直接执行 docker-compose up -d ,最终访问地址:http://服务器 IP:8111
封装使用
其实tileserver-gl 提供的瓦片服务器可以无缝从高德等地图展示切换过来。格式就是:http://服务器ip:8111/data/v3/{z}/{x}/{y}.pbf
其实到了这一步之后只是做了基础的工作,要真正实现生产可用还有很大一段距离,比如开源数据的点位只有一些大致的街道没有标注很详细让用户选择很头疼,搜索也只能搜索大概,但是对于技术预研已经足够,最后 vibe coding 了一个串联了整个流程做在线演示,比如有离线场景的可以直接引入瓦片地图,我了解到的自己手机相片根据定位展示到地图上肯定是可以显示的,虽然不是很详细够用了。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>Map - 搜索与定位集成版</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" />
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css" />
<style>
body { margin: 0; padding: 0; font-family: "Helvetica Neue", Arial, sans-serif; background: #f0f2f5; }
#map { height: calc(100vh - 70px); width: 100%; box-shadow: inset 0 0 10px rgba(0,0,0,0.1); }
.controls {
padding: 10px 20px;
background: #ffffff;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
border-bottom: 1px solid #dee2e6;
min-height: 50px;
}
.controls input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
}
/* 搜索框加宽 */
#searchInput { width: 220px; border-color: #007bff; }
#latInput, #lonInput { width: 100px; }
.controls button {
padding: 8px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.controls button:hover { background: #0069d9; }
.controls button.geo-btn { background: #28a745; }
.controls button.search-btn { background: #6f42c1; }
.leaflet-popup-content {
font-size: 14px;
line-height: 1.6;
min-width: 200px;
}
.popup-detail-item {
margin-bottom: 4px;
border-bottom: 1px solid #eee;
padding-bottom: 2px;
}
.popup-detail-item strong {
color: #007bff;
display: inline-block;
width: 50px;
}
</style>
</head>
<body>
<div class="controls">
<input type="text" id="searchInput" placeholder="搜索地点名称..." onkeypress="if(event.keyCode==13) searchLocation()" />
<button class="search-btn" onclick="searchLocation()">搜索</button>
<div style="border-left: 1px solid #ddd; height: 30px; margin: 0 10px;"></div>
<input type="text" id="latInput" placeholder="纬度 (Lat)" />
<input type="text" id="lonInput" placeholder="经度 (Lon)" />
<button onclick="locate()">定位坐标</button>
<button class="geo-btn" onclick="getCurrentLocation()">我的位置</button>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet@0.0.22/leaflet-maplibre-gl.js"></script>
<script>
var map = L.map('map').setView([30.66, 104.03], 12);
var currentMarker = null;
var glLayer = L.maplibreGL({
style: 'https://192.168.0.227:8998/styles/basic-preview/style.json',
attribution: '© Local Vector Map'
}).addTo(map);
// 监听点击地图
map.on('click', function(e) {
var lat = e.latlng.lat.toFixed(6);
var lon = e.latlng.lng.toFixed(6);
updateInput(lat, lon);
fetchLocationInfo(lat, lon);
});
// 1. 地点搜索逻辑
function searchLocation() {
var q = document.getElementById('searchInput').value;
if (!q) return alert("请输入搜索关键词");
// 获取当前地图中心点作为搜索偏好参考(符合接口参数需求)
var center = map.getCenter();
var lat = center.lat;
var lon = center.lng;
fetch(`https://192.168.0.227:8999/geocode/api?q=${encodeURIComponent(q)}&lat=${lat}&lon=${lon}&lang=default`)
.then(res => res.json())
.then(data => {
if (data.features && data.features.length > 0) {
// 取搜索结果的第一项
var bestMatch = data.features[0];
var coords = bestMatch.geometry.coordinates; // 注意:geojson通常是 [lon, lat]
var targetLon = coords[0];
var targetLat = coords[1];
updateInput(targetLat, targetLon);
fetchLocationInfo(targetLat, targetLon);
} else {
alert("未找到相关地点");
}
})
.catch(err => {
console.error("搜索失败:", err);
alert("搜索服务响应异常");
});
}
// 2. 坐标定位逻辑
function locate() {
var lat = document.getElementById('latInput').value;
var lon = document.getElementById('lonInput').value;
if (lat && lon) fetchLocationInfo(lat, lon);
}
function getCurrentLocation() {
if (!navigator.geolocation) return alert('浏览器不支持定位');
navigator.geolocation.getCurrentPosition(function(p) {
var lat = p.coords.latitude.toFixed(6);
var lon = p.coords.longitude.toFixed(6);
updateInput(lat, lon);
fetchLocationInfo(lat, lon);
});
}
function updateInput(lat, lon) {
document.getElementById('latInput').value = lat;
document.getElementById('lonInput').value = lon;
}
// 核心展示函数:获取逆地理信息并在地图点位展示 Popup
function fetchLocationInfo(lat, lon) {
if (currentMarker) map.removeLayer(currentMarker);
map.setView([lat, lon], 16);
fetch(`https://192.168.0.227:8999/geocode/reverse?lon=${lon}&lat=${lat}&lang=default`)
.then(res => res.json())
.then(data => {
let htmlContent = "";
if (data.features && data.features.length > 0) {
const p = data.features[0].properties;
htmlContent = `
<div class="popup-detail-item"><strong>国家</strong>${p.country || '-'}</div>
<div class="popup-detail-item"><strong>省份</strong>${p.state || '-'}</div>
<div class="popup-detail-item"><strong>城市</strong>${p.city || '-'}</div>
<div class="popup-detail-item"><strong>区县</strong>${p.district || '-'}</div>
<div class="popup-detail-item"><strong>地点</strong>${p.name || '未知'}</div>
<div style="font-size:11px; color:#999; margin-top:5px;">坐标: ${lat}, ${lon}</div>
`;
} else {
htmlContent = `<strong>位置详情</strong><br>无法解析地址<br><small>${lat}, ${lon}</small>`;
}
currentMarker = L.marker([lat, lon]).addTo(map)
.bindPopup(htmlContent, { maxWidth: 300, autoPan: true })
.openPopup();
})
.catch(err => {
currentMarker = L.marker([lat, lon]).addTo(map)
.bindPopup(`坐标: ${lat}, ${lon}`)
.openPopup();
});
}
</script>
</body>
</html>避坑
这里有个坑,如果是本地开发 localhost 获取浏览器定位是可以信任的,但是部署到服务器必须要 https 才能访问,https 又不能访问部署到后端的 http 服务,并且默认对外暴露的是 http 端口,没有具体研究,直接申请了一个 ip 的 https 证书然后套上,虽然其他人访问不安全好歹是功能可用了。需要在 nginx 上做一点点配置,下面是两个服务器的 nginx 代理配置,仅供参考。
tileserver-gl 反向代理配置https并替换后端默认地址 nginx 配置参考。
server {
listen 8998 ssl;
server_name 192.168.0.227;
charset utf-8;
ssl_certificate /etc/nginx/cert/cert.pem;
ssl_certificate_key /etc/nginx/cert/key.pem;
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
proxy_buffering off;
location / {
proxy_pass http://192.168.0.227:8111/; # 这里的端口是你容器映射出来的物理端口
# --- 核心:强制替换 http 为 https ---
# 查找 JSON 里的 http 地址并替换 #www.51it.wang博客出品;
sub_filter 'http://192.168.0.227:8998' 'https://192.168.0.227:8998';
# 替换所有匹配项,而不仅仅是第一个
sub_filter_once off;
# 指定哪些类型的文件需要执行替换(TileServer 返回的是 json)
sub_filter_types application/json text/javascript;
# --- 重要:禁用压缩 ---
# 如果后端返回的是 gzip 压缩后的数据,Nginx 无法进行字符串替换
proxy_set_header Accept-Encoding "";
# --- 基础代理头 ---
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}demo页面、photon后端配置https nginx配置参考:
server {
listen 8999 ssl;
server_name 192.168.0.227;
charset utf-8;
ssl_certificate /etc/nginx/cert/cert.pem;
ssl_certificate_key /etc/nginx/cert/key.pem;
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
proxy_buffering off;
location / {
root /usr/local/product/front/maptest #www.51it.wang博客出品;
index index.html index.html;
}
location /geocode/ {
# 添加 CORS 响应头
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST,
OPTIONS' always;
add_header 'Access-Control-Allow-Headers'
'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modifie
d-Since,Cache-Control,Content-Type,Authorization' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST,
OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://192.168.0.227:2322/;
}
}总结
以前没怎么接触过 gis 地图相关的,刚好项目有这个需求借着这个机会了解了一下皮毛,虽然没有专业做 gis 地图服务的专业性高,大概的流程是知道了,后期如果项目能真正接入使用再花时间把 geoserver 这套给深入一下。在做这件事的时候参考了网上大量文章以及 AI对话深度思考,一路踩了很多坑,前前后后花了 3 天的时间最终形成了这篇文章,任何事情只有自己去动手做了才知道里面的坑,而不是学了看了就是会了。也感谢网上很多高质量的文章都列到了文章的底部参考链接中,如果本文不清楚的地方可以查阅这些参考链接更多具体细节。
如果能接受更重的方式还有很多方案可以选择:关键字:mapboxgl、openlayer、PostGIS、Nominatim、pelias、maplibre/martin、MBTiles、openmaptiles、mapbox/tippecanoe
参考链接
使用Tileserver-GL+MapLiberGL+OSM创建自己的地图
github An open source visual editor for the 'MapLibre Style Specification'
商业转载请联系作者获得授权,非商业转载请注明本文出处及文章链接



