一、前言

普通相册只能回答“拍了什么”,地图则能回答“去过哪里”。本篇从一份 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 可以来自外部,但运行库本身应尽量由站点控制。

新建或编辑 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 %}
![](https://example.com/jinan/01.jpg)
![](https://example.com/jinan/02.jpg)
{% 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
})

再建立 circlesymbol 图层分别显示光点与名称。

十八、第十六步:点击省份后加载城市行政区

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/

检查:

  1. 地图首页只高亮去过地区;
  2. 城市点位、标签和影集 URL 正确;
  3. 点击省份后按需加载对应行政区;
  4. 点击城市可以进入影集;
  5. 中国与全球视图切换正常;
  6. 照片数量来自 Markdown 自动统计;
  7. 地图 GeoJSON 请求失败时,影集入口仍可用;
  8. PJAX 往返后地图只初始化一次;
  9. 手机端地图控制器和卡片不溢出;
  10. MapLibre JS/CSS 使用站内路径。

二十五、以后新增一座城市

  1. gallery.yml.map.cities 增加城市坐标和 adcode;
  2. albums 增加城市影集,并加入上级 children
  3. 创建对应 Markdown 页面;
  4. 填入 {% blog_gallery_album 城市ID %} 和照片;
  5. 执行 clean、build、verify;
  6. 在地图上点击城市并检查影集数量。

二十六、常见问题

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

这套结构让地图和相册共用同一数据源:地图负责空间导航,静态影集负责内容,构建脚本负责统计和连接。地图可以增强体验,但不会成为浏览照片的单点故障。