教程:绘制 TileKey 线框与 cursor 坐标探针
本教程在空地图运行时中加入调试网格。完成后,你可以拖动、缩放、旋转和倾斜地图,并观察:
z/x/y@wrap标签与线框一起移动;- canonical world 与重复世界使用不同颜色;
- cursor 同时显示 screen、render-local、Mercator 与经纬度;
- 同一个内容 ID 可以出现在不同 wrap;
- viewport 每次变化都只消费同一代 TransformSnapshot。
本节只绘制本地诊断对象,不连接任何网络数据源。
1. 本节文件
text
yinqing/examples/vector-tile-handbook/shared/
├── tiles/
│ └── tile-key.js
└── render/
└── tile-grid-layer.js
2. 创建严格 TileKey
TileKey 构造器不接受模糊的 z/x/y 别名。调用者必须指出哪个 z 是显示级、哪个 z 是内容级:
js
const key = createTileKey({
displayZ: 13,
canonicalZ: 13,
canonicalX: 6745,
canonicalY: 3342,
wrap: 0,
});
构造器依次验证:
- zoom 是
[0,30]内的安全整数; displayZ >= canonicalZ;- canonical x/y 位于该层索引范围;
- wrap 是可正可负的安全整数;
- 返回对象被冻结。
上限 30 是通用 TileKey 构造器对 JavaScript 安全整数和现实数据层级的明确护栏。Stage 02 使用独立的离线剖面,把实际调试层级限制在 z0--z15;构造器边界不等于某个实验当前采用的空间范围。
3. 派生两种字符串 ID
js
getTileId(key);
// "13/6745/3342"
getTileSpatialId(key);
// "13/13/6745/3342@0"
不要把人类可读标签当成全部领域模型。字符串适合 Map key、日志和 inspector;算法仍读取结构化整数,避免每次解析字符串。
4. 由中心生成 7×7 调试邻域
Stage 02 使用以下帮助函数。它故意只返回固定邻域:
js
function createDebugNeighborhood(viewState, radius = 3) {
const displayZ = clamp(
Math.floor(viewState.zoom),
DEBUG_TILE_PROFILE.minZoom,
DEBUG_TILE_PROFILE.maxZoom,
);
const center = lngLatToTileAddress(
viewState.center[0],
viewState.center[1],
displayZ,
);
const size = 2 ** displayZ;
const centerUnwrappedX = center.x + center.wrap * size;
const keys = [];
for (let dy = -radius; dy <= radius; dy += 1) {
const y = center.y + dy;
if (y < 0 || y >= size) continue;
for (let dx = -radius; dx <= radius; dx += 1) {
const address = canonicalizeTileAddress(
displayZ,
centerUnwrappedX + dx,
y,
);
keys.push(createTileKey({
displayZ,
canonicalZ: address.z,
canonicalX: address.x,
canonicalY: address.y,
wrap: address.wrap,
}));
}
}
return keys;
}
这里的关键是 centerUnwrappedX + dx。若先在 canonical x 上 clamp,邻域到反经线就会停止;若直接用 JavaScript %,向西世界会出现负 x。
5. 创建并加入场景
js
const gridLayer = new TileGridLayer({ showLabels: true });
runtime.scene.add(gridLayer);
TileGridLayer 继承 THREE.Group,因此它是普通 scene 节点。它只增加 setKeys(keys, transformSnapshot) 与幂等 dispose()。
6. 把 key bounds 转成四个顶点
对每个 key,先恢复 canonical Mercator 边界,再减 snapshot origin:
js
const west = (bounds.west - snapshot.origin.x) * snapshot.worldSize;
const north = (bounds.north - snapshot.origin.y) * snapshot.worldSize;
const east = (bounds.east - snapshot.origin.x) * snapshot.worldSize;
const south = (bounds.south - snapshot.origin.y) * snapshot.worldSize;
按固定顺序创建 line:
js
const outline = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(west, 0.5, north),
new THREE.Vector3(east, 0.5, north),
new THREE.Vector3(east, 0.5, south),
new THREE.Vector3(west, 0.5, south),
]),
key.wrap === 0 ? canonicalMaterial : wrappedMaterial,
);
把 tileKey 与 tileSpatialId 写入 outline.userData,浏览器开发工具就能从 scene 对象追溯身份,但算法不依赖 userData 反推状态。
7. 创建 world-space label
每个标签使用 384×112 canvas:
js
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
});
const sprite = new THREE.Sprite(material);
sprite.position.set((west + east) / 2, 7, (north + south) / 2);
宽度由当前 tile 的 world-unit 宽度限制,最大不超过 360 world units。depthTest: false 让诊断标签不被未来地面遮住;这是调试可读性选择,不应直接复制为生产标注策略。
8. 订阅同代 snapshot
更新函数必须接收事件参数,而不是稍后从多个 live 对象拼装:
js
function updateGrid(viewState, transformSnapshot) {
const keys = createDebugNeighborhood(viewState);
gridLayer.setKeys(keys, transformSnapshot);
runtime.requestRender();
}
runtime.addEventListener("viewchange", (event) => {
updateGrid(event.viewState, event.transformSnapshot);
});
updateGrid(runtime.viewState, runtime.transform.snapshot);
第一次手动调用建立初始内容;后续只跟随 viewchange。setKeys 内会按 spatial ID 去重,所以错误地传入同一实例两次不会画双线。
当去重并排序后的 spatial ID signature 没变时,setKeys 不重建 child。它保留建立 geometry 时的 base origin/worldSize,并更新整个 Group:
js
const scale = snapshot.worldSize / baseFrame.worldSize;
grid.scale.setScalar(scale);
grid.position.set(
(baseFrame.origin.x - snapshot.origin.x) * snapshot.worldSize,
0,
(baseFrame.origin.y - snapshot.origin.y) * snapshot.worldSize,
);
因此 tile 内连续平移/连续 zoom 只产生一次 group transform;跨 tile 或整数 zoom 导致 key 集合变化时才重建标签。
9. 增加 cursor inspector
canvas pointermove 先换成 element-local 坐标:
js
const bounds = canvas.getBoundingClientRect();
const screenX = event.clientX - bounds.left;
const screenY = event.clientY - bounds.top;
const hit = runtime.transform.screenPointToGround(screenX, screenY);
若 hit === null,面板显示"sky / no ground intersection",不要保留上一次地面值,让读者误以为天空也有坐标。
命中后:
js
const lngLat = unprojectMercator(hit.mercator.x, hit.mercator.y);
const z = Math.floor(runtime.viewState.zoom);
const address = lngLatToTileAddress(lngLat.lng, lngLat.lat, z);
展示单位时保持严格:
| 值 | 单位 |
|---|---|
| screen x/y | CSS pixels |
| render-local X/Y/Z | world units |
| Mercator x/y | unitless |
| longitude/latitude | degrees |
| tile x/y/wrap | integer indices |
10. 释放旧对象
当 spatial key 集合变化时,setKeys 才需要:
- dispose 每个旧 outline geometry;
- dispose 每个旧 SpriteMaterial;
- dispose 每个旧 CanvasTexture;
- 从 Group 移除旧 child;
- 保留共享 LineBasicMaterial 供新 outline 使用。
整个页面卸载时:
js
gridLayer.dispose();
runtime.dispose();
此时再释放两份共享 line material,并把 group 从 parent 移除。layer/runtime 的 dispose 都是幂等的。
11. 手工验证
平移
向东拖动,地址随中心变化但线框像贴在地面上;没有相机与线框相反方向漂移。
zoom
整数 zoom 边界处,debug 层级整体切换;这不是父子替换,只是调试邻域换层,短暂跳变在本阶段是预期行为。
bearing/pitch
线框跟随地面透视,Sprite 面向相机但中心不脱离所属 tile。若 label 像 HUD 一样固定在屏幕,说明它没有加入 world scene。
wrap
把中心经度改到接近 180° 并继续向东,canonical x 回到 0、wrap 增加;橙色 wrapped 边界在空间上连续接到规范世界。
cursor
沿一条边缓慢移动,tile address 应在越过线的同一位置变化。高 pitch 指向天空时,inspector 明确显示无地面交点。
12. 完成标准
- TileKey 严格区分 display/canonical/wrap;
- content ID 与 spatial ID 的相等关系符合预期;
- canonical tile 在 zoom 相等时宽
512world units; - wrap
+1的同 key 水平偏移恰好一个worldSize; - spatial key 集合不变时复用 geometry/texture,仅更新 Group transform;
- label position/scale 都是 world-space;
- cursor inspector 明确标注四种空间及单位;
- 文档与 UI 都声明邻域不是 viewport cover。
下一节把 z0--z15、512 CSS pixels 与全局 XYZ 固定为离线逻辑剖面,并在开发者工具中证明 Stage 02 没有远端地图数据依赖。