构建自己的Running Page

  1. 导出Keep数据
  2. 合并Strava数据
  3. 通用Image的构建
  4. 自动化数据同步
  5. 站点发布
    1. Cloudflare Pages
    2. GitHub Pages
  6. 相关问题记录
    1. 香港的跑步记录没有统计
    2. Strava App location_country无效
  7. 新知识
    1. Polyline Encoding Format
    2. GCJ-02和WGS84
    3. OpenStreetMap
    4. Mapbox
    5. GPX

去年的时候,就想开始整理一下自己Keep的跑步数据,最好能够导出来,今年打算切到Strava跑步,因为其相对开放的平台,还提供了开发者中心,我还是希望能够掌控自己的数据,最近发现了yihong的开源项目:running_page用来构建自己的跑步主页,很不错,完全符合我的预期;running_page支持的主要功能包括:

  1. 支持主流的运动设备和App导出数据,包括GPX数据,最终统一存储到data.db的本地数据文件中,最终生成展示页面;
  2. 支持GitHub Actions 自动同步跑步数据;
  3. 通过Mapbox 进行地图展示;
  4. 支持 Vercel(推荐)和 GitHub Pages 自动部署;

通过running_page的README以及阅读和分析其代码,发现很符合我想要的要求:自己掌控所有跑步数据。因为该项目最终拉取的数据都会集中维护在data.db中,然后通过此数据文件进行页面的生成。

下面是running_page的README里关于该项目是如何进行工作的流程图:

基于running_page的功能,我打算将Keep数据迁移出来,然后使用Strava进行跑步,所有的数据都收敛到running_pagedata.db进行管理,以及借助running_page提供的功能进行页面的展示。后面如果切换到其他设备或者App,只要能够导出数据,都可以沿用此方案,数据始终是可控和安全的。

话不多说,先贴一下效果:

下面记录的是我迁移的相关过程,包括此过程中遇到的一些问题,以及学习到的相关知识,仅供参考:

导出Keep数据

running_page支持导出Keep的历史数据,由于环境的要求,我一般喜欢直接通过docker 来进行环境的构建,所幸此项目提供了Dockerfile,不过一开始发现build失败了,看来大家还是很少通过image来进行版本的交付。

提了一个fix Dockerfile: package manager from yarn->pnpm #761修复了工程构建方式切换导致的Dockerfile失效的问题。

如下进行Image的构建:

1
2
sudo docker build -t running_page . --build-arg app=Keep  --build-arg keep_phone_number="13247648888" --build-arg keep_password="123456" 
sudo docker run -p 8888:80 -dit running_page

此时running_page的页面已经可以通过8888端口进行访问了,然后我们看一下run_page/data.db已经生成了导出的数据,run_page/keep_sync.py在执行的时候,可以手动传入参数--with-gpx导出所有的GPX文件到GPX_OUT目录中。

我们分析一下run_page/data.db的跑步记录的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sqlite> .schema
CREATE TABLE activities (
run_id INTEGER NOT NULL,
name VARCHAR,
distance FLOAT,
moving_time DATETIME,
elapsed_time DATETIME,
type VARCHAR,
subtype VARCHAR,
start_date VARCHAR,
start_date_local VARCHAR,
location_country VARCHAR,
summary_polyline VARCHAR,
average_heartrate FLOAT,
average_speed FLOAT,
PRIMARY KEY (run_id)
);
sqlite> select * from activities limit 1;
9223370301558008307|Run from keep|7281.57285577042|1970-01-01 00:36:58.000000|1970-01-01 00:37:00.000000|Run|Run|2024-12-27 10:15:47|2024-12-27 18:15:47|{'latitude': 22.546147636420635, 'longitude': 113.94178621721184, 'country': '中国', 'nationCode': '156', 'province': '广东省', 'city': '深圳市', 'cityCode': '0755', 'district': '南山区', 'districtCode': '440305', 'startLatitude': 22.54643929813539, 'startLongitude': 113.94204416059658}|ceshCijlvTPXZPNDPPCX@RKXIhAFlAF~@Cd@B|@@f@?`AC^FJd@TJJ~@HNDz@HHD^Al@LP@RGNHh@DNGFS][H[Ee@D_@Gw@Ga@BUGe@AqAA_AC[@c@GKCy@@WGYc@WQHy@A]BUESBg@Cw@F_@HOPC|@Ej@?XIlAJ~@C`@BdAEZBXAn@@Vv@t@jALl@Nj@@XFfANr@AJM@WGo@Bw@CU@[G]?y@Gm@Ca@@MCcAC]?QG}@Do@?]QI]CS?[IeAJWCQ@UCU?o@GFD[BYLE`@@t@Gr@C`@CLBXBrAF`ACjACn@Dn@PPJ?PRx@Pn@FVDVAh@LhADd@DRCBUAoA@w@Ks@@WEmAEq@A}@EY@[MQBg@FUCUBOKc@UAk@GeA@]Ak@@QAQ@q@?i@JC^@JEZDd@AZ@b@Cv@DXARA\@V?TDXAjABtAPHJLLJj@Lv@H`AHTHfAJT@b@CHa@@eAAkAEW@UCY@QG]CkAC_A?s@Cy@Ak@CMOQYQWAq@?QBOEUAKEQFQCq@HY@m@FQNF\AN?`A@PEh@Ff@CVFfA@p@AZ@ZARDt@A\FRp@Xj@DXJhAL|@Jv@FR?^DJSGe@?mABc@IeA@KIo@CcA?]EQ@a@Ca@FcAI_@Ae@[Qq@KiA?e@?UBGLa@GY?}@G[PTh@M`A@XAPBVCd@JX@NChA@dAGhA?j@LZf@\dAJV@l@L`AHl@HjADHKEi@@qAEq@@YCQGqA?WGi@@q@G_ADm@@g@CS?[QS[KW@SLUE_@Jw@IeAAsAASJJ^BRAV?n@CXBj@BJ@XCVBl@AX@lAAl@@|@AXPJT\~@NXBt@Jh@HZ@f@Fb@BZD^C?UG[?kAEmA?]ESB_@Im@CkAGcAAW@oAAk@USUEUF[?WEcA?cA@ICa@Dc@KSBGHAB|148.0|3.2829453813212

summary_polyline字段的数据是一个编码后的数据,该字段编码了我们的跑步轨迹,即编码后的经纬度路线图。查看实现发现使用了polyline,这个库实现了Google提出的 Encoded Polyline Algorithm Format。后面再分析和学习相关概念;

合并Strava数据

Keep的数据导出后落地到了到running_pagedata.db进行管理,其实到running_page还提供了keep_to_strava_sync.py脚本,可以将Keep数据导入到Strava,对我来说没什么必要,前面我也阐述了,我想要达到的效果是:所有的数据都收敛到running_pagedata.db进行管理,以及借助running_page提供的功能进行页面的展示。随时可以切换到其他设备或者App,只要能够导出数据,都可以沿用此方案,数据始终是可控和安全的。

所以如何合并Strava的数据到data.db中呢,看了一下running_page的数据同步的逻辑,如下:

1
2
3
4
5
6
7
8
9
10
11
12
# https://github.com/yihong0618/running_page/blob/master/run_page/generator/__init__.py
...
if force:
filters = {"before": datetime.datetime.utcnow()}
else:
last_activity = self.session.query(func.max(Activity.start_date)).scalar()
if last_activity:
last_activity_date = arrow.get(last_activity)
last_activity_date = last_activity_date.shift(days=-7)
filters = {"after": last_activity_date.datetime}
else:
filters = {"before": datetime.datetime.utcnow()}

项目在每次数据同步的时候的逻辑是:

  1. 从本地数据文件data.db中,找出最新的一条运动记录;
  2. 如果存在,则迁移7天,尝试去同步7天前到目前最新的全部活动记录;
  3. 如果不存在,则尝试拉取所有的活动记录;

所以将上面Keep导出生成的data.db直接复用,直接通过strava_sync.py进行Strava数据的刷新,然后Strava的数据和Keep的数据都会统一落地到data.db中了。

去年年底为了试用Strava,同时开了Keep和Strava进行跑步,所以基于Keep的数据进行Strava数据同步的时候,发现data.db最终会有多条重复数据,有三种处理方式:

  1. 直接修改同步脚本,上面我们知道了脚本针对活动的拉取方式,可以修改拉取的时间跨度来屏蔽这个问题。
  2. 直接的删了Strava里面的重复跑步记录。
  3. 直接删除data.db中的数据。

当然将Strava和Keep的数据进行合并,也可以直接从数据文件data.db的层面直接进行合并,这个针对多个数据源的管理也不失为一种方式,如下:

1
2
3
sqlite> ATTACH DATABASE 'strava.data.db' as stravadb
sqlite> INSERT INTO activities SELECT * FROM stravadb.activities;
sqlite> DETACH DATABASE stravadb;

通用Image的构建

我还是希望能够有一个通用的docker image可以随时随地拉取使用的,为什么喜欢Image,是因为它是一个不可变的交付结果,什么时候有了这个Image,都不需要再进行环境的构建,只需要执行就可以进行数据的同步和页面的生成。

running_page本身提供了的Dockerfile,存在两个问题:

  1. 需要自己进行Image的构建,不可进行分发;
  2. 此Dockerfile需要将关键的帐号信息构建到Image的Layer中,安全性有问题,且在构建过程中进行相关数据的拉取,也导致了其不可进行分发;

基于项目的Dockerfile,改造了一个可以分发的构建版本,具体Dockerfile的实现放在了自己fork的repo内,主要修改包括:

  1. 删除多个build stage,将所有的环境依赖初始化集成到一个Image;
  2. 数据同步的逻辑以脚本的形式写入构建的Image的root目录的refresh.sh中,剔除构建过程中需传入敏感信心的设计;
  3. Image的ENTERYPOINT 显示启动nginx;

基于running_page构建的Image以及Push到Docker Hub上了,有需要可以使用,具体步骤如下:

1
2
3
4
5
6
7
$ docker pull walkerdu/running-page

# 根据需要使用的App类型,进行参数的传递
$ docker run -p 80:80 -dit --name running-page --env app=RUNNING_PAGE_APP_NAME --env client_id=RUNNING_PAGE_STRAVA_CLIENT_ID --env client_secret=RUNNING_PAGE_STRAVA_CLIENT_SECRET --env refresh_token=RUNNING_PAGE_STRAVA_REFRESH_TOKEN --env YOUR_NAME="walkerdu" walkerdu/running-page

# 执行数据的刷新和同步
$ docker exec -i running-page sh refresh.sh

然后就可以通过暴露的端口访问了。

自动化数据同步

前面说了,我想要的是所有的数据都收敛到running_pagedata.db进行管理,以及借助running_page提供的功能进行页面的展示。随时可以切换到其他设备或者App,只要能够导出数据,都可以沿用此方案,数据始终是可控和安全的。

所以目前我的所有运动数据都存放在walkerdu/exercise仓库中,然后借助上面构建的Image以及Github Actions,每天定时的进行数据的同步和导出,以及页面的导出和生成,所有最新的数据都会归档同步到本repo中,独立进行维护管理。

目前整个CI的配置可以参考walkerdu/exercise仓库的running-page-refresh.yml

站点发布

Cloudflare Pages

Cloudflare Pages 是一个用于构建和部署网站和全栈应用程序的平台,结合了 Cloudflare Workers 的功能,允许开发者使用 JavaScript 和其他现代 Web 技术来构建动态网站。

简单的说:就是它提供了一个简单的方式来托管静态网站和动态内容,支持自定义域名、HTTPS 和自动 SSL 证书。它与 Workers 集成,可以处理复杂的请求和动态生成内容。

Cloudflare Pages提供了两种方式创建站点:

  1. 通过连接GitHub/GitLab已有的repo,进行站点部署;
  2. 通过直接上传站点资源来进行静态的部署,此中方式每次都需要手动刷新,所以一般很少用;

下面是通过连接GitHub来进行站点的自动化部署的过程:

这里授权Cloudflare对GitHub Repo的访问权限后,可以选择此repo的对应分支,并根据项目自己的构建方式进行站点的构建生成,也可以不使用构建命令,直接指定构建输出的目录,如下:

然后就可以发布了,后续所有此Repo的提交事件都会触发此Pages的重新构建和发布,如下:

GitHub Pages

GitHub Pages 是一项静态站点托管服务,它提供以下两种方式进行站点的发布:

  1. 直接从 GitHub 上的仓库获取 HTML、CSS 和 JavaScript 文件,进行发布。
  2. 通过GitHub Actions来进行站点的构建,然后发布网站。

两种发布的方式主要针对的场景为:

  1. 如果你不需要对站点的生成过程进行任何控制,则建议直接使用特定分支进行站点发布。 可以指定要用作发布源的分支和文件夹。 源分支可以是Repo的任何分支,源文件夹可以是源分支上的存储库根目录 /,也可以是源分支上的 /docs 文件夹。 将更改推送到源分支时,源文件夹中的更改将发布到 GitHub Pages 站点。
  2. 如果想使用项目自己的站点生成过程,或者不想使用专用分支来保存已编译的静态文件,则建议编写 GitHub Actions 工作流来发布站点。 GitHub 为常见的发布应用场景提供工作流模板,以帮助编写工作流。

关于官方About publishing sources 对于上面场景1的阐述,其实发布的源分支可以是任何分支,但是进过测试发现,其实不能使用默认分支

针对上面的两种发布方式,其实本质上都是通过GitHub Actions在发生git事件的时候进行站点的发布,只不过第一种方式是GitHub Pages自动给你创建了一个GitHub Actions的Workflow,如下:

采用第二种Github Actions方式就是自己通过workflow来构建自己站点的生成和发布逻辑,如下:

Github Actions方式提供了两个模版,点击都会跳转到两个通用的workflow的yaml配置文件,只需要按照自己的修改,就会保存到repo的.github/workflows/目录下,然后当repo发生变化的时候自动触发和部署。

由于项目的配置,且我没有使用项目的Github Actions进行站点的构建和生成,所以通过GitHub Pages的方式发布且通过https://walkerdu.github.io/exercise/访问会导致站点空白,原因是主页依赖的路径不对,找对相关的资源,这个应该可以通过修改repo的名字,直接通过https://walkerdu.github.io就可以了,相对Cloudflare Pages直接提供不含Path的URL还是体验差了点,所以我还是直接用了Cloudflare来进行部署。

关于 GitHub Pages 站点的示例 可以更多的参考GitHub Pages Examples

相关问题记录

香港的跑步记录没有统计

Keep有在香港的跑半马记录,但是跑过的省市里面却并没有香港,为什么呢?

如下是data.db中的此条记录,

1
9223370496484699717|Run from keep|21776.688|1970-01-01 01:50:12.000000|1970-01-01 01:50:55.000000|Run|Run|2019-02-17 00:30:20|2019-02-17 08:30:20|{'latitude': 22.278450792100696, 'longitude': 114.19376980251737, 'country': '中国', 'nationCode': '156', 'province': '香港特別行政區', 'city': '香港特別行政區', 'cityCode': '1852', 'district': None, 'districtCode': None, 'startLatitude': 0.0, 'startLongitude': 0.0}

参考相关的Issue,发现彩蛋这里的统计是基于location_country字段的字符串匹配来实现的,这个真是有点不可控,直接基于跑步的轨迹来进行统计应该更加准确。可以看到下面的匹配都是基于简体,而上面Keep中香港的记录是繁体字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// https://github.com/yihong0618/running_page/blob/master/src/utils/utils.ts
const extractCities = (str: string): string[] => {
...
const pattern = /([\u4e00-\u9fa5]{2,}(市|自治州|特别行政区|盟|地区))/g;

// https://github.com/yihong0618/running_page/blob/master/src/utils/const.ts
const MUNICIPALITY_CITIES_ARR = [
...
'香港特别行政区',

// https://github.com/yihong0618/running_page/blob/master/src/static/city.ts
export const chinaCities: ICity[] = [
...
[
name: '香港特别行政区',

解决方式,修改了一下导出的脚本,针对location_country字段进行繁转简的操作,然后重新执行数据的导出落地,如下:

1
2
3
4
5
6
7
8
9
10
11
# https://github.com/yihong0618/running_page/blob/master/run_page/generator/db.py
# pip install opencc-python-reimplemented
import opencc

def update_or_create_activity(session, run_activity):
...
# 繁体转换为简体
converter = opencc.OpenCC('t2s')
location_country = converter.convert(location_country)
activity = Activity(
...

这里由于繁体存在,可以在数据导出的流程里面统一做一个上面的繁转简的操作,以避免上面的字符串匹配带来的问题。

Strava App location_country无效

去年由于所谓的安全问题,Strava在国区的App Store下架,我还是从历史记录中下的,即使我修改了我个人资料的位置,所有拉取到的跑步记录的location_country全部为HongKong,但是我是在国内,所以最终生成的省市统计就会有误差,

如下:

1
9223370496484699717|Run from keep|21776.688|1970-01-01 01:50:12.000000|1970-01-01 01:50:55.000000|Run|Run|2019-02-17 00:30:20|2019-02-17 08:30:20|Hong Kong|...

这里只能加一个的过滤,如果location_country全部为HongKong,则使用跑步的轨迹的经纬度通过Nominatim库来进行地区的转换,如下:

1
2
3
4
# https://github.com/yihong0618/running_page/blob/master/run_page/generator/db.py
def update_or_create_activity(session, run_activity):
...
if not location_country and start_point or location_country == "China" or location_country == "Hong Kong":

这里看了相关实现,知道相关城市的统计都是基于对location_country字段的字符串匹配进行提取的,具体如下相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// https://github.com/yihong0618/running_page/blob/master/src/utils/utils.ts
const extractCities = (str: string): string[] => {
...
const pattern = /([\u4e00-\u9fa5]{2,}(市|自治州|特别行政区|盟|地区))/g;
...

const extractDistricts = (str: string): string[] => {
...
const pattern = /([\u4e00-\u9fa5]{2,}(区|县))/g;
...

const extractCoordinate = (str: string): [number, number] | null => {
...
const pattern = /'latitude': ([-]?\d+\.\d+).*?'longitude': ([-]?\d+\.\d+)/;
...

const locationForRun = (
...
const provinceMatch = location.match(/[\u4e00-\u9fa5]{2,}(省|自治区)/);

这里就依赖db中location_country自动的有效,目前我的Strava个人资料中如何设置,用Apple Watch跑步后,拉取到的数据依然是Hong Kong,所有我觉得应该直接用跑步路线的Polyline中直接获取经纬度,然后计算出对应位置就可以了。

新知识

Polyline Encoding Format

上面在导出的Keep数据中,我们可以看到落地到data.db的文件中summary_polyline字段是一个编码后的数据:

1
2
select * from activities limit 1;
9223370301558008307|Run from keep|7281.57285577042|1970-01-01 00:36:58.000000|1970-01-01 00:37:00.000000|Run|Run|2024-12-27 10:15:47|2024-12-27 18:15:47|{'latitude': 22.546147636420635, 'longitude': 113.94178621721184, 'country': '中国', 'nationCode': '156', 'province': '广东省', 'city': '深圳市', 'cityCode': '0755', 'district': '南山区', 'districtCode': '440305', 'startLatitude': 22.54643929813539, 'startLongitude': 113.94204416059658}|ceshCijlvTPXZPNDPPCX@RKXIhAFlAF~@Cd@B|@@f@?`AC^FJd@TJJ~@HNDz@HHD^Al@LP@RGNHh@DNGFS][H[Ee@D_@Gw@Ga@BUGe@AqAA_AC[@c@GKCy@@WGYc@WQHy@A]BUESBg@Cw@F_@HOPC|@Ej@?XIlAJ~@C`@BdAEZBXAn@@Vv@t@jALl@Nj@@XFfANr@AJM@WGo@Bw@CU@[G]?y@Gm@Ca@@MCcAC]?QG}@Do@?]QI]CS?[IeAJWCQ@UCU?o@GFD[BYLE`@@t@Gr@C`@CLBXBrAF`ACjACn@Dn@PPJ?PRx@Pn@FVDVAh@LhADd@DRCBUAoA@w@Ks@@WEmAEq@A}@EY@[MQBg@FUCUBOKc@UAk@GeA@]Ak@@QAQ@q@?i@JC^@JEZDd@AZ@b@Cv@DXARA\@V?TDXAjABtAPHJLLJj@Lv@H`AHTHfAJT@b@CHa@@eAAkAEW@UCY@QG]CkAC_A?s@Cy@Ak@CMOQYQWAq@?QBOEUAKEQFQCq@HY@m@FQNF\AN?`A@PEh@Ff@CVFfA@p@AZ@ZARDt@A\FRp@Xj@DXJhAL|@Jv@FR?^DJSGe@?mABc@IeA@KIo@CcA?]EQ@a@Ca@FcAI_@Ae@[Qq@KiA?e@?UBGLa@GY?}@G[PTh@M`A@XAPBVCd@JX@NChA@dAGhA?j@LZf@\dAJV@l@L`AHl@HjADHKEi@@qAEq@@YCQGqA?WGi@@q@G_ADm@@g@CS?[QS[KW@SLUE_@Jw@IeAAsAASJJ^BRAV?n@CXBj@BJ@XCVBl@AX@lAAl@@|@AXPJT\~@NXBt@Jh@HZ@f@Fb@BZD^C?UG[?kAEmA?]ESB_@Im@CkAGcAAW@oAAk@USUEUF[?WEcA?cA@ICa@Dc@KSBGHAB|148.0|3.2829453813212

该字段编码了我们的跑步轨迹,即编码后的经纬度路线图。这个就是采用了Google提出的 Encoded Polyline Algorithm Format

Encoded Polyline Algorithm Format(折线编码算法,常称为Polyline)是一种用来压缩和编码地理路径(如地图上的路线)的数据格式。其目的是将大量的地理坐标点压缩成一条较短的ASCII码字符串,以便于在网络传输中节省带宽和存储空间

这种格式最初是由 Google 为其地图服务开发的,现在已经成为地图应用中的一种通用标准。简而言之,Polyline通过将经纬度数据转换为一串字符,从而可以更高效地传输和存储。

Polyline的压缩原理还是比较巧妙的,基本原理包括:

  1. 使用相对坐标(与前一个点的差值)而不是绝对坐标
  2. 将坐标放大100000倍转为整数以保持精度
  3. 使用变长编码方式存储数值

主要优点:

  1. 高效的压缩率,通常可以减少超过50%的数据量
  2. 编解码速度快
  3. 可以保持足够的精度(约5位小数)
  4. 编码结果是可打印的ASCII字符

如下是通过Google提供的Polyline编码工具对编码后的路线数据解码后的显示页面,可以更直观的感受地图轨迹数据是如何存储和保存的。

Google提供的另一个Polyline解码工具,可以直接显示路线轨迹,如下:

Google提供的自家Polyline Encoding 算法的在线工具还是很好用的,可以编解码以及轨迹展示。

话说Google自家的Polyline的差值+可变长编码压缩率就是高。

GCJ-02和WGS84

上面的Polyline在Google提供的编解码调试工具中,可以发现明显有轨迹偏移,然后通过Claude又学到了GCJ-02和WGS84的差异。

  • WGS:世界大地测量系统(World Geodetic System)

一种用于地图学、大地测量学和导航(包括全球定位系统)的大地测量系统标准。WGS的最新版本为WGS 84(也称作WGS 1984、EPSG:4326),1984年定义、最后修订于2004年。之前的版本有WGS 72、WGS 66、WGS 60。全球定位系统使用的就是WGS 84参考系。WGS 84定义了以下几个关键要素:

  1. 一个地心地固坐标系统(Earth-centered, Earth-fixed coordinate system,ECEF),简称地心坐标系,一个三维坐标系统,原点位于地球质心,用于精确定位地球表面上的任何位置。
  2. 一个大地基准面(geodetic datum),简称大地坐标系,用于定义地球的形状和大小的数学模型,我们常使用的经纬度坐标数据就采用此坐标系。这里有一个详细介绍大地坐标系的文章。
  3. 相关的地球重力模型(Earth Gravitational Model,EGM)。
  4. 世界磁场模型(World Magnetic Model,WMM)。
  • GCJ-02: 官方称地形图非线性保密处理算法,俗称火星坐标系、国测局坐标

GCJ-02是一种基于WGS-84制定的大地测量系统,由中国大陆国家测绘地理信息局制定,国家科学技术进步奖一等奖得主李成名开发。此坐标系所采用的混淆算法,会在经纬度中加入看似随机的偏移。

使用GCJ-02记录下的地点在GCJ-02的地图中会显示在正确位置,然而换成WGS-84的地图或地点记录就可能造成100~700米不等的偏移。据测量,Google.com的地图与真实坐标相差约50~500米,而中国大陆区的Google.cn地图则与卫星图无偏差,雅虎地图显示的街道图也与卫星图偏差不大。MapQuest地图与众包测绘、不受限制的OpenStreetMap重合。

虽然GCJ-02坐标系统本身保密,但是目前已有C#、C、Go、Java、JavaScript、PHP、Python、R、Ruby[23][24]等多种语言的开源转换实现。这些实现似乎都基于某份泄露出的WGS到GCJ加偏代码实现。根据泄露代码注释,GCJ-02在加偏时使用的是SK-42参考系统的椭球体参数。这些参数用于计算一根经/纬线上一度的弧长,由此将之前算出的偏移从米数转换为度数,与输入值相加。

我们真的是啥都要保密,说GCJ加密后是非线性的偏移,但明显感觉是线性的。

OpenStreetMap

OpenStreetMap(OSM,开放街道地图),是一个由英国人史蒂夫·克斯特(Steve Coast)创立,类似维基百科的自由编辑的世界地图协作计划,目标是创造一个内容自由且能让所有人编辑的世界地图,并且让一般的移动设备有方便的导航方案。

Nominatim 是一个开源的地理编码(Geocoding)工具,由社区开发和OpenStreetMap Foundation资助,主要用于处理地址和位置数据。它是 OpenStreetMap 数据的搜索引擎,提供以下主要功能:

  • 正向地理编码(Geocoding):将地址转换成坐标;
  • 反向地理编码(Reverse Geocoding):将坐标转换成地址;

如下是Nominatim的Reverse功能测试:

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
$ curl -H 'Accept-Language:zh-CN' 'https://nominatim.openstreetmap.org/reverse?format=json&lat=22.5493049&lon=113.941975&zoom=18&addressdetails=1'| jq .
{
"place_id": 219967251,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "way",
"osm_id": 833749485,
"lat": "22.549931575737077",
"lon": "113.94200063197923",
"class": "highway",
"type": "secondary",
"place_rank": 26,
"importance": 0.05340363241016247,
"addresstype": "road",
"name": "科华路",
"display_name": "科华路, 科技园社区, 粤海街道, 南山区, 深圳市, 广东省, 518000, 中国",
"address": {
"road": "科华路",
"neighbourhood": "科技园社区",
"suburb": "粤海街道",
"city": "南山区",
"state": "广东省",
"ISO3166-2-lvl4": "CN-GD",
"postcode": "518000",
"country": "中国",
"country_code": "cn"
},
"boundingbox": [
"22.5485040",
"22.5499343",
"113.9407730",
"113.9448320"
]
}

其实running_page的国家和省市的彩蛋可以基于此功能全部通过轨迹生成对应的location_country,然后进行统计,这样兼容性会比较好。

Mapbox

Mapbox 是一家专注于位置数据和地图服务的科技公司。于2010年6月01日在美国成立。是一个很棒的地图制作及分享网站,用户可以使用Mapbox Studio创建一个自定义、交互式的地图,然后可以将这些自定义的地图和数据服务你自己的网站(Web)或移动应用程序(Mobile Web/Android/IOS)上。running_page的跑步轨迹的展示服务就是基于Mapbox的;

Mapbox主要提供的服务内容:

  • 矢量地图服务
  • 导航服务
  • 地理编码和反地理编码
  • 实时位置追踪
  • 地理数据可视化
  • 3D 地图渲染
  • 离线地图支持

相比较OpenStreetMap是一个开放的地图数据源,Mapbox是一家基于这些地图数据构建的商业服务,根据Mapbox 官方的介绍,Mapbox本身的数据源主要包括如下:可见OpenStreetMap的重要性。

根据WIKI说明:Mapbox从其用户所使用的客户端(包含了Strava及RunKeeper等)截取GPS轨迹,来自动识别在OpenStreetMap中丢失的数据,然后手动应用修复或是向OSM贡献者报告问题。

GPX

GPXGPS eXchange Format,GPS交换格式)是一个为了在应用程序之间交互GPS数据(包括:waypoints, routes, tracks)而设计的轻量级XML数据格式。

它可以用来描述路点(waypoint)、轨迹(track)、路程(route)。它的标签可以保存位置,海拔和时间,可以用来在不同的GPS设备和软件之间交换数据。如查看轨迹、在照片的exif数据中嵌入地理数据。

目前最广泛使用的是 GPX 1.1 版本,这个规范由 TopoGrafix 公司维护。具体格式可参考GPX 1.1 Schema Documentation

主要元素结构内容如下:

1
2
3
4
5
6
7
8
9
<gpx
version="1.1 [1] ?"
creator="xsd:string [1] ?">
<metadata> metadataType </metadata> [0..1] ?
<wpt> wptType </wpt> [0..*] ?
<rte> rteType </rte> [0..*] ?
<trk> trkType </trk> [0..*] ?
<extensions> extensionsType </extensions> [0..1] ?
</gpx>

一个GPX文件主要包含三个组成部分:

  • Waypoint,路点(wptType):由一系列的WGS84坐标点及描述信息组成。路点可能是各自独立互不相干的重要标记点, 例如照相的地点或用户手动标记的休息站或路口等等;
  • Route,路线(rteType):由一个有序路点列表组成,经常是建议未来用路人可以走的路径;
  • Track,轨迹(trkType):由一系列的路点组成,包括时间信息,就是常用的GPS设备自动定时记录的则是轨迹点;

GPX文件内的点,至少要包含经纬度座标两项信息;其它字段都是可有可无的。如下是WIKI内的一个关于GPX中上面三个重要概念的示意图:

gpx.studio可以支持GPX文件的展示,编辑,导出功能。如下是我把2024苏州100城市越野赛轨迹的GPX文件导入到gpx.studio的示意动画

由此可见GPX 相对 Polyline 提供了更多的元数据,可以支持很多维度的数据展示,应用场景更为广泛,例如运动追踪(除了轨迹外的时间、海拔、心率等)、详细路线记录(提供赛事路线,CP点的信息)。

但是Polyline体积小,编码效率高,用来自己记录跑步路线数据也是足够了。