一、前言
普通相册只能回答“拍了什么”,地图则能回答“去过哪里”。本篇从一份 gallery.yml 开始,把省份、城市、坐标、影集页面与照片数量组织成统一数据,再使用 MapLibre 显示去过的行政区和城市点位。即使地图加载失败,访客仍能通过下方影集入口浏览照片。
地图是相册的导航层,不是相册本身。照片仍由静态页面和灯箱承载,避免外部地图服务失败时整个相册不可用。
二、交互演示:从相册数据到足迹地图
Hexo 博客根目录 ├─ source/_data/gallery.yml # 地图、地区、城市和影集关系 ├─ source/gallery/**/*.md # 地图、分组和城市影集入口 ├─ scripts/blog-gallery.js # 照片统计与三类标签渲染 ├─ source/js/blog-gallery.js # MapLibre、行政区与影集增强 ├─ source/css/blog-gallery.css # 地图和相册样式 ├─ source/js/vendor/maplibre-gl.js # 本地地图运行库 └─ source/css/vendor/maplibre-gl.css # 本地地图样式
|

三、第一步:安装并本地化 MapLibre
npm install maplibre-gl --save
|
项目构建时从依赖复制 MapLibre 发布文件到:
public/js/vendor/maplibre-gl.js public/css/vendor/maplibre-gl.css
|
不要长期依赖远程 JS/CDN。地图底图和行政区 GeoJSON 可以来自外部,但运行库本身应尽量由站点控制。
四、第二步:建立 gallery.yml 的总体结构
新建或编辑 source/_data/gallery.yml:
hero: title: 光落在走过的地方 subtitle: 天津、齐鲁、江南、雍凉,以及四年校园记忆 image: "https://example.com/gallery-hero.jpg"
map: title: 光落在走过的地方 subtitle: 在地图上重访每一段旅程 china_geojson: "https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json" admin_geojson: "https://geo.datav.aliyun.com/areas_v3/bound/{adcode}_full.json" world_geojson: "https://example.com/world.geo.json"
|
admin_geojson 中的 {adcode} 会在浏览器端替换为省、市、区县行政代码。
五、第三步:填写去过的省级地区
map: regions: - id: shandong adcode: 370000 name: 山东省 label: 山东 center: [117.02, 36.67] album: shandong
- id: jiangsu adcode: 320000 name: 江苏省 label: 江苏 center: [118.78, 32.06] album: jiangsu
|
字段说明:
id:站内稳定标识;
adcode:行政区代码;
name:GeoJSON 中可能使用的完整名称;
label:页面短名称;
center:地图定位经纬度,顺序是 [经度, 纬度];
album:关联影集 ID。
六、第四步:填写城市点位
map: cities: - id: jinan adcode: 370100 name: 济南 region: shandong center: [117.12, 36.65] marker_offset: [-10, -11] album: jinan
- id: qingdao adcode: 370200 name: 青岛 region: shandong center: [120.38, 36.07] album: qingdao
|
marker_offset 只在两个点位过近时使用,不要通过随意修改真实坐标来避免重叠。
七、第五步:建立影集关系
root_ids: - shandong - jiangsu
albums: - id: shandong title: 山东 description: 齐鲁大地 intro: 从海岸到泉城,再到泰山脚下。 path: /gallery/山东/index-shandong/ cover: "https://example.com/shandong-cover.jpg" children: - qingdao - jinan - taian
- id: jinan title: 济南 description: 泉城印象 path: /gallery/山东/济南/index-jinan/ cover: "https://example.com/jinan-cover.jpg"
|
父影集使用 children 指向城市影集,地图和影集导航都从这里读取关系。
八、第六步:创建地图首页
source/gallery/index.md:
--- title: 我的相册 aside: false comments: true --- {% blog_gallery_index %}
|
这个标签负责输出英雄区、地图容器、视图切换和根影集导航。
九、第七步:创建省级分组页
例如 source/gallery/山东/index-shandong.md:
--- title: 山东 aside: false comments: true --- {% blog_gallery_group shandong %}
|
参数 shandong 必须与 gallery.yml 中的影集 ID 一致。
十、第八步:创建城市影集页
例如 source/gallery/山东/济南/index-jinan.md:
--- title: 济南 aside: false comments: true --- {% blog_gallery_album jinan %}
{% gallery %}   {% endgallery %}
|
blog_gallery_album 输出城市头部、统计和前后影集导航;Butterfly 原生 gallery 块继续承载照片。
十一、第九步:扫描照片数量
新建 scripts/blog-gallery.js,查找文章中的相册块:
const ALBUM_TAG_PATTERN = /\{%\s*blog_gallery_album\s+([^\s%]+)(?:\s+[^%]*)?%\}/u const IMAGE_PATTERN = /!\[[^\]]*\]\(([^)]+)\)/g
const photoCountFromSource = content => { let count = 0 for (const _match of String(content || '').matchAll(IMAGE_PATTERN)) { count += 1 } return count }
|
遍历 source/gallery/**/*.md,找到影集 ID 并写回构建快照。照片数量不需要手工维护,避免新增图片后统计忘记同步。
十二、第十步:注册三类 Hexo 标签
hexo.extend.tag.register('blog_gallery_index', galleryIndex) hexo.extend.tag.register('blog_gallery_group', galleryGroup) hexo.extend.tag.register('blog_gallery_album', galleryAlbum)
|
渲染时要检查未知 ID:
const galleryAlbum = args => { const id = String(args[0] || '') const album = getSnapshot().albumById.get(id)
if (!album) { return `<p class="blog-gallery-error">未找到影集:${escapeHtml(id)}</p>` }
return albumMarkup(album) }
|
错误配置应显示清晰提示,而不是让整个构建崩溃或输出空白页面。
十三、第十一步:安全交接地图数据
地图首页只需要地区、城市、坐标和 URL,不需要把全部照片地址都传给浏览器:
const safeJson = value => JSON.stringify(value) .replaceAll('<', '\\u003c') .replaceAll('>', '\\u003e') .replaceAll('&', '\\u0026')
const mapDataMarkup = snapshot => ` <script type="application/json" id="blog-gallery-map-data"> ${safeJson(snapshot.map)} </script>`
|
运行时读取:
const data = JSON.parse( document.querySelector('#blog-gallery-map-data')?.textContent || '{}' )
|
十四、第十二步:初始化 MapLibre
新建 source/js/blog-gallery.js:
const initMap = root => { const container = root.querySelector('[data-gallery-map]') if (!container || typeof maplibregl === 'undefined') { showGalleryFallback(root) return }
const map = new maplibregl.Map({ container, style: createMapStyle(), center: [104.2, 35.8], zoom: 3.2, minZoom: 2.2, maxZoom: 12 })
map.addControl(new maplibregl.NavigationControl(), 'top-right') }
|
不要把地图初始化失败等同于相册失败。showGalleryFallback() 应隐藏地图控制器,但保留下方影集卡片。
十五、第十三步:加载中国行政区数据
const loadGeoJson = async url => { const response = await fetch(url, { headers: { Accept: 'application/json' } }) if (!response.ok) throw new Error(`GeoJSON ${response.status}`) return response.json() }
map.on('load', async () => { const china = await loadGeoJson(data.chinaGeojson) map.addSource('china-admin', { type: 'geojson', data: china }) addRegionLayers(map, data.regions) addCityLayer(map, data.cities) })
|
只高亮 gallery.yml 中列出的地区和城市,不要把全部行政区都当作“已到访”。
十六、第十四步:建立去过地区的填充层
const visitedNames = data.regions.map(region => region.name)
map.addLayer({ id: 'visited-regions', type: 'fill', source: 'china-admin', paint: { 'fill-color': [ 'case', ['in', ['get', 'name'], ['literal', visitedNames]], '#168bb2', 'rgba(80, 110, 130, 0.08)' ], 'fill-opacity': 0.66 } })
|
边界线单独放在 line 图层,鼠标悬停状态可以通过 feature state 或独立过滤图层实现。
十七、第十五步:建立城市点位
const cityCollection = { type: 'FeatureCollection', features: data.cities.map(city => ({ type: 'Feature', properties: { id: city.id, name: city.name, path: city.path, adcode: city.adcode }, geometry: { type: 'Point', coordinates: city.center } })) }
map.addSource('visited-cities', { type: 'geojson', data: cityCollection })
|
再建立 circle 和 symbol 图层分别显示光点与名称。
十八、第十六步:点击省份后加载城市行政区
map.on('click', 'visited-regions', async event => { const name = event.features?.[0]?.properties?.name const region = data.regions.find(item => item.name === name) if (!region) return
map.easeTo({ center: region.center, zoom: 6 }) const url = data.adminGeojson.replace('{adcode}', region.adcode) const children = await loadGeoJson(url) setAdminSource(map, children) })
|
行政区数据按需要加载,避免首屏一次请求全国所有省、市、区县文件。
十九、第十七步:点击城市进入影集
map.on('click', 'visited-cities', event => { const feature = event.features?.[0] const city = data.cities.find(item => item.id === feature?.properties?.id) if (city?.path) location.href = city.path })
|
地图点击和下方城市卡片都应指向同一 path,不要维护两份 URL。
二十、第十八步:加入全球视图切换
const setViewMode = async mode => { if (mode === 'world') { const world = await loadGeoJson(data.worldGeojson) setWorldSource(map, world) map.easeTo({ center: [10, 24], zoom: 1.25, pitch: 38 }) return }
map.easeTo({ center: [104.2, 35.8], zoom: 3.2, pitch: 0 }) }
|
平面中国视图和全球视图共享已到访数据,切换时只改变数据源可见性与相机状态。
二十一、第十九步:增强城市影集
相册页可增加封面信息、照片数量、前后影集按钮与返回地图入口。运行时只增强 Butterfly 已经生成的画廊,不重新实现灯箱。
如果使用 MutationObserver 等待画廊出现,必须设置停止条件:
const observer = new MutationObserver(() => { const gallery = document.querySelector('.gallery-group') if (!gallery) return enhanceAlbum(gallery) observer.disconnect() })
observer.observe(document.body, { childList: true, subtree: true }) window.setTimeout(() => observer.disconnect(), 8000)
|
二十二、第二十步:处理 PJAX
const initBlogGallery = () => { const root = document.querySelector('[data-blog-gallery]') if (!root || root.dataset.galleryBound === 'true') return root.dataset.galleryBound = 'true' initGalleryPage(root) }
document.addEventListener('DOMContentLoaded', initBlogGallery, { once: true }) document.addEventListener('pjax:complete', initBlogGallery)
|
页面离开时销毁 MapLibre 实例和观察器,避免内存与事件累积。
二十三、第二十一步:加入响应式样式
.blog-gallery-map { position: relative; min-height: 620px; overflow: hidden; border-radius: 18px; }
.blog-gallery-map__canvas { position: absolute; inset: 0; }
.blog-gallery-albums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
@media (max-width: 768px) { .blog-gallery-map { min-height: 520px; } .blog-gallery-albums { grid-template-columns: 1fr; } }
|
影集封面使用 object-fit: cover,不要为了卡片高度把原图压缩变形。
二十四、第二十二步:构建和验证
npm run clean npm run build npm run verify hexo server
|
依次打开:
http://localhost:4000/gallery/ http://localhost:4000/gallery/山东/index-shandong/ http://localhost:4000/gallery/山东/济南/index-jinan/
|
检查:
- 地图首页只高亮去过地区;
- 城市点位、标签和影集 URL 正确;
- 点击省份后按需加载对应行政区;
- 点击城市可以进入影集;
- 中国与全球视图切换正常;
- 照片数量来自 Markdown 自动统计;
- 地图 GeoJSON 请求失败时,影集入口仍可用;
- PJAX 往返后地图只初始化一次;
- 手机端地图控制器和卡片不溢出;
- MapLibre JS/CSS 使用站内路径。
二十五、以后新增一座城市
- 在
gallery.yml.map.cities 增加城市坐标和 adcode;
- 在
albums 增加城市影集,并加入上级 children;
- 创建对应 Markdown 页面;
- 填入
{% blog_gallery_album 城市ID %} 和照片;
- 执行 clean、build、verify;
- 在地图上点击城市并检查影集数量。
二十六、常见问题
1. 地图空白但影集正常
检查 MapLibre 本地资源、地图容器高度和 GeoJSON 请求。保留影集回退,不要把容器整体隐藏。
2. 点击省份没有进入下一级
检查 adcode 和 {adcode} URL 替换结果,并确认外部数据源是否要求 Referer。
3. 城市点位偏移或经纬度颠倒
MapLibre 坐标顺序是 [经度, 纬度],不是 [纬度, 经度]。
4. 照片数量没有更新
确认图片位于 Hexo 能读取的 Markdown 相册块中,执行 hexo clean 后重新生成。
5. PJAX 后地图报容器已存在
离开页面时调用 map.remove(),并使用 data-gallery-bound 防止重复初始化。
二十七、维护入口与结语
- 地点、影集与地图源:
source/_data/gallery.yml
- 页面与照片:
source/gallery/**/*.md
- 构建统计与标签:
scripts/blog-gallery.js
- 地图和影集交互:
source/js/blog-gallery.js
- 样式:
source/css/blog-gallery.css
这套结构让地图和相册共用同一数据源:地图负责空间导航,静态影集负责内容,构建脚本负责统计和连接。地图可以增强体验,但不会成为浏览照片的单点故障。