271 lines
12 KiB
Markdown
271 lines
12 KiB
Markdown
# 智能星球 Interactable 使用说明
|
||
|
||
`Interactable` 是智能星球地表“图标类可交互元素”的通用渲染入口。它把船只图层验证过的模式抽成公共能力:普通态用批量 `THREE.Points`,hover / locked 用少量 overlay,拾取走屏幕空间命中,图标资源统一转进 canvas texture,并在公共层处理 glow、状态、尺寸、贴地渲染和同坐标避让。
|
||
|
||
当前已接入:
|
||
|
||
| 图层 | 业务文件 | 图标来源 | 补充动画 |
|
||
| --- | --- | --- | --- |
|
||
| AIS 船只 | `frontend/public/earth/js/vessels.js` | canvas draw,航行三角形 / 停泊圆点 | 船只轨迹仍由业务层维护 |
|
||
| 算力中心 | `frontend/public/earth/js/compute-centers.js` | `assets/icons/compute-*.svg` | 估算位置 `?` badge 通过 `icon.afterDraw()` 叠加 |
|
||
| BGP 事件 | `frontend/public/earth/js/bgp.js` | canvas draw,按事件类型绘制符号 | 向外扩散圈仍由 BGP 业务层维护 |
|
||
| BGP 观测站 | `frontend/public/earth/js/bgp.js` | `assets/icons/bgp-broadcast-pin.svg` | halo、活跃度 core、覆盖扇形和雷达扫掠仍由 BGP 业务层维护 |
|
||
|
||
登陆点曾尝试接入 Interactable,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 深度测试裁切成碎片;当前退回 `THREE.Sprite` 专用路径,并改为由 canvas 生成黄色扁平球纹理。旧 SVG 资产保留在 `assets/icons/` 目录中,但登陆点运行时不再依赖 SVG。
|
||
|
||
## 为什么需要 Interactable
|
||
|
||
之前每个地表图标图层都容易各写一套:
|
||
|
||
- icon texture 生成
|
||
- hover / locked 状态
|
||
- glow 样式
|
||
- picking 命中半径
|
||
- zoom 下的尺寸策略
|
||
- 同经纬度对象重叠避让
|
||
|
||
这些逻辑如果分散在业务文件里,视觉会漂移,后续调参也会变成逐图层修补。`Interactable` 的边界是:公共层负责“图标怎么在地球上稳定显示和被选中”,业务层负责“数据从哪里来、图标表达什么语义、详情卡展示什么、是否有额外动画”。
|
||
|
||
## 入口
|
||
|
||
```javascript
|
||
import { createInteractableLayer } from "./interactable.js";
|
||
```
|
||
|
||
核心调用形态:
|
||
|
||
```javascript
|
||
const layer = createInteractableLayer({
|
||
id: "example",
|
||
objectType: "example_object",
|
||
renderOrder: 4.4,
|
||
altitudeOffset: 0.2,
|
||
pointSize: 34,
|
||
icon: {
|
||
draw(context, options) {
|
||
// draw canvas icon
|
||
},
|
||
},
|
||
getPosition: (item) => ({
|
||
latitude: item.latitude,
|
||
longitude: item.longitude,
|
||
}),
|
||
getKind: (item) => item.kind || "default",
|
||
});
|
||
```
|
||
|
||
业务模块通常只暴露一层薄封装:
|
||
|
||
```javascript
|
||
export function getExampleMarkers() {
|
||
return layer.getMarkers();
|
||
}
|
||
|
||
export function getExamplePointerIntersections(options) {
|
||
return layer.getPointerIntersections(options);
|
||
}
|
||
|
||
export function setExampleMarkerState(marker, state = "normal") {
|
||
layer.setMarkerState(marker, state);
|
||
}
|
||
|
||
export function updateExampleVisualState(lockedObjectType, lockedObject, camera) {
|
||
layer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||
}
|
||
```
|
||
|
||
## 配置参数
|
||
|
||
| 参数 | 默认值 | 说明 |
|
||
| --- | --- | --- |
|
||
| `id` | 必填 | 图层唯一标识,用于 group name、避让注册和 debug。 |
|
||
| `objectType` | `id` | marker 写入 `userData.type` 的业务类型,主交互层用它判断 locked 对象。 |
|
||
| `renderOrder` | `4` | 普通 points 和 hover / locked overlay 的基础渲染顺序。 |
|
||
| `altitudeOffset` | `0.2` | 业务高度,按 `CONFIG.earthRadius + altitudeOffset` 计算原始地表位置。 |
|
||
| `pointSize` | `32` | 基准屏幕像素尺寸。普通 points 和 overlay 都以它为基础。 |
|
||
| `sizeMode` | `"fixed"` | 默认固定屏幕尺寸;非 `"fixed"` 时会按相机距离做比例缩放。 |
|
||
| `sizeScale` | `{ referenceFov: 75, min: 0.12, max: 3 }` | `sizeMode !== "fixed"` 时的缩放范围。 |
|
||
| `atlasCellSize` | `128` | icon canvas texture 尺寸。 |
|
||
| `colors` | `{}` | 支持 `normal`、按 kind 的平铺 key,以及 `byKind`。 |
|
||
| `opacity` | `{ normal: 0.88, dimmed: 0.26, hover: 0.98, locked: 1 }` | 各状态透明度。 |
|
||
| `stateScale` | `{ hover: 1, locked: 1, dimmed: 1 }` | 各状态尺寸倍率。 |
|
||
| `pulse` | `{}` | locked 态可选呼吸缩放,支持 `enabled`、`speed`、`amplitude`。 |
|
||
| `avoidance` | `{ enabled: true, precision: 4, radius: 1.1, step: 0.35 }` | 跨 Interactable 的同坐标避让配置。 |
|
||
| `icon` | 必填 | 图标来源,支持 canvas draw、SVG / 图片 asset、状态 asset、锚点和后处理。 |
|
||
| `getPosition(item)` | 必填 | 返回 `{ latitude, longitude }` 或 `THREE.Vector3`。 |
|
||
| `getKind(item)` | `item.type || "default"` | 返回业务类型,用于颜色和 texture 分桶。 |
|
||
| `getRotationBin(marker)` | `0` | 返回旋转分桶,例如船只按航向分 32 桶。 |
|
||
| `getBucketKey(marker)` | `String(getRotationBin(marker))` | 返回 texture / geometry 分桶 key。 |
|
||
| `getPointSizeMultiplier(marker)` | `1` | 单 marker 尺寸倍率。BGP 事件按严重级别、观测站按活跃度使用它。 |
|
||
| `getUserData(item)` | `item` | 写入 marker 的业务字段。 |
|
||
|
||
## Icon 配置
|
||
|
||
`icon.anchor` 可选,默认 `{ x: 0.5, y: 0.5 }`,表示纹理中心对齐 marker 坐标。它只适合小范围的视觉锚点偏移;如果图标主体很大、且需要在地球边缘完整显示,例如登陆点曾使用过的 pin 类图标,不应强行走 `THREE.Points + depthTest`,否则图标主体会被地球深度裁切。
|
||
|
||
### Canvas 图标
|
||
|
||
canvas 图标适合船只、BGP 事件这类需要按状态或旋转动态绘制的符号:
|
||
|
||
```javascript
|
||
const vesselIconLayer = createInteractableLayer({
|
||
id: "vessels",
|
||
objectType: "vessel",
|
||
pointSize: 34,
|
||
icon: {
|
||
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
|
||
if (!marker.userData.anchored) {
|
||
context.rotate((rotationBin / 32) * Math.PI * 2);
|
||
}
|
||
context.fillStyle = color;
|
||
context.shadowColor = color;
|
||
context.shadowBlur = glow ? 14 : 0;
|
||
context.beginPath();
|
||
context.moveTo(0, -37);
|
||
context.lineTo(28, 32);
|
||
context.lineTo(0, 17);
|
||
context.lineTo(-28, 32);
|
||
context.closePath();
|
||
context.fill();
|
||
},
|
||
},
|
||
getRotationBin: getCourseBin,
|
||
getBucketKey: (marker) => `${marker.userData.anchored ? "anchored" : "moving"}:${getCourseBin(marker)}`,
|
||
});
|
||
```
|
||
|
||
当 `icon.coordinates !== "canvas"` 时,`Interactable` 会先把 context 平移到 atlas 中心;船只这类自己使用中心坐标绘制的图标不需要声明 `coordinates`。
|
||
|
||
### SVG / 图片 Asset 图标
|
||
|
||
asset 图标适合算力中心、BGP 观测站这类已有 SVG 的设施图标:
|
||
|
||
```javascript
|
||
const computeCenterIconLayer = createInteractableLayer({
|
||
id: "computeCenters",
|
||
objectType: "compute_center",
|
||
pointSize: 36,
|
||
atlasCellSize: 128,
|
||
icon: {
|
||
coordinates: "canvas",
|
||
colorable: false,
|
||
fitSize: 60,
|
||
glowBlur: 16,
|
||
getSource({ marker, item }) {
|
||
const siteType = marker?.userData?.site_type || item?.site_type || "gpu_cluster";
|
||
return COMPUTE_CENTER_ICON_SOURCES[siteType];
|
||
},
|
||
afterDraw(context, { marker, item }) {
|
||
if (marker?.userData?.is_estimated ?? item?.is_estimated) {
|
||
drawComputeCenterEstimatedBadge(context, true);
|
||
}
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
使用 asset 时有几个约定:
|
||
|
||
- SVG / 图片文件放在 `frontend/public/earth/assets/icons/`,以 `/earth/assets/icons/name.svg` 引用。
|
||
- 原始 SVG 应尽量保留标准 `viewBox` 和路径,不要为了显示大小写死 transform。
|
||
- 显示尺寸由 `icon.fitSize` 控制;它可以是数字、`{ width, height }`,也可以是函数。
|
||
- `icon.colorable !== false` 且提供状态颜色时,公共层会先把 asset 画到临时 canvas,再用 `source-in` tint 成目标颜色。
|
||
- 多色图片或不希望被 tint 的 SVG 应设置 `colorable: false`。
|
||
|
||
## 生命周期
|
||
|
||
常规加载流程:
|
||
|
||
```javascript
|
||
export async function loadExampleLayer(_scene, earth) {
|
||
clearExampleData(earth);
|
||
|
||
const markerData = await fetchExampleData();
|
||
await layer.preloadAssets(markerData);
|
||
layer.setData(markerData);
|
||
layer.attach(earth);
|
||
layer.setVisible(showExampleLayer);
|
||
|
||
return { totalCount: layer.getCount() };
|
||
}
|
||
```
|
||
|
||
各方法职责:
|
||
|
||
| 方法 | 说明 |
|
||
| --- | --- |
|
||
| `preloadAssets(items)` | 收集 normal / hover / locked 可能用到的 asset source,并用浏览器 `Image` 预加载。canvas draw 图标可跳过。 |
|
||
| `setData(items)` | 清理旧 points,生成 marker,注册避让,按 bucket 重建 `THREE.Points`。 |
|
||
| `attach(parent)` | 将图层 group 挂到 Earth root。 |
|
||
| `setVisible(next)` | 控制 group、points 和 overlay 可见性。 |
|
||
| `setMarkerState(marker, state)` | 设置 `normal` / `hover` 等状态并触发视觉状态失效。 |
|
||
| `updateVisualState(focusType, focusObject, camera)` | 更新普通态 opacity / size,并刷新 hover / locked overlay。 |
|
||
| `getPointerIntersections(options)` | 屏幕空间拾取,返回按像素距离排序的命中结果。 |
|
||
| `clearData(parent)` | 注销避让、释放 geometry / material、清空 marker 并从 parent 移除 group。 |
|
||
|
||
## Picking 接入
|
||
|
||
`Interactable` 不依赖 Three.js 对 `Points` 的默认 raycast。主交互层只要把 Earth、camera、pointer 和命中半径传入:
|
||
|
||
```javascript
|
||
const intersects = getVesselPointerIntersections({
|
||
earth,
|
||
camera,
|
||
pointer,
|
||
radiusPx: 22,
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
});
|
||
```
|
||
|
||
公共层会做这些事:
|
||
|
||
1. 把相机位置转到 Earth local 坐标。
|
||
2. 跳过背面 marker。
|
||
3. 把 marker world position 投影到屏幕坐标。
|
||
4. 用 `radiusPx` 做像素距离命中。
|
||
5. 返回最近的候选对象。
|
||
|
||
拖动地球、惯性旋转、hover 节流这些策略仍属于 `main.js`,因为它们和全局输入状态有关。
|
||
|
||
## 同坐标避让
|
||
|
||
避让默认开启,作用范围是所有通过 `createInteractableLayer()` 创建的图层。公共层会按经纬度或 `THREE.Vector3` 生成 `icon_avoidance_key`,同 key 的 marker 会沿地表切平面排成小圈。
|
||
|
||
关键点:
|
||
|
||
- `icon_base_position` 保留业务原始位置。
|
||
- 避让只改渲染位置和 picking 位置,不改业务经纬度。
|
||
- 单个 marker 回到原始位置时会直接使用 `altitudeOffset` 计算出的业务贴地位置。
|
||
- 多个 marker 同坐标时,第一圈用 `avoidance.radius`,后续每圈加 `avoidance.step`。
|
||
|
||
如果某个业务图层需要严格压在原始点位,可以显式关闭:
|
||
|
||
```javascript
|
||
createInteractableLayer({
|
||
id: "strict-layer",
|
||
avoidance: { enabled: false },
|
||
});
|
||
```
|
||
|
||
## 业务动画边界
|
||
|
||
`Interactable` 当前只负责图标本体和通用 hover / locked overlay。复杂动画仍放在业务模块里,但要跟随 Interactable marker 的位置:
|
||
|
||
- BGP 事件扩散圈由 `bgp.js` 创建独立 ring sprite,并在每帧 `position.copy(marker.position)`。
|
||
- BGP 观测站 halo、status core、coverage halo 和覆盖扇形由 `bgp.js` 管理,图标本体由 Interactable 管理。
|
||
- 船只轨迹线仍由 `vessels.js` 管理,因为它依赖点击后额外加载的轨迹数据。
|
||
|
||
这个边界能避免通用接口过早承载所有动画类型。后续如果多个图层复用同一类动画,再把它收进 Interactable 的 `animations` 扩展。
|
||
|
||
## 新图层接入清单
|
||
|
||
1. 在业务文件中准备 marker data,并保留必要的业务字段。
|
||
2. 选择 icon 类型:canvas draw、SVG / 图片 asset,或 `getSource()` 动态选择。
|
||
3. 配置 `pointSize`、`icon.fitSize`、`colors`、`opacity`、`stateScale`。
|
||
4. 如果需要业务尺寸差异,提供 `getPointSizeMultiplier()`。
|
||
5. 如果有旋转,提供 `getRotationBin()` 和稳定的 `getBucketKey()`。
|
||
6. 加载时先 `preloadAssets()`,再 `setData()`、`attach()`、`setVisible()`。
|
||
7. 在 `main.js` 接入 `getPointerIntersections()`,并复用现有 hover / locked 状态更新流程。
|
||
8. 在图层样式索引和渲染顺序文档中记录 altitude、renderOrder、pointSize 和动画层级。
|