← Back to Blog
EN中文

从手表到网页:为静态博客打造运动数据可视化系统

作为一个热爱运动的程序员,我一直想在自己的博客上展示运动记录。市面上的运动 App 虽然功能强大,但数据被锁在各自的围墙花园里,缺少个性化和归属感。我的个人博客是一个纯静态网站,能不能在这里开辟一个「运动」板块,把 Amazfit T-Rex 3 手表导出的数据变成可交互的可视化页面?

经过一番折腾,我成功搭建了一套完整的运动数据可视化系统——Apple Fitness 风格的健身圆环追踪每周目标、Leaflet.js 渲染 GPS 轨迹地图、月度日历展示运动分布。最重要的是,这一切构建在纯静态架构之上,无需后端服务器。

整体架构:静态优先,按需加载

核心思路很简单:

  1. 数据源:将 Zepp App 导出的 .fit.gpx.tcx 文件存放在 _content/fit/ 目录下
  2. 构建时处理:Node.js 脚本在 build 阶段解析所有运动文件,生成结构化 JSON
  3. 两层 JSON 架构——这是性能优化的关键:
    • sports.json:所有活动的元数据(运动类型、时长、距离、卡路里、心率等)+ 每周/每月统计 + 目标配置。首页和运动页面加载这一个文件即可
    • tracks/{id}.json:每个活动的 GPS 轨迹点。仅在用户点击查看地图时按需加载
  4. 前端渲染:读取 JSON 数据,渲染圆环、日历、活动列表和地图

这种设计让初始加载量降到最低——sports.json 通常只有几十 KB,而体积较大的 GPS 数据被拆分到独立文件中实现懒加载。

数据管道:从 FIT 文件到结构化 JSON

构建脚本 build-sports-json.js 是整个系统的核心。

解析 FIT 文件

FIT 是一种紧凑的二进制格式,记录了非常详尽的运动数据。我使用了 fit-file-parser 这个 npm 包来解析:

const FitParser = require('fit-file-parser').default;
const fitParser = new FitParser({
    force: true,
    speedUnit: 'km/h',
    lengthUnit: 'km',
    elapsedRecordField: true,
});

function parseFitFile(filePath) {
    const buffer = fs.readFileSync(filePath);
    return new Promise((resolve, reject) => {
        fitParser.parse(buffer, (error, data) => {
            if (error) return reject(error);

            const session = data.activity.sessions[0];
            const activity = {
                sport: session.sport,
                sub_sport: session.sub_sport,
                distance: session.total_distance,
                duration: session.total_elapsed_time,
                calories: session.total_calories,
                avgHeartRate: session.avg_heart_rate,
                // ...
            };

            // GPS 轨迹在 records 数组中
            const records = data.activity.records;
            resolve({ activity, records });
        });
    });
}

对于 .gpx.tcx 文件,使用 Node.js 内置的字符串解析处理 XML。脚本支持三种格式并自动去重——同一活动存在多种格式时,按 .fit > .gpx > .tcx 的优先级选择。

运动类型智能识别

FIT 文件中的 sportsub_sport 字段提供了精确的运动分类。例如「室内划船」记录为 sport: 'fitness_equipment', sub_sport: 'indoor_rowing'。我写了一个两级映射逻辑:

const SPORT_MAP = {
    running: 'running', cycling: 'cycling',
    swimming: 'swimming', walking: 'walking',
    hiking: 'hiking', tennis: 'tennis',
    fitness_equipment: 'training', training: 'training',
};

const SUB_SPORT_MAP = {
    indoor_rowing: 'rowing',
    stair_climbing: 'stair_climbing',
    strength_training: 'strength',
};

// 优先使用 sub_sport 的精细映射
const baseSport = SPORT_MAP[session.sport] || 'training';
const sport = SUB_SPORT_MAP[session.sub_sport] || baseSport;

目前支持跑步、骑行、游泳、网球、划船、登山机、力量训练、瑜伽、篮球、足球、羽毛球等 15+ 种运动类型,每种都有对应的颜色和图标。

GPS 轨迹降采样

一场一小时的跑步可能产生数千个 GPS 点。全部加载到前端既浪费带宽又导致地图渲染卡顿。我的策略是保留约每 5 秒一个点,并使用紧凑的数组格式存储:

function downsampleTrack(records) {
    const points = [];
    let lastTs = 0;

    for (const r of records) {
        if (!r.position_lat || !r.position_long) continue;
        const ts = new Date(r.timestamp).getTime() / 1000;
        if (ts - lastTs >= 5) {
            // 紧凑数组格式:[纬度, 经度, 海拔, 心率, 时间戳]
            points.push([
                r.position_lat,
                r.position_long,
                r.altitude || 0,
                r.heart_rate || 0,
                ts,
            ]);
            lastTs = ts;
        }
    }
    return points;
}

用数组 [lat, lon, ele, hr, ts] 代替对象 {lat, lon, ele, hr, ts},文件体积能减少约 40%。降采样后每个活动的轨迹文件通常在 3-8 KB 之间。

数据清理:孤儿文件处理

每次重新构建时,脚本会自动清理不再对应任何活动的"孤儿"轨迹文件。这确保了当我删除或重命名原始 FIT 文件时,旧数据不会残留:

// 构建完成后,清理孤儿文件
const existingFiles = fs.readdirSync(tracksDir).filter(f => f.endsWith('.json'));
const validIds = new Set(Object.keys(newTracks));
for (const f of existingFiles) {
    const id = f.replace('.json', '');
    if (!validIds.has(id)) {
        fs.unlinkSync(path.join(tracksDir, f));
        console.log(`Removed orphan: ${f}`);
    }
}

前端实现

Apple Fitness 风格健身圆环

页面的视觉核心是三色进度圆环,分别代表每周的距离(红)、时长(绿)、卡路里(橙)目标完成度。实现原理是 SVG stroke-dasharraystroke-dashoffset 属性:

const circumference = 2 * Math.PI * radius;
const percentage = Math.min(currentValue / goalValue, 1);
const offset = circumference * (1 - percentage);

// 背景环(低透明度)
`<circle r="${radius}" stroke="${color}" stroke-width="${sw}" opacity="0.15"/>`
// 进度环
`<circle r="${radius}" stroke="${color}" stroke-width="${sw}"
    stroke-dasharray="${circumference}"
    stroke-dashoffset="${offset}"
    transform="rotate(-90 50 50)"/>`

目标值可在 sports.json 中配置(默认:每周 10km / 3 小时 / 1000 kcal),构建时自动计算当前周的完成百分比。配合 CSS transition 就能实现圆环"生长"的动画效果。

交互式 GPS 地图

地图使用 Leaflet.js + OpenStreetMap 瓦片,采用双重懒加载策略:

  • 库懒加载:Leaflet 的 JS/CSS 仅在用户第一次点击地图按钮时从 CDN 动态加载
  • 数据懒加载:点击后 fetch 对应的 tracks/{id}.json,拿到数据后绘制轨迹线和起终点标记
async function showMap(activityId, bounds) {
    // 动态加载 Leaflet(如果尚未加载)
    await ensureLeafletLoaded();

    const resp = await fetch(`/sports/tracks/${activityId}.json`);
    const points = await resp.json();

    const latLngs = points.map(p => [p[0], p[1]]);
    const polyline = L.polyline(latLngs, {
        color: '#C41E3A', weight: 3, opacity: 0.8
    }).addTo(map);

    map.fitBounds(polyline.getBounds(), { padding: [30, 30] });
}

轨迹使用站点主色朱红色(#C41E3A)绘制,起点绿色圆点、终点红色圆点。地图实例全局复用,避免重复初始化。

月度运动日历

首页和运动页面都有月度日历视图。每天的格子里显示日期数字,如果当天有运动,则在数字下方显示对应运动类型的彩色小圆圈图标。同一天有多次不同运动时,会显示多个小圆圈——跑步红色、网球金色、划船青色,一目了然。

首页组件

首页的运动模块是一个两栏卡片布局:左侧是迷你圆环 + 当周详细统计(距离、时长、卡路里及其目标完成度)+ 累计总数据;右侧是当月运动日历。信息密度高但不拥挤。

挑战与收获

  • 数据不一致性:不同设备导出的 FIT 文件字段有时会缺失。脚本需要大量防御性编程,比如处理没有心率或 GPS 数据的室内运动
  • 运动类型识别sport 字段太粗粒度(如 fitness_equipment 囊括了划船、登山机、椭圆机等),需要结合 sub_sport 字段二次映射才能得到精确的运动类型
  • 性能平衡:在"展示更多数据"和"保持页面轻快"之间需要不断权衡。两层 JSON、GPS 降采样、Leaflet 懒加载都是为此而做的设计决策

总结

把运动数据集成到博客里,是一次把数据处理、SVG 动画、地图可视化和性能优化串联起来的有趣实践。整个系统完全可扩展——未来可以加入年度统计、个人最佳记录、更丰富的数据分析图表。如果你也想把智能手表的数据玩出花样,希望这篇分享能给你一些启发。