release: bump version to 0.46.0

This commit is contained in:
rayd1o
2026-04-30 04:42:29 +08:00
parent ba54545ac7
commit b1a5934b80
29 changed files with 2351 additions and 1095 deletions

View File

@@ -1 +1 @@
0.45.0 0.46.0

View File

@@ -8,6 +8,24 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.46.0] — 2026-04-30
Released: 2026-04-30
### ✨ Highlights
- Earth 新增通用 Interactable 图标层船只、算力中心、BGP 事件与观测站统一使用批量 Points、屏幕拾取、状态 glow 和状态缩放。
- BGP 事件保留向外扩散圈,观测站保留雷达扫描层,并与 Interactable 主图标解耦到稳定的地表渲染层级。
- 登陆点回归黄色球形 Sprite贴近海缆层级并保持更稳定的地表显示和遮挡表现。
### 🔧 Improvements
- 新增 SVG asset 到 canvas texture 的 Interactable 资产加载路径,支持统一图标资源、缓存和可选染色。
- 同坐标 Interactable 自动做地表切向避让,降低重叠物件无法选择的问题。
- 优化 Earth toolbar 初始尺寸注入,避免首次显示原始尺寸后再跳到缩放尺寸。
- 补充 Interactable 计划、使用说明、图层顺序和 Earth 前端上下文文档。
- 修复船只 hover/locked 状态仅发光但放大反馈不明显的问题,将已有状态缩放接入通用图标层。
---
## [0.45.0] — 2026-04-29 ## [0.45.0] — 2026-04-29
### ✨ Highlights ### ✨ Highlights

View File

@@ -26,6 +26,7 @@
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md) - [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md) - [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
- [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md) - [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md) - [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md) - [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md) - [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)

View File

@@ -0,0 +1,312 @@
# Earth Interactable Layer Plan
## 背景
状态Phase 1 已经开始落地Phase 2 的 BGP 事件 / 观测站迁移和 Phase 3 的算力中心迁移也已完成。`frontend/public/earth/js/interactable.js` 已新增AIS 船只、BGP 事件、BGP 观测站和算力中心图层已经改为通过 `createInteractableLayer()` 使用通用批量 `Points`、hover / locked overlay、默认 glow、状态更新、asset icon 预加载、屏幕空间 picking、固定 / 距离缩放和跨 Interactable 同坐标避让。登陆点因 `THREE.Points` 边缘深度裁切和贴地层级要求,已退回专用 `THREE.Sprite` 黄色球路径,并与海缆同高度同 renderOrder。后续阶段聚焦把可复用的扩圈 / 雷达扇形动画正式沉淀成 `animations` 扩展。
当前实现说明和接入示例见:
- [earth-interactable-usage.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
当前 AIS 船只图层已经形成了一个适合作为基准的交互图标模式:
- 普通态使用批量 `THREE.Points` 渲染,避免每个对象一个 `Sprite` 带来的 draw call 和透明排序压力。
- hover / locked 态使用单点 overlay 叠加 glow不改变普通批次交互反馈清晰且成本低。
- moving / anchored 船只通过 canvas 点纹理表达不同形状moving 船只还按航向分桶。
- 拾取走屏幕空间命中,拖动和惯性期间跳过高频 hover picking。
- 图层高度贴近地表,仅保留很小的深度余量,避免“浮在表面层”的观感。
这个模式不应该只服务船只。后续 BGP 事件、BGP 观测站、算力中心、新闻事件、告警、地面传感器等都可能需要“图标类可交互元素”。如果每个图层继续各写一套 icon、glow、hover、locked、动画、picking 和图例逻辑,视觉会漂移,性能策略也会重复分叉。登陆点已经验证为例外:需要完整贴地且不被球面边缘裁切时,专用 Sprite 路径比通用 `Points` 更合适。
目标是把船只图层的成功做法抽象成一个通用接口:业务图层只描述“要画什么、在哪里、怎么交互”,底层统一负责批量渲染、默认 glow、状态 overlay、动画槽位、拾取和生命周期。
## 目标
1. 建立统一的 Earth 交互图标接口,作为未来地表图标类元素的默认入口。
2. 以 AIS 船只 glow 为默认 glow 视觉,其它图标默认沿用同一套 glow 质感。
3. 保留图标颜色、状态颜色、hover 放大、locked 强调、dimmed 聚焦、动画扩展等能力。
4. 支持 canvas / SVG / image icon不强行要求所有图标都可重着色。
5. 保持船只当前性能路线:批量绘制普通态,少量 overlay 处理交互态。
6. 给 BGP 事件扩圈、BGP 观测站雷达扇形等补充动画留出正式扩展点。
## 非目标
- 不在第一阶段重写所有 Earth 图层。
- 不把卫星、海缆、国家边界、真实地形这类非图标图层纳入同一个接口。
- 不为了抽象牺牲业务图标的差异表达例如船只航向、BGP 事件严重级别、观测站雷达扫掠。
- 不要求图片图标支持运行时重着色;图片图标只能通过预制多状态图片或 overlay tint 做有限表达。
## 核心设计
建议新增一个通用模块,例如:
```text
frontend/public/earth/js/interactable.js
```
它导出一个工厂或注册函数:
```js
createInteractableLayer({
id,
earth,
renderOrder,
altitudeOffset,
icon,
scale,
glow,
colors,
states,
animations,
picking,
data,
getPosition,
getKind,
getRotation,
getPayload,
});
```
业务模块仍保留自己的数据加载、图例、详情卡字段和业务语义。例如 `vessels.js` 负责 AIS 数据和船型映射,但 icon 渲染、hover overlay、locked overlay、默认 glow 和屏幕空间 picking 可以逐步迁入 `interactable.js`
## 参数草案
| 参数 | 类型 / 示例 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `id` | `"vessels"` | 必填 | 图层唯一标识,用于 debug、picking、legend 和状态缓存。 |
| `earth` | `THREE.Object3D` | 必填 | 图层挂载目标,通常是 Earth root。 |
| `renderOrder` | `4.4` | `4` | 普通 icon 批次和 overlay 的基础渲染顺序。 |
| `altitudeOffset` | `0.2` | `0.2` | 图层高度,语义为 `CONFIG.earthRadius + altitudeOffset`。地表图标默认贴近真实地形基础层。 |
| `icon` | `{ type, source, draw, size, bins }` | 必填 | 图标来源。支持 canvas draw、SVG URL、image URL、内置 shape。 |
| `icon.fitSize` | `60``{ width: 60, height: 60 }` | `atlasCellSize` | asset 图标在 atlas canvas 内的最大绘制尺寸,默认居中等比 contain。SVG / 图片文件只负责原始形状,不需要为了显示大小手写 transform。 |
| `scale` | `{ base, min, max }` | `{ base: 1 }` | 基础缩放和距离稳定范围。当前船只可映射到 `VESSEL_POINT_SIZE` / `baseScale`。 |
| `sizeMode` | `"fixed" / "distance"` | `"fixed"` | 是否固定屏幕像素尺寸;非 fixed 时按相机到地表距离做比例缩放。 |
| `sizeScale` | `{ min, max, referenceFov }` | `{ min: 0.12, max: 3, referenceFov: 75 }` | `sizeMode !== "fixed"` 时的缩放限制和参考视角。 |
| `glow.enabled` | `true / false` | `true` | 是否启用默认 glow。默认 glow 以船只 hover / locked overlay 为基准。 |
| `glow.intensity` | `0.0 - 2.0` | `1` | glow 强度,内部映射到 canvas `shadowBlur`、opacity 或 shader uniform。 |
| `glow.colorMode` | `"state" / "icon" / "fixed"` | `"state"` | glow 颜色来源,默认跟随状态颜色。 |
| `hover.scale` | `1.0 - 2.0` | `1.18` | hover 放大倍率。当前船只保持同尺寸 glow overlay接口仍保留放大能力供其它图层使用。 |
| `hover.mode` | `"scale" / "glow-only" / "custom"` | `"scale"` | hover 反馈方式。船只可用 `"glow-only"`,其它图标默认放大。 |
| `colors.normal` | `"#4A90D9"` | icon 原色 | 普通态颜色。只有可上色 icon 生效。 |
| `colors.hover` | `"#7dd3fc"` | normal | hover 态颜色。 |
| `colors.locked` | `"#ffffff"` | hover | locked 态颜色。 |
| `colors.dimmed` | `"#9B9B9B"` | normal | 聚焦其它对象时的弱化颜色。 |
| `colors.byKind` | `{ cargo: "#4A90D9" }` | `{}` | 按业务类型着色如船型、BGP 严重级别。 |
| `colorable` | `true / false` | 由 icon 类型推断 | canvas shape 和 SVG mask 通常可上色;图片默认不可上色。 |
| `opacity` | `{ normal, hover, locked, dimmed }` | 船只当前值 | 各状态透明度。 |
| `rotation` | `{ enabled, bins, getAngle }` | disabled | 是否按角度分桶,例如船只按 COG 分 32 桶。 |
| `animations` | `IconAnimationSpec[]` | `[]` | 补充动画列表,例如扩圈、雷达扇形、脉冲、轨迹尾迹。 |
| `picking.radiusPx` | `22` | `20` | 屏幕空间命中半径。 |
| `picking.throttleMs` | `100` | `80` | hover picking 节流。 |
| `picking.skipWhileDragging` | `true` | `true` | 拖动和惯性期间跳过 hover picking。 |
| `zIndexPolicy` | `"surface-icon"` | `"surface-icon"` | 预设层级策略,避免每个业务图层手写高度和 renderOrder。 |
| `avoidance.enabled` | `true / false` | `true` | 是否参与跨 Interactable 的同坐标避让。默认开启,同一经纬度下的图标会沿地表切平面小幅排开,方便辨认和选择。 |
| `avoidance.radius` | `number` | `1.1` | 同坐标避让的第一圈半径,单位为地球本地坐标单位。 |
| `avoidance.precision` | `number` | `4` | 经纬度归并精度,默认约等于只处理几乎完全重叠的图标。 |
| `legend` | `{ label, color, shape }[]` | `[]` | 可选图例声明,业务层也可以继续自己导出。 |
| `metadata` | object | `{}` | 业务扩展数据,不参与渲染但参与 tooltip / info-card / search。 |
## Icon 规格
图标输入建议分三类:
```js
{
type: "canvas-shape",
size: 128,
draw(ctx, state) {
// draw triangle / dot / custom shape
},
}
```
```js
{
type: "svg-mask",
source: "/earth/assets/icons/bgp-event-dot.svg",
colorable: true,
}
```
```js
{
type: "image",
source: "/earth/assets/icons/vendor-logo.png",
colorable: false,
stateSources: {
hover: "/earth/assets/icons/vendor-logo-hover.png",
},
}
```
颜色策略:
- `canvas-shape` 默认可上色,适合船只、事件点、雷达站这类符号。
- `svg-mask` 如果能作为 mask 使用,则可上色;如果是完整多色 SVG则按图片处理。
- `image` 默认不可上色;需要状态变化时使用 `stateSources` 或额外 glow / ring。
## 默认 Glow 规范
默认 glow 以当前船只 overlay 为视觉基准:
- 普通态尽量不启用 glow保持地图干净。
- hover / locked 态叠加同位置 overlay。
- glow 颜色默认跟随状态颜色或业务类型颜色。
- glow blur 应该稳定,不随 camera zoom 夸张膨胀。
- 允许通过 `glow.intensity` 控制强度,但不要让业务图层各自发明完全不同的光晕语言。
建议内部把 glow 拆成两个层次:
1. `textureGlow`canvas texture 里的 `shadowBlur`,适合小图标 hover / locked。
2. `effectGlow`:额外 ring / halo / pulse适合告警、BGP 事件和锁定强调。
## 状态模型
通用状态至少包含:
| 状态 | 触发 | 默认表现 |
| --- | --- | --- |
| `normal` | 普通显示 | 批量 Points使用 normal 颜色和 opacity。 |
| `hover` | 指针悬停 | 默认放大并显示 glow船只可配置为同尺寸 glow-only。 |
| `locked` | 点击锁定 / 详情打开 | 强 glow、更高 opacity可选 ring 或 pulse。 |
| `dimmed` | 聚焦其它对象 | 降低 opacity保留上下文。 |
| `hidden` | 图层关闭或过滤 | 不参与绘制和 picking。 |
| `alert` | 业务告警 | 可叠加动画,不替代 locked 状态。 |
状态更新需要增量化:只在 hover 目标、locked 目标、过滤条件、数据版本或相机距离阈值变化时更新,不在每帧遍历全部 icon 写材质属性。
## 动画扩展
动画不直接塞进 icon 基础参数,而是作为 `animations` 列表注册。每个动画声明自己的 geometry / material / update 策略:
```js
{
type: "expanding-ring",
when: ["alert", "locked"],
color: "state",
radiusPx: [10, 42],
durationMs: 1400,
opacity: [0.8, 0],
}
```
```js
{
type: "radar-sweep",
when: ["normal", "hover", "locked"],
angleDeg: 72,
rotationMs: 2600,
opacity: 0.36,
}
```
首批建议内置动画:
| 动画 | 用例 | 说明 |
| --- | --- | --- |
| `pulse-ring` | locked、告警点 | 原地呼吸环,强调选中对象。 |
| `expanding-ring` | BGP 事件 | 向外扩散的事件波纹。 |
| `radar-sweep` | BGP 观测站 | 扇形扫描,可持续旋转。 |
| `orbiting-dot` | 数据流 / collector 活跃态 | 小点绕 icon 环绕,表达活动状态。 |
| `trail` | 移动目标 | 可选短尾迹,船只或飞机类目标使用。 |
动画必须支持批量或分组绘制,避免为每个对象创建独立的高频更新对象。只有 locked / hover / 少量 alert 对象可以使用单对象 overlay。
## 渲染策略
### 普通态
普通态优先使用分桶 `THREE.Points`
- 按 icon 类型、可上色策略、旋转分桶、纹理 key 分组。
- 每组一个 `BufferGeometry`,存 `position``color`、必要的 `payloadIndex`
- `PointsMaterial.sizeAttenuation = false`,保持屏幕尺寸稳定。
- `depthTest = true``depthWrite = false`,避免遮挡关系破坏地表。
### 交互态
hover / locked 使用少量 overlay
- overlay 复用 `THREE.Points` 单点对象或小型 ring mesh。
- overlay texture 从统一 cache 获取。
- overlay 更新只写当前 hover / locked 的 position、texture、opacity、size。
### 高密度升级
当某类图标超过分桶 Points 的舒适区,才考虑升级:
- `InstancedBufferGeometry` billboard。
- 自定义 shader 支持 per-instance rotation / scale / opacity。
- 视口 bbox / LOD / cluster。
这个升级不应该改变业务接口,只替换底层 renderer。
## Picking 策略
沿用船只当前方向:
- 默认屏幕空间 picking而不是 Three.js 对每个 Sprite / Points 做 raycast。
- 每个 icon 保留世界坐标和业务 payload。
- 每次 pointer move 将候选点投影到屏幕,按半径和深度判断命中。
- 拖动、惯性旋转、相机剧烈变化期间跳过 hover picking。
- click 时允许做一次更精确的 picking。
后续可以按图层或经纬度网格增加空间索引,减少候选点数量。
## 与现有图层的迁移路径
### Phase 1抽出船只基准能力
-`vessels.js` 提取 texture cache、canvas icon draw、overlay glow、分桶 Points 创建、状态增量更新。
- 保持 `vessels.js` 的公开 API 不变:`loadVessels()``toggleVessels()``getVesselMarkers()` 等继续可用。
- 新模块先只服务船只,确保视觉没有回退。
### Phase 2迁移 BGP 事件和观测站
- BGP 事件使用 `canvas-shape`,已接入 `Interactable`
- 严重级别映射到 `colors.byKind`,并通过通用 `getPointSizeMultiplier` 保留严重级别尺寸倍率。
- 当前扩圈效果保留在 BGP 业务动画中,并跟随 `Interactable` marker 位置更新。
- BGP 观测站主图标已接入 `Interactable`,活跃度映射到颜色和 `getPointSizeMultiplier`
- BGP 观测站 halo / 覆盖扇形继续由 BGP 业务动画表达扫描,并跟随 `Interactable` marker 位置更新。
### Phase 3迁移算力中心并评估登陆点
- 算力中心保留现有业务 icon但接入统一 hover / locked / glow。已完成
- 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。后续如要重新设计登陆点,需要先确认图标能在边缘视角完整显示。
- 检查图例、搜索和 info-card 是否只依赖业务 payload而不是依赖渲染对象类型。
### Phase 4形成 Earth 图标层规范
-`docs/technical/zh/earth-frontend-context.md` 记录当前实现入口。
-`docs/technical/zh/earth-layer-style-reference.md` 记录默认 glow、状态颜色、默认高度和动画参数。
-`docs/technical/zh/earth-render-layer-order.md` 记录 surface icon renderOrder 范围。
## 风险与约束
- 过早抽象可能让船只这种高质量基准被平均化,因此第一阶段必须以船只视觉不回退为验收标准。
- 图片 icon 不可上色,接口需要明确 `colorable = false` 的行为,避免业务层误以为颜色一定生效。
- 动画如果默认开启过多,会重新引入 overdraw 和每帧更新压力;默认只给 hover / locked 或少量 alert 使用。
- 地形开启时,贴地 icon 需要在高度、`depthTest``polygonOffset` 和 renderOrder 之间保持平衡。
- 统一 glow 不等于所有图标一模一样;业务可以调强度和颜色,但不应破坏整体视觉语言。
## 验收标准
1. 船只迁入通用接口后普通态、hover、locked、航向、颜色、轨迹和 picking 行为保持一致。
2. 新增一个 BGP 事件示例图层配置,不需要复制船只渲染代码即可得到 icon、glow、hover 和扩圈动画。
3. 新增一个 BGP 观测站示例图层配置,不需要自写独立动画循环即可得到雷达扇形。
4. 关闭图层后对应 icon、overlay、动画和 picking 全部停止。
5. 高密度数据下普通态仍走批量绘制hover / locked 只更新少量 overlay。
6. 文档同步说明默认高度、默认 glow、状态模型和动画扩展点。
## 相关文件
| 文件 | 当前角色 | 未来关系 |
| --- | --- | --- |
| `frontend/public/earth/js/vessels.js` | 船只基准实现,包含分桶 Points、hover / locked overlay、默认 glow 形态 | Phase 1 的抽象来源 |
| `frontend/public/earth/js/constants.js` | 保存船只高度、颜色、透明度、轨迹参数 | 后续可加入通用 surface icon 默认配置 |
| `frontend/public/earth/js/bgp.js` | BGP 事件和观测站视觉逻辑 | BGP 事件和观测站主图标已接入 Interactable扩圈、halo 和覆盖扇形仍保留业务动画 |
| `frontend/public/earth/js/compute-centers.js` | 算力中心 icon 和交互 | 已通过 Interactable 接入统一 Points、overlay、glow 和 picking |
| `frontend/public/earth/js/cables.js` | 登陆点 icon 和海缆线 | 登陆点当前使用专用 `THREE.Sprite` 黄色球,不再走 Interactable海缆线仍独立渲染 |
| `frontend/public/earth/js/main.js` | 当前集中处理 hover、click、locked 和 info-card 入口 | 后续需要接入通用 icon picking 结果 |
| `docs/technical/zh/earth-layer-style-reference.md` | 当前视觉参数参考 | 实现后同步默认 glow 和通用参数 |
| `docs/technical/zh/earth-render-layer-order.md` | 当前层级参考 | 实现后同步 surface icon 层级范围 |

View File

@@ -140,17 +140,17 @@ This document records the material, color, opacity, line width, radius offset, a
| Cable line width | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` | | Cable line width | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| Cable opacity | `CABLE_CONFIG.line.opacity` | `1.0` | Cable line opacity | | Cable opacity | `CABLE_CONFIG.line.opacity` | `1.0` | Cable line opacity |
| Cable renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | Cable line level | | Cable renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | Cable line level |
| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | Aligns with compute center marker height | | Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | Same surface height as cable lines, avoiding a floating marker |
| Landing point icon texture size | `CABLE_CONFIG.landingPoint.textureSize` | `256` | Canvas size for solid map-pin icon | | Landing point sprite height | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` base height |
| Landing point icon aspect ratio | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` | | Landing point reference FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | Matches the current Earth camera FOV |
| Landing point icon anchor | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`, aligns pin tip to landing point lat/lon | | Landing point scale minimum | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | Minimum multiplier at maximum zoom; `3 * 0.36 = 1.08` |
| Landing point base scale | `CABLE_CONFIG.landingPoint.baseScale` | `12` | Matches compute center sprite height | | Landing point scale maximum | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | Maximum multiplier at far distance; current minimum zoom reaches roughly `2.50` |
| Landing point atlas size | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | Canvas flat shaded sphere texture size |
| Landing point color | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` | | Landing point color | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` | | Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | Aligns with compute center surface level | | Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | Same level as cable lines; `depthTest: false` keeps the ball whole, while camera-to-center globe occlusion hides back-side points |
| Landing point dim brightness | `landingPointVisual.dimBrightness` | `0.62` | Dim state color multiplier | | Landing point dim brightness | `landingPointVisual.dimBrightness` | `0.62` | Dim state color multiplier |
| Dimmed landing point color | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | Dim state color; avoids dark base showing through as a dark hole | | Dimmed landing point color | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | Dim state color; avoids dark base showing through as a dark hole |
| Dimmed landing point emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | Dim state weak amber self-emission |
| Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base | | Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base |
## Satellites, Trails, and Footprints ## Satellites, Trails, and Footprints
@@ -175,11 +175,13 @@ This document records the material, color, opacity, line width, radius offset, a
| Name | Variable | Current Value | Location / Notes | | Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Compute center radius offset | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | Marker position | | Compute center radius offset | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | Marker position |
| Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` | | Compute center point size | local `COMPUTE_CENTER_POINT_SIZE` | `36` | Shared Interactable base size for normal markers and hover / locked overlays |
| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Supercomputer marker | | Compute center asset fit size | local `COMPUTE_CENTER_ICON_FIT_SIZE` | `60` | Maximum SVG asset draw size inside the `128x128` atlas canvas, controlled by `icon.fitSize` |
| GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker | | Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | Normal `PointsMaterial.opacity` |
| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover state | | Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Legacy Sprite scale; not directly used by the current Interactable path |
| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked state | | GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | Legacy Sprite scale; not directly used by the current Interactable path |
| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover overlay size multiplier |
| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked overlay size multiplier, with pulse |
| Dimmed scale / opacity | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | Dim state | | Dimmed scale / opacity | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | Dim state |
| Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | Marker texture | | Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | Marker texture |
| GPU cluster color | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | Marker texture | | GPU cluster color | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | Marker texture |
@@ -190,12 +192,16 @@ This document records the material, color, opacity, line width, radius offset, a
| Name | Variable | Current Value | Location / Notes | | Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `2.1` | Anomaly marker | | BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP event Interactable marker |
| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | Collector marker | | BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP collector Interactable marker, aligned with the vessel layer |
| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Anomaly sprite | | BGP event point size | local `BGP_EVENT_POINT_SIZE` | `34` | Event Interactable base size, adjusted by severity through `getPointSizeMultiplier()` |
| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector plane | | BGP event symbol draw size | local `BGP_EVENT_SYMBOL_SIZE` | `60` | Event canvas symbol draw size inside the `128x128` atlas |
| BGP collector point size | local `BGP_COLLECTOR_POINT_SIZE` | `36` | Collector Interactable base size, adjusted by activity through `getPointSizeMultiplier()` |
| BGP collector asset fit size | local `BGP_COLLECTOR_ICON_FIT_SIZE` | `60` | Maximum `bgp-broadcast-pin.svg` draw size inside the atlas canvas |
| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Event ring anchor |
| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector halo / coverage animation anchor |
| Hover / dim scale | `hoverScale / dimmedScale` | `1.16 / 0.92` | Interaction states | | Hover / dim scale | `hoverScale / dimmedScale` | `1.16 / 0.92` | Interaction states |
| Normal event opacity | `BGP_CONFIG.opacity.normal` | `0.78` | Anomaly sprite | | Normal event opacity | `BGP_CONFIG.opacity.normal` | `0.78` | BGP event Interactable normal state |
| Hover opacity | `BGP_CONFIG.opacity.hover` | `1.0` | Hover state | | Hover opacity | `BGP_CONFIG.opacity.hover` | `1.0` | Hover state |
| Dimmed opacity | `BGP_CONFIG.opacity.dimmed` | `0.24` | Dim state | | Dimmed opacity | `BGP_CONFIG.opacity.dimmed` | `0.24` | Dim state |
| Collector opacity | `BGP_CONFIG.opacity.collector` | `0.62` | Collector state | | Collector opacity | `BGP_CONFIG.opacity.collector` | `0.62` | Collector state |

View File

@@ -24,6 +24,7 @@
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径 - [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
- [manual.md](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册 - [manual.md](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路 - [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
- [earth-interactable-usage.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例
不适合放入这里的内容: 不适合放入这里的内容:

View File

@@ -249,11 +249,13 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
AIS 船只图层入口: AIS 船只图层入口:
- [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js) - [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js)
- [interactable.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/interactable.js)
船只图层当前负责: 船只图层当前负责:
- 请求 `/api/v1/visualization/geo/vessels` - 请求 `/api/v1/visualization/geo/vessels`
- 将 BarentsWatch AIS GeoJSON 转为地球局部坐标 marker 数据 - 将 BarentsWatch AIS GeoJSON 转为地球局部坐标 marker 数据
- 通过 `createInteractableLayer()` 注册 Interactable 图标层
- 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker - 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker
- 按船型映射颜色 - 按船型映射颜色
- 根据航行/停泊状态绘制三角形或圆点纹理 - 根据航行/停泊状态绘制三角形或圆点纹理
@@ -271,7 +273,7 @@ AIS 船只图层入口:
方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。 方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。
船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()``main.js` 船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()``main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成
1. 拖动地球或惯性旋转时跳过 hover picking。 1. 拖动地球或惯性旋转时跳过 hover picking。
2. 对 hover picking 做轻量节流。 2. 对 hover picking 做轻量节流。
@@ -293,6 +295,24 @@ AIS 船只图层入口:
图例项颜色来自 `VESSEL_CONFIG.colors`,不要在 `legend.css` 里重新定义业务颜色。新增船型时,应优先改 `vessels.js``constants.js` 的船型映射,再同步图例项。 图例项颜色来自 `VESSEL_CONFIG.colors`,不要在 `legend.css` 里重新定义业务颜色。新增船型时,应优先改 `vessels.js``constants.js` 的船型映射,再同步图例项。
`interactable.js` 是后续地表图标类图层的共用入口。它当前已经承载船只、BGP 事件、BGP 观测站和算力中心图层的批量 `Points`、texture cache、hover / locked overlay、默认 glow、状态增量更新和屏幕空间 pickingBGP 事件的向外扩散圈、BGP 观测站的 halo / 覆盖扇形仍由 `bgp.js` 保留业务动画,但图标本体和 pointer 命中已经接入通用层。新增小型、中心对齐、可以参与深度测试的图标类元素时,应优先复用这个接口,而不是再次复制船只渲染逻辑。
登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset``renderOrder` 与海缆线一致避免漂在海缆之上Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。
图标资源可以继续用 canvas draw也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg``assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加估算位置的 `?` badge。
asset 图标大小由 `Interactable``icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform``drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
`Interactable` 默认使用固定屏幕像素尺寸适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 `sizeMode` 设为非 `"fixed"`,并用 `sizeScale.min / max / referenceFov` 控制缩放范围;单个 marker 的业务尺寸差异可以通过 `getPointSizeMultiplier()` 表达,例如 BGP 事件按严重级别调整点大小BGP 观测站按活跃度调整点大小。
`Interactable` 不再把图标本体额外抬离业务高度。`altitudeOffset` 就是 marker、hover glow、locked glow 和 picking 共同使用的地表高度;这样船只图标会继续贴着船只轨迹线,不会因为单独抬高显示位置而显得漂浮。后续如果要解决边缘 glow 裁切,应优先考虑 glow 纹理、overlay 尺寸或图层专属特效,而不是把通用图标层整体抬高。
跨 Interactable 的同坐标避让也在公共层处理。每个 marker 会保留 `icon_base_position` 作为业务原始位置;当多个 Interactable marker 归入同一个经纬度 key 时,公共层会把它们沿地表切平面排成小圈,并刷新已创建的 `THREE.Points` geometry。这样视觉位置和屏幕空间 picking 位置一致,不需要业务层再单独判断“算力中心和 BGP 事件重叠”这类场景。
接口细节、生命周期和接入示例见:
- [earth-interactable-usage.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
### 视角控制反馈 ### 视角控制反馈
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护 Earth 缩放状态。滚轮缩放、缩放按钮和触屏双指捏合最终都会更新 `zoomLevel`,并通过 `showZoomStatusCapsule()` 显示当前缩放比例: [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护 Earth 缩放状态。滚轮缩放、缩放按钮和触屏双指捏合最终都会更新 `zoomLevel`,并通过 `showZoomStatusCapsule()` 显示当前缩放比例:

View File

@@ -0,0 +1,270 @@
# Earth Interactable 使用说明
`Interactable` 是 Earth 地表“图标类可交互元素”的通用渲染入口。它把船只图层验证过的模式抽成公共能力:普通态用批量 `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 和动画层级。

View File

@@ -146,21 +146,18 @@
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` | | 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity | | 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 | | 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | 对齐算力中心贴地表 marker 高度 | | 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 与海缆线同层贴地,避免凌空 |
| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin中间圆孔透明镂空 | | 登陆点 sprite 高度 | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` 基准高度 |
| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` | | 登陆点缩放参考 FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | 与当前 Earth 相机 FOV 一致 |
| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 | | 登陆点缩放下限 | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | 地球放到最大时的最小倍率;`3 * 0.36 = 1.08` |
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 | | 登陆点缩放上限 | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | 远距离时的最大倍率;当前最小缩放约只能到 `2.50` |
| 登陆点 atlas 尺寸 | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | canvas 扁平立体球纹理尺寸 |
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` | | 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质sprite 不使用 |
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质sprite 不使用 |
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` | | 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | 对齐算力中心地表设施层级 | | 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | 与海缆线同层;`depthTest: false` 保持球体完整,背面通过相机到球心的球体遮挡判断隐藏 |
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 | | 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 | | 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 | | 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
| 非相关登陆点 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | dim 状态弱琥珀自发光 |
| 非相关登陆点 emissive 强度 | `landingPointVisual.dimmed.emissiveIntensity` | `0.18` | dim 状态弱发光强度 |
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 | | 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
## 卫星、轨迹和 footprint ## 卫星、轨迹和 footprint
@@ -212,11 +209,13 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 | | 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` | | 算力中心点像素尺寸 | local `COMPUTE_CENTER_POINT_SIZE` | `36` | `Interactable` 普通 marker 与 hover / locked overlay 共享基准尺寸 |
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker | | 算力中心 asset fit size | local `COMPUTE_CENTER_ICON_FIT_SIZE` | `60` | SVG asset 在 `128x128` atlas canvas 内的最大绘制尺寸,由 `icon.fitSize` 控制 |
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker | | 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | 普通 `PointsMaterial.opacity` |
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 | | 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 旧 Sprite 缩放参数;当前 Interactable 路径不再直接使用 |
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 | | GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | 旧 Sprite 缩放参数;当前 Interactable 路径不再直接使用 |
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover overlay 尺寸倍率 |
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked overlay 尺寸倍率,并叠加 pulse |
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 | | dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture | | 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture | | GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
@@ -227,12 +226,16 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker | | BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP 事件 Interactable marker |
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker | | BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP 观测站 Interactable marker与船只同层贴地 |
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite | | BGP 事件点像素尺寸 | local `BGP_EVENT_POINT_SIZE` | `34` | 事件 icon 的 Interactable 基准尺寸,按严重级别通过 `getPointSizeMultiplier()` 调整 |
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane | | BGP 事件符号绘制尺寸 | local `BGP_EVENT_SYMBOL_SIZE` | `60` | 事件 canvas 符号在 `128x128` atlas 中的绘制尺寸 |
| BGP collector 点像素尺寸 | local `BGP_COLLECTOR_POINT_SIZE` | `36` | 观测站 Interactable 基准尺寸,按活跃度通过 `getPointSizeMultiplier()` 调整 |
| BGP collector asset fit size | local `BGP_COLLECTOR_ICON_FIT_SIZE` | `60` | `bgp-broadcast-pin.svg` 在 atlas canvas 内的最大绘制尺寸 |
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | 事件扩散圈锚点 |
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | 观测站 halo / 覆盖动画锚点 |
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 | | hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite | | 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | BGP 事件 Interactable 普通态 |
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 | | hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 | | dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 | | collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
@@ -244,8 +247,8 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 | | region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring | | BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring | | BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
| collector marker renderOrder | inline | `3` | `marker.renderOrder` | | collector marker renderOrder | local `BGP_COLLECTOR_RENDER_ORDER` | `4.4` | 观测站主图标,与船只同层 |
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` | | anomaly marker renderOrder | local `BGP_EVENT_RENDER_ORDER` | `4.5` | BGP 事件主图标,与算力中心同层 |
## 天体与星空 ## 天体与星空

View File

@@ -21,17 +21,17 @@
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 | | 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 | | 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 | | 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 | | 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 | | 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 | | 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 | | 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 |
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 | | 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 | | 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedGroup renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 | | 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedGroup renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 | | 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4``BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking并参与同坐标避让 | BGP 观测站主图标与船只同层BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1``CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1``CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
| 4.4 | AIS 船只 marker | `vessels.js` | `VESSEL_RENDER_ORDER``CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`hover / locked 为单点 `THREE.Points` overlay | `depthTest: true``main.js` 使用屏幕空间 picking只取正面 marker | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow交互态叠加同尺寸 glow低于算力中心 `4.5`。 | | 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`hover / locked 为单点 `THREE.Points` overlay | `depthTest: true``main.js` 使用屏幕空间 picking只取正面 marker;参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow交互态叠加同尺寸 glow低于算力中心 `4.5`。 |
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 | | 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking参与同坐标避让 | 地表设施,保持在卫星下方。登陆点已下沉到海缆层。 |
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 | | 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | | 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | | 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.45.0` - `dev` 当前开发分支历史推导到:`0.46.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.46.0` | feature | `dev` | `pending` | Earth 新增通用 Interactable 图标层统一船只、算力中心、BGP 事件/观测站交互图标,并优化登陆点与 toolbar 初始渲染 |
| `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context | | `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context |
| `0.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 | | `0.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 |
| `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 | | `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 |

View File

@@ -1,6 +1,6 @@
{ {
"name": "planet-frontend", "name": "planet-frontend",
"version": "0.45.0", "version": "0.46.0",
"private": true, "private": true,
"packageManager": "bun@1", "packageManager": "bun@1",
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ffffff" viewBox="0 0 16 16">
<path d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -1,16 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#2dd4bf" viewBox="0 0 16 16">
<!-- GPU cluster marker: database/cylinder stack icon. --> <path fill-rule="evenodd" d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223Z"/>
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster --> <path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) --> <path d="M2 9.161V10c0 1.007.875 1.755 1.904 2.223C4.978 12.711 6.427 13 8 13s3.022-.289 4.096-.777C13.125 11.755 14 11.007 14 10v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse --> <path d="M2 12.161V13c0 1.007.875 1.755 1.904 2.223C4.978 15.711 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
<g fill="#2dd4bf">
<rect x="46" y="46" width="36" height="28"/>
<ellipse cx="64" cy="46" rx="18" ry="8"/>
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
<rect x="52" y="58" width="24" height="6"/>
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f8fafc" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h5.5v3A1.5 1.5 0 0 0 6 11.5H.5a.5.5 0 0 0 0 1H6A1.5 1.5 0 0 0 7.5 14h1a1.5 1.5 0 0 0 1.5-1.5h5.5a.5.5 0 0 0 0-1H10A1.5 1.5 0 0 0 8.5 10V7H14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm.5 3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -1,10 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" viewBox="0 0 16 16">
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. --> <path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v7A1.5 1.5 0 0 0 1.5 10H6v1H1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5v-1h4.5A1.5 1.5 0 0 0 16 8.5v-7A1.5 1.5 0 0 0 14.5 0h-13Zm0 1h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM12 12.5a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm2 0a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0ZM1.5 12h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1ZM1 14.25a.25.25 0 0 1 .25-.25h5.5a.25.25 0 1 1 0 .5h-5.5a.25.25 0 0 1-.25-.25Z"/>
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<g fill="#38bdf8">
<rect x="40" y="42" width="48" height="30" rx="7"/>
<rect x="58" y="74" width="12" height="8" rx="3"/>
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 4a4 4 0 1 1 4.5 3.969V13.5a.5.5 0 0 1-1 0V7.97A4 4 0 0 1 4 3.999zm2.493 8.574a.5.5 0 0 1-.411.575c-.712.118-1.28.295-1.655.493a1.319 1.319 0 0 0-.37.265.301.301 0 0 0-.057.09V14l.002.008a.147.147 0 0 0 .016.033.617.617 0 0 0 .145.15c.165.13.435.27.813.395.751.25 1.82.414 3.024.414s2.273-.163 3.024-.414c.378-.126.648-.265.813-.395a.619.619 0 0 0 .146-.15.148.148 0 0 0 .015-.033L12 14v-.004a.301.301 0 0 0-.057-.09 1.318 1.318 0 0 0-.37-.264c-.376-.198-.943-.375-1.655-.493a.5.5 0 1 1 .164-.986c.77.127 1.452.328 1.957.594C12.5 13 13 13.4 13 14c0 .426-.26.752-.544.977-.29.228-.68.413-1.116.558-.878.293-2.059.465-3.34.465-1.281 0-2.462-.172-3.34-.465-.436-.145-.826-.33-1.116-.558C3.26 14.752 3 14.426 3 14c0-.599.5-1 .961-1.243.505-.266 1.187-.467 1.957-.594a.5.5 0 0 1 .575.411z"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -24,16 +24,16 @@
} }
.earth-toolbar { .earth-toolbar {
--toolbar-scale: 1; --toolbar-scale: var(--initial-toolbar-scale, 1);
--toolbar-orb-size: calc(46px * var(--toolbar-scale)); --toolbar-orb-size: var(--initial-toolbar-orb-size, calc(46px * var(--toolbar-scale)));
--toolbar-hub-size: calc(58px * var(--toolbar-scale)); --toolbar-hub-size: var(--initial-toolbar-hub-size, calc(58px * var(--toolbar-scale)));
--toolbar-arc-width: calc(420px * var(--toolbar-scale)); --toolbar-arc-width: var(--initial-toolbar-arc-width, calc(420px * var(--toolbar-scale)));
--toolbar-arc-height: calc(160px * var(--toolbar-scale)); --toolbar-arc-height: var(--initial-toolbar-arc-height, calc(160px * var(--toolbar-scale)));
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale)); --toolbar-inner-arc-width: var(--initial-toolbar-inner-arc-width, calc(260px * var(--toolbar-scale)));
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale)); --toolbar-inner-arc-height: var(--initial-toolbar-inner-arc-height, calc(56px * var(--toolbar-scale)));
position: relative; position: relative;
width: min(620px, calc(100vw - 40px)); width: min(620px, calc(100vw - 40px));
height: calc(200px * var(--toolbar-scale)); height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale)));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -155,7 +155,8 @@
height: var(--toolbar-orb-size); height: var(--toolbar-orb-size);
min-width: var(--toolbar-orb-size); min-width: var(--toolbar-orb-size);
min-height: var(--toolbar-orb-size); min-height: var(--toolbar-orb-size);
border-radius: 50%; aspect-ratio: 1 / 1;
border-radius: 9999px;
overflow: hidden; overflow: hidden;
} }
@@ -164,7 +165,8 @@
height: var(--toolbar-hub-size); height: var(--toolbar-hub-size);
min-width: var(--toolbar-hub-size); min-width: var(--toolbar-hub-size);
min-height: var(--toolbar-hub-size); min-height: var(--toolbar-hub-size);
border-radius: 50%; aspect-ratio: 1 / 1;
border-radius: 9999px;
overflow: hidden; overflow: hidden;
color: var(--hud-title); color: var(--hud-title);
} }

View File

@@ -44,6 +44,87 @@
document.documentElement.style.setProperty("--hud-scale", clampedScale.toFixed(3)); document.documentElement.style.setProperty("--hud-scale", clampedScale.toFixed(3));
})(); })();
(function applyInitialToolbarLayout() {
var width = window.innerWidth;
var height = window.innerHeight;
var toolbarBaseWidth = 620;
var toolbarMinScale = 0.68;
var orbSizeBase = 46;
var hubSizeBase = 58;
var orbGapBase = 12;
var archSpanBase = 232;
var archRiseBase = 40;
var sidePaddingBase = 12;
var bottomClearanceBase = 34;
var extraHeightBase = 34;
var visibleOrbCount = 8;
var toolbarWidth = Math.min(toolbarBaseWidth, Math.max(0, width - 40));
var viewportScale = Math.min(width / 1920, height / 1080);
var toolbarScale = Math.max(
toolbarMinScale,
Math.min(1, Math.min(toolbarWidth / toolbarBaseWidth, viewportScale)),
);
var orbSize = orbSizeBase * toolbarScale;
var desiredGap = orbGapBase * toolbarScale;
var span = archSpanBase * toolbarScale;
var rise = archRiseBase * toolbarScale;
var minSpanForSpacing =
visibleOrbCount > 1
? (visibleOrbCount - 1) * (orbSize + desiredGap)
: orbSize;
var maxSpanByWidth =
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
if (minSpanForSpacing > maxSpanByWidth) {
toolbarScale = Math.max(
toolbarMinScale,
Math.min(toolbarScale, maxSpanByWidth / minSpanForSpacing),
);
orbSize = orbSizeBase * toolbarScale;
desiredGap = orbGapBase * toolbarScale;
span = archSpanBase * toolbarScale;
rise = archRiseBase * toolbarScale;
minSpanForSpacing =
visibleOrbCount > 1
? (visibleOrbCount - 1) * (orbSize + desiredGap)
: orbSize;
maxSpanByWidth =
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
}
span = Math.max(minSpanForSpacing, Math.min(maxSpanByWidth, span));
rise = Math.min(rise, span * 0.32);
var hubSize = hubSizeBase * toolbarScale;
var maxVerticalReach = rise + orbSize * 0.5;
var toolbarHeight =
maxVerticalReach +
hubSize +
bottomClearanceBase * toolbarScale +
extraHeightBase * toolbarScale;
var rootStyle = document.documentElement.style;
var roundedOrbSize = Math.round(orbSize);
var roundedHubSize = Math.round(hubSize);
rootStyle.setProperty("--initial-toolbar-scale", toolbarScale.toFixed(3));
rootStyle.setProperty("--initial-toolbar-orb-size", roundedOrbSize + "px");
rootStyle.setProperty("--initial-toolbar-hub-size", roundedHubSize + "px");
rootStyle.setProperty("--initial-toolbar-height", Math.ceil(toolbarHeight) + "px");
rootStyle.setProperty(
"--initial-toolbar-arc-width",
Math.ceil(span + orbSize + sidePaddingBase * 2 * toolbarScale) + "px",
);
rootStyle.setProperty(
"--initial-toolbar-arc-height",
Math.ceil(rise + orbSize * 0.95) + "px",
);
rootStyle.setProperty("--initial-toolbar-inner-arc-width", Math.ceil(span * 0.72) + "px");
rootStyle.setProperty(
"--initial-toolbar-inner-arc-height",
Math.ceil(hubSize * 0.8 + desiredGap * 0.5) + "px",
);
})();
</script> </script>
<link rel="stylesheet" href="css/base.css"> <link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/hud.css"> <link rel="stylesheet" href="css/hud.css">

View File

@@ -1,10 +1,13 @@
import * as THREE from "three"; import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group(); const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group(); const bgpOverlayGroup = new THREE.Group();
const bgpEventOverlayGroup = new THREE.Group();
const bgpCollectorRadarGroup = new THREE.Group();
const collectorMarkers = []; const collectorMarkers = [];
const anomalyMarkers = []; const anomalyMarkers = [];
const activeEventCountByCollector = new Map(); const activeEventCountByCollector = new Map();
@@ -14,7 +17,6 @@ let totalAnomalyCount = 0;
let totalIncidentCount = 0; let totalIncidentCount = 0;
let textureCache = null; let textureCache = null;
let eventRingTextureCache = null; let eventRingTextureCache = null;
let collectorTextureCache = null;
const eventTextureCache = new Map(); const eventTextureCache = new Map();
let activeEventOverlay = null; let activeEventOverlay = null;
let activeCollectorOverlayContext = null; let activeCollectorOverlayContext = null;
@@ -22,17 +24,27 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto", numeric: "auto",
}); });
const collectorWorldPosition = new THREE.Vector3(); const collectorWorldPosition = new THREE.Vector3();
const collectorSurfaceNormal = new THREE.Vector3();
const collectorNorthPole = new THREE.Vector3(0, 1, 0);
const collectorFallbackForward = new THREE.Vector3(0, 0, 1);
const collectorNorthTangent = new THREE.Vector3();
const collectorEastTangent = new THREE.Vector3();
const collectorOrientationMatrix = new THREE.Matrix4();
const colorScratchA = new THREE.Color(); const colorScratchA = new THREE.Color();
const colorScratchB = new THREE.Color(); const colorScratchB = new THREE.Color();
const COLLECTOR_SCAN_SPEED_RAD = 0.00018; const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
const COLLECTOR_SCAN_REBUILD_MS = 80; const COLLECTOR_SCAN_REBUILD_MS = 80;
const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"; const BGP_EVENT_RENDER_ORDER = 4.5;
const BGP_EVENT_POINT_SIZE = 34;
const BGP_EVENT_SYMBOL_SIZE = 60;
const BGP_COLLECTOR_RENDER_ORDER = 4.4;
const BGP_COLLECTOR_ALTITUDE_OFFSET = BGP_CONFIG.collectorAltitudeOffset;
const BGP_COLLECTOR_POINT_SIZE = 36;
const BGP_COLLECTOR_ICON_FIT_SIZE = 60;
const BGP_COLLECTOR_ICON_SOURCE = "/earth/assets/icons/bgp-broadcast-pin.svg";
const BGP_COLLECTOR_HOVER_SCALE = 1.08;
const BGP_COLLECTOR_LOCKED_SCALE = 1.12;
const BGP_COLLECTOR_PULSE_AMPLITUDE = 0.14;
bgpOverlayGroup.name = "bgp-overlay-root";
bgpEventOverlayGroup.name = "bgp-event-overlay-layer";
bgpCollectorRadarGroup.name = "bgp-collector-radar-layer";
bgpOverlayGroup.add(bgpEventOverlayGroup);
bgpOverlayGroup.add(bgpCollectorRadarGroup);
function getMarkerTexture() { function getMarkerTexture() {
if (textureCache) return textureCache; if (textureCache) return textureCache;
@@ -85,48 +97,6 @@ function getEventRingTexture() {
return eventRingTextureCache; return eventRingTextureCache;
} }
function getCollectorTexture() {
if (collectorTextureCache) return collectorTextureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
collectorTextureCache = new THREE.Texture(canvas);
return collectorTextureCache;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke;
context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth;
context.beginPath();
context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2);
context.stroke();
context.save();
context.translate(16, 16);
context.scale(4, 4);
const path = new Path2D(MATERIAL_ACCESS_POINT_PATH);
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth;
context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke;
context.stroke(path);
context.fillStyle = BGP_CONFIG.collectorIcon.pathFill;
context.shadowBlur = 0;
context.fill(path);
context.fillStyle = BGP_CONFIG.collectorIcon.centerFill;
context.beginPath();
context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2);
context.fill();
context.restore();
collectorTextureCache = new THREE.CanvasTexture(canvas);
return collectorTextureCache;
}
function getEventSymbolKind(anomalyType) { function getEventSymbolKind(anomalyType) {
const value = String(anomalyType || "").toLowerCase(); const value = String(anomalyType || "").toLowerCase();
if (value.includes("origin")) return "triangle"; if (value.includes("origin")) return "triangle";
@@ -266,6 +236,169 @@ function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)]; return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
} }
function severityColorHex(severity) {
return `#${getSeverityColor(severity).toString(16).padStart(6, "0")}`;
}
function colorNumberHex(colorNumber) {
return `#${Number(colorNumber || 0xffffff).toString(16).padStart(6, "0")}`;
}
function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false }) {
const kind = getEventSymbolKind(
marker?.userData?.incident_type || marker?.userData?.anomaly_type,
);
context.save();
context.fillStyle = color;
context.strokeStyle = color;
context.lineJoin = "round";
context.lineCap = "round";
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
const inset = (128 - BGP_EVENT_SYMBOL_SIZE) / 2;
context.translate(inset, inset);
context.scale(BGP_EVENT_SYMBOL_SIZE / 128, BGP_EVENT_SYMBOL_SIZE / 128);
if (kind === "triangle") {
drawTriangleSymbol(context);
} else if (kind === "exclamation") {
drawExclamationSymbol(context);
} else if (kind === "wave") {
drawWaveSymbol(context);
} else if (kind === "burst") {
drawBurstSymbol(context);
} else if (kind === "leak") {
drawLeakSymbol(context);
} else {
drawDotSymbol(context);
}
context.restore();
}
const bgpEventIconLayer = createInteractableLayer({
id: "bgp-events",
objectType: "bgp",
renderOrder: BGP_EVENT_RENDER_ORDER,
altitudeOffset: BGP_CONFIG.altitudeOffset,
pointSize: BGP_EVENT_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.keys(BGP_CONFIG.severityColors).map((severity) => [
severity,
severityColorHex(severity),
]),
),
normal: severityColorHex("medium"),
},
opacity: {
normal: BGP_CONFIG.opacity.normal,
hover: BGP_CONFIG.opacity.hover,
locked: BGP_CONFIG.opacity.lockedMax,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_CONFIG.marker.hoverScale,
locked: BGP_CONFIG.marker.hoverScale,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.eventSpeed,
amplitude: BGP_CONFIG.pulse.lockedAmplitude,
},
icon: {
coordinates: "canvas",
draw: drawBGPEventIcon,
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => normalizeSeverity(item.severity),
getBucketKey: (marker) => [
getEventSymbolKind(marker.userData?.incident_type || marker.userData?.anomaly_type),
normalizeSeverity(marker.userData?.severity),
].join(":"),
getPointSizeMultiplier: (marker) => getSeverityScale(marker.userData?.severity),
getUserData: (item) => ({
...item,
baseScale: BGP_CONFIG.marker.eventBaseScale * getSeverityScale(item.severity),
baseColor: getSeverityColor(item.severity),
pulseOffset: Math.random() * Math.PI * 2,
}),
});
const bgpCollectorIconLayer = createInteractableLayer({
id: "bgp-collectors",
objectType: "bgp_collector",
renderOrder: BGP_COLLECTOR_RENDER_ORDER,
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
pointSize: BGP_COLLECTOR_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.entries(BGP_CONFIG.collectorHeatColors).map(([tier, color]) => [
tier,
colorNumberHex(color),
]),
),
normal: colorNumberHex(BGP_CONFIG.collectorColor),
},
opacity: {
normal: BGP_CONFIG.collectorIcon.idleOpacity,
hover: 0.98,
locked: 1,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_COLLECTOR_HOVER_SCALE,
locked: BGP_COLLECTOR_LOCKED_SCALE,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.collectorSpeed,
amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE,
},
icon: {
coordinates: "canvas",
fitSize: BGP_COLLECTOR_ICON_FIT_SIZE,
glowBlur: 16,
source: BGP_COLLECTOR_ICON_SOURCE,
},
getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => getCollectorActivityProfile(item).tier,
getBucketKey: (marker) => {
const scaleBoost = marker.userData?.activity?.scaleBoost ?? 1;
return `${marker.userData?.activity?.tier || "idle"}:${scaleBoost.toFixed(2)}`;
},
getPointSizeMultiplier: (marker) => marker.userData?.activity?.scaleBoost ?? 1,
getUserData: (item) => {
const activity = getCollectorActivityProfile(item);
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
return {
...item,
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
};
},
});
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
@@ -281,7 +414,7 @@ function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1; if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
return getSurfaceMarkerCameraScale(camera, { return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset, altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
referenceFov: 75, referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6), min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9), max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
@@ -299,32 +432,6 @@ function getEventDistanceScale(marker, camera) {
}); });
} }
function orientCollectorMarkerToSurface(marker, position) {
collectorSurfaceNormal.copy(position).normalize();
collectorNorthTangent
.copy(collectorNorthPole)
.projectOnPlane(collectorSurfaceNormal);
if (collectorNorthTangent.lengthSq() < 1e-6) {
collectorNorthTangent
.copy(collectorFallbackForward)
.projectOnPlane(collectorSurfaceNormal);
}
collectorNorthTangent.normalize();
collectorEastTangent
.copy(collectorNorthTangent)
.cross(collectorSurfaceNormal)
.normalize();
collectorOrientationMatrix.makeBasis(
collectorEastTangent,
collectorNorthTangent,
collectorSurfaceNormal,
);
marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix);
}
function getCollectorActivityProfile(markerData) { function getCollectorActivityProfile(markerData) {
const recent24h = Number(markerData?.recent_24h_observation_count || 0); const recent24h = Number(markerData?.recent_24h_observation_count || 0);
const recent7d = Number(markerData?.recent_7d_observation_count || 0); const recent7d = Number(markerData?.recent_7d_observation_count || 0);
@@ -784,13 +891,26 @@ function clearMarkerArray(markers) {
const marker = markers.pop(); const marker = markers.pop();
while (marker.children.length > 0) { while (marker.children.length > 0) {
const child = marker.children.pop(); const child = marker.children.pop();
child.geometry?.dispose?.();
child.material?.dispose(); child.material?.dispose();
} }
disposeCollectorEffectSprites(marker);
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
marker.material?.dispose(); marker.material?.dispose();
bgpGroup.remove(marker); marker.parent?.remove?.(marker);
} }
} }
function disposeAnomalyRingSprites() {
anomalyMarkers.forEach((marker) => {
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
delete marker.userData.ringA;
delete marker.userData.ringB;
});
}
function clearGroup(group) { function clearGroup(group) {
while (group.children.length > 0) { while (group.children.length > 0) {
const child = group.children[group.children.length - 1]; const child = group.children[group.children.length - 1];
@@ -979,46 +1099,14 @@ function createRadialBoundaryPoints(
return points; return points;
} }
function createCollectorMarker(markerData) { function attachCollectorEffectSprites(marker) {
const activity = getCollectorActivityProfile(markerData); const activity = marker.userData.activity;
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
const marker = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({
map: getCollectorTexture(),
color: idleColor,
transparent: true,
opacity: BGP_CONFIG.collectorIcon.idleOpacity,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
}),
);
const position = latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
);
marker.position.copy(position);
marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1);
marker.renderOrder = 3;
marker.visible = showBGP;
orientCollectorMarkerToSurface(marker, position);
const heatHalo = createOverlaySprite({ const heatHalo = createOverlaySprite({
color: activity.color, color: activity.color,
opacity: 0.0, opacity: 0.0,
scale: activity.haloScale * 0.58, scale: activity.haloScale * 0.58,
}); });
heatHalo.renderOrder = 1; heatHalo.renderOrder = 1;
marker.add(heatHalo);
const pulseHalo = createOverlaySprite({ const pulseHalo = createOverlaySprite({
color: activity.color, color: activity.color,
@@ -1026,7 +1114,6 @@ function createCollectorMarker(markerData) {
scale: activity.pulseHaloScale * 0.48, scale: activity.pulseHaloScale * 0.48,
}); });
pulseHalo.renderOrder = 0; pulseHalo.renderOrder = 0;
marker.add(pulseHalo);
const statusCore = createOverlaySprite({ const statusCore = createOverlaySprite({
color: activity.color, color: activity.color,
@@ -1037,9 +1124,7 @@ function createCollectorMarker(markerData) {
BGP_CONFIG.marker.collectorStatusCoreMinScale, BGP_CONFIG.marker.collectorStatusCoreMinScale,
), ),
}); });
statusCore.position.set(0, 0, 0.02);
statusCore.renderOrder = 4; statusCore.renderOrder = 4;
marker.add(statusCore);
const coverageHalo = createOverlaySprite({ const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor, color: BGP_CONFIG.regionColor,
@@ -1048,65 +1133,52 @@ function createCollectorMarker(markerData) {
}); });
coverageHalo.renderOrder = 0; coverageHalo.renderOrder = 0;
coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1); coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1);
marker.add(coverageHalo);
marker.userData = { marker.userData.heatHalo = heatHalo;
type: "bgp_collector", marker.userData.pulseHalo = pulseHalo;
state: "normal", marker.userData.statusCore = statusCore;
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost, marker.userData.coverageHalo = coverageHalo;
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
heatHalo,
pulseHalo,
statusCore,
coverageHalo,
...markerData,
};
collectorMarkers.push(marker); [heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => {
bgpGroup.add(marker); sprite.position.copy(marker.position);
sprite.visible = showBGP;
bgpGroup.add(sprite);
});
} }
function createAnomalyMarker(markerData) { function disposeCollectorEffectSprites(marker) {
const sprite = new THREE.Sprite( [
new THREE.SpriteMaterial({ marker?.userData?.heatHalo,
map: getEventTexture(markerData.incident_type || markerData.anomaly_type), marker?.userData?.pulseHalo,
color: getSeverityColor(markerData.severity), marker?.userData?.statusCore,
transparent: true, marker?.userData?.coverageHalo,
opacity: BGP_CONFIG.opacity.normal, ].forEach((sprite) => {
depthWrite: false, if (!sprite) return;
depthTest: true, sprite.parent?.remove?.(sprite);
blending: THREE.NormalBlending, sprite.material?.dispose?.();
}), sprite.geometry?.dispose?.();
); });
}
const position = latLonToVector3( async function setCollectorMarkers(markerData, earth) {
markerData.latitude, collectorMarkers.forEach(disposeCollectorEffectSprites);
markerData.longitude, collectorMarkers.length = 0;
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset, await bgpCollectorIconLayer.preloadAssets(markerData);
); bgpCollectorIconLayer.setData(markerData);
bgpCollectorIconLayer.attach(earth);
bgpCollectorIconLayer.setVisible(showBGP);
const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity); bgpCollectorIconLayer.getMarkers().forEach((marker) => {
sprite.position.copy(position); attachCollectorEffectSprites(marker);
sprite.scale.setScalar(baseScale); collectorMarkers.push(marker);
sprite.renderOrder = 5; });
sprite.visible = showBGP; }
sprite.userData = {
type: "bgp",
state: "normal",
baseScale,
baseColor: getSeverityColor(markerData.severity),
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
const ringA = new THREE.Sprite( function createEventRingSprite(marker) {
const ring = new THREE.Sprite(
new THREE.SpriteMaterial({ new THREE.SpriteMaterial({
map: getEventRingTexture(), map: getEventRingTexture(),
color: getSeverityColor(markerData.severity), color: marker.userData.baseColor || getSeverityColor(marker.userData.severity),
transparent: true, transparent: true,
opacity: 0, opacity: 0,
depthWrite: false, depthWrite: false,
@@ -1114,30 +1186,40 @@ function createAnomalyMarker(markerData) {
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
}), }),
); );
ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA); ring.position.copy(marker.position);
ringA.position.set(0, 0, -0.01); ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05;
sprite.add(ringA); ring.visible = showBGP;
return ring;
}
const ringB = new THREE.Sprite( function disposeEventRingSprite(ring) {
new THREE.SpriteMaterial({ if (!ring) return;
map: getEventRingTexture(), ring.parent?.remove?.(ring);
color: getSeverityColor(markerData.severity), ring.material?.dispose?.();
transparent: true, ring.geometry?.dispose?.();
opacity: 0, }
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB);
ringB.position.set(0, 0, -0.02);
sprite.add(ringB);
sprite.userData.ringA = ringA; function attachAnomalyRingSprites(marker) {
sprite.userData.ringB = ringB; const ringA = createEventRingSprite(marker);
const ringB = createEventRingSprite(marker);
ringB.visible = false;
marker.userData.ringA = ringA;
marker.userData.ringB = ringB;
bgpGroup.add(ringA);
bgpGroup.add(ringB);
}
anomalyMarkers.push(sprite); function setAnomalyMarkers(markerData, earth) {
bgpGroup.add(sprite); disposeAnomalyRingSprites();
anomalyMarkers.length = 0;
bgpEventIconLayer.setData(markerData);
bgpEventIconLayer.attach(earth);
bgpEventIconLayer.setVisible(showBGP);
bgpEventIconLayer.getMarkers().forEach((marker) => {
attachAnomalyRingSprites(marker);
anomalyMarkers.push(marker);
});
} }
function dedupeAnomalies(features) { function dedupeAnomalies(features) {
@@ -1304,17 +1386,18 @@ export async function loadBGPAnomalies(scene, earth) {
totalIncidentCount = selectedEventData.totalIncidentCount; totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear(); activeEventCountByCollector.clear();
spreadCollectorPositions( const collectorMarkersData = spreadCollectorPositions(
collectorFeatures collectorFeatures
.map(buildCollectorFeatureData) .map(buildCollectorFeatureData)
.filter(Boolean), .filter(Boolean),
).forEach(createCollectorMarker); );
await setCollectorMarkers(collectorMarkersData, earth);
if (selectedEventData.mode === "incident") { const eventMarkers =
dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker); selectedEventData.mode === "incident"
} else { ? dedupeIncidents(selectedEventData.features)
dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker); : dedupeAnomalies(selectedEventData.features);
} setAnomalyMarkers(eventMarkers, earth);
applyCollectorCounts(); applyCollectorCounts();
if (!bgpGroup.parent) { if (!bgpGroup.parent) {
@@ -1360,7 +1443,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
); );
let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera); let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera);
let opacity = BGP_CONFIG.collectorIcon.idleOpacity;
let haloOpacity = 0.0; let haloOpacity = 0.0;
let pulseOpacity = 0.0; let pulseOpacity = 0.0;
let coverageOpacity = 0.0; let coverageOpacity = 0.0;
@@ -1374,14 +1456,12 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (isLocked) { if (isLocked) {
scale *= 1.1 + 0.14 * pulse; scale *= 1.1 + 0.14 * pulse;
opacity = 0.96;
haloOpacity = 0.05; haloOpacity = 0.05;
pulseOpacity = 0.024; pulseOpacity = 0.024;
coverageOpacity = 0.036; coverageOpacity = 0.036;
markerColor = 0xcff2ff; markerColor = 0xcff2ff;
} else if (isHovered) { } else if (isHovered) {
scale *= 1.08; scale *= 1.08;
opacity = 0.88;
haloOpacity = 0.03; haloOpacity = 0.03;
pulseOpacity = 0.014; pulseOpacity = 0.014;
coverageOpacity = 0.02; coverageOpacity = 0.02;
@@ -1392,7 +1472,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
); );
} else if (hasLockedLayer) { } else if (hasLockedLayer) {
scale *= 0.98; scale *= 0.98;
opacity = BGP_CONFIG.collectorIcon.idleOpacity;
haloOpacity = 0.0; haloOpacity = 0.0;
pulseOpacity = 0.0; pulseOpacity = 0.0;
coverageOpacity = 0.0; coverageOpacity = 0.0;
@@ -1401,12 +1480,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
scale *= 1 + 0.05 * pulse; scale *= 1 + 0.05 * pulse;
} }
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
if (marker.userData.heatHalo) { if (marker.userData.heatHalo) {
marker.userData.heatHalo.position.copy(marker.position);
marker.userData.heatHalo.material.opacity = haloOpacity; marker.userData.heatHalo.material.opacity = haloOpacity;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar( marker.userData.heatHalo.scale.setScalar(
@@ -1414,6 +1489,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
); );
} }
if (marker.userData.pulseHalo) { if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.position.copy(marker.position);
marker.userData.pulseHalo.material.opacity = pulseOpacity; marker.userData.pulseHalo.material.opacity = pulseOpacity;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar( marker.userData.pulseHalo.scale.setScalar(
@@ -1421,6 +1497,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
); );
} }
if (marker.userData.statusCore) { if (marker.userData.statusCore) {
marker.userData.statusCore.position.copy(marker.position);
marker.userData.statusCore.material.opacity = marker.userData.statusCore.material.opacity =
isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18; isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18;
marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
@@ -1433,6 +1510,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
); );
} }
if (marker.userData.coverageHalo) { if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity; marker.userData.coverageHalo.material.opacity = coverageOpacity;
marker.userData.coverageHalo.scale.set( marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012), marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
@@ -1442,6 +1520,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
} }
}); });
const focusedCollector =
lockedObjectType === "bgp"
? collectorMarkers.find(
(marker) => marker.userData.collector === lockedObject?.userData?.collector,
)
: lockedObjectType === "bgp_collector"
? lockedObject
: null;
bgpCollectorIconLayer.updateVisualState(
focusedCollector ? "bgp_collector" : lockedObjectType,
focusedCollector || lockedObject,
camera,
);
anomalyMarkers.forEach((marker) => { anomalyMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "bgp" && lockedObject === marker; const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
const isLinkedCollectorLocked = const isLinkedCollectorLocked =
@@ -1459,7 +1551,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
const iconAnchorScale = const iconAnchorScale =
marker.userData.baseScale * getEventDistanceScale(marker, camera); marker.userData.baseScale * getEventDistanceScale(marker, camera);
let scale = iconAnchorScale; let scale = iconAnchorScale;
let opacity = BGP_CONFIG.opacity.normal;
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
const isIncidentMarker = marker.userData.source === "bgp_incident"; const isIncidentMarker = marker.userData.source === "bgp_incident";
let ringBaseOpacity = isIncidentMarker let ringBaseOpacity = isIncidentMarker
@@ -1468,49 +1559,38 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (isLocked || isLinkedCollectorLocked) { if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
markerColor = 0xfff1a8; markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2; ringBaseOpacity *= 1.2;
} else if (isCruise) { } else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
ringBaseOpacity *= 1.2; ringBaseOpacity *= 1.2;
} else if (isHovered) { } else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale; scale *= BGP_CONFIG.marker.hoverScale;
opacity = 0.9;
ringBaseOpacity *= 1.05; ringBaseOpacity *= 1.05;
} else if (isOtherLocked) { } else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale; scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.22;
markerColor = 0x7d8ca3; markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02; ringBaseOpacity = 0.02;
} else { } else {
scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse; scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse;
opacity = isIncidentMarker ? 0.7 : 0.62;
} }
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1; const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => { const applyRingState = (ring, phase, maxScale) => {
if (!ring) return; if (!ring) return;
const progress = Math.max(0, Math.min(1, phase)); const progress = Math.max(0, Math.min(1, phase));
const minScale = 1.28; const minScale = 1.28;
const desiredWorldScale = const desiredWorldScale =
iconAnchorScale * (minScale + progress * (maxScale - minScale)); scale * (minScale + progress * (maxScale - minScale));
const parentScale = Math.max(scale, 0.0001);
const localRingScale = desiredWorldScale / parentScale;
const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14)); const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14));
const fadeOut = 1 - progress; const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut; const visibility = fadeIn * fadeOut;
ring.scale.setScalar(localRingScale); ring.position.copy(marker.position);
ring.scale.setScalar(desiredWorldScale);
ring.material.color.setHex(markerColor); ring.material.color.setHex(markerColor);
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0; ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
ring.visible = showBGP; ring.visible = showBGP;
ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05;
}; };
applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA); applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA);
@@ -1519,6 +1599,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
marker.userData.ringB.visible = false; marker.userData.ringB.visible = false;
} }
}); });
bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
} }
export function setBGPMarkerState(marker, state = "normal") { export function setBGPMarkerState(marker, state = "normal") {
@@ -1526,15 +1608,19 @@ export function setBGPMarkerState(marker, state = "normal") {
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") { if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return; return;
} }
marker.userData.state = state; if (marker.userData.type === "bgp") {
bgpEventIconLayer.setMarkerState(marker, state);
return;
}
bgpCollectorIconLayer.setMarkerState(marker, state);
} }
export function clearBGPSelection() { export function clearBGPSelection() {
collectorMarkers.forEach((marker) => { collectorMarkers.forEach((marker) => {
marker.userData.state = "normal"; bgpCollectorIconLayer.setMarkerState(marker, "normal");
}); });
anomalyMarkers.forEach((marker) => { anomalyMarkers.forEach((marker) => {
marker.userData.state = "normal"; bgpEventIconLayer.setMarkerState(marker, "normal");
}); });
clearBGPEventOverlay(); clearBGPEventOverlay();
} }
@@ -1542,6 +1628,8 @@ export function clearBGPSelection() {
export function clearBGPData(earth) { export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers); clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers); clearMarkerArray(anomalyMarkers);
bgpCollectorIconLayer.clearData(earth);
bgpEventIconLayer.clearData(earth);
clearBGPEventOverlay(); clearBGPEventOverlay();
activeEventCountByCollector.clear(); activeEventCountByCollector.clear();
totalAnomalyCount = 0; totalAnomalyCount = 0;
@@ -1559,11 +1647,21 @@ export function toggleBGP(show) {
showBGP = Boolean(show); showBGP = Boolean(show);
bgpGroup.visible = showBGP; bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP;
bgpCollectorIconLayer.setVisible(showBGP);
bgpEventIconLayer.setVisible(showBGP);
collectorMarkers.forEach((marker) => { collectorMarkers.forEach((marker) => {
marker.visible = showBGP; [
marker.userData.heatHalo,
marker.userData.pulseHalo,
marker.userData.statusCore,
marker.userData.coverageHalo,
].forEach((sprite) => {
if (sprite) sprite.visible = showBGP;
});
}); });
anomalyMarkers.forEach((marker) => { anomalyMarkers.forEach((marker) => {
marker.visible = showBGP; marker.userData.ringA && (marker.userData.ringA.visible = showBGP);
marker.userData.ringB && (marker.userData.ringB.visible = false);
}); });
} }
@@ -1579,6 +1677,14 @@ export function getBGPAnomalyMarkers() {
return anomalyMarkers; return anomalyMarkers;
} }
export function getBGPAnomalyPointerIntersections(options = {}) {
return bgpEventIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorPointerIntersections(options = {}) {
return bgpCollectorIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorMarkers() { export function getBGPCollectorMarkers() {
return collectorMarkers; return collectorMarkers;
} }
@@ -1644,11 +1750,11 @@ export function showBGPEventOverlay(marker, earth) {
latLonToVector3( latLonToVector3(
region.latitude, region.latitude,
region.longitude, region.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1, CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1,
), ),
); );
halo.renderOrder = 2; halo.renderOrder = 2;
bgpOverlayGroup.add(halo); bgpEventOverlayGroup.add(halo);
overlayItems.push(halo); overlayItems.push(halo);
}); });
@@ -1687,11 +1793,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
latLonToVector3( latLonToVector3(
marker.userData.displayLatitude ?? marker.userData.latitude, marker.userData.displayLatitude ?? marker.userData.latitude,
marker.userData.displayLongitude ?? marker.userData.longitude, marker.userData.displayLongitude ?? marker.userData.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15, CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.15,
), ),
); );
halo.renderOrder = 2; halo.renderOrder = 2;
bgpOverlayGroup.add(halo); bgpCollectorRadarGroup.add(halo);
const pulseHalo = createOverlaySprite({ const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor, color: BGP_CONFIG.collectorColor,
@@ -1700,7 +1806,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
}); });
pulseHalo.position.copy(halo.position); pulseHalo.position.copy(halo.position);
pulseHalo.renderOrder = 1; pulseHalo.renderOrder = 1;
bgpOverlayGroup.add(pulseHalo); bgpCollectorRadarGroup.add(pulseHalo);
const innerRing = createOverlaySprite({ const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor, color: BGP_CONFIG.collectorColor,
opacity: 0.12, opacity: 0.12,
@@ -1708,7 +1814,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
}); });
innerRing.position.copy(halo.position); innerRing.position.copy(halo.position);
innerRing.renderOrder = 3; innerRing.renderOrder = 3;
bgpOverlayGroup.add(innerRing); bgpCollectorRadarGroup.add(innerRing);
const overlayItems = [halo, pulseHalo, innerRing]; const overlayItems = [halo, pulseHalo, innerRing];
const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude; const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude;
@@ -1723,8 +1829,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI); const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI); const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44; const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4; const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
const leftBoundaryPoints = createRadialBoundaryPoints( const leftBoundaryPoints = createRadialBoundaryPoints(
anchorLatitude, anchorLatitude,
anchorLongitude, anchorLongitude,
@@ -1765,7 +1871,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.12, 0.12,
); );
sectorFill.renderOrder = 2; sectorFill.renderOrder = 2;
bgpOverlayGroup.add(sectorFill); bgpCollectorRadarGroup.add(sectorFill);
overlayItems.push(sectorFill); overlayItems.push(sectorFill);
const outerArc = createCoverageBoundaryLine( const outerArc = createCoverageBoundaryLine(
@@ -1774,7 +1880,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.9, 0.9,
); );
outerArc.renderOrder = 3; outerArc.renderOrder = 3;
bgpOverlayGroup.add(outerArc); bgpCollectorRadarGroup.add(outerArc);
overlayItems.push(outerArc); overlayItems.push(outerArc);
const leftBoundary = createCoverageBoundaryLine( const leftBoundary = createCoverageBoundaryLine(
@@ -1783,7 +1889,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.76, 0.76,
); );
leftBoundary.renderOrder = 3; leftBoundary.renderOrder = 3;
bgpOverlayGroup.add(leftBoundary); bgpCollectorRadarGroup.add(leftBoundary);
overlayItems.push(leftBoundary); overlayItems.push(leftBoundary);
const rightBoundary = createCoverageBoundaryLine( const rightBoundary = createCoverageBoundaryLine(
@@ -1792,7 +1898,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.76, 0.76,
); );
rightBoundary.renderOrder = 3; rightBoundary.renderOrder = 3;
bgpOverlayGroup.add(rightBoundary); bgpCollectorRadarGroup.add(rightBoundary);
overlayItems.push(rightBoundary); overlayItems.push(rightBoundary);
activeEventOverlay = overlayItems; activeEventOverlay = overlayItems;
@@ -1808,7 +1914,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
export function clearBGPEventOverlay() { export function clearBGPEventOverlay() {
activeEventOverlay = null; activeEventOverlay = null;
activeCollectorOverlayContext = null; activeCollectorOverlayContext = null;
clearGroup(bgpOverlayGroup); clearGroup(bgpEventOverlayGroup);
clearGroup(bgpCollectorRadarGroup);
} }
function updateCollectorOverlayScan(lockedObjectType, lockedObject) { function updateCollectorOverlayScan(lockedObjectType, lockedObject) {

View File

@@ -20,80 +20,110 @@ export let lockedCable = null;
let cableIdMap = new Map(); let cableIdMap = new Map();
let cableStates = new Map(); let cableStates = new Map();
let cablesVisible = true; let cablesVisible = true;
let landingPointTexture = null;
const _lpEarthWorldPos = new THREE.Vector3(); const _lpEarthWorldPos = new THREE.Vector3();
const _lpWorldPos = new THREE.Vector3(); const _lpWorldPos = new THREE.Vector3();
const _lpCameraRel = new THREE.Vector3(); const _lpCameraRel = new THREE.Vector3();
const _lpPointRel = new THREE.Vector3(); const _lpPointRel = new THREE.Vector3();
const _lpCameraToPoint = new THREE.Vector3(); const _lpCameraToPoint = new THREE.Vector3();
const LANDING_POINT_SPRITE_HEIGHT = 3;
const LANDING_POINT_SPRITE_ASPECT = 1;
const LANDING_POINT_SIZE_REFERENCE_FOV = 75;
const LANDING_POINT_SIZE_SCALE_MIN = 0.36;
const LANDING_POINT_SIZE_SCALE_MAX = 3;
const LANDING_POINT_ATLAS_CELL_SIZE = 128;
let landingPointTexture = null;
function createLandingPointTexture() { function getLandingPointPulse() {
const size = CABLE_CONFIG.landingPoint.textureSize; return (
const canvas = document.createElement("canvas"); Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1
canvas.width = size; ) * 0.5;
canvas.height = size; }
const ctx = canvas.getContext("2d");
const iconPath = new Path2D( function getLandingPointDimColor() {
[ const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
"M400 704", const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
"C386 704 375 697 367 684", const color = new THREE.Color(
"L173 378", (dimColor.r * brightness) / 255,
"C117 290 144 173 229 111", (dimColor.g * brightness) / 255,
"C278 75 337 57 400 57", (dimColor.b * brightness) / 255,
"C463 57 522 75 571 111",
"C656 173 683 290 627 378",
"L433 684",
"C425 697 414 704 400 704",
"Z",
].join(" "),
); );
return `#${color.getHexString()}`;
}
ctx.clearRect(0, 0, size, size); function createLandingPointBallTexture() {
ctx.save(); const canvas = document.createElement("canvas");
ctx.translate(size * 0.12, size * 0.02); canvas.width = LANDING_POINT_ATLAS_CELL_SIZE;
ctx.scale(size / 1000, size / 1000); canvas.height = LANDING_POINT_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
const center = LANDING_POINT_ATLAS_CELL_SIZE / 2;
const radius = 46;
ctx.fillStyle = "#ffffff"; context.clearRect(0, 0, canvas.width, canvas.height);
ctx.fill(iconPath);
ctx.globalCompositeOperation = "destination-out"; const shadow = context.createRadialGradient(
ctx.beginPath(); center - 16,
ctx.arc(400, 320, 86, 0, Math.PI * 2); center - 18,
ctx.fill(); 8,
ctx.restore(); center,
center,
radius,
);
shadow.addColorStop(0, "rgba(255, 255, 255, 1)");
shadow.addColorStop(0.48, "rgba(238, 238, 238, 0.98)");
shadow.addColorStop(0.82, "rgba(178, 178, 178, 0.94)");
shadow.addColorStop(1, "rgba(92, 92, 92, 0.88)");
context.beginPath();
context.arc(center, center, radius, 0, Math.PI * 2);
context.fillStyle = shadow;
context.fill();
context.beginPath();
context.ellipse(center - 14, center - 18, 14, 9, -0.45, 0, Math.PI * 2);
context.fillStyle = "rgba(255, 255, 255, 0.38)";
context.fill();
context.beginPath();
context.arc(center, center, radius - 1, 0, Math.PI * 2);
context.strokeStyle = "rgba(255, 255, 255, 0.24)";
context.lineWidth = 2;
context.stroke();
const texture = new THREE.CanvasTexture(canvas); const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true; texture.needsUpdate = true;
return texture; return texture;
} }
function getLandingPointTexture() { async function getLandingPointTexture() {
if (!landingPointTexture) { if (landingPointTexture) return landingPointTexture;
landingPointTexture = createLandingPointTexture(); landingPointTexture = createLandingPointBallTexture();
}
return landingPointTexture; return landingPointTexture;
} }
function clamp(value, min, max) { function getLandingPointDistanceScale(camera) {
return Math.min(max, Math.max(min, value)); if (!camera) return 1;
}
function getLandingPointDistanceScale(point, camera) {
if (
!point ||
!camera ||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
) return 1;
return getSurfaceMarkerCameraScale(camera, { return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset, altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75, referenceFov: LANDING_POINT_SIZE_REFERENCE_FOV,
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12, min: LANDING_POINT_SIZE_SCALE_MIN,
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0, max: LANDING_POINT_SIZE_SCALE_MAX,
}); });
} }
function setLandingPointScale(point, camera = null) {
const height = LANDING_POINT_SPRITE_HEIGHT * getLandingPointDistanceScale(camera);
point.scale.set(height * LANDING_POINT_SPRITE_ASPECT, height, 1);
}
function setLandingPointMaterialState(point, { color, opacity }) {
point.material.color.set(color);
point.material.opacity = opacity;
}
function disposeMaterial(material) { function disposeMaterial(material) {
if (!material) return; if (!material) return;
@@ -122,22 +152,6 @@ function disposeObject(object, parent) {
} }
} }
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
point.material.color.set(color);
point.material.opacity = opacity;
if (point.material.emissive && emissive !== undefined) {
point.material.emissive.setHex(emissive);
}
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
point.material.emissiveIntensity = emissiveIntensity;
}
}
function setLandingPointScale(point, heightScale) {
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
point.scale.set(heightScale * aspect, heightScale, 1);
}
function getCableColor(properties) { function getCableColor(properties) {
if (properties.color) { if (properties.color) {
if ( if (
@@ -426,7 +440,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
clearLandingPoints(earthObj); clearLandingPoints(earthObj);
let validCount = 0; const markerTexture = await getLandingPointTexture();
for (const feature of data.features) { for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue; if (!feature.geometry || !feature.geometry.coordinates) continue;
@@ -460,20 +474,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
const marker = new THREE.Sprite( const marker = new THREE.Sprite(
new THREE.SpriteMaterial({ new THREE.SpriteMaterial({
map: getLandingPointTexture(), map: markerTexture,
color: CABLE_CONFIG.landingPoint.color, color: CABLE_CONFIG.landingPoint.color,
transparent: true, transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity, opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: false, depthTest: false,
depthWrite: false, depthWrite: false,
alphaTest: 0.01,
}), }),
); );
marker.material.userData.sharedMap = true; marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder; marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set( marker.center.set(0.5, 0.5);
CABLE_CONFIG.landingPoint.anchorX,
CABLE_CONFIG.landingPoint.anchorY,
);
marker.position.copy(position); marker.position.copy(position);
marker.userData = { marker.userData = {
type: "landingPoint", type: "landingPoint",
@@ -481,15 +493,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
cableNames: properties.cable_names || [], cableNames: properties.cable_names || [],
country: properties.country || "未知国家", country: properties.country || "未知国家",
status: properties.status || "Unknown", status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale, latitude: lat,
longitude: lon,
landing_visual_state: "normal",
}; };
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale); setLandingPointScale(marker);
earthObj.add(marker); earthObj.add(marker);
landingPoints.push(marker); landingPoints.push(marker);
validCount++;
} }
const validCount = landingPoints.length;
setEarthStatValue("landing-point-count", `${validCount}`); setEarthStatValue("landing-point-count", `${validCount}`);
if (!silent) { if (!silent) {
@@ -619,10 +634,8 @@ function isFacingCamera(lp, camera) {
const distance = Math.sqrt(distanceSq); const distance = Math.sqrt(distanceSq);
_lpCameraToPoint.multiplyScalar(1 / distance); _lpCameraToPoint.multiplyScalar(1 / distance);
// The pin sprite is rendered without depth testing so its full shape does // Sprite rendering keeps the full pin visible. Hide it when the anchor point
// not get sliced by the globe. Instead, hide it when the camera-to-anchor // is occluded by the globe so back-side landing points do not bleed through.
// segment is occluded by a slightly inflated globe, matching the behavior of
// the BGP and compute-center markers near the limb.
const occlusionRadius = const occlusionRadius =
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45; CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45;
const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint); const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint);
@@ -638,75 +651,52 @@ function isFacingCamera(lp, camera) {
} }
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) { export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
const pulse =
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
const relatedNames = Array.isArray(lockedCableName) const relatedNames = Array.isArray(lockedCableName)
? lockedCableName.filter(Boolean) ? lockedCableName.filter(Boolean)
: lockedCableName : lockedCableName
? [lockedCableName] ? [lockedCableName]
: []; : [];
landingPoints.forEach((lp) => { landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true); const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isRelated = const isRelated =
!dimAll && !dimAll &&
Array.isArray(lp.userData.cableNames) && Array.isArray(lp.userData.cableNames) &&
lp.userData.cableNames.some((name) => relatedNames.includes(name)); lp.userData.cableNames.some((name) => relatedNames.includes(name));
lp.visible = isVisible;
setLandingPointScale(lp, camera);
if (isRelated) { if (isRelated) {
const pulse = getLandingPointPulse();
setLandingPointMaterialState(lp, { setLandingPointMaterialState(lp, {
color: 0xffd27a, color: 0xffd27a,
emissive: 0x7a4a00,
emissiveIntensity:
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
opacity: Math.max( opacity: Math.max(
0.92, 0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase + CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse, pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
), ),
}); });
const distanceScale = getLandingPointDistanceScale(lp, camera); lp.userData.landing_visual_state = "related";
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(
lp,
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
baseScale *
distanceScale,
);
} else { } else {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const r = dimColor.r * brightness;
const g = dimColor.g * brightness;
const b = dimColor.b * brightness;
setLandingPointMaterialState(lp, { setLandingPointMaterialState(lp, {
color: new THREE.Color(r / 255, g / 255, b / 255), color: getLandingPointDimColor(),
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity, opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
}); });
const distanceScale = getLandingPointDistanceScale(lp, camera); lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden";
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
} }
}); });
} }
export function resetLandingPointVisualState(camera = null) { export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => { landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true); const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
lp.visible = isVisible;
setLandingPointScale(lp, camera);
setLandingPointMaterialState(lp, { setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color, color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
opacity: CABLE_CONFIG.landingPoint.opacity, opacity: CABLE_CONFIG.landingPoint.opacity,
}); });
const distanceScale = getLandingPointDistanceScale(lp, camera); lp.userData.landing_visual_state = isVisible ? "normal" : "hidden";
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
}); });
} }

View File

@@ -1,12 +1,15 @@
import * as THREE from "three"; import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const COMPUTE_CENTER_RENDER_ORDER = 4.5; const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const textureCache = new Map(); const COMPUTE_CENTER_POINT_SIZE = 36;
const COMPUTE_CENTER_ICON_FIT_SIZE = 60;
const COMPUTE_CENTER_ATLAS_CELL_SIZE = 128;
const COMPUTE_CENTER_ICON_SOURCES = {
supercomputer: "/earth/assets/icons/compute-supercomputer.svg",
gpu_cluster: "/earth/assets/icons/compute-gpu-cluster.svg",
infrastructure: "/earth/assets/icons/compute-hdd-network.svg",
};
let showComputeCenters = true; let showComputeCenters = true;
let supercomputerCount = 0; let supercomputerCount = 0;
let gpuClusterCount = 0; let gpuClusterCount = 0;
@@ -69,71 +72,13 @@ function spreadComputeCenterPositions(markers) {
return markers; return markers;
} }
function createMarkerTexture(siteType, isEstimated = false) { function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
if (textureCache.has(textureKey)) {
return textureCache.get(textureKey);
}
const color =
COMPUTE_CENTER_CONFIG.colors[siteType] ||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
const centerX = 64;
const centerY = 64;
const baseFill = color;
function fillPath(draw, options = {}) {
const { fillStyle = color } = options;
context.save();
context.fillStyle = fillStyle;
context.beginPath();
draw();
context.fill();
context.restore();
}
context.clearRect(0, 0, 128, 128);
if (siteType === "supercomputer") {
fillPath(() => {
context.roundRect(40, 42, 48, 30, 7);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.roundRect(58, 74, 12, 8, 3);
context.roundRect(50, 84, 28, 5, 2.5);
}, {
fillStyle: baseFill,
});
} else {
fillPath(() => {
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
context.rect(46, 46, 36, 28);
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
context.rect(52, 58, 24, 6);
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
}
if (isEstimated) { if (isEstimated) {
fillPath(() => {
context.arc(94, 36, 12, 0, Math.PI * 2);
}, {
fillStyle: "rgba(15,23,42,0.92)",
});
context.save(); context.save();
context.fillStyle = "rgba(15,23,42,0.92)";
context.beginPath();
context.arc(94, 36, 12, 0, Math.PI * 2);
context.fill();
context.fillStyle = "rgba(255,255,255,0.98)"; context.fillStyle = "rgba(255,255,255,0.98)";
context.font = "bold 18px sans-serif"; context.font = "bold 18px sans-serif";
context.textAlign = "center"; context.textAlign = "center";
@@ -141,76 +86,74 @@ function createMarkerTexture(siteType, isEstimated = false) {
context.fillText("?", 94, 36); context.fillText("?", 94, 36);
context.restore(); context.restore();
} }
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
} }
function normalizeSiteType(siteType) { function normalizeSiteType(siteType) {
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster"; return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
} }
function getBaseScale(siteType) { const computeCenterIconLayer = createInteractableLayer({
return siteType === "supercomputer" id: "computeCenters",
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale objectType: "compute_center",
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale; renderOrder: COMPUTE_CENTER_RENDER_ORDER,
}
function getDistanceScale(marker, camera) {
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
return 1;
}
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset, altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
referenceFov: 75, pointSize: COMPUTE_CENTER_POINT_SIZE,
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min, atlasCellSize: COMPUTE_CENTER_ATLAS_CELL_SIZE,
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max, colors: {
}); byKind: COMPUTE_CENTER_CONFIG.colors,
} normal: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
function clearGroup(group) { opacity: {
for (let index = group.children.length - 1; index >= 0; index -= 1) { normal: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
const child = group.children[index]; dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedOpacity,
child.material?.dispose?.(); hover: 0.98,
group.remove(child); locked: 1,
} },
} stateScale: {
hover: COMPUTE_CENTER_CONFIG.marker.hoverScale,
function createComputeCenterMarker(markerData) { locked: COMPUTE_CENTER_CONFIG.marker.lockedScale,
const siteType = markerData.site_type; dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedScale,
const material = new THREE.SpriteMaterial({ },
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)), pulse: {
transparent: true, enabled: true,
depthWrite: false, speed: COMPUTE_CENTER_CONFIG.marker.pulseSpeed,
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity, amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude,
}); },
const marker = new THREE.Sprite(material); icon: {
const baseScale = getBaseScale(siteType); coordinates: "canvas",
marker.position.copy( colorable: false,
latLonToVector3( fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
markerData.displayLatitude, glowBlur: 16,
markerData.displayLongitude, getSource({ marker, item }) {
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset, const siteType =
), marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return (
COMPUTE_CENTER_ICON_SOURCES[siteType] ||
COMPUTE_CENTER_ICON_SOURCES.infrastructure
); );
marker.scale.setScalar(baseScale); },
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER; afterDraw(context, { marker, item }) {
marker.visible = showComputeCenters; drawComputeCenterEstimatedBadge(
marker.userData = { context,
...markerData, Boolean(marker?.userData?.is_estimated ?? item?.is_estimated),
site_type: siteType, );
type: "compute_center", },
baseScale, },
state: "normal", getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => item.site_type || "gpu_cluster",
getBucketKey: (marker) =>
[
marker.userData?.site_type || "gpu_cluster",
marker.userData?.is_estimated ? "estimated" : "precise",
].join(":"),
getUserData: (item) => ({
...item,
pulseOffset: Math.random() * Math.PI * 2, pulseOffset: Math.random() * Math.PI * 2,
}; }),
computeCenterGroup.add(marker); });
computeCenterMarkers.push(marker);
return marker;
}
export function formatComputeCenterTypeLabel(siteType) { export function formatComputeCenterTypeLabel(siteType) {
return siteType === "supercomputer" ? "超算中心" : "GPU 集群"; return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
@@ -252,11 +195,11 @@ export function getComputeCenterLegendItems() {
} }
export function getComputeCenterMarkers() { export function getComputeCenterMarkers() {
return computeCenterMarkers; return computeCenterIconLayer.getMarkers();
} }
export function getComputeCenterCount() { export function getComputeCenterCount() {
return computeCenterMarkers.length; return computeCenterIconLayer.getCount();
} }
export function getComputeCenterSupercomputerCount() { export function getComputeCenterSupercomputerCount() {
@@ -268,35 +211,27 @@ export function getComputeCenterGPUClusterCount() {
} }
export function getComputeCenterStatusSummary() { export function getComputeCenterStatusSummary() {
if (computeCenterMarkers.length === 0) return "暂无算力中心数据"; if (getComputeCenterCount() === 0) return "暂无算力中心数据";
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`; return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
} }
export function setComputeCenterMarkerState(marker, state = "normal") { export function setComputeCenterMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "compute_center") return; computeCenterIconLayer.setMarkerState(marker, state);
marker.userData.state = state;
} }
export function clearComputeCenterSelection() { export function clearComputeCenterSelection() {
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal")); getComputeCenterMarkers().forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
} }
export function clearComputeCenterData(earth) { export function clearComputeCenterData(earth) {
computeCenterMarkers.length = 0;
supercomputerCount = 0; supercomputerCount = 0;
gpuClusterCount = 0; gpuClusterCount = 0;
clearGroup(computeCenterGroup); computeCenterIconLayer.clearData(earth);
if (earth && computeCenterGroup.parent === earth) {
earth.remove(computeCenterGroup);
}
} }
export function toggleComputeCenters(show) { export function toggleComputeCenters(show) {
showComputeCenters = Boolean(show); showComputeCenters = Boolean(show);
computeCenterGroup.visible = showComputeCenters; computeCenterIconLayer.setVisible(showComputeCenters);
computeCenterMarkers.forEach((marker) => {
marker.visible = showComputeCenters;
});
} }
export function getShowComputeCenters() { export function getShowComputeCenters() {
@@ -313,64 +248,37 @@ export async function loadComputeCenters(_scene, earth) {
clearComputeCenterData(earth); clearComputeCenterData(earth);
spreadComputeCenterPositions( const markerData = spreadComputeCenterPositions(
features features
.map((feature) => buildComputeCenterMarkerData(feature)) .map((feature) => buildComputeCenterMarkerData(feature))
.filter(Boolean), .filter(Boolean),
) )
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers) .slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
.forEach((markerData) => {
const marker = createComputeCenterMarker(markerData); markerData.forEach((item) => {
if (!marker) return; if (item.site_type === "supercomputer") {
if (marker.userData.site_type === "supercomputer") {
supercomputerCount += 1; supercomputerCount += 1;
} else { } else {
gpuClusterCount += 1; gpuClusterCount += 1;
} }
}); });
await computeCenterIconLayer.preloadAssets(markerData);
if (earth && !computeCenterGroup.parent) { computeCenterIconLayer.setData(markerData);
earth.add(computeCenterGroup); computeCenterIconLayer.attach(earth);
} computeCenterIconLayer.setVisible(showComputeCenters);
computeCenterGroup.visible = showComputeCenters;
return { return {
totalCount: computeCenterMarkers.length, totalCount: getComputeCenterCount(),
supercomputerCount, supercomputerCount,
gpuClusterCount, gpuClusterCount,
summary: getComputeCenterStatusSummary(), summary: getComputeCenterStatusSummary(),
}; };
} }
export function getComputeCenterPointerIntersections(options) {
return computeCenterIconLayer.getPointerIntersections(options);
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) { export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
const hasFocus = lockedObjectType === "compute_center" && lockedObject; computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
const now = Date.now();
computeCenterMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
const state = marker.userData?.state || "normal";
const pulse =
1 +
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
let scaleMultiplier = 1;
if (isLocked) {
opacity = 1;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
} else if (state === "hover") {
opacity = 0.98;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
} else if (hasFocus) {
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
}
const distanceScale = getDistanceScale(marker, camera);
marker.material.opacity = showComputeCenters ? opacity : 0;
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
marker.visible = showComputeCenters;
});
} }

View File

@@ -293,39 +293,20 @@ export const CABLE_CONFIG = {
renderOrder: 1, renderOrder: 1,
}, },
landingPoint: { landingPoint: {
altitudeOffset: 0.48, altitudeOffset: 0.2,
textureSize: 256,
iconAspectRatio: 0.82,
anchorX: 0.52,
anchorY: 0.276,
baseScale: 12,
color: 0xffaa00, color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5,
opacity: 1.0, opacity: 1.0,
renderOrder: 4.5, renderOrder: 1,
},
landingPointSizeStabilization: {
enabled: true,
referenceFov: 75,
min: 0.12,
max: 3.0,
}, },
landingPointVisual: { landingPointVisual: {
pulseSpeed: 0.003, pulseSpeed: 0.003,
dimBrightness: 0.62, dimBrightness: 0.62,
related: { related: {
emissiveIntensityBase: 0.5,
emissiveIntensityPulse: 0.5,
opacityBase: 0.8, opacityBase: 0.8,
opacityPulse: 0.2, opacityPulse: 0.2,
scaleBase: 1.2,
scalePulse: 0.3,
}, },
dimmed: { dimmed: {
colorRGB: { r: 180, g: 116, b: 28 }, colorRGB: { r: 180, g: 116, b: 28 },
emissive: 0x3a2200,
emissiveIntensity: 0.18,
opacity: 0.78, opacity: 0.78,
}, },
}, },
@@ -364,8 +345,8 @@ export const SATELLITE_CONFIG = {
export const BGP_CONFIG = { export const BGP_CONFIG = {
defaultFetchLimit: 200, defaultFetchLimit: 200,
maxRenderedMarkers: 200, maxRenderedMarkers: 200,
altitudeOffset: 2.1, altitudeOffset: 0.48,
collectorAltitudeOffset: 1.6, collectorAltitudeOffset: 0.2,
marker: { marker: {
eventBaseScale: 6.2, eventBaseScale: 6.2,
collectorBaseScale: 7.4, collectorBaseScale: 7.4,

View File

@@ -3301,6 +3301,8 @@ function setupToolbarHubCluster() {
const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale; const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale;
toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3)); toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3));
toolbar.style.setProperty("--toolbar-orb-size", `${Math.round(orbSize)}px`);
toolbar.style.setProperty("--toolbar-hub-size", `${Math.round(hubSize)}px`);
toolbar.style.height = `${Math.ceil(toolbarHeight)}px`; toolbar.style.height = `${Math.ceil(toolbarHeight)}px`;
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`); toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`); toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);

View File

@@ -0,0 +1,899 @@
import * as THREE from "three";
import { CONFIG } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const assetImageCache = new Map();
const assetImageLoadPromises = new Map();
const surfaceAvoidanceBuckets = new Map();
const interactableLayerControllers = new Map();
const DEFAULT_AVOIDANCE_PRECISION = 4;
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
const DEFAULT_AVOIDANCE_STEP = 0.35;
const AVOIDANCE_RING_SLOT_COUNT = 8;
const TANGENT_EPSILON_SQ = 1e-6;
const avoidanceNorthPole = new THREE.Vector3(0, 1, 0);
const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0);
const avoidanceCenterScratch = new THREE.Vector3();
const avoidanceEastScratch = new THREE.Vector3();
const avoidanceNorthScratch = new THREE.Vector3();
const avoidancePositionScratch = new THREE.Vector3();
function colorToRgbArray(colorValue, fallback = "#ffffff") {
const color = new THREE.Color(colorValue || fallback);
return [color.r, color.g, color.b];
}
function toFiniteNumber(value, fallback) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function disposeGroupChildren(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
child.geometry?.dispose?.();
group.remove(child);
}
}
function normalizePosition(position, radius) {
if (position instanceof THREE.Vector3) {
return position.clone();
}
const lat = Number(position?.latitude ?? position?.lat);
const lon = Number(position?.longitude ?? position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return latLonToVector3(lat, lon, radius);
}
function createCanvas(width, height) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(width, height);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function getAvoidanceKey(position, basePosition, precision = 4) {
if (position instanceof THREE.Vector3) {
return [
"vec",
basePosition.x.toFixed(precision),
basePosition.y.toFixed(precision),
basePosition.z.toFixed(precision),
].join(":");
}
const lat = Number(position?.latitude ?? position?.lat);
const lon = Number(position?.longitude ?? position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return ["geo", lat.toFixed(precision), lon.toFixed(precision)].join(":");
}
function notifyAvoidancePositionChanged(layerIds) {
layerIds.forEach((layerId) => {
interactableLayerControllers.get(layerId)?.refreshPositions?.();
});
}
function recomputeAvoidanceBucket(key) {
const entries = surfaceAvoidanceBuckets.get(key);
if (!entries || entries.length === 0) return;
const affectedLayerIds = new Set(entries.map((entry) => entry.layerId));
if (entries.length === 1) {
const entry = entries[0];
entry.marker.position.copy(entry.marker.userData.icon_base_position);
entry.marker.userData.icon_avoidance_index = 0;
entry.marker.userData.icon_avoidance_count = 1;
notifyAvoidancePositionChanged(affectedLayerIds);
return;
}
const count = entries.length;
entries.forEach((entry, index) => {
const basePosition =
entry.marker.userData.icon_base_position || entry.marker.position;
const altitudeRadius = basePosition.length();
const ringIndex = Math.floor(index / AVOIDANCE_RING_SLOT_COUNT);
const radius =
Math.max(0, entry.radius) + ringIndex * Math.max(0, entry.step);
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / count;
avoidanceCenterScratch.copy(basePosition).normalize();
avoidanceEastScratch
.copy(avoidanceNorthPole)
.cross(avoidanceCenterScratch);
if (avoidanceEastScratch.lengthSq() < TANGENT_EPSILON_SQ) {
avoidanceEastScratch.copy(avoidanceFallbackEast);
}
avoidanceEastScratch.normalize();
avoidanceNorthScratch
.copy(avoidanceCenterScratch)
.cross(avoidanceEastScratch)
.normalize();
avoidancePositionScratch
.copy(basePosition)
.addScaledVector(avoidanceEastScratch, Math.cos(angle) * radius)
.addScaledVector(avoidanceNorthScratch, Math.sin(angle) * radius)
.normalize()
.multiplyScalar(altitudeRadius);
entry.marker.position.copy(avoidancePositionScratch);
entry.marker.userData.icon_avoidance_index = index;
entry.marker.userData.icon_avoidance_count = count;
});
notifyAvoidancePositionChanged(affectedLayerIds);
}
function unregisterLayerAvoidance(layerId) {
const affectedKeys = new Set();
surfaceAvoidanceBuckets.forEach((entries, key) => {
const nextEntries = entries.filter((entry) => entry.layerId !== layerId);
if (nextEntries.length !== entries.length) {
affectedKeys.add(key);
}
if (nextEntries.length === 0) {
surfaceAvoidanceBuckets.delete(key);
} else {
surfaceAvoidanceBuckets.set(key, nextEntries);
}
});
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
}
function registerLayerAvoidance(layerId, markers, avoidanceConfig) {
if (avoidanceConfig.enabled === false) return;
const affectedKeys = new Set();
markers.forEach((marker) => {
const key = marker.userData?.icon_avoidance_key;
if (!key) return;
if (!surfaceAvoidanceBuckets.has(key)) {
surfaceAvoidanceBuckets.set(key, []);
}
surfaceAvoidanceBuckets.get(key).push({
layerId,
marker,
radius: toFiniteNumber(
avoidanceConfig.radius,
DEFAULT_AVOIDANCE_RADIUS,
),
step: toFiniteNumber(avoidanceConfig.step, DEFAULT_AVOIDANCE_STEP),
});
affectedKeys.add(key);
});
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
}
function loadAssetImage(source) {
if (!source) return Promise.resolve(null);
if (assetImageCache.has(source)) {
return Promise.resolve(assetImageCache.get(source));
}
if (assetImageLoadPromises.has(source)) {
return assetImageLoadPromises.get(source);
}
const loadPromise = new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
assetImageCache.set(source, image);
assetImageLoadPromises.delete(source);
resolve(image);
};
image.onerror = () => {
assetImageLoadPromises.delete(source);
reject(new Error(`Failed to load interactable icon asset: ${source}`));
};
image.src = source;
});
assetImageLoadPromises.set(source, loadPromise);
return loadPromise;
}
export function createInteractableLayer(options = {}) {
const {
id,
objectType = id,
renderOrder = 4,
altitudeOffset = 0.2,
pointSize = 32,
sizeMode = "fixed",
sizeScale = {},
atlasCellSize = 128,
material = {},
colors = {},
opacity = {},
stateScale = {},
pulse = {},
avoidance = {},
icon,
getPosition,
getKind = (item) => item?.type || "default",
getRotationBin = () => 0,
getBucketKey = (marker) => String(getRotationBin(marker)),
getPointSizeMultiplier = () => 1,
getPointOpacity = null,
getUserData = (item) => item,
dynamicVisuals = false,
} = options;
if (!id) {
throw new Error("createInteractableLayer requires an id");
}
if (!icon?.draw && !icon?.source && !icon?.getSource) {
throw new Error(`Interactable layer ${id} requires icon.draw, icon.source, or icon.getSource`);
}
const group = new THREE.Group();
group.name = `interactable-layer:${id}`;
group.renderOrder = renderOrder;
group.userData = { type: "interactable_layer", id };
const markers = [];
const pointObjects = [];
const textureCache = new Map();
let pointsGroup = null;
let hoverOverlay = null;
let lockedOverlay = null;
let visible = false;
let lastVisualStateKey = "";
let visualStateVersion = 0;
const scratchDirection = new THREE.Vector3();
const scratchCameraLocal = new THREE.Vector3();
const scratchWorldPosition = new THREE.Vector3();
const scratchScreenPosition = new THREE.Vector3();
const viewportSize = new THREE.Vector2(1, 1);
const baseOpacity = opacity.normal ?? 0.88;
const dimmedOpacity = opacity.dimmed ?? 0.26;
const hoverOpacity = opacity.hover ?? 0.98;
const lockedOpacity = opacity.locked ?? 1;
const hoverScale = stateScale.hover ?? 1;
const lockedScale = stateScale.locked ?? 1;
const dimmedScale = stateScale.dimmed ?? 1;
const depthTest = material.depthTest ?? true;
const depthWrite = material.depthWrite ?? false;
const alphaTest = material.alphaTest ?? 0.01;
const usesDistanceScaling = sizeMode !== "fixed";
const iconAnchor = new THREE.Vector2(
Number(icon.anchor?.x ?? icon.anchor?.[0] ?? 0.5),
Number(icon.anchor?.y ?? icon.anchor?.[1] ?? 0.5),
);
const usesIconAnchor =
Math.abs(iconAnchor.x - 0.5) > 0.001 ||
Math.abs(iconAnchor.y - 0.5) > 0.001;
const avoidanceConfig = {
enabled: true,
precision: DEFAULT_AVOIDANCE_PRECISION,
radius: DEFAULT_AVOIDANCE_RADIUS,
step: DEFAULT_AVOIDANCE_STEP,
...avoidance,
};
function invalidateVisualState() {
visualStateVersion += 1;
lastVisualStateKey = "";
}
function refreshViewportSize() {
const pixelRatio = window.devicePixelRatio || 1;
viewportSize.set(
(window.innerWidth || 1) * pixelRatio,
(window.innerHeight || 1) * pixelRatio,
);
}
function applyIconAnchor(material) {
if (!usesIconAnchor) return material;
material.defines = {
...(material.defines || {}),
USE_INTERACTABLE_ICON_ANCHOR: "",
};
material.onBeforeCompile = (shader) => {
shader.uniforms.interactableIconAnchor = { value: iconAnchor };
shader.uniforms.interactableViewportSize = { value: viewportSize };
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
[
"#include <common>",
"uniform vec2 interactableIconAnchor;",
"uniform vec2 interactableViewportSize;",
].join("\n"),
)
.replace(
"#include <project_vertex>",
[
"#include <project_vertex>",
"#ifdef USE_INTERACTABLE_ICON_ANCHOR",
" vec2 interactableAnchorOffset = vec2((0.5 - interactableIconAnchor.x) * size, (interactableIconAnchor.y - 0.5) * size);",
" gl_Position.xy += (interactableAnchorOffset / interactableViewportSize) * 2.0 * gl_Position.w;",
"#endif",
].join("\n"),
);
};
material.customProgramCacheKey = () =>
`interactable-icon-anchor:${iconAnchor.x.toFixed(3)}:${iconAnchor.y.toFixed(3)}`;
return material;
}
function getMarkerColor(marker) {
const kind = marker?.userData?.icon_kind || getKind(marker?.userData);
return colors.byKind?.[kind] || colors[kind] || colors.normal || "#ffffff";
}
function getIconSource(drawOptions) {
const stateSource = icon.stateSources?.[drawOptions.state];
if (stateSource) return stateSource;
if (typeof icon.getSource === "function") {
return icon.getSource(drawOptions);
}
return icon.source;
}
function drawAssetIcon(context, source, drawOptions) {
const image = assetImageCache.get(source);
if (!image) {
icon.fallbackDraw?.(context, drawOptions);
return;
}
if (drawOptions.glow) {
context.shadowColor = drawOptions.color || "#ffffff";
context.shadowBlur = icon.glowBlur ?? 14;
}
const fitSize =
typeof icon.fitSize === "function"
? icon.fitSize(drawOptions)
: icon.fitSize;
const fitWidth =
typeof fitSize === "number"
? fitSize
: Number(fitSize?.width ?? atlasCellSize);
const fitHeight =
typeof fitSize === "number"
? fitSize
: Number(fitSize?.height ?? atlasCellSize);
const maxWidth = Number.isFinite(fitWidth) ? fitWidth : atlasCellSize;
const maxHeight = Number.isFinite(fitHeight) ? fitHeight : atlasCellSize;
const sourceWidth = image.naturalWidth || image.width || atlasCellSize;
const sourceHeight = image.naturalHeight || image.height || atlasCellSize;
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
const drawX = (atlasCellSize - drawWidth) / 2;
const drawY = (atlasCellSize - drawHeight) / 2;
if (icon.colorable !== false && drawOptions.color) {
const tintCanvas = createCanvas(atlasCellSize, atlasCellSize);
const tintContext = tintCanvas.getContext("2d");
tintContext.clearRect(0, 0, atlasCellSize, atlasCellSize);
tintContext.drawImage(image, drawX, drawY, drawWidth, drawHeight);
tintContext.globalCompositeOperation = "source-in";
tintContext.fillStyle = drawOptions.color;
tintContext.fillRect(0, 0, atlasCellSize, atlasCellSize);
context.drawImage(tintCanvas, 0, 0);
return;
}
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function drawIconTexture(textureKey, drawOptions) {
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = atlasCellSize;
canvas.height = atlasCellSize;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
const resolvedDrawOptions = {
atlasCellSize,
...drawOptions,
};
if (icon.coordinates !== "canvas") {
context.translate(canvas.width / 2, canvas.height / 2);
}
if (icon.draw) {
icon.draw(context, resolvedDrawOptions);
} else {
drawAssetIcon(context, getIconSource(resolvedDrawOptions), resolvedDrawOptions);
}
icon.afterDraw?.(context, resolvedDrawOptions);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function createPointTexture(bucketKey, bucketMarkers) {
const sampleMarker = bucketMarkers[0];
const rotationBin = getRotationBin(sampleMarker);
return drawIconTexture(`point:${bucketKey}`, {
marker: sampleMarker,
bucketKey,
rotationBin,
glow: false,
color: "#ffffff",
state: "normal",
});
}
function createOverlayTexture(marker, state) {
const kind = marker?.userData?.icon_kind || "default";
const rotationBin = getRotationBin(marker);
const color = getMarkerColor(marker);
const textureKey = [
"overlay",
state,
kind,
getBucketKey(marker),
rotationBin,
color,
].join(":");
return drawIconTexture(textureKey, {
marker,
bucketKey: getBucketKey(marker),
rotationBin,
glow: true,
color,
state,
});
}
function getCameraScale(camera) {
if (!usesDistanceScaling || !camera) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset,
referenceFov: sizeScale.referenceFov ?? 75,
min: sizeScale.min ?? 0.12,
max: sizeScale.max ?? 3,
});
}
function buildPoints() {
refreshViewportSize();
pointsGroup = new THREE.Group();
pointsGroup.visible = visible;
pointsGroup.renderOrder = renderOrder;
pointsGroup.userData = { type: `${id}_points`, id };
pointObjects.length = 0;
const buckets = new Map();
markers.forEach((marker) => {
const key = getBucketKey(marker);
if (!buckets.has(key)) {
buckets.set(key, []);
}
buckets.get(key).push(marker);
});
buckets.forEach((bucketMarkers, bucketKey) => {
const count = bucketMarkers.length;
const positions = new Float32Array(count * 3);
const colorValues = new Float32Array(count * 3);
bucketMarkers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const pointColor =
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
const [r, g, b] = colorToRgbArray(pointColor);
colorValues[index * 3] = r;
colorValues[index * 3 + 1] = g;
colorValues[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3));
geometry.computeBoundingSphere();
const material = applyIconAnchor(
new THREE.PointsMaterial({
map: createPointTexture(bucketKey, bucketMarkers),
size: pointSize * getPointSizeMultiplier(bucketMarkers[0]),
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
depthWrite,
depthTest,
alphaTest,
}),
);
const points = new THREE.Points(geometry, material);
points.renderOrder = renderOrder;
points.frustumCulled = false;
points.userData = {
type: `${id}_points`,
id,
bucketKey,
markers: bucketMarkers,
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
};
pointObjects.push(points);
pointsGroup.add(points);
});
group.add(pointsGroup);
}
function ensureOverlay(kind) {
const existing = kind === "locked" ? lockedOverlay : hoverOverlay;
if (existing) return existing;
refreshViewportSize();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(3), 3),
);
const material = applyIconAnchor(
new THREE.PointsMaterial({
size: pointSize,
sizeAttenuation: false,
transparent: true,
depthWrite,
depthTest,
opacity: 1,
alphaTest,
}),
);
const overlay = new THREE.Points(geometry, material);
overlay.renderOrder = renderOrder + (kind === "locked" ? 0.2 : 0.1);
overlay.frustumCulled = false;
overlay.visible = false;
overlay.userData = { type: `${id}_${kind}_overlay`, id };
group.add(overlay);
if (kind === "locked") {
lockedOverlay = overlay;
} else {
hoverOverlay = overlay;
}
return overlay;
}
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createOverlayTexture(marker, state);
if (overlay.material.map !== texture) {
overlay.material.map = texture;
overlay.material.needsUpdate = true;
}
overlay.material.opacity = nextOpacity;
overlay.material.size =
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
const positionAttribute = overlay.geometry.getAttribute("position");
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
positionAttribute.needsUpdate = true;
overlay.visible = visible;
}
function clearRenderObjects() {
if (pointsGroup?.parent) {
pointsGroup.parent.remove(pointsGroup);
}
pointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
hoverOverlay?.geometry?.dispose?.();
hoverOverlay?.material?.dispose?.();
hoverOverlay?.parent?.remove?.(hoverOverlay);
lockedOverlay?.geometry?.dispose?.();
lockedOverlay?.material?.dispose?.();
lockedOverlay?.parent?.remove?.(lockedOverlay);
pointsGroup = null;
pointObjects.length = 0;
hoverOverlay = null;
lockedOverlay = null;
}
function refreshPositions() {
pointObjects.forEach((points) => {
const bucketMarkers = points.userData?.markers || [];
const positionAttribute = points.geometry?.getAttribute("position");
if (!positionAttribute) return;
bucketMarkers.forEach((marker, index) => {
positionAttribute.setXYZ(
index,
marker.position.x,
marker.position.y,
marker.position.z,
);
});
positionAttribute.needsUpdate = true;
points.geometry.computeBoundingSphere();
});
invalidateVisualState();
}
function refreshVisuals() {
invalidateVisualState();
if (!pointsGroup) return;
clearRenderObjects();
buildPoints();
group.visible = visible;
}
function setData(items = []) {
invalidateVisualState();
unregisterLayerAvoidance(id);
markers.length = 0;
clearRenderObjects();
disposeGroupChildren(group);
const radius = CONFIG.earthRadius + altitudeOffset;
items.forEach((item) => {
const rawPosition = getPosition(item);
const position = normalizePosition(rawPosition, radius);
if (!position) return;
const kind = getKind(item);
const avoidanceKey = getAvoidanceKey(
rawPosition,
position,
avoidanceConfig.precision,
);
const marker = new THREE.Object3D();
marker.position.copy(position);
marker.userData = {
...getUserData(item),
type: objectType,
icon_layer_id: id,
icon_kind: kind,
icon_base_position: position.clone(),
icon_avoidance_key: avoidanceKey,
state: "normal",
};
markers.push(marker);
});
registerLayerAvoidance(id, markers, avoidanceConfig);
buildPoints();
group.visible = visible;
}
async function preloadAssets(items = []) {
if (icon.draw && !icon.source && !icon.getSource && !icon.stateSources) {
return;
}
const sources = new Set();
const states = ["normal", "hover", "locked"];
items.forEach((item) => {
const kind = getKind(item);
const marker = {
userData: {
...getUserData(item),
type: objectType,
icon_layer_id: id,
icon_kind: kind,
state: "normal",
},
};
states.forEach((state) => {
const source = getIconSource({
marker,
item,
state,
color:
colors.byKind?.[kind] ||
colors[kind] ||
colors.normal ||
"#ffffff",
});
if (source) sources.add(source);
});
});
await Promise.all(Array.from(sources).map((source) => loadAssetImage(source)));
}
function clearData(parent) {
invalidateVisualState();
unregisterLayerAvoidance(id);
markers.length = 0;
clearRenderObjects();
disposeGroupChildren(group);
if (parent && group.parent === parent) {
parent.remove(group);
}
}
function attach(parent) {
if (parent && !group.parent) {
parent.add(group);
}
group.visible = visible;
}
function setVisible(nextVisible) {
visible = Boolean(nextVisible);
invalidateVisualState();
group.visible = visible;
if (pointsGroup) {
pointsGroup.visible = visible;
}
}
function setMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== objectType) return;
if (marker.userData.state === state) return;
marker.userData.state = state;
invalidateVisualState();
}
function updateVisualState(focusType, focusObject, camera) {
refreshViewportSize();
if (!visible || markers.length === 0 || !pointsGroup) {
if (lastVisualStateKey !== "hidden") {
if (pointsGroup) pointsGroup.visible = false;
if (hoverOverlay) hoverOverlay.visible = false;
if (lockedOverlay) lockedOverlay.visible = false;
lastVisualStateKey = "hidden";
}
return;
}
pointsGroup.visible = true;
const hasFocus = focusType === objectType && focusObject;
const lockedKey = hasFocus
? focusObject?.userData?.mmsi || focusObject?.uuid || "locked"
: "none";
const stateKey = [
"visible",
focusType || "none",
lockedKey,
visualStateVersion,
].join(":");
const cameraScale = getCameraScale(camera);
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
const nextStateKey = `${stateKey}:${scaleKey}`;
if (
nextStateKey === lastVisualStateKey &&
!(pulse.enabled && hasFocus) &&
!dynamicVisuals
) return;
lastVisualStateKey = nextStateKey;
pointObjects.forEach((points) => {
const sampleMarker = points.userData?.markers?.[0];
points.visible = visible;
points.material.opacity =
getPointOpacity?.(sampleMarker) ??
(hasFocus ? dimmedOpacity : baseOpacity);
points.material.size =
pointSize *
getPointSizeMultiplier(sampleMarker) *
cameraScale *
(hasFocus ? dimmedScale : 1);
});
const hoverMarker = markers.find(
(marker) => marker.userData?.state === "hover" && marker !== focusObject,
);
updateOverlay(
ensureOverlay("hover"),
hoverMarker,
"hover",
hoverOpacity,
hoverScale * cameraScale,
);
const lockedPulse =
pulse.enabled && hasFocus
? 1 + (pulse.amplitude ?? 0) * Math.sin(Date.now() * (pulse.speed ?? 0) + (focusObject?.userData?.pulseOffset ?? 0))
: 1;
updateOverlay(
ensureOverlay("locked"),
hasFocus ? focusObject : null,
"locked",
lockedOpacity,
lockedScale * lockedPulse * cameraScale,
);
}
function getPointerIntersections({
earth,
camera,
pointer,
radiusPx = 20,
width = window.innerWidth,
height = window.innerHeight,
frontFacingDotThreshold = 0,
} = {}) {
if (!earth || !camera || !pointer) return [];
scratchCameraLocal.copy(camera.position);
earth.worldToLocal(scratchCameraLocal);
scratchCameraLocal.normalize();
const pointerX = ((pointer.x + 1) / 2) * width;
const pointerY = ((1 - pointer.y) / 2) * height;
const radiusSq = radiusPx * radiusPx;
const intersections = [];
const cameraScale = getCameraScale(camera);
markers.forEach((marker) => {
scratchDirection.copy(marker.position).normalize();
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
return;
}
scratchWorldPosition.copy(marker.position);
earth.localToWorld(scratchWorldPosition);
scratchScreenPosition.copy(scratchWorldPosition).project(camera);
if (scratchScreenPosition.z < -1 || scratchScreenPosition.z > 1) {
return;
}
const screenX = (scratchScreenPosition.x * 0.5 + 0.5) * width;
const screenY = (-scratchScreenPosition.y * 0.5 + 0.5) * height;
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
const visualCenterX =
screenX + (0.5 - iconAnchor.x) * pointSize * pointSizeMultiplier;
const visualCenterY =
screenY + (0.5 - iconAnchor.y) * pointSize * pointSizeMultiplier;
const deltaX = visualCenterX - pointerX;
const deltaY = visualCenterY - pointerY;
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
if (distancePxSq > radiusSq) return;
intersections.push({
object: marker,
point: scratchWorldPosition.clone(),
distance: camera.position.distanceTo(scratchWorldPosition),
distancePxSq,
});
});
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
}
interactableLayerControllers.set(id, { refreshPositions, refreshVisuals });
return {
group,
markers,
getMarkers: () => markers,
getCount: () => markers.length,
isVisible: () => visible,
setData,
preloadAssets,
clearData,
attach,
setVisible,
setMarkerState,
updateVisualState,
getPointerIntersections,
refreshVisuals,
};
}

View File

@@ -123,6 +123,8 @@ import {
import { import {
loadBGPAnomalies, loadBGPAnomalies,
getBGPAnomalyMarkers, getBGPAnomalyMarkers,
getBGPAnomalyPointerIntersections as getBGPEventIconPointerIntersections,
getBGPCollectorPointerIntersections as getBGPCollectorIconPointerIntersections,
getBGPCollectorMarkers, getBGPCollectorMarkers,
getBGPLegendItems, getBGPLegendItems,
getBGPCount, getBGPCount,
@@ -162,6 +164,7 @@ import {
getComputeCenterCount, getComputeCenterCount,
getComputeCenterLegendItems, getComputeCenterLegendItems,
getComputeCenterMarkers, getComputeCenterMarkers,
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
getShowComputeCenters, getShowComputeCenters,
loadComputeCenters, loadComputeCenters,
setComputeCenterMarkerState, setComputeCenterMarkerState,
@@ -175,6 +178,7 @@ import {
getVesselCount, getVesselCount,
getVesselLegendItems, getVesselLegendItems,
getVesselMarkers, getVesselMarkers,
getVesselPointerIntersections as getVesselIconPointerIntersections,
loadVessels, loadVessels,
setVesselMarkerState, setVesselMarkerState,
showVesselTrack, showVesselTrack,
@@ -291,14 +295,6 @@ const interactionMouse = new THREE.Vector2();
const scratchCameraToEarth = new THREE.Vector3(); const scratchCameraToEarth = new THREE.Vector3();
const scratchCableCenter = new THREE.Vector3(); const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3(); const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchVesselDirection = new THREE.Vector3();
const scratchVesselCameraLocal = new THREE.Vector3();
const scratchVesselWorldPosition = new THREE.Vector3();
const scratchVesselScreenPosition = new THREE.Vector3();
const scratchSatelliteWorldPosition = new THREE.Vector3(); const scratchSatelliteWorldPosition = new THREE.Vector3();
const scratchSatelliteScreenPosition = new THREE.Vector3(); const scratchSatelliteScreenPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3(); const scratchViewCenterWorld = new THREE.Vector3();
@@ -315,6 +311,7 @@ const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8; const DRAG_POINTER_THRESHOLD_PX = 8;
const VESSEL_HOVER_PICK_INTERVAL_MS = 100; const VESSEL_HOVER_PICK_INTERVAL_MS = 100;
const VESSEL_POINTER_RADIUS_PX = 22; const VESSEL_POINTER_RADIUS_PX = 22;
const INTERACTABLE_POINTER_RADIUS_PX = 24;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging"; const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
const HUD_INTERACTIVE_SELECTORS = [ const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column", ".earth-left-column",
@@ -548,70 +545,37 @@ function clearTransientHoverState() {
setHoveredSatelliteIndex(null); setHoveredSatelliteIndex(null);
} }
function getFrontFacingVesselMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchVesselCameraLocal.copy(camera.position);
earth.worldToLocal(scratchVesselCameraLocal);
scratchVesselCameraLocal.normalize();
return markers.filter((marker) => {
scratchVesselDirection.copy(marker.position).normalize();
return (
scratchVesselCameraLocal.dot(scratchVesselDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function getVesselPointerIntersections() { function getVesselPointerIntersections() {
const earth = getEarth(); const earth = getEarth();
if (!earth) return []; return getVesselIconPointerIntersections({
scratchVesselCameraLocal.copy(camera.position); earth,
earth.worldToLocal(scratchVesselCameraLocal); camera,
scratchVesselCameraLocal.normalize(); pointer: interactionMouse,
radiusPx: VESSEL_POINTER_RADIUS_PX,
const pointerX = ((interactionMouse.x + 1) / 2) * window.innerWidth; frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
const pointerY = ((1 - interactionMouse.y) / 2) * window.innerHeight; });
const radiusSq = VESSEL_POINTER_RADIUS_PX * VESSEL_POINTER_RADIUS_PX;
const intersections = [];
getVesselMarkers().forEach((marker) => {
scratchVesselDirection.copy(marker.position).normalize();
if (
scratchVesselCameraLocal.dot(scratchVesselDirection) <=
SATELLITE_CONFIG.frontFacingDotThreshold
) {
return;
} }
scratchVesselWorldPosition.copy(marker.position); function getBGPEventPointerIntersections() {
earth.localToWorld(scratchVesselWorldPosition); const earth = getEarth();
scratchVesselScreenPosition.copy(scratchVesselWorldPosition).project(camera); return getBGPEventIconPointerIntersections({
if ( earth,
scratchVesselScreenPosition.z < -1 || camera,
scratchVesselScreenPosition.z > 1 pointer: interactionMouse,
) { radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
return; frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
} }
const screenX = (scratchVesselScreenPosition.x * 0.5 + 0.5) * window.innerWidth; function getBGPCollectorPointerIntersections() {
const screenY = (-scratchVesselScreenPosition.y * 0.5 + 0.5) * window.innerHeight; const earth = getEarth();
const deltaX = screenX - pointerX; return getBGPCollectorIconPointerIntersections({
const deltaY = screenY - pointerY; earth,
const distancePxSq = deltaX * deltaX + deltaY * deltaY; camera,
if (distancePxSq > radiusSq) return; pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
intersections.push({ frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
object: marker,
point: scratchVesselWorldPosition.clone(),
distance: camera.position.distanceTo(scratchVesselWorldPosition),
distancePxSq,
}); });
});
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
} }
function shouldSkipVesselHoverPicking() { function shouldSkipVesselHoverPicking() {
@@ -3115,41 +3079,14 @@ function getFrontFacingCables(cableLines) {
}); });
} }
function getFrontFacingBGPMarkers(markers) { function getComputeCenterPointerIntersections() {
const earth = getEarth(); const earth = getEarth();
if (!earth) return markers; return getComputeCenterIconPointerIntersections({
earth,
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); camera,
pointer: interactionMouse,
return markers.filter((marker) => { radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
scratchBGPWorldPosition.copy(marker.position); frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
marker.parent?.localToWorld(scratchBGPWorldPosition);
scratchBGPDirection
.subVectors(scratchBGPWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchBGPDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function getFrontFacingComputeCenterMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchComputeCenterWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
scratchComputeCenterDirection
.subVectors(scratchComputeCenterWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
}); });
} }
@@ -3199,23 +3136,14 @@ function onMouseMove(event) {
const frontCables = getFrontFacingCables(getCableLines()); const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables); const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP() const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) ? getBGPEventPointerIntersections()
: []; : [];
const bgpCollectorIntersects = getShowBGP() const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) ? getBGPCollectorPointerIntersections()
: []; : [];
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
getComputeCenterMarkers(),
);
const computeCenterIntersects = getShowComputeCenters() const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers) ? getComputeCenterPointerIntersections()
: []; : [];
const vesselPick = getVesselHoverIntersections(); const vesselPick = getVesselHoverIntersections();
const vesselIntersects = vesselPick.intersects; const vesselIntersects = vesselPick.intersects;
@@ -3552,22 +3480,14 @@ function onClick(event) {
const cableIntersects = interactionRaycaster.intersectObjects( const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()), getFrontFacingCables(getCableLines()),
); );
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP() const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) ? getBGPEventPointerIntersections()
: []; : [];
const bgpCollectorIntersects = getShowBGP() const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) ? getBGPCollectorPointerIntersections()
: []; : [];
const computeCenterIntersects = getShowComputeCenters() const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects( ? getComputeCenterPointerIntersections()
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
: []; : [];
const vesselIntersects = getShowVessels() const vesselIntersects = getShowVessels()
? getVesselPointerIntersections() ? getVesselPointerIntersections()

View File

@@ -1,30 +1,17 @@
import * as THREE from "three"; import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js"; import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { latLonToVector3 } from "./utils.js"; import { latLonToVector3 } from "./utils.js";
const vesselGroup = new THREE.Group();
const vesselMarkers = [];
const textureCache = new Map();
let vesselPoints = null;
let vesselPointObjects = [];
let hoverOverlaySprite = null;
let lockedOverlaySprite = null;
let showVessels = false; let showVessels = false;
let activeTrackLine = null; let activeTrackLine = null;
let lastVisualStateKey = "";
let visualStateVersion = 0;
const VESSEL_RENDER_ORDER = 4.4; const VESSEL_RENDER_ORDER = 4.4;
const VESSEL_POINT_SIZE = 34; const VESSEL_POINT_SIZE = 34;
const VESSEL_ATLAS_CELL_SIZE = 128; const VESSEL_ATLAS_CELL_SIZE = 128;
const VESSEL_COURSE_BINS = 32; const VESSEL_COURSE_BINS = 32;
function invalidateVesselVisualState() {
visualStateVersion += 1;
lastVisualStateKey = "";
}
function normalizeVesselType(value, code) { function normalizeVesselType(value, code) {
const type = String(value || "").trim().toLowerCase(); const type = String(value || "").trim().toLowerCase();
const numericCode = Number(code); const numericCode = Number(code);
@@ -56,72 +43,6 @@ function drawVesselShape(context, anchored, glow, color = "#ffffff") {
context.fill(); context.fill();
} }
function createVesselPointTexture(anchored, courseBin = 0) {
const textureKey = `vessel-point:${anchored ? "anchored" : "moving"}:${courseBin}`;
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = VESSEL_ATLAS_CELL_SIZE;
canvas.height = VESSEL_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
context.translate(canvas.width / 2, canvas.height / 2);
if (!anchored) {
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(context, anchored, false);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function createVesselOverlayTexture(marker, glow = true) {
const kind = marker?.userData?.vessel_kind || "other";
const anchored = Boolean(marker?.userData?.anchored);
const courseBin = marker ? getCourseBin(marker) : 0;
const textureKey = [
"vessel-overlay",
kind,
anchored ? "anchored" : "moving",
courseBin,
glow ? "glow" : "plain",
].join(":");
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = VESSEL_ATLAS_CELL_SIZE;
canvas.height = VESSEL_ATLAS_CELL_SIZE;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
context.translate(canvas.width / 2, canvas.height / 2);
if (!anchored) {
context.rotate((courseBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(
context,
anchored,
glow,
VESSEL_CONFIG.colors[kind] || VESSEL_CONFIG.colors.other,
);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function buildVesselMarkerData(feature) { function buildVesselMarkerData(feature) {
const props = feature?.properties || {}; const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || []; const coordinates = feature?.geometry?.coordinates || [];
@@ -144,30 +65,6 @@ function buildVesselMarkerData(feature) {
}; };
} }
function createVesselMarker(markerData) {
const marker = new THREE.Object3D();
marker.position.copy(
latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset,
),
);
marker.userData = {
...markerData,
type: "vessel",
vessel_kind: markerData.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
state: "normal",
};
vesselMarkers.push(marker);
}
function colorToRgbArray(colorValue) {
const color = new THREE.Color(colorValue || VESSEL_CONFIG.colors.other);
return [color.r, color.g, color.b];
}
function getCourseBin(marker) { function getCourseBin(marker) {
if (marker.userData.anchored) return 0; if (marker.userData.anchored) return 0;
const course = Number(marker.userData.course || 0); const course = Number(marker.userData.course || 0);
@@ -175,108 +72,61 @@ function getCourseBin(marker) {
return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS; return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS;
} }
function buildVesselPoints() { const vesselIconLayer = createInteractableLayer({
vesselPoints = new THREE.Group(); id: "vessels",
vesselPoints.visible = showVessels; objectType: "vessel",
vesselPoints.renderOrder = VESSEL_RENDER_ORDER; renderOrder: VESSEL_RENDER_ORDER,
vesselPoints.userData = { type: "vessel_points" }; altitudeOffset: VESSEL_CONFIG.altitudeOffset,
vesselPointObjects = []; pointSize: VESSEL_POINT_SIZE,
atlasCellSize: VESSEL_ATLAS_CELL_SIZE,
const groups = new Map(); colors: {
vesselMarkers.forEach((marker, index) => { byKind: VESSEL_CONFIG.colors,
normal: VESSEL_CONFIG.colors.other,
},
opacity: {
normal: VESSEL_CONFIG.marker.baseOpacity,
dimmed: VESSEL_CONFIG.marker.dimmedOpacity,
hover: 0.98,
locked: 1,
},
stateScale: {
hover: VESSEL_CONFIG.marker.hoverScale,
locked: VESSEL_CONFIG.marker.lockedScale,
dimmed: VESSEL_CONFIG.marker.dimmedScale,
},
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
const anchored = Boolean(marker?.userData?.anchored);
if (!anchored) {
context.rotate((rotationBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(context, anchored, glow, color);
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.type || "other",
getRotationBin: getCourseBin,
getBucketKey: (marker) => {
const anchored = Boolean(marker.userData.anchored); const anchored = Boolean(marker.userData.anchored);
const courseBin = getCourseBin(marker); const courseBin = getCourseBin(marker);
const key = `${anchored ? "anchored" : "moving"}:${courseBin}`; return `${anchored ? "anchored" : "moving"}:${courseBin}`;
if (!groups.has(key)) { },
groups.set(key, { getUserData: (item) => ({
anchored, ...item,
courseBin, vessel_kind: item.type,
markers: [], baseScale: VESSEL_CONFIG.marker.baseScale,
markerIndexes: [], }),
}); });
}
const group = groups.get(key);
group.markers.push(marker);
group.markerIndexes.push(index);
});
groups.forEach((group) => {
const count = group.markers.length;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
group.markers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const [r, g, b] = colorToRgbArray(VESSEL_CONFIG.colors[marker.userData.vessel_kind]);
colors[index * 3] = r;
colors[index * 3 + 1] = g;
colors[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
map: createVesselPointTexture(group.anchored, group.courseBin),
size: VESSEL_POINT_SIZE,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: VESSEL_CONFIG.marker.baseOpacity,
depthWrite: false,
depthTest: true,
alphaTest: 0.01,
});
const points = new THREE.Points(geometry, material);
points.renderOrder = VESSEL_RENDER_ORDER;
points.frustumCulled = false;
points.userData = {
type: "vessel_points",
};
vesselPointObjects.push(points);
vesselPoints.add(points);
});
vesselGroup.add(vesselPoints);
}
function clearGroup(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
child.geometry?.dispose?.();
group.remove(child);
}
}
function disposeVesselRenderObjects() {
if (vesselPoints?.parent) {
vesselPoints.parent.remove(vesselPoints);
}
vesselPointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
hoverOverlaySprite?.geometry?.dispose?.();
hoverOverlaySprite?.material?.dispose?.();
hoverOverlaySprite?.parent?.remove?.(hoverOverlaySprite);
lockedOverlaySprite?.geometry?.dispose?.();
lockedOverlaySprite?.material?.dispose?.();
lockedOverlaySprite?.parent?.remove?.(lockedOverlaySprite);
vesselPoints = null;
vesselPointObjects = [];
hoverOverlaySprite = null;
lockedOverlaySprite = null;
}
export function getVesselMarkers() { export function getVesselMarkers() {
return vesselMarkers; return vesselIconLayer.getMarkers();
} }
export function getVesselCount() { export function getVesselCount() {
return vesselMarkers.length; return vesselIconLayer.getCount();
} }
export function getShowVessels() { export function getShowVessels() {
@@ -285,18 +135,14 @@ export function getShowVessels() {
export function toggleVessels(show) { export function toggleVessels(show) {
showVessels = Boolean(show); showVessels = Boolean(show);
invalidateVesselVisualState(); vesselIconLayer.setVisible(showVessels);
vesselGroup.visible = showVessels;
if (vesselPoints) {
vesselPoints.visible = showVessels;
}
if (activeTrackLine) { if (activeTrackLine) {
activeTrackLine.visible = showVessels; activeTrackLine.visible = showVessels;
} }
} }
export function clearVesselSelection() { export function clearVesselSelection() {
vesselMarkers.forEach((marker) => setVesselMarkerState(marker, "normal")); getVesselMarkers().forEach((marker) => setVesselMarkerState(marker, "normal"));
clearVesselTrack(); clearVesselTrack();
} }
@@ -310,21 +156,16 @@ function clearVesselTrack() {
} }
export function setVesselMarkerState(marker, state = "normal") { export function setVesselMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "vessel") return; vesselIconLayer.setMarkerState(marker, state);
if (marker.userData.state === state) return; }
marker.userData.state = state;
invalidateVesselVisualState(); export function getVesselPointerIntersections(options) {
return vesselIconLayer.getPointerIntersections(options);
} }
export function clearVesselData(earth) { export function clearVesselData(earth) {
invalidateVesselVisualState();
clearVesselSelection(); clearVesselSelection();
vesselMarkers.length = 0; vesselIconLayer.clearData(earth);
disposeVesselRenderObjects();
clearGroup(vesselGroup);
if (earth && vesselGroup.parent === earth) {
earth.remove(vesselGroup);
}
} }
export async function loadVessels(_scene, earth, options = {}) { export async function loadVessels(_scene, earth, options = {}) {
@@ -338,20 +179,17 @@ export async function loadVessels(_scene, earth, options = {}) {
const features = Array.isArray(payload?.features) ? payload.features : []; const features = Array.isArray(payload?.features) ? payload.features : [];
clearVesselData(earth); clearVesselData(earth);
features const markerData = features
.map((feature) => buildVesselMarkerData(feature)) .map((feature) => buildVesselMarkerData(feature))
.filter(Boolean) .filter(Boolean)
.slice(0, VESSEL_CONFIG.maxRenderedMarkers) .slice(0, VESSEL_CONFIG.maxRenderedMarkers);
.forEach((markerData) => createVesselMarker(markerData)); vesselIconLayer.setData(markerData);
buildVesselPoints();
if (earth && !vesselGroup.parent) { vesselIconLayer.attach(earth);
earth.add(vesselGroup); vesselIconLayer.setVisible(showVessels);
}
vesselGroup.visible = showVessels;
return { return {
totalCount: vesselMarkers.length, totalCount: getVesselCount(),
stats: payload?.stats || {}, stats: payload?.stats || {},
}; };
} }
@@ -404,102 +242,6 @@ export function getVesselLegendItems() {
]; ];
} }
function ensureOverlaySprite(kind) {
const existing = kind === "locked" ? lockedOverlaySprite : hoverOverlaySprite;
if (existing) return existing;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(3), 3),
);
const material = new THREE.PointsMaterial({
size: VESSEL_POINT_SIZE,
sizeAttenuation: false,
transparent: true,
depthWrite: false,
depthTest: true,
opacity: 1,
alphaTest: 0.01,
});
const overlay = new THREE.Points(geometry, material);
overlay.renderOrder = VESSEL_RENDER_ORDER + (kind === "locked" ? 0.2 : 0.1);
overlay.frustumCulled = false;
overlay.visible = false;
vesselGroup.add(overlay);
if (kind === "locked") {
lockedOverlaySprite = overlay;
} else {
hoverOverlaySprite = overlay;
}
return overlay;
}
function updateOverlaySprite(overlay, marker, opacity) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createVesselOverlayTexture(marker, true);
if (overlay.material.map !== texture) {
overlay.material.map = texture;
overlay.material.needsUpdate = true;
}
overlay.material.opacity = opacity;
overlay.material.size = VESSEL_POINT_SIZE;
const positionAttribute = overlay.geometry.getAttribute("position");
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
positionAttribute.needsUpdate = true;
overlay.visible = showVessels;
}
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) { export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) { vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
if (lastVisualStateKey !== "hidden") {
if (vesselPoints) vesselPoints.visible = false;
if (hoverOverlaySprite) hoverOverlaySprite.visible = false;
if (lockedOverlaySprite) lockedOverlaySprite.visible = false;
lastVisualStateKey = "hidden";
}
return;
}
vesselPoints.visible = true;
const hasFocus = lockedObjectType === "vessel" && lockedObject;
const lockedKey = hasFocus
? lockedObject?.userData?.mmsi || lockedObject?.uuid || "locked"
: "none";
const stateKey = [
"visible",
lockedObjectType || "none",
lockedKey,
visualStateVersion,
].join(":");
if (stateKey === lastVisualStateKey) return;
lastVisualStateKey = stateKey;
vesselPointObjects.forEach((points) => {
points.visible = showVessels;
points.material.opacity = hasFocus
? VESSEL_CONFIG.marker.dimmedOpacity
: VESSEL_CONFIG.marker.baseOpacity;
points.material.size = VESSEL_POINT_SIZE;
});
const hoverMarker = vesselMarkers.find(
(marker) => marker.userData?.state === "hover" && marker !== lockedObject,
);
updateOverlaySprite(
ensureOverlaySprite("hover"),
hoverMarker,
0.98,
);
updateOverlaySprite(
ensureOverlaySprite("locked"),
hasFocus ? lockedObject : null,
1,
);
} }

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "planet" name = "planet"
version = "0.45.0" version = "0.46.0"
description = "智能星球计划 - 态势感知系统" description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]] [[package]]
name = "planet" name = "planet"
version = "0.45.0" version = "0.46.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },