国庆假后的第一个周六调休上班,中午给自己十分钟:一个坐标互转的小库、一个流式接口必踩的坑、一段比个人电脑还早的太空法律,外加一个跟今天日期有关的梗。
coordtransform:WGS84 / GCJ-02 / BD-09 之间,就 4 个函数
上次聊过为什么手机 GPS 拿到的坐标画到高德底图上会偏一截(GCJ-02 加偏)。知道为什么偏是一回事,手上那个点到底偏了多少、怎么转回来,是另一回事。
npm 上有个仓库名叫coordtransform的老库专门干这事,我装上跑了一遍。它一共导出 4 个函数,名字直白到没什么好记的:
constc=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.js5070 字节,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(帧分隔符)时才切一帧出来:
asyncfunctionstreamText(url,body,onDelta){constres=awaitfetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),})constreader=res.body.getReader()constdecoder=newTextDecoder()letbuf=''while(true){const{value,done}=awaitreader.read()if(done)breakbuf+=decoder.decode(value,{stream:true})// 注意这个 stream: trueletiwhile((i=buf.indexOf('\n\n'))>=0){constframe=buf.slice(0,i)buf=buf.slice(i+2)constdata=frame.split('\n').filter((l)=>l.startsWith('data:')).map((l)=>l.slice(5).trim()).join('\n')if(!data||data==='[DONE]')continueonDelta(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。
明天中午见。