把 500 米的偏移转回来,和一份管住所有导航卫星的 1967 年条约

国庆假后的第一个周六调休上班,中午给自己十分钟:一个坐标互转的小库、一个流式接口必踩的坑、一段比个人电脑还早的太空法律,外加一个跟今天日期有关的梗。

coordtransform:WGS84 / GCJ-02 / BD-09 之间,就 4 个函数

上次聊过为什么手机 GPS 拿到的坐标画到高德底图上会偏一截(GCJ-02 加偏)。知道为什么偏是一回事,手上那个点到底偏了多少、怎么转回来,是另一回事。

npm 上有个仓库名叫 coordtransform 的老库专门干这事,我装上跑了一遍。它一共导出 4 个函数,名字直白到没什么好记的:

js 复制代码
const c = require('coordtransform')

c.wgs84togcj02(lng, lat)  // GPS 原始坐标 -> 国测局(高德/腾讯用的火星坐标)
c.gcj02towgs84(lng, lat)  // 反过来
c.gcj02tobd09(lng, lat)   // 国测局 -> 百度
c.bd09togcj02(lng, lat)   // 百度 -> 国测局

拿杭州西湖边上随便一个点 120.155, 30.274 实测,WGS84 转 GCJ-02 得到 120.159695, 30.271671。换算成距离:横向偏了约 451 米,纵向约 -257 米,直线偏移 520 米。这就是"你把 GPS 原值直接丢给高德, marker 落在马路对面"的量级------一个操场的长度。

再看两个源码里的细节,比 API 文档有用:

  • 境外的坐标它原样返回 。函数开头有个 out_of_china 判断,落在国外就直接 return [lng, lat],不加偏也不报错。所以别指望它能处理全球数据。
  • 反算是近似,不是迭代 。gcj02towgs84 里最后一行是 [lng * 2 - mglng, lat * 2 - mglat],也就是正着算一遍再对称减回去,没做逼近。我把一个点转成 GCJ-02 再转回 WGS84,来回一圈差了 1.64 米。做围栏、做轨迹匹配的话,这个量级得留个余量。

包的状态也值得说一句:0 依赖,UMD 格式(<script> 直接引也行),主文件 index.js 5070 字节,CommonJS 入口------webpack 4 的老项目不需要额外配置。latest 是 2.1.2,发布时间 2017-02-13,之后九年没再动过。听起来吓人,但加偏算法本身是固定的数学公式,没更新反而意味着不会突然变。

AI 一角:流式返回会把你的 JSON 从中间劈开,中文尤其容易踩

先看一个对 Vue 2 项目不太友好的事实。AI SDK 官方是有 Vue 包的,我 npm view @ai-sdk/vue 查了下:

复制代码
version         = '4.0.137'
peerDependencies = { vue: '^3.3.4' }

要求 Vue 3.3.4 起。Vue 2 项目想接大模型的流式输出,这个包基本用不上,自己写解析反而省事------而自己写,就一定会踩下面这个坑。

我去找了个本地 AI 服务(比如 Ollama、或者你们后端代理的接口)问一句话,它返回的是 text/event-stream,内容是一段一段推过来的。关键在于:分块的边界跟 SSE 的帧边界完全不对齐 。服务端可能正好在 data: {"delta":"世 这里断掉,剩下的字节下一块才来。

我起了个本地服务,故意把一帧从中间劈成两次写,然后分别用两种写法消费同一个流。这是"拿到一块就按空行切"的写法的结果:

复制代码
naive    -> ["你好","<<parse失败:[data: {\"delt]>>","!","<<parse失败:[data: [DONE]]>>"]
buffered -> ["你好",",世界","!"]

看到问题了吗------",世界"这两个字彻底消失了 ,而且不报错。它被切在帧中间,JSON.parse 失败,或者干脆不以 data: 开头,就在 filter 那一步被静默丢掉了。线上表现是:模型明明说了这句话,页面上少了半句,日志干干净净。

正确写法是先攒 buffer,只在攒到 \n\n(帧分隔符)时才切一帧出来:

js 复制代码
async function streamText(url, body, onDelta) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buf = ''
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    buf += decoder.decode(value, { stream: true })  // 注意这个 stream: true
    let i
    while ((i = buf.indexOf('\n\n')) >= 0) {
      const frame = buf.slice(0, i)
      buf = buf.slice(i + 2)
      const data = frame
        .split('\n')
        .filter((l) => l.startsWith('data:'))
        .map((l) => l.slice(5).trim())
        .join('\n')
      if (!data || data === '[DONE]') continue
      onDelta(JSON.parse(data).delta)
    }
  }
}

里面那个 { stream: true } 不是可选的。同一个实验里,我把 UTF-8 字节切断在"世"字中间(它的三个字节是 e4 b8 96):

复制代码
不带 stream:true -> "data: {\"delta\":\"界\"}"   // U+FFFD 替换字符
带   stream:true -> "data: {\"delta\":\"世界\"}"

不带就是乱码替换字符。一个汉字三个字节,流式传中文时被切断的概率比传英文高得多,所以:解码器留状态,帧边界自己攒。

历史上的今天:1967 年 10 月 10 日,《外层空间条约》生效

联合国把每年 10 月 4 日到 10 日定为 World Space Week(世界空间周)。官网对这两个日期的说明是:10 月 4 日是 1957 年第一颗人造卫星 Sputnik 1 升空,10 月 10 日则是《各国探索和利用外层空间活动的原则条约》------通常叫 Outer Space Treaty------正式生效的日子。

它第二条的原文大意是:外层空间不得通过主权主张、使用或占领,或其他任何方法,被据为国家财产。(联合国太空事务办公室页面上的英文原句是 "outer space is not subject to national appropriation by claim of sovereignty, by means of use or occupation, or by any other..."。)

跟你手上的活有几层关系。你调高德 getGeolocation 或者用设备 GPS 拿到的经纬度,来自在轨的导航卫星;这些卫星属于哪个国家、能在轨道上干什么,法律框架就是这份 1967 年的条约。另外它生效的时间点很有意思:比第一台个人电脑、比 ARPANET 那次著名的通信都早------人类先约定了太空不能划地盘,然后才有了能发卫星的微型电子。

顺带一提,那份条约到今天仍然是外层空间活动的基础框架,后来的月球协议、各种"太空采矿法"都是在它上面打补丁。所以如果哪天产品要接北斗或者卫星互联网相关的能力,法规层面的根子是这颗 1967 年的种子。

轻松一刻

今天是 10 月 10 日,写成 10/10。

按二进制读,10 等于 2。所以严格来讲,今天不是"满分的一天",而是那句老梗的正式节日:世界上有 2 种人,一种是 10/10,另一种是 10/10------读法不同而已。

最后

今天这四样东西其实串在一条线上:偏了 500 米的坐标,流式里丢掉的两个字,1967 年那条不许划地盘的条约,还有个把 2 读成 10 的梗。它们共同说明了一件挺实在的事------工程里的坑大多不是"算法不会",而是"边界不对齐":坐标系之间差一截,分包和分帧错位,一个词被切成两半。看到 JSON.parse 静默失败、页面上少了半句话,先别怀疑模型,去看看你的 buffer。

明天中午见。