轨迹在地图上“漂“了 500 米?一文吃透 WGS-84→GCJ-02 坐标转换引擎(GPX→JSON 实战源码剖析)

一句话结论: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 需要重采。


六、总结与干货清单

本文核心收获:

  1. 坐标系差异是地图偏移的根源:WGS-84(GPS)→ GCJ-02(国内地图)必须转换,否则漂移数百米。
  2. GCJ-02 是参考点 (105,35) 的非线性三角扰动,可用纯数学实现,无需联网。
  3. 里程用 Haversine 球面公式,别在平面上直接加减经纬度。
  4. 累计爬升要加"死区"滤波,否则 GPS 高程噪声会被误算成爬升。
  5. 文件输出必须"去重 + 不覆盖",这是生产脚本的基本素养。
  6. 自定义 JSON 序列化能让大数组既省体积又可读。
  7. 批处理脚本要 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>

如果这篇对你有用,点赞 👍 + 收藏 ⭐ + 关注

相关推荐
Flynt4 小时前
"只输出 JSON"根本不够:LLM 结构化输出的坑,我实测了 60 次调用
llm·json
lhldsg6 小时前
全民健身解决方案软件开发实战:从架构设计到部署指南
java·前端·数据库·小程序
2501_9160074711 小时前
使用Apple Dashboards显示和自定iOS应用性能指标与数据可视化指南
android·ios·小程序·https·uni-app·iphone·webview
她说..12 小时前
MySQL 与 Java 的 JSON 数据处理
java·mysql·json·springboot
软件技术新观察12 小时前
企业数字化开发科普:软件定制、小程序、APP 开发全解析
人工智能·小程序
凡泰AI12 小时前
小程序容器技术解析:一个能够同时在多端APP运行同一个小程序的SDK需要关注哪些内容?
开发语言·小程序·mpaas·uni·技术实践
爱折腾的编程老炮13 小时前
潮玩小程序订单模型-一张表装下所有玩法
spring boot·小程序·uni-app
万岳科技程序员小金13 小时前
同城O2O外卖系统源码开发详解:从APP、小程序到后台管理平台的完整架构解析
小程序·架构·同城外卖系统源码·外卖app开发·外卖小程序开发·外卖软件开发·外卖平台搭建
博、、13 小时前
全民健身解决方案小程序系统开发实战:从架构设计到上线指南
小程序