低空项目里总有人问:这片空域怎么存、怎么查、怎么跟隔壁系统对上号。多边形能画,一到相交、套娃、跨系统传,就开始扯皮。
我们后来在自己的三维地图里做了全球网格这一层。相机往下滚,格子跟着变细;点一下能看到北斗位置码。先看成品,免得读了三千字还不知道长什么样。
国标二维一共 10 级。下面这张是第 7 级,赤道附近一格大约 7.7 米,不是第 8 级。白线是网格,字是国标码,比如 N50H675...:



合肥附近 7 级全球网格

再拉远一点,格子还在,字会收掉------一屏上百个 N50H67xxxx 叠在一起谁也看不清:
7 级密网格,编号按视距隐藏
下面不讲组件 API 清单。主要想说两件事:格子到底在剖什么,以及 Cesium 里怎么画才不会把自己累死。
多边形为什么不够用
管制区用 GeoJSON 画一个面,存库、上图都顺。麻烦在后面。
无人机在不在区内,得做点面判断。两片空域叠不叠,得做多边形布尔,凹的、自交的都要防。按航线去扫全省空域,索引基本帮不上忙。把这串坐标丢给另一个系统,精度、点序、闭不闭合,每次都能吵一架。
根子就一句:经纬度擅长描述点,业务里几乎全是区域。区域需要能包含、能分级、能当地址传的东西。网格干的就是这件事。
一个格子一个码。子格的码以父格为前缀,所以:
N50J47539
N50J47539B8
点在不在区里,比前缀就行。SQL 也能 LIKE 'N50J47539%'。这比每次上几何库便宜得多。
低空还多一个高度。国标把高度也切层,层厚跟该级赤道上的格子边长对齐,高空不至于扁成一张纸。空域、航线、禁飞,都能落成「哪一格 + 哪一层」。
GeoSOT 和北斗网格位置码
国内落地绕不开这两个名字。
GeoSOT 是北大程承旗团队那套:二维(三维)空间映射成一维整数,剖分粒度走 2 的幂,父子是整倍数,不会除出一堆缝。
GB/T 39409-2020《北斗网格位置码》在这套模型上定了国家标准,2021 年 6 月实施。二维十级:
| 级别 | 格子大小(赤道附近) | 大概什么尺度 |
|---|---|---|
| 1 | 6° × 4° | 跟 1:100 万图幅对得上,北京那一块就是常说的 N50J |
| 2 | 30′ × 30′ | 市域,大概 56 km |
| 3 | 15′ × 10′ | 县,跟 1:5 万图幅接近 |
| 4 | 1′ × 1′ | 乡镇,约 1.8 km |
| 5 | 4″ × 4″ | 街区,约 124 m |
| 6 | 2″ × 2″ | 建筑,约 62 m |
| 7 | 1/4″ × 1/4″ | 房间,约 7.7 m |
| 8~10 | 1/32″ → 1/2048″ | 米、分米、厘米 |
码本身以 N / S 开头,后面按级往下拼。合肥在 1 级是 N50H,2 级大概 N50H67。我们组件上的 geoLevel 就是这套级数,不是另起的「业务层级」。早期文章里我写错过一次,这里改回来:可视化用 17,编码规则跟国标一致,810 太细,球面上没必要铺。
极区(±88° 以外)国标单独处理,第一级合成 N000 / S000。我们全球底图直接裁在 ±88,北极空一块是故意的。
剖分本身没那么玄。把地球当一张纸,经向、纬向切开,再切,就是四叉树:
!四叉树递归剖分
这点重要,因为它跟 Web 地图的瓦片金字塔是同构的:都是 (列, 行, 级),子块落在父块里。能换算,就不用自己从视锥开始重写一套。
三个会把人劝退的问题
7 级全球铺开是亿级格子。全画,浏览器先死。
所以只能画眼前这一层,还得跟缩放走:放大变细,缩小变粗。这就是 LOD。
自己算「当前视野有哪些格」也不划算。视锥和椭球求交、跨日界线、贴地、每帧剔除,Cesium 已经做了,而且有屏幕空间误差、瓦片池、加载优先级。我们再手写一版,大概率又慢又脆。
问题就变成:怎么把 Cesium 算完的瓦片结果接出来。
办法:挂一张不发请求的假底图
Cesium 加影像底图的路径是:
scss
ImageryLayer → ImageryProvider.requestImage(x, y, level) → 一张图
它只要你返回一张图,不追问图从哪来。那我们就给一张空白 canvas,顺便把 (x, y, level) 和瓦片范围拿走。
ts
requestImage(x: number, y: number, level: number): Promise<HTMLCanvasElement> {
const canvas = document.createElement("canvas");
if (level < 3) {
return Promise.resolve(canvas);
}
const bbox = this.getTileCornerCoordinates(x, y, level);
this._callback?.(x, y, level, bbox);
return Promise.resolve(canvas);
}
零次 HTTP。调度还是 Cesium 那套。
挂到场景上大概这样:
ts
layer = new GridBasemapLayer({
url: "",
extent: [-180, -90, 180, 90],
zIndex: 88,
callback: callBack,
} as GridMapSourceParams);
viewer.basemaps?.add(layer);
内部会变成一个 GradientImageryProvider。Cesium 以为在加载透明底图,其实在帮我们跑四叉树。
!假底图调度链路
requestImage 只在「有新瓦片要加载」时来。已经在场的瓦片不会再回调,所以相机停稳后我们还补一刀:去读当前正在画的地形瓦片。
ts
const tiles = (viewer.scene.globe as any)._surface._tilesToRender;
这是私有字段。现在能用,Cesium 升大版本可能直接没了。生产里判空、try/catch,升级列入回归。不要把它当正式 API。
瓦片级对不上网格级
手里有一堆 (x, y, level, bbox) 之后,不能拿瓦片级当网格级用。
瓦片每深一级,边长大致折半。北斗网格不是这样均分的:1 级 6°×4°,2 级 0.5°,3 级又变成 15′×10′,后面还有 1′、4″。所以我们做了一张表,让 Cesium 的瓦片级去对某一个国标级,中间用子瓦片或父瓦片把尺寸找齐。
ts
const TILE_LEVEL_MAP: Record<number, { geo: number; mode: "next" | "grid" | "pre" }> = {
5: { geo: 2, mode: "next" },
6: { geo: 2, mode: "grid" },
7: { geo: 2, mode: "pre" },
// 8~10 → 3 级,11~13 → 4 级......18~20 → 7 级
};
next 是拆成 4 个子瓦片再画,grid 按当前 bbox 画,pre 回到父瓦片再画。连续缩放时,屏幕上的格子不会突然密成一团,也不会突然稀掉。
!瓦片级与网格级映射

组件加的也不是「一格一个 Feature」。一个瓦片 bbox 进图层,引擎里再用 GridDivision 按 geoLevel 剖成真正的 GridCell。去重用 x-y-level 当 key,主路径和补洞路径可能打到同一块。
画什么、留什么、删什么
填充只在 7 级加。更粗的级只描边。低级格子在屏幕上很大,半透明一铺,底图就糊了。所以 color 注释写着「目前只在北斗 7 级展示」,不是没做完。
格子会一直加。不删会把内存吃满。我们现在两头清:
- 缩放过程中 200ms 节流,按当前 zoom 丢掉过期级别,不然新旧叠在一起会花屏
- 相机停稳后再补当前视野,再清一次
1 级是写死的全球底衬(大约 ±178 / ±88),清的时候留着,否则缩到很远会空一块。
点击不靠填充面拾取------1~6 级根本没有填充。点到边框用边框的级别,点空白就取当前最深的可见级,再用坐标反算格子。弹窗里是北斗码,下面带着级别和内部行列,方便对计算。
编号标签是另一路:格子中心一个点要素,白字黑描边。细级别会抽稀,并且用视距藏字。第 7 级一格大约 7.7 米,相机稍远字就比格子还宽,必须藏。第 8~10 级到米、厘米,球面上铺不开,我们可视化停在 7 级。
我们踩过的坑
编号别自己发明。 第一版标签是 L7-1684915-1725728,后来改成 7-4915-5728,别人看了还是一脸问号。行业里认的是 N50J 这种。格子尺寸已经按国标剖了,码却用内部行列,等于白剖。现在展示走 GB/T 39409 二维码。
视距按「正俯视」估会吃亏。 distanceDisplayCondition 用的是相机到点的直线距离。Story 默认高度 1000 米、俯仰 −45°,距离已经到 1.4 公里。L7 还按「格子米数 × 20」藏,字整级消失。斜视要把系数放开,或者按屏幕上格子够不够放下字来藏。
黑底小标签在卫星图上很脏。 半透明黑胶囊对比度是够了,也像调试 stamp。后来改成字加描边。引擎里浅色描边用 lighter 合成没问题,深色不行,纯黑会被吃掉,深色得走普通描边。
缩放过程只在 move-end 清,会花屏。 连续滚轮时新级别往上堆,旧的还在。深层级留在低 zoom 上,就是一片白网。过程中也要清。
西半球邻接。 航线 26 邻接里如果写 col > -1,西经列号是负的,邻居会被扔掉。全球底图不走这条,沿航线占格的那条会。
点拾取别 split("-")。 几何实例 id 是 JSON.stringify(gridCell)-guid。西半球坐标带负号,split 会截断,JSON.parse 直接炸。用最后一个 }- 定位。
怎么挂到图上
图层本身几乎只干三件事:听相机停稳、听缩放过程、听点击。真正剖格、加要素、清过期级别,都在 hook 里。它要往矢量图层上丢 Feature,所以得套在矢量图层里面,不能单独扔在场景外。
vue
<cesium-map @ready="onReady">
<vector-layer>
<globe-grid
:show-label="true"
@click-grid="onClickGrid" />
</vector-layer>
</cesium-map>
填充色只在 7 级生效,线框各级都画。编号和点击弹窗可以关掉。点到格子时,回调里带上 GridCell:范围、级别、行列、高度层都在。
同一套剖分还能喂给区域、航线、单点------输入从「当前视野」换成面、线、点,格子还是那些格子。
还想做的
国标三维码还没接完整,高度层现在是我们自己的 layerIdx。极区球冠也没画。颜色更新还在扫整层要素,格子多了会顿一下。_tilesToRender 那条备用路径,等哪次 Cesium 升级坏了再换正式口子。
这套东西值钱的不是「图上多了几条线」。是区域计算可以从几何运算收成编码比较,而可视化可以借引擎已经在算的瓦片树,不必再造一个四叉树。
假 Provider 这招别处也能用。下次未必还是 requestImage,换个合法的扩展点就行。
GB/T 39409-2020《北斗网格位置码》;GB/T 40087-2021《地球空间网格编码规则》;程承旗等,GeoSOT。文中代码按真实实现精简过,变量名改过。