three.js最小地图运行时(九):TileKey 线框与 cursor 坐标探针

教程:绘制 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,
});

构造器依次验证:

  1. zoom 是 [0,30] 内的安全整数;
  2. displayZ >= canonicalZ
  3. canonical x/y 位于该层索引范围;
  4. wrap 是可正可负的安全整数;
  5. 返回对象被冻结。

上限 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,
);

tileKeytileSpatialId 写入 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);

第一次手动调用建立初始内容;后续只跟随 viewchangesetKeys 内会按 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 相等时宽 512 world 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 没有远端地图数据依赖。


相关推荐
CarIise17 分钟前
JavaScript进阶与轮播图实现 课堂笔记
开发语言·javascript·笔记
专业抄代码选手18 分钟前
05|把递归渲染拆成 Fiber:让一棵大树可以暂停
前端·javascript·react.js
像我这样帅的人丶你还21 分钟前
🚀苹果的液态玻璃咋做?🚀
前端·webgl·three.js
mayaairi22 分钟前
JS DOM节点操作完全指南:增删改查与性能优化
开发语言·javascript·性能优化
天才熊猫君24 分钟前
Vue 3 插槽机制深度解析:两条线与三层树
前端·javascript
李溪白29 分钟前
事件循环(Event Loop):JavaScript 里的“时间管理大师”与“插队狂魔”
javascript
mONESY31 分钟前
告别 Vibe Coding:用 SDD 把一个 Chrome 翻译插件从 0 做到 1
javascript
xcLeigh35 分钟前
Go入门:rune与byte的区别和使用场景
android·javascript·golang
mayaairi1 小时前
JS DOM与事件处理完全指南
服务器·前端·javascript