
一句话结论:GPS 设备给的是 WGS-84,微信/腾讯地图要的是 GCJ-02,两者天然差着几百米。本文带你逐行拆解一个生产级 GPX 转换工具的设计与实现,把"坐标偏移"这个坑一次性填平。
一、背景:为什么你的轨迹"飘"了?
做户外导航类小程序时,几乎每个开发者都会踩同一个坑:
- 你用 Garmin、手机 GPS 记录的轨迹是 WGS-84(地球坐标系);
- 但微信小程序、高德、腾讯地图底层使用的是 GCJ-02(火星坐标系) ------这是一套国家强制加密偏移算法,在中国大陆范围内对真实经纬度做了非线性扰动;
- 结果:直接把 WGS-84 点画到地图上,整条轨迹会偏移几百米,雪道直接"飘"到山外面去了。
本项目就是一个轻量、零依赖、可一键批量运行的 Node.js 工具,专门解决这件事:
把
data/下所有 GPX 轨迹文件,批量转换成已纠偏的 GCJ-02 坐标 JSON ,并自动算出路线长度、累计爬升、预计耗时,同时保证不覆盖、不重复生成已有文件。
二、整体架构:一条数据流水线的设计
整个工具是一条纯函数式的数据流水线,从输入到输出可分 6 个阶段:
┌─────────────┐
GPX 文件 ──▶│ 解析层 │ 正则提取 <trkpt> 经纬度/高程/时间
(WGS-84) └─────┬───────┘
│ points[]
▼
┌─────────────┐
│ 坐标纠偏层 │ WGS-84 → GCJ-02(核心算法)
└─────┬───────┘
│
┌──────────┼───────────┬────────────┐
▼ ▼ ▼ ▼
里程计算 累计爬升 异常检测 耗时估算
(Haversine) (死区滤波) (断点/跳变) (时间差累加)
└──────────┴───────────┴────────────┘
│ 组装 track 对象
▼
┌─────────────┐
│ 安全输出层 │ 去重 + 不覆盖 + 别名另存
└─────┬───────┘
▼
output/*.json(GCJ-02)
设计亮点(先记住这几个,后文逐一展开):
| 亮点 | 说明 | 对应模块 |
|---|---|---|
| ✅ 零第三方依赖 | 纯 Node 内置 node:fs / node:path,无需 npm install |
全局 |
| ✅ 坐标系自动纠偏 | 内置 GCJ-02 算法,可一键开关 | wgs84ToGcj02 |
| ✅ 生产线级里程/爬升 | Haversine + 死区滤波,抑制 GPS 高程噪声 | calcTotalKm / calcTotalAscent |
| ✅ 防误删文件 | 内容去重 + 绝不覆盖已有 JSON | resolveOutPath |
| ✅ 一键批量 / 单文件 | convert.bat 双击即用,支持 --outdir --alias |
main() + .bat |
| ✅ 数据质量预警 | 空间跳跃 / 时间断层仅提示不写入 | detectAnomalies |
三、目录结构与运行界面
CSDN 发稿时,建议按下面 3 张图补上,阅读体验直接拉满。
项目目录树
WGS-84 转GCJ-02/
├─ convert.bat ← Windows 一键启动脚本(双击即用)
├─ gpx-to-json.mjs ← 核心转换逻辑(本文主角)
├─ data/ ← 放原始 GPX(1 个 .gpx)
│ ├─ 1号线.gpx
└─ output/ ← 转换产物(同名 .json)
├─ 1号线.json

convert.bat 运行界面
双击 convert.bat 后控制台应输出:
截完整控制台日志,体现"写入 / 跳过 / 失败"统计与每条轨迹的里程、爬升、耗时。
JSON 产物片段
打开 output/1号线.json,重点展示 points 一行一个点、totalKm / totalAscentM / estMin 三个统计字段。
配合源 GPX 首行
lat="40.976385" lon="115.298749"同框对比,肉眼可见经纬度已被纠偏。
四、核心模块逐行精讲
4.1 坐标系纠偏:WGS-84 → GCJ-02(最核心)
GCJ-02 不是简单加减常量,而是以 (105°, 35°) 为参考点的多项式 + 三角函数非线性扰动。代码实现:
js
// ── WGS-84 -> GCJ-02 火星坐标系转换 ──
const PI = Math.PI
const A = 6378245.0 // 克拉索夫斯基椭球长半轴(米)
const EE = 0.00669342162296594323 // 椭球第一偏心率平方
// 判断是否在中国境外:境外不做偏移(GCJ-02 仅限中国境内)
function outOfChina(lat, lng) {
return !(lng > 72.004 && lng < 137.8347 && lat > 0.8293 && lat < 55.8271)
}
// 纬度方向偏移量计算(核心多项式)
function transformLat(x, y) {
let 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 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0
return ret
}
// 经度方向偏移量计算(结构同纬度,系数不同)
function transformLng(x, y) {
let 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
}
// 主入口:返回 [lng, lat] 纠偏后的坐标
function wgs84ToGcj02(lng, lat) {
if (!CONVERT_TO_GCJ02 || outOfChina(lat, lng)) return [lng, lat] // 开关关闭或境外→原样返回
let dLat = transformLat(lng - 105.0, lat - 35.0) // 相对参考点(105,35)计算偏移
let dLng = transformLng(lng - 105.0, lat - 35.0)
const radLat = (lat / 180.0) * PI // 纬度转弧度,用于椭球修正
let magic = Math.sin(radLat)
magic = 1 - EE * magic * magic // 子午圈曲率半径相关项
const 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 [lng + dLng, lat + dLat] // 叠加偏移,得到 GCJ-02
}
原理一句话 :以 (105,35) 为中国几何中心做相对坐标,用一组三角多项式算出"加密扰动量",再按该纬度下"1 度经/纬 = 多少真实米"换算回度数并叠加。本项目实测首点偏移约 500 米量级(lat +0.001685°≈188m,lng +0.006510°≈547m),正是地图"漂移"的根源。
4.2 里程计算:Haversine 球面距离
GPS 点是离散的,路线长度 = 相邻点球面距离之和。不能在平面直接加减经纬度(地球是圆的):
js
/** 两点球面距离(米) */
function haversineM(a, b) {
const R = 6371000 // 地球平均半径(米)
const dLat = ((b.latitude - a.latitude) * PI) / 180 // 纬度差→弧度
const dLon = ((b.longitude - a.longitude) * PI) / 180 // 经度差→弧度
// Haversine 半正矢公式:专门算球面上两点大圆距离
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.latitude * PI) / 180) * Math.cos((b.latitude * PI) / 180) * Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)) // 反算弧长
}
// 累加所有相邻段
function calcTotalKm(points) {
let sum = 0
for (let i = 1; i < points.length; i++) sum += haversineM(points[i - 1], points[i])
return sum / 1000 // 米→千米
}
4.3 累计爬升:带"死区"的高程滤波
GPS 高程噪声极大(抖动 ±5m 很常见),直接累加会把噪声算成"爬升"。这里用 2 米死区(hysteresis) 过滤:
js
function calcTotalAscent(pts) {
const NOISE_M = 2 // 死区阈值:2 米以内视为噪声,不计入
let base = null // 当前基准高程
let ascent = 0
for (const p of pts) {
const e = p.ele
if (typeof e !== 'number' || Number.isNaN(e)) continue // 无高程则跳过
if (base === null) { base = e; continue } // 初始化基准
if (e - base > NOISE_M) { // 真实上升超过死区 → 计入爬升,抬高基准
ascent += e - base
base = e
} else if (base - e > NOISE_M) { // 真实下降超过死区 → 仅下移基准(不算爬升)
base = e
}
// 死区内的微小抖动:什么都不做,抑制噪声
}
return Math.round(ascent)
}
💡 通俗理解:像水库水位,只有涨过 2 米才算"真爬升",小幅上下晃当没看见。
4.4 数据质量预警:异常检测(只提示不写入)
js
function detectAnomalies(pts) {
const warns = []
for (let i = 1; i < pts.length; i++) {
const dist = haversineM(pts[i - 1], pts[i]) // 相邻点间距
let dt = NaN
if (pts[i].time && pts[i - 1].time)
dt = (new Date(pts[i].time) - new Date(pts[i - 1].time)) / 1000 // 时间差(秒)
if (dist > 500) // 单步超过 500m → 空间跳跃(可能丢星)
warns.push({ index: i, type: '空间跳跃', distanceM: Math.round(dist), ... })
else if (dt > 300) // 时间断层超过 300s → 记录中断
warns.push({ index: i, type: '时间断层', timeGapS: Math.round(dt), ... })
}
return warns // 仅返回给控制台,绝不写进 JSON 污染数据
}
4.5 安全输出:去重 + 绝不覆盖(最容易被忽视的坑)
很多脚本一跑就 覆盖 旧文件,导致历史数据丢失。本工具用 resolveOutPath 做三重保护:
js
function resolveOutPath(outputDir, baseName, mode, content) {
const target = join(outputDir, `${baseName}${OUT_EXT}`)
// 1) 目标不存在 → 直接原名写入
if (!existsSync(target)) return { path: target, skip: false }
// 2) 已存在且内容完全相同 → 跳过(不生成重复副本)
if (readTextSafe(target) === content) return { skip: true, ... }
// 3) 内容不同 → 按别名规则另存,原文件纹丝不动
const date = new Date()
let index = 1
let candidate = join(outputDir, aliasName(baseName, mode, index, date))
while (existsSync(candidate)) {
if (readTextSafe(candidate) === content) return { skip: true, ... } // 别名也已存在相同内容→跳过
index++
candidate = join(outputDir, aliasName(baseName, mode, index, date))
}
return { path: candidate, isAlias: true, skip: false } // 例如 1号线_1.json
}
别名规则:
number(默认):名称_1.json、名称_2.json ...timestamp:名称_20260908-153012.json(避免 Windows 非法字符)
4.6 自定义序列化:points 一行一点,文件体积暴降 🪶
JSON.stringify 默认会把每个点展开成 5 行,几千个点就是上万行。本工具手写序列化,让 points 每个对象压成一行:
js
function stringifyTrack(track) {
const lines = ['{']
const keys = Object.keys(track)
keys.forEach((key, i) => {
const tail = i < keys.length - 1 ? ',' : ''
if (key === 'points') { // 特殊处理 points 数组
lines.push(' "points": [')
track.points.forEach((p, j) => {
const comma = j < track.points.length - 1 ? ',' : ''
// 一行一个坐标点,体积小、可读性强
lines.push(` { "latitude": ${p.latitude}, "longitude": ${p.longitude} }${comma}`)
})
lines.push(` ]${tail}`)
} else {
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(track[key])}${tail}`)
}
})
lines.push('}')
return lines.join('\n')
}
4.7 一键启动:Windows 批处理脚本 🪟
convert.bat 让不懂命令行的同事也能双击使用,且刻意只用 ASCII (避免 cmd 读 UTF-8 乱码)、自带 node 缺失检测:
bat
@echo off
setlocal EnableDelayedExpansion
set "INPUT_DIR=%~dp0data"
set "OUTPUT_DIR=%~dp0output"
cd /d "%~dp0" ← 切到脚本所在目录,保证相对路径正确
:parse
if "%~1"=="" goto run
if /i "%~1"=="/nopause" set "NOPAUSE=1" & shift & goto parse ← 解析 /nopause 参数
set "NODE_ARGS=!NODE_ARGS! %1" ← 透传文件名/--alias 等参数(保留空格/括号)
shift & goto parse
:run
where node >nul 2>nul
if errorlevel 1 goto nonode ← 没装 Node 直接报错引导安装
if not exist "%INPUT_DIR%\" goto noinput
if not exist "%OUTPUT_DIR%\" mkdir "%OUTPUT_DIR%"
node "gpx-to-json.mjs" --outdir "%OUTPUT_DIR%" !NODE_ARGS! ← 真正调用核心脚本
五、实战场景:它到底解决了什么?
场景 1:雪场导览小程序画轨迹
你导出了 10 条雪道 GPX,直接喂给 data/,双击 convert.bat,10 秒拿到 10 个 GCJ-02 JSON。前端 wxMap.addPolyline() 直接画,雪道精准贴合地图,不再"飘出山外"。
场景 2:自动生成难度标签
JSON 里的 totalKm(长度)、totalAscentM(爬升)、estMin(耗时)可前端直接展示为"初级道 1.2km / 爬升 80m / 约 6 分钟",无需后端再算。
场景 3:反复跑不丢数据
你改了配色或重跑,工具会比对内容:完全相同就跳过 ,不同就另存 _1,永远不覆盖你上次的成果,安全感拉满。
场景 4:脏数据拦截
某段轨迹丢星导致两点间距 800m,detectAnomalies 在控制台标红预警,你一眼就能定位是哪段 GPX 需要重采。
六、总结与干货清单
本文核心收获:
- 坐标系差异是地图偏移的根源:WGS-84(GPS)→ GCJ-02(国内地图)必须转换,否则漂移数百米。
- GCJ-02 是参考点
(105,35)的非线性三角扰动,可用纯数学实现,无需联网。 - 里程用 Haversine 球面公式,别在平面上直接加减经纬度。
- 累计爬升要加"死区"滤波,否则 GPS 高程噪声会被误算成爬升。
- 文件输出必须"去重 + 不覆盖",这是生产脚本的基本素养。
- 自定义 JSON 序列化能让大数组既省体积又可读。
- 批处理脚本要 ASCII 化 + 参数透传,保证双击即用、路径带空格/中文不崩。
可直接复用的代码片段(建议收藏):
wgs84ToGcj02()------ 坐标纠偏,任何国内地图项目都用得上haversineM()------ 两点球面距离,出行/配送/轨迹通用calcTotalAscent()------ 死区滤波版爬升统计模板resolveOutPath()------ 防覆盖的安全写文件范式
最后献上代码
convert.bat
javascript
@echo off
rem ASCII only. Do NOT save this file as UTF-8 (cmd would misread it).
setlocal EnableDelayedExpansion
rem GPX Convert Tool (Windows)
rem Input : data (all .gpx files in this folder)
rem Output: output (<same file name>.json)
rem
rem Double click : convert every .gpx in data -> output
rem convert.bat 1.gpx : single file (looked up in data)
rem convert.bat --alias timestamp : use timestamp alias on name conflict
rem (default alias is number: name_1.json)
rem convert.bat /nopause : do not wait for a key at the end
set "INPUT_DIR=%~dp0data"
set "OUTPUT_DIR=%~dp0output"
set "NOPAUSE=0"
set "RUN_ERR=0"
set "NODE_ARGS="
cd /d "%~dp0"
rem --- parse args (paths may contain spaces/parens, keep them quoted) ---
:parse
if "%~1"=="" goto run
if /i "%~1"=="/nopause" set "NOPAUSE=1" & shift & goto parse
set "NODE_ARGS=!NODE_ARGS! %1"
shift
goto parse
:run
echo ============================================
echo GPX Convert Tool
echo ============================================
echo Input : "%INPUT_DIR%"
echo Output: "%OUTPUT_DIR%"
echo.
where node >nul 2>nul
if errorlevel 1 goto nonode
if not exist "%INPUT_DIR%\" goto noinput
if not exist "%OUTPUT_DIR%\" mkdir "%OUTPUT_DIR%"
if errorlevel 1 goto nomkdir
node "gpx-to-json.mjs" --outdir "%OUTPUT_DIR%" !NODE_ARGS!
set "RUN_ERR=!errorlevel!"
goto done
:nonode
echo [ERROR] Node.js not found. Install it: https://nodejs.org
set "RUN_ERR=1"
goto done
:noinput
echo [ERROR] Input folder not found: "%INPUT_DIR%"
set "RUN_ERR=1"
goto done
:nomkdir
echo [ERROR] Cannot create output folder: "%OUTPUT_DIR%"
set "RUN_ERR=1"
goto done
:done
echo.
echo --------------------------------------------
if not "!RUN_ERR!"=="0" echo [ERROR] Conversion failed, see log above.
if not "!NOPAUSE!"=="1" pause
:end
endlocal
gpx-to-json.mjs
javascript
#!/usr/bin/env node
/**
* GPX -> JSON 转换工具
*
* 用法(在项目根目录执行,或直接双击 convert.bat):
* 批量模式: node gpx-to-json.mjs 转换 data 目录下所有 .gpx
* 单文件: node gpx-to-json.mjs 1号线.gpx
* 指定输出: node gpx-to-json.mjs --outdir ./output
*
* 输出:根目录下 output/<原文件名>.json(文件名与源文件一致)
* 输出策略:内容去重 + 绝不覆盖已有文件
* 1) 已存在内容与本次完全相同 -> 跳过写入,不生成副本
* 2) 已存在但内容不同 -> 按别名规则另存,原文件不动(扩展名不变)
* --alias number -> 名称_1.json、名称_2.json ...(默认)
* --alias timestamp -> 名称_20260908-153012.json
* 别名规则可用上面的参数指定,也可修改文件顶部 ALIAS_MODE 默认值。
*
* 【输出字段】
* id, name, icon, color, category, description,
* points[{latitude, longitude}](每个点对象压缩在一行),
* totalKm(路线长度 km), totalAscentM(累计爬升 m), estMin(预计耗时 min), mode
* 数据间断仅打印到控制台,不写入 JSON。
*
* 说明:GPX 原始为 WGS-84,微信/腾讯地图用 GCJ-02,默认自动转换。
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
import { join, basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const DATA_DIR = join(__dirname, 'data')
// 是否将 WGS-84 转为 GCJ-02(中国地图必需)
const CONVERT_TO_GCJ02 = true
// 输出文件重名时的别名生成规则(不覆盖已有文件,扩展名保持不变)
// 'number' -> <名称>_1.json、<名称>_2.json ...(默认)
// 'timestamp' -> <名称>_YYYYMMDD-HHmmss.json(仍冲突则追加序号)
const ALIAS_MODE = 'number'
const OUT_EXT = '.json'
// 轨迹配色(按文件名排序循环取色)
const PALETTE = [
'#1677FF', '#52C41A', '#FA8C16', '#F5222D', '#722ED1',
'#13C2C2', '#EB2F96', '#A0D911', '#2F54EB', '#FAAD14'
]
// ── WGS-84 -> GCJ-02 火星坐标系转换 ──
const PI = Math.PI
const A = 6378245.0
const EE = 0.00669342162296594323
function outOfChina(lat, lng) {
return !(lng > 72.004 && lng < 137.8347 && lat > 0.8293 && lat < 55.8271)
}
function transformLat(x, y) {
let 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 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0
return ret
}
function transformLng(x, y) {
let 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 wgs84ToGcj02(lng, lat) {
if (!CONVERT_TO_GCJ02 || outOfChina(lat, lng)) return [lng, lat]
let dLat = transformLat(lng - 105.0, lat - 35.0)
let dLng = transformLng(lng - 105.0, lat - 35.0)
const radLat = (lat / 180.0) * PI
let magic = Math.sin(radLat)
magic = 1 - EE * magic * magic
const 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 [lng + dLng, lat + dLat]
}
/** 两点球面距离(米) */
function haversineM(a, b) {
const R = 6371000
const dLat = ((b.latitude - a.latitude) * PI) / 180
const dLon = ((b.longitude - a.longitude) * PI) / 180
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.latitude * PI) / 180) * Math.cos((b.latitude * PI) / 180) * Math.sin(dLon / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
}
function calcTotalKm(points) {
let sum = 0
for (let i = 1; i < points.length; i++) sum += haversineM(points[i - 1], points[i])
return sum / 1000
}
/** 时长(分钟):累加相邻时间差,忽略 >6 小时大间隔 */
function calcEstMin(pts, km) {
const times = pts
.map(p => p.time)
.filter(Boolean)
.map(t => new Date(t).getTime())
.filter(t => !Number.isNaN(t))
.sort((a, b) => a - b)
if (times.length < 2) return Math.max(1, Math.round((km / 8) * 60))
const GAP = 6 * 60 * 60 * 1000
let activeMs = 0
for (let i = 1; i < times.length; i++) {
const d = times[i] - times[i - 1]
if (d >= 0 && d <= GAP) activeMs += d
}
const m = activeMs / 60000
return m >= 1 ? Math.max(1, Math.round(m)) : Math.max(1, Math.round((km / 8) * 60))
}
/** 解析 trkpt:lat / lon / time / ele(输出的 points 只含 latitude、longitude) */
function parseTrkpt(text) {
const points = []
const ptRe = /<trkpt\s+([^>]+)>([\s\S]*?)<\/trkpt>/g
let m
while ((m = ptRe.exec(text)) !== null) {
const latM = /lat="([^"]+)"/.exec(m[1])
const lonM = /lon="([^"]+)"/.exec(m[1])
if (!latM || !lonM) continue
let time = null
const timeM = /<time>([^<]+)<\/time>/.exec(m[2])
if (timeM) time = timeM[1].trim()
// 高程(米),用于累计爬升;缺失或非法时为 null
let ele = null
const eleM = /<ele>([^<]+)<\/ele>/.exec(m[2])
if (eleM) {
const v = parseFloat(eleM[1])
if (!Number.isNaN(v)) ele = v
}
points.push({ latitude: parseFloat(latM[1]), longitude: parseFloat(lonM[1]), time, ele })
}
return points
}
/** 累计爬升(米):带死区的高程累加,抑制 GPS 高程噪声(阈值 2m) */
function calcTotalAscent(pts) {
const NOISE_M = 2
let base = null
let ascent = 0
for (const p of pts) {
const e = p.ele
if (typeof e !== 'number' || Number.isNaN(e)) continue
if (base === null) {
base = e
continue
}
if (e - base > NOISE_M) {
ascent += e - base
base = e
} else if (base - e > NOISE_M) {
base = e
}
}
return Math.round(ascent)
}
/** 检测数据间断(时间断层 / 空间跳跃),仅用于控制台提示,不写入 JSON */
function detectAnomalies(pts) {
const warns = []
for (let i = 1; i < pts.length; i++) {
const dist = haversineM(pts[i - 1], pts[i])
let dt = NaN
if (pts[i].time && pts[i - 1].time) {
dt = (new Date(pts[i].time).getTime() - new Date(pts[i - 1].time).getTime()) / 1000
}
if (dist > 500) {
warns.push({ index: i, type: '空间跳跃', distanceM: Math.round(dist), timeGapS: isNaN(dt) ? null : Math.round(dt) })
} else if (dt > 300) {
warns.push({ index: i, type: '时间断层', distanceM: Math.round(dist), timeGapS: Math.round(dt) })
}
}
return warns
}
/** 时间戳别名:YYYYMMDD-HHmmss(不含 Windows 非法字符) */
function timeStamp(date) {
const p = n => String(n).padStart(2, '0')
return (
`${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
)
}
/** 按别名规则生成候选文件名(扩展名保持不变) */
function aliasName(baseName, mode, index, date) {
if (mode === 'timestamp') {
const ts = timeStamp(date)
return index <= 1 ? `${baseName}_${ts}${OUT_EXT}` : `${baseName}_${ts}_${index}${OUT_EXT}`
}
return `${baseName}_${index}${OUT_EXT}`
}
/**
* 序列化为 JSON:整体缩进 2 空格,但 points 中每个点对象压缩在一行,
* 避免每对经纬度占 5 行导致文件过长。
* 形如:
* "points": [
* { "latitude": 39.123456, "longitude": 116.123456 },
* ...
* ]
*/
function stringifyTrack(track) {
const lines = ['{']
const keys = Object.keys(track)
keys.forEach((key, i) => {
const tail = i < keys.length - 1 ? ',' : ''
if (key === 'points') {
lines.push(' "points": [')
track.points.forEach((p, j) => {
const comma = j < track.points.length - 1 ? ',' : ''
lines.push(` { "latitude": ${p.latitude}, "longitude": ${p.longitude} }${comma}`)
})
lines.push(` ]${tail}`)
} else {
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(track[key])}${tail}`)
}
})
lines.push('}')
return lines.join('\n')
}
/** 读取已存在文件内容用于比对(读取失败返回 null,不影响转换) */
function readTextSafe(p) {
try {
return readFileSync(p, 'utf-8')
} catch {
return null
}
}
/**
* 解析实际输出路径(内容去重 + 不覆盖):
* 1. 目标文件不存在 -> 直接用原名写入
* 2. 目标文件或任一别名文件内容相同 -> 跳过写入(skip=true),避免生成重复副本
* 3. 内容不同 -> 按别名规则生成不冲突的文件名写入
*/
function resolveOutPath(outputDir, baseName, mode, content) {
const target = join(outputDir, `${baseName}${OUT_EXT}`)
if (!existsSync(target)) return { path: target, isAlias: false, origin: null, skip: false }
if (readTextSafe(target) === content) return { path: target, isAlias: false, origin: target, skip: true }
const date = new Date()
let index = 1
let candidate = join(outputDir, aliasName(baseName, mode, index, date))
while (existsSync(candidate)) {
if (readTextSafe(candidate) === content) {
return { path: candidate, isAlias: true, origin: target, skip: true }
}
index++
candidate = join(outputDir, aliasName(baseName, mode, index, date))
}
return { path: candidate, isAlias: true, origin: target, skip: false }
}
/**
* 转换单个 GPX 文件,输出到 outputDir/<原文件名>.json
* 内容相同则跳过写入(返回 true),内容不同则不覆盖、改用别名另存(返回 false)
* 字段:id/name/icon/color/category/description/points/totalKm/totalAscentM/estMin/mode
*/
function convertOne(gpxPath, outputDir, color, aliasMode) {
const srcName = basename(gpxPath)
// 输出文件名与源文件名保持一致(仅去扩展名,兼容 .gpx / .GPX)
const baseName = srcName.replace(/\.gpx$/i, '')
// 1) 存在性 + 格式校验
if (!existsSync(gpxPath)) throw new Error('源文件不存在')
if (!statSync(gpxPath).isFile()) throw new Error('不是有效文件')
const text = readFileSync(gpxPath, 'utf-8').trim()
if (!text) throw new Error('文件为空')
if (!/<gpx[\s>]/i.test(text)) throw new Error('不是有效的 GPX 文件(缺少 <gpx> 根节点)')
// 2) 解析轨迹点
const rawPts = parseTrkpt(text)
if (!rawPts.length) throw new Error('未解析到任何 <trkpt> 轨迹点(文件可能损坏或格式不支持)')
// 3) 坐标转换(WGS-84 -> GCJ-02),points 仅保留 latitude / longitude
const points = rawPts.map(p => {
const [lng, lat] = wgs84ToGcj02(p.longitude, p.latitude)
return { latitude: Number(lat.toFixed(6)), longitude: Number(lng.toFixed(6)) }
})
const totalKm = calcTotalKm(points)
const totalAscentM = calcTotalAscent(rawPts)
const estMin = calcEstMin(rawPts, totalKm)
const warnings = detectAnomalies(rawPts)
const hasEle = rawPts.some(p => typeof p.ele === 'number')
// 4) 组装:路线长度 totalKm、累计爬升 totalAscentM、预计耗时 estMin
const track = {
id: baseName.replace(/\s+/g, ''),
name: baseName,
icon: '',
color: color,
category: 'sports',
description: '',
points: points,
totalKm: Number(totalKm.toFixed(2)),
totalAscentM: totalAscentM,
estMin: estMin,
mode: 'skiing'
}
// 5) 输出目录不存在则创建
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true })
// 6) 内容相同则跳过写入;内容不同则不覆盖原文件,改用别名另存
const content = stringifyTrack(track)
const out = resolveOutPath(outputDir, baseName, aliasMode, content)
if (out.skip) {
console.log(`[跳过] ${srcName} -> ${basename(out.path)} 已存在内容完全相同的文件,未写入`)
} else {
writeFileSync(out.path, content, 'utf-8')
console.log(`[成功] ${srcName} -> ${basename(out.path)} (${points.length} 点, ${track.totalKm}km, 爬升 ${totalAscentM}m, ${estMin}min)`)
if (out.isAlias) {
console.log(` 已存在 ${basename(out.origin)} 且内容不同,未覆盖,另存为 ${basename(out.path)}`)
}
}
if (!hasEle) {
console.log(' 提示:GPX 中未解析到 <ele> 高程,累计爬升按 0 处理')
}
if (warnings.length) {
console.log(` 提示:检测到 ${warnings.length} 处数据间断(仅提示,未写入 JSON)`)
warnings.slice(0, 5).forEach(w => {
console.log(` - 点${w.index} 附近: ${w.type}${w.type === '空间跳跃' ? ` ${w.distanceM}m` : ` ${w.timeGapS}s`}`)
})
}
return out.skip
}
function main() {
const args = process.argv.slice(2)
const outdirIdx = args.indexOf('--outdir')
const outputDir = outdirIdx >= 0 && args[outdirIdx + 1] ? resolve(args[outdirIdx + 1]) : join(__dirname, 'output')
const aliasIdx = args.indexOf('--alias')
const aliasArg = aliasIdx >= 0 && args[aliasIdx + 1] ? String(args[aliasIdx + 1]).toLowerCase() : ''
const aliasMode = aliasArg === 'number' || aliasArg === 'timestamp' ? aliasArg : ALIAS_MODE
const fileArgs = args.filter(
(a, i) =>
!a.startsWith('--') &&
!(outdirIdx >= 0 && i === outdirIdx + 1) &&
!(aliasIdx >= 0 && i === aliasIdx + 1)
)
// data 目录下的完整有序文件列表(用于批量转换与配色)
const allFiles = existsSync(DATA_DIR)
? readdirSync(DATA_DIR)
.filter(f => f.toLowerCase().endsWith('.gpx'))
.sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true }))
: []
const colorOf = filePath => {
const i = allFiles.indexOf(basename(filePath))
return PALETTE[(i >= 0 ? i : 0) % PALETTE.length]
}
// 收集待转换文件
const targets = []
const errors = []
if (fileArgs.length > 0) {
for (const f of fileArgs) {
const candidates = [resolve(f), join(DATA_DIR, f), join(DATA_DIR, `${f}.gpx`)]
const hit = candidates.find(p => existsSync(p) && statSync(p).isFile())
if (hit) targets.push(hit)
else errors.push(`找不到文件: ${f}`)
}
} else {
if (!existsSync(DATA_DIR)) {
console.error(`[错误] data 目录不存在: ${DATA_DIR}`)
process.exit(1)
}
targets.push(...allFiles.map(f => join(DATA_DIR, f)))
}
console.log('============================================')
console.log(' GPX 转换工具')
console.log('============================================')
console.log(`模式: ${fileArgs.length > 0 ? '单文件' : '批量'}`)
console.log(`输出目录: ${outputDir}`)
console.log(`坐标系: ${CONVERT_TO_GCJ02 ? 'WGS-84 -> GCJ-02(已转换)' : 'WGS-84(未转换)'}`)
console.log(`输出字段: totalKm(路线长度 km) / totalAscentM(累计爬升 m) / estMin(预计耗时 min)`)
console.log(`重名策略: 内容相同则跳过写入;内容不同不覆盖,按别名另存(规则 = ${aliasMode === 'timestamp' ? '时间戳' : '序号'})`)
if (aliasArg && aliasArg !== 'number' && aliasArg !== 'timestamp') {
console.log(` 提示:--alias 仅支持 number / timestamp,已回退为 ${ALIAS_MODE}`)
}
console.log('')
if (!targets.length && !errors.length) {
console.log('data 目录下没有找到 .gpx 文件')
return
}
if (errors.length) errors.forEach(e => console.error(`[错误] ${e}`))
let ok = 0
let skipped = 0
let failed = 0
for (const p of targets) {
try {
if (convertOne(p, outputDir, colorOf(p), aliasMode)) skipped++
else ok++
} catch (e) {
failed++
console.error(`[失败] ${basename(p)} -> ${e.message}`)
}
}
console.log('')
console.log(`全部完成:写入 ${ok} 个,跳过 ${skipped} 个(内容相同未写入),失败 ${failed} 个`)
if (ok) console.log(`JSON 已输出到: ${outputDir}`)
}
try {
main()
} catch (e) {
console.error('[严重错误]', e.message)
process.exit(1)
}
map文件
javascript
<template>
<view class="page-container">
<view class="map-wrapper">
<map id="main-map" class="main-map" :latitude="mapCenter.latitude" :longitude="mapCenter.longitude"
:scale="mapScale" :polyline="mapPolyline" :markers="trackMarkers" :show-location="showUserLocation"
:enable-zoom="true" :enable-scroll="true" :enable-3d="false" :skew="0" :rotate="0" :layer-style="1" />
</view>
<!-- 顶部标题 -->
<view class="top-bar">
<text class="top-title">户外运动</text>
</view>
<!-- 底部:轨迹选择 + 自身定位状态 + 运动数据 -->
<view class="bottom-panel">
<scroll-view scroll-x class="track-selector" :show-scrollbar="false">
<view class="selector-inner">
<view :class="['track-chip', { active: isAll }]" @click="onSelectAll">
<text class="chip-name">全部</text>
</view>
<view v-for="track in tracks" :key="track.id" :class="['track-chip', { active: currentTrackId === track.id }]"
@click="onSelectTrack(track)">
<text class="chip-icon">{{ track.icon }}</text>
<text class="chip-name">{{ track.name }}</text>
</view>
</view>
</scroll-view>
<!-- 自身定位:是否在轨迹线上 -->
<view class="loc-bar" :class="onTrackClass">
<view class="loc-left">
<text class="loc-dot" />
<text class="loc-text">{{ locationStatusText }}</text>
</view>
<view class="loc-btn" @click="centerOnUser">
<text class="loc-btn-text">回到我的位置</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ currentTrackDistance }}</text>
<text class="stat-label">路线长度</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ currentTrackDuration }}</text>
<text class="stat-label">预计耗时</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ currentTrackAscent }}</text>
<text class="stat-label">累计爬升</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { TRACKS, type Track } from '@/data/tracks' //可以是json文件
// ── 地图中心(园区) ──
const CAMPUS_CENTER = { latitude: 40.9716, longitude: 115.3074 }
const mapCenter = ref({ ...CAMPUS_CENTER })
const mapScale = ref(15)
const tracks = ref<Track[]>(TRACKS)
// 'all' 表示「全部」模式,否则为具体轨迹 id
const currentTrackId = ref<string>('all')
// 是否处于「全部」模式
const isAll = computed(() => currentTrackId.value === 'all')
// 当前选中的轨迹(全部模式下为 null)
const currentTrack = computed<Track | null>(() => {
return tracks.value.find(t => t.id === currentTrackId.value) || null
})
// 轨迹线:全部模式下显示所有轨迹,单选模式下只显示选中轨迹
const mapPolyline = computed(() => {
const list = isAll.value
? tracks.value
: tracks.value.filter(t => t.id === currentTrackId.value)
return list
.filter(t => t.points && t.points.length >= 2)
.map(t => ({
points: t.points,
color: t.color,
width: 5,
dottedLine: false,
arrowLine: false
}))
})
// 起终点标记(仅单选模式显示选中轨迹的起终点)
const trackMarkers = computed(() => {
if (isAll.value) return []
const t = currentTrack.value
if (!t || !t.points || t.points.length < 2) return []
const start = t.points[0]
const end = t.points[t.points.length - 1]
return [
{
id: 1,
latitude: start.latitude,
longitude: start.longitude,
width: 22,
height: 22,
callout: {
content: '起点',
display: 'ALWAYS',
borderRadius: 12,
padding: 6,
fontSize: 12,
color: '#FFFFFF',
bgColor: '#52C41A',
borderWidth: 0
}
},
{
id: 2,
latitude: end.latitude,
longitude: end.longitude,
width: 22,
height: 22,
callout: {
content: '终点',
display: 'ALWAYS',
borderRadius: 12,
padding: 6,
fontSize: 12,
color: '#FFFFFF',
bgColor: '#FF4D4F',
borderWidth: 0
}
}
]
})
// 统计数据源:全部模式用合计,单选模式用单条
const statsSource = computed(() => {
if (isAll.value) {
return {
km: tracks.value.reduce((s, t) => s + (t.totalKm || 0), 0),
min: tracks.value.reduce((s, t) => s + (t.estMin || 0), 0),
ascent: tracks.value.reduce((s, t) => s + (t.totalAscentM || 0), 0)
}
}
return {
km: currentTrack.value?.totalKm || 0,
min: currentTrack.value?.estMin || 0,
ascent: currentTrack.value?.totalAscentM || 0
}
})
// 距离
const currentTrackDistance = computed(() => {
const km = statsSource.value.km
if (!km) return '--'
return km < 1 ? Math.round(km * 1000) + ' m' : km.toFixed(1) + ' km'
})
// 预计耗时不确定带:以经验运动时间(estMin)为中心,下限=高效滑行(接近运动时间),上限=含休息/排队/慢速
const DURATION_BAND_LOW = 0.85
const DURATION_BAND_HIGH = 1.35
// 时长:以 h 为单位、保留 1 位小数,呈现为一个范围
const currentTrackDuration = computed(() => {
const min = statsSource.value.min
if (!min) return '--'
const centerH = min / 60
const lowR = Math.round(centerH * DURATION_BAND_LOW * 10) / 10
let highR = Math.round(centerH * DURATION_BAND_HIGH * 10) / 10
// 避免四舍五入后上下界重合,至少拉开 0.1h
if (highR - lowR < 0.1) highR = lowR + 0.1
return `${lowR.toFixed(1)}--${highR.toFixed(1)} h`
})
// 爬升(取自 TRACKS 的 totalAscentM,单位米;0 为合法值,显示 0 m,不出现 --)
const currentTrackAscent = computed(() => {
const m = statsSource.value.ascent
return Math.round(m || 0) + ' m'
})
// ── 自身定位:判断是否在轨迹线上(仅做几何判定,不提供导航/路线指引) ──
const ON_TRACK_M = 20 // 判定「在轨迹上」的距离阈值(米)
const LOC_INTERVAL_MS = 5000 // 定位刷新间隔(毫秒)
const showUserLocation = ref(false)
const userPos = ref<{ latitude: number; longitude: number } | null>(null)
const distToTrackM = ref<number | null>(null)
const locationError = ref('')
let _locTimer: ReturnType<typeof setInterval> | null = null
// 当前可见的轨迹(全部模式=全部,单选模式=选中的那条)
const visibleTracks = computed(() => {
return isAll.value ? tracks.value : tracks.value.filter(t => t.id === currentTrackId.value)
})
/**
* 计算定位点到一条轨迹的最短距离(米)。
* 做法:按当前纬度把经纬度换算成平面米,再逐段求「点到线段」的距离取最小值。
*/
function distanceToTrackMeters(
user: { latitude: number; longitude: number },
points: { latitude: number; longitude: number }[]
): number {
if (!points || !points.length) return Infinity
const kx = 111320 * Math.cos((user.latitude * Math.PI) / 180)
const ky = 111320
const px = user.longitude * kx
const py = user.latitude * ky
let min = Infinity
for (let i = 0; i < points.length; i++) {
const ax = points[i].longitude * kx
const ay = points[i].latitude * ky
// 最后一个点:直接算点距离
if (i === points.length - 1) {
min = Math.min(min, Math.hypot(px - ax, py - ay))
break
}
const bx = points[i + 1].longitude * kx
const by = points[i + 1].latitude * ky
const dx = bx - ax
const dy = by - ay
if (dx === 0 && dy === 0) {
min = Math.min(min, Math.hypot(px - ax, py - ay))
continue
}
// 投影参数 t 夹在 [0,1],得到点到线段的最短距离
let t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
t = Math.max(0, Math.min(1, t))
min = Math.min(min, Math.hypot(px - (ax + t * dx), py - (ay + t * dy)))
}
return min
}
/** 刷新:我到最近可见轨迹的距离 */
function refreshTrackDistance() {
const p = userPos.value
if (!p) return
let min = Infinity
visibleTracks.value.forEach(t => {
if (t.points && t.points.length) {
const d = distanceToTrackMeters(p, t.points)
if (d < min) min = d
}
})
distToTrackM.value = min === Infinity ? null : min
}
/** 单次定位 */
function requestLocation() {
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 4000,
success: (res: any) => {
showUserLocation.value = true
userPos.value = { latitude: res.latitude, longitude: res.longitude }
locationError.value = ''
refreshTrackDistance()
},
fail: (err: any) => {
const msg = err?.errMsg || ''
locationError.value = msg.includes('auth') || msg.includes('deny') ? '未授权定位' : '定位失败'
}
})
}
/** 启动持续定位(定时刷新) */
function startLocating() {
requestLocation()
if (_locTimer) clearInterval(_locTimer)
_locTimer = setInterval(requestLocation, LOC_INTERVAL_MS)
}
/** 把地图中心移到我的位置 */
function centerOnUser() {
if (!userPos.value) {
requestLocation()
return
}
mapCenter.value = { ...userPos.value }
mapScale.value = 17
}
// 状态文案:在轨迹上 / 偏离轨迹 / 定位中 / 失败
const locationStatusText = computed(() => {
if (locationError.value) return locationError.value
if (!userPos.value || distToTrackM.value === null) return '定位中...'
const d = Math.round(distToTrackM.value)
return d <= ON_TRACK_M ? `在轨迹上(${d}m)` : `偏离轨迹 ${d}m`
})
// 状态配色:on=绿 / off=橙 / unknown=灰
const onTrackClass = computed(() => {
if (!userPos.value || distToTrackM.value === null) return 'unknown'
return distToTrackM.value <= ON_TRACK_M ? 'on' : 'off'
})
// 切换轨迹时重新计算距离
watch([currentTrackId, isAll], () => refreshTrackDistance())
function onSelectTrack(track: Track) {
currentTrackId.value = track.id
focusOnTrack(track)
}
// 「全部」:恢复显示所有轨迹
function onSelectAll() {
currentTrackId.value = 'all'
focusOnAllTracks()
}
// 地图视野聚焦到单条轨迹
function focusOnTrack(track: Track) {
if (!track.points || track.points.length < 2) return
uni.createMapContext('main-map').includePoints({
points: track.points,
padding: [80, 60, 280, 60]
})
}
// 地图视野聚焦到全部轨迹
function focusOnAllTracks() {
const allPoints: { latitude: number; longitude: number }[] = []
tracks.value.forEach(t => {
if (t.points && t.points.length) allPoints.push(...t.points)
})
if (allPoints.length >= 2) {
uni.createMapContext('main-map').includePoints({
points: allPoints,
padding: [80, 60, 280, 60]
})
}
}
onMounted(() => {
// 初始聚焦到全部轨迹
setTimeout(() => focusOnAllTracks(), 500)
// 启动自身定位,判断是否落在轨迹线上
startLocating()
})
onUnmounted(() => {
if (_locTimer) {
clearInterval(_locTimer)
_locTimer = null
}
})
</script>
<style lang="scss">
.page-container {
height: 100vh;
width: 100vw;
background: #0D1A2D;
position: relative;
overflow: hidden;
}
.map-wrapper {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.main-map {
width: 100%;
height: 100%;
}
.top-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: calc(env(safe-area-inset-top) + 24rpx) 32rpx 24rpx;
background: linear-gradient(180deg, rgba(13, 26, 45, 0.9), rgba(13, 26, 45, 0));
z-index: 100;
}
.top-title {
font-size: 36rpx;
font-weight: 700;
color: #FFFFFF;
}
.bottom-panel {
position: absolute;
left: 24rpx;
right: 24rpx;
bottom: calc(env(safe-area-inset-bottom) + 24rpx);
z-index: 100;
}
.track-selector {
white-space: nowrap;
}
.selector-inner {
display: inline-flex;
padding: 8rpx;
}
.track-chip {
display: inline-flex;
align-items: center;
padding: 16rpx 28rpx;
margin: 0 8rpx;
border-radius: 40rpx;
background: rgba(0, 0, 0, 0.6);
transition: all 0.2s;
&.active {
background: #2F54EB;
}
.chip-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.chip-name {
font-size: 26rpx;
color: #FFFFFF;
}
}
/* 自身定位状态条 */
.loc-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
padding: 18rpx 24rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 20rpx;
box-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.12);
.loc-left {
display: flex;
align-items: center;
}
.loc-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #BFBFBF;
margin-right: 14rpx;
}
.loc-text {
font-size: 26rpx;
font-weight: 600;
color: #999999;
}
.loc-btn {
padding: 10rpx 22rpx;
background: #2F54EB;
border-radius: 24rpx;
&:active {
opacity: 0.85;
}
}
.loc-btn-text {
font-size: 22rpx;
color: #FFFFFF;
}
/* 在轨迹上 */
&.on {
.loc-dot {
background: #52C41A;
}
.loc-text {
color: #52C41A;
}
}
/* 偏离轨迹 */
&.off {
.loc-dot {
background: #FA8C16;
}
.loc-text {
color: #FA8C16;
}
}
}
.stats-row {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 16rpx;
padding: 28rpx 16rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 24rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-value {
font-size: 34rpx;
font-weight: 700;
color: #2F54EB;
}
.stat-label {
font-size: 22rpx;
color: #999999;
margin-top: 6rpx;
}
.stat-divider {
width: 2rpx;
height: 56rpx;
background: #EEEEEE;
}
</style>
如果这篇对你有用,点赞 👍 + 收藏 ⭐ + 关注