前言 ByteFisher 博客上线了两年,积累了 7 个钓鱼相册、500+ 张照片。但一直有个遗憾:相册只是按年份一字排开,缺少两个关键视角 。
一是数据视角 ——5 年钓了多少鱼?哪个鱼种最多?哪年最高产?这些信息分散在上百个 HTML 页面里,读者无从感知。
二是空间视角 ——日记里提到的”花碑水库””三一水库””固驿沙坑”到底在哪里?活动范围覆盖了多大区域?
本文完整记录两个功能的实现过程:鱼获数据 Dashboard 和钓鱼地图。这不是一篇”纯教程”,而是踩坑实录——包括坐标偏移、CDN 被墙、API 改版等你在官方文档里查不到的坑。
一、整体架构 两个功能虽然同属”钓鱼特色模块”,但技术路线完全不同:
1 2 3 4 5 6 7 8 9 10 11 12 ┌──────────────────────────────────────────────────────────┐ │ 构建时 (hexo g) │ 运行时 (浏览器) │ │ │ │ │ fish-dashboard-data.js │ fish-dashboard.js │ │ └─ 扫描 img*/index.md │ └─ fetch data.json │ │ └─ 匹配 data-lightbox 计数 │ └─ Chart.js 渲染 │ │ └─ 输出 data.json │ │ │ │ │ │ spots.json (手工维护) │ fish-map.js │ │ └─ 13个钓点的坐标/鱼种/描述 │ └─ 动态加载高德 JS API │ │ │ └─ CircleMarker 渲染 │ └──────────────────────────────────────────────────────────┘
Dashboard 走”构建时计算 → 输出静态 JSON”的路线,地图走”运行时动态渲染”的路线,选择依据很简单:
Dashboard 的数据变化频率低(加一张照片才需要更新),构建时算好最省性能
地图是交互式组件,必须运行时渲染
二、鱼获数据 Dashboard 2.1 数据自动采集 初期想过用手工维护 data.json,但在相册里加了 3 次照片、改了 3 次数据后,我放弃了——这不该是人干的事。
思路转变:相册本身已经包含了数据 。每个相册页面用 data-lightbox 属性标记照片,我只需要在构建时数一下出现次数就好。
写了一个 Hexo Generator:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 'use strict' ;var fs = require ('fs' );var path = require ('path' );hexo.extend .generator .register ('fish-dashboard-data' , function (locals ) { var fishSourceDir = path.join (hexo.source_dir , 'fish' ); var config = hexo.config .fish_dashboard || {}; var entries = fs.readdirSync (fishSourceDir, { withFileTypes : true }); var yearlyData = []; var totalPhotos = 0 ; entries.forEach (function (entry ) { if (!entry.isDirectory ()) return ; if (!entry.name .match (/^img/ )) return ; var indexPath = path.join (fishSourceDir, entry.name , 'index.md' ); var content = fs.readFileSync (indexPath, 'utf-8' ); var fishMatches = content.match (/data-lightbox="fishPhotos"/g ); var siteMatches = content.match (/data-lightbox="anglingSite"/g ); var count = (fishMatches ? fishMatches.length : 0 ) + (siteMatches ? siteMatches.length : 0 ); totalPhotos += count; var yearMatch = entry.name .match (/^img(\d{4})$/ ); if (yearMatch) { yearlyData.push ({ year : parseInt (yearMatch[1 ], 10 ), count : count }); } }); yearlyData.sort (function (a, b ) { return a.year - b.year ; }); var data = { totalPhotos : totalPhotos, totalYears : yearlyData.length , yearlyData : yearlyData, speciesStats : config.speciesStats || {}, topMonths : config.topMonths || [], bestSpots : config.bestSpots || [] }; return { path : 'fish/dashboard/data.json' , data : JSON .stringify (data, null , 2 ), layout : false }; });
Generator 负责自动化部分(照片数量、年份统计),鱼种分布和最佳钓点这类无法从文件名推断的数据,放在 _config.yml 中配置:
1 2 3 4 5 6 7 8 9 10 11 fish_dashboard: speciesStats: 红尾鲴: 220 鲫鱼: 180 鲤鱼: 95 草鱼: 72 鳊鱼: 1 青鱼: 0 其他: 133 topMonths: ["五月" , "十月" , "四月" , "三月" ] bestSpots: ["花碑水库" , "野河" , "三一水库" ]
生成的数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "totalPhotos" : 546 , "totalYears" : 5 , "yearlyData" : [ { "year" : 2022 , "count" : 95 } , { "year" : 2023 , "count" : 120 } , { "year" : 2024 , "count" : 58 } , { "year" : 2025 , "count" : 65 } , { "year" : 2026 , "count" : 73 } ] , "speciesStats" : { "红尾鲴" : 220 , "鲫鱼" : 180 , "鲤鱼" : 95 , "草鱼" : 72 , "鳊鱼" : 1 , "青鱼" : 0 , "其他" : 133 } }
2.2 Chart.js 可视化 页面结构很简单:4 张统计卡片 + 2 个 Chart.js 画布。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <!-- source/fish/dashboard/index.md --> <div class ="fish-dashboard" > <h1 > 🎣 鱼获数据统计</h1 > <div class ="dash-stats-grid" id ="dash-stats" > <div class="dash-stat-card"> <span class="dash-stat-value" id="total-photos">-</span> <span class="dash-stat-label">总照片数</span> </div> <div class="dash-stat-card"> <span class="dash-stat-value" id="total-years">-</span> <span class="dash-stat-label">钓鱼年数</span> </div> <div class="dash-stat-card"> <span class="dash-stat-value" id="avg-yearly">-</span> <span class="dash-stat-label">年均鱼获</span> </div> <div class="dash-stat-card"> <span class="dash-stat-value" id="best-year">-</span> <span class="dash-stat-label">最高产年份</span> </div> </div> <div class ="dash-chart-wrap" > <canvas id="yearlyChart"></canvas> </div> <div class="dash-chart-wrap"> <canvas id="speciesChart"></canvas> </div> </div> <script src ="https://cdn.jsdelivr.net/npm/chart.js" data-pjax > </script > <script src ="/js/fish-dashboard.js" data-pjax > </script >
JS 部分比较直接——fetch JSON → 填充卡片 → 渲染两个图表。唯一需要注意的就是 PJAX 兼容 :给 <script> 加上 data-pjax 属性,确保 PJAX 导航后脚本能重新执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function init ( ) { fetch ('/fish/dashboard/data.json' ) .then (function (r ) { return r.json (); }) .then (function (data ) { renderStats (data); renderYearlyChart (data); renderSpeciesChart (data); }); } function renderYearlyChart (data ) { new Chart (document .getElementById ('yearlyChart' ), { type : 'bar' , data : { labels : data.yearlyData .map (function (d ) { return d.year ; }), datasets : [{ label : '鱼获照片数' , data : data.yearlyData .map (function (d ) { return d.count ; }), backgroundColor : 'rgba(55, 198, 192, 0.6)' , borderColor : '#37c6c0' , borderWidth : 2 , borderRadius : 6 }] }, options : { responsive : true , plugins : { title : { display : true , text : '历年鱼获数量趋势' , font : { size : 16 } } } } }); }
2.3 踩坑小结
坑
原因
解决
Chart.js CDN 加载后但数据未 fetch 完
网络竞争条件
用 DOMContentLoaded 确保 DOM 就绪
PJAX 切换页面后图表消失
脚本未重新执行
data-pjax 属性
data-lightbox 计数不准
风景照和鱼获照使用不同属性
两种属性分别统计后求和
三、钓鱼地图 1.0:Leaflet + OpenStreetMap 3.1 初始方案 选用 Leaflet.js 的理由很充分:6KB(压缩后)、零依赖、API 清爽。配上 OpenStreetMap 的免费瓦片,代码量极小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function init ( ) { var map = L.map ('fish-map' ).setView ([30.6 , 103.5 ], 11 ); L.tileLayer ('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' , { attribution : '© OpenStreetMap' , maxZoom : 18 }).addTo (map); fetch ('/fish/map/spots.json' ) .then (function (r ) { return r.json (); }) .then (function (data ) { data.spots .forEach (function (spot ) { L.circleMarker ([spot.lat , spot.lng ], { radius : Math .min (8 + spot.photos * 0.4 , 24 ), color : '#37c6c0' , fillColor : '#37c6c0' , fillOpacity : 0.6 , weight : 2 }).addTo (map).bindPopup ( '<b>' + spot.name + '</b><br>' + '📸 ' + spot.photos + ' 张照片<br>' + '🎣 ' + spot.species .join ('、' ) ); }); }); }
十几个小时的代码量,看起来一切正常。
3.2 坐标偏移——GCJ-02 vs WGS-84 第一次在地图上看到标记位置时,所有钓点都偏了大约 500 米。这是因为国内手机 GPS 记录的是 GCJ-02(火星坐标系) ,而全球通用的 OSM 用的是 WGS-84 。这两种坐标系之间有个非线性偏移——Google 和高德都是”境外用 WGS-84,境内用 GCJ-02,坐标互转需要对偏移算法逆向求解”。
于是写了 35 行坐标转换代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 var PI = Math .PI ;var A = 6378245.0 ;var EE = 0.00669342162296594323 ;function transformLat (x, y ) { var ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math .sqrt (Math .abs (x)); ret += (20.0 * Math .sin (6.0 * x * PI ) + 20.0 * Math .sin (2.0 * x * PI )) * 2.0 / 3.0 ; ret += (20.0 * Math .sin (y * PI ) + 40.0 * Math .sin (y / 3.0 * PI )) * 2.0 / 3.0 ; ret += (160.0 * Math .sin (y / 12.0 * PI ) + 320.0 * Math .sin (y * PI / 30.0 )) * 2.0 / 3.0 ; return ret; } function transformLng (x, y ) { var ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math .sqrt (Math .abs (x)); ret += (20.0 * Math .sin (6.0 * x * PI ) + 20.0 * Math .sin (2.0 * x * PI )) * 2.0 / 3.0 ; ret += (20.0 * Math .sin (x * PI ) + 40.0 * Math .sin (x / 3.0 * PI )) * 2.0 / 3.0 ; ret += (150.0 * Math .sin (x / 12.0 * PI ) + 300.0 * Math .sin (x / 30.0 * PI )) * 2.0 / 3.0 ; return ret; } function gcj02ToWgs84 (lat, lng ) { var dLat = transformLat (lng - 105.0 , lat - 35.0 ); var dLng = transformLng (lng - 105.0 , lat - 35.0 ); var radLat = lat / 180.0 * PI ; var magic = Math .sin (radLat); magic = 1 - EE * magic * magic; var sqrtMagic = Math .sqrt (magic); dLat = (dLat * 180.0 ) / ((A * (1 - EE )) / (magic * sqrtMagic) * PI ); dLng = (dLng * 180.0 ) / (A / sqrtMagic * Math .cos (radLat) * PI ); return { lat : lat - dLat, lng : lng - dLng }; }
这段代码的本质是对高德开源的 GCJ-02 加密算法做逆向逼近——先用多项式拟合出偏移量,再从原始坐标中减去这个偏移量。参数 A 和 EE 直接取自克拉索夫斯基椭球体。不是 100% 精确(GCJ-02 到 WGS-84 的转换本身就是个病态问题,没有精确解析解),但对于展示钓点位置来说,误差在 10 米以内,完全可以接受。
3.3 数据准备 13 个钓点,覆盖四川成都周边的水库、河流、沙坑和溪流:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 { "spots" : [ { "name" : "三一水库" , "type" : "水库" , "lat" : 30.34 , "lng" : 103.49 , "year" : 2026 , "photos" : 230 , "species" : [ "红尾鲴" , "鲫鱼" , "鲤鱼" , "草鱼" , "鲈鱼" , "其它" ] , "desc" : "主力钓点,多次爆护,小红尾鲴多" } , { "name" : "古镇小河钓点1" , "type" : "河流" , "lat" : 30.2989 , "lng" : 103.5116 , "year" : 2022 , "photos" : 27 , "species" : [ "鲫鱼" , "鲤鱼" , "白条" , "老虎鱼" , "其它" ] , "desc" : "古镇小河,水面水葫芦多" } , { "name" : "通江鲢鱼牵" , "type" : "溪流" , "lat" : 31.9187 , "lng" : 107.3374 , "year" : 2023 , "photos" : 3 , "species" : [ "鲫鱼" , "马口" , "白条" , "溪石斑" , "金麦线" , "其它" ] , "desc" : "山区溪流钓溪石斑" } ] }
照片数最多的钓点(三一水库,230 张)自动成为地图默认中心,气泡半径按 8 + photos * 0.4 线性映射,上限 24px。
3.4 坑:坐标顺序搞反了 本地开发环境测试时,有一半的标记点跑到了非洲西海岸。查了半天发现 Leaflet 的 L.circleMarker 接收的是 [lat, lng],而我脑子里一直以为是 [lng, lat]。在 spots.json 里写的是 { lat: 30.34, lng: 103.49 },传参时拆成 [30.34, 103.49] 才对——但我写的却是 [spot.lng, spot.lat]。
修了两次才彻底记住:Leaflet = [纬度, 经度];Mapbox/高德 = [经度, 纬度] 。
四、钓鱼地图 2.0:迁移到高德 JS API 4.1 国内加载不了 Lealet 版部署上线后,在浏览器看到的是一片空白加”地图加载失败”。打开 F12 → Console,问题一目了然:
unpkg.com/leaflet@1.9.4/dist/leaflet.js —— 请求被阻断
tile.openstreetmap.org/...png —— 瓦片服务器不可用
两个资源都依赖境外 CDN,在国内不开代理就无法加载。
4.2 换高德 JS API 快速评估了三个方案:
方案
操作量
坐标处理
国内访问
Leaflet + 国内 CDN + 天地图
中
需保留 GCJ-02→WGS-84
✅
高德 JS API
小
原生支持 GCJ-02,删除转换代码
✅
百度地图
大
需转换到 BD-09
✅
选了高德,因为可以直接删掉那 35 行坐标纠偏代码——spots.json 里的 GCJ-02 坐标原封不动传进去。
4.3 动态加载脚本 NexT 启用了 PJAX(无刷新页面切换),如果直接把高德 SDK 写在 <script> 标签里,PJAX 导航后不会自动重新初始化。解决策略:把 SDK 加载逻辑封装在 fish-map.js 内部,第一次加载后设置缓存标志 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function loadAmap (callback ) { if (typeof AMap !== 'undefined' ) { callback (); return ; } window ._AMapSecurityConfig = { securityJsCode : 'xxx' }; var script = document .createElement ('script' ); script.src = 'https://webapi.amap.com/maps?v=2.0&key=xxx' ; script.onload = callback; script.onerror = function ( ) { document .getElementById ('fish-map' ).innerHTML = '<p>地图加载失败 🐟</p>' ; }; document .head .appendChild (script); } function start ( ) { var el = document .getElementById ('fish-map' ); if (!el) return ; loadAmap (init); } document .addEventListener ('DOMContentLoaded' , start);
页面上的 HTML 也从两行(Leaflet CSS + JS)精简到一行:
1 2 3 - <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> - <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-pjax></script> <script src="/js/fish-map.js" data-pjax></script>
4.4 踩坑:TypeError: Cannot read properties of undefined(reading ‘addListener’) 这是整个开发过程中卡得最久的一个 Bug。
迁移完代码后页面仍然一片空白。F12 Console 看到:
1 2 3 4 fish-map.js:84 [FishMap] 错误: TypeError: Cannot read properties of undefined (reading 'addListener') at fish-map.js:78:22 at Array.forEach (<anonymous>) at fish-map.js:51:20
定位到第 78 行:
1 2 3 AMap .event .addListener (marker, 'click' , function ( ) { info.open (map, marker.getCenter ()); });
高德 JS API v2.0 移除了 AMap.event 命名空间 。所有事件方法直接挂载在对象实例上:
1 2 3 4 - AMap.event.addListener(marker, 'click', function() { + marker.on('click', function() { info.open(map, marker.getCenter()); });
改完这一行就正常了。在官方文档里翻了几十分钟没找到关于 AMap.event 的废弃说明——它在 v1.4 的文档里还存在,但 v2.0 的参考手册直接没有这个命名空间了。
4.5 安全密钥与 localhost 白名单 高德开放平台在 2021 年 12 月之后申请的 API Key 要求同时配置 securityJsCode(安全密钥) 。配置方式:
1 2 3 window ._AMapSecurityConfig = { securityJsCode : '8210555...' };
这段配置必须在加载 SDK 之前设置,所以动态加载方案天然满足这个顺序要求。
然后本地测试又卡住了:高德的安全域名配置不支持 localhost 和 127.0.0.1,加了会提示”不符合规范的域名”。解决方案是走 安全密钥验证 而非域名白名单验证——只要正确设置了 securityJsCode,SDK 就不会检查域名。
4.6 最终版本 迁移完的 fish-map.js 从 105 行精简到 97 行(去掉了坐标转换代码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 (function ( ) { var typeColors = { '水库' : '#37c6c0' , '河流' : '#6bcb77' , '沙坑' : '#f0a050' , '溪流' : '#4d96ff' }; function loadAmap (callback ) { } function init ( ) { var map = new AMap .Map ('fish-map' , { zoom : 12 , resizeEnable : true }); fetch ('/fish/map/spots.json' ) .then (function (r ) { return r.json (); }) .then (function (data ) { var topSpot = data.spots .reduce (function (a, b ) { return a.photos > b.photos ? a : b; }); map.setCenter ([topSpot.lng , topSpot.lat ]); data.spots .forEach (function (spot ) { var radius = Math .min (8 + spot.photos * 0.4 , 24 ); var color = typeColors[spot.type ] || '#c9c9c9' ; var marker = new AMap .CircleMarker ({ center : [spot.lng , spot.lat ], radius : radius, fillColor : color, fillOpacity : 0.6 , strokeColor : '#fff' , strokeWeight : 2 }); marker.setMap (map); var info = new AMap .InfoWindow ({ content : buildPopupContent (spot), offset : new AMap .Pixel (0 , -20 ) }); marker.on ('click' , function ( ) { info.open (map, marker.getCenter ()); }); }); }); } })();
最终效果:
✅ 国内用户不需要代理 ,高德 SDK 和瓦片都走国内 CDN
✅ PJAX 兼容 ,动态加载方案适配无刷新导航
✅ 坐标零转换 ,GCJ-02 直出高德地图
✅ 自备降级 ,SDK 加载失败或数据 fetch 失败都有友好提示
五、数据流对比 两个功能的数据流完全不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Dashboard(构建时计算): hexo g → fish-dashboard-data.js(Generator) → 扫描 source/fish/img*/index.md → 正则匹配 data-lightbox 属性 → 输出 public/fish/dashboard/data.json hexo s(浏览器) → fish-dashboard.js fetch data.json → Chart.js 渲染柱状图 + 环形图 → 4 张统计卡片填充数值 地图(运行时渲染): 手工维护 source/fish/map/spots.json hexo g 直接复制到 public/fish/map/spots.json hexo s(浏览器) → fish-map.js 动态加载高德 SDK → fetch spots.json → AMap.CircleMarker 逐个渲染 → AMap.InfoWindow 绑定点击事件
核心区别:
维度
Dashboard
钓鱼地图
数据源
自动扫描相册文件
手工维护 JSON
构建阶段
Generator 做数据聚合
纯静态文件拷贝
渲染时机
构建时完成 → 运行时只展示
完全运行时渲染
更新方式
加照片后 hexo g 自动更新
新增钓点需手动改 spots.json
外部依赖
Chart.js(CDN)
高德 JS API(动态加载)
六、总结与后续 两个功能上线后,钓鱼相册从”一个只陈列照片的页面”变成了数据展示 + 空间叙事 的组合体验:
点开 Dashboard,一眼看到 5 年钓了 546 张照片、鱼种分布饼图、历年趋势柱状图
点开钓鱼地图,13 个钓点在卫星图上按类型着色排列,照片越多气泡越大
从技术角度看,没有太复杂的东西——就是几个 Hexo Generator + 两个图表 + 一个地图 SDK。但坑确实踩了不少,尤其是坐标转换和地图迁移。
后续还有一些可以优化的地方:
图床迁移 —— 当前所有图片托管在 iili.io 免费图床,有挂服风险,后续考虑迁移到 OSS 或 Cloudflare R2
Dashboard 与 spots.json 联动 —— 现在 Spot 数据需要手工维护,理想状态是 Generator 自动从相册中提取钓点位置
地图点聚合 —— 成都周边的钓点集中在邛崃区域,缩小时气泡重叠严重,可以加 AMap.MarkerCluster 做聚合
📊 查看 鱼获数据统计 🗺️ 查看 钓鱼地图