release: bump version to 0.46.0
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
312
docs/plans/earth-interactable-layer-plan.md
Normal 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 层级范围 |
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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` 的接口、生命周期和接入示例
|
||||||
|
|
||||||
不适合放入这里的内容:
|
不适合放入这里的内容:
|
||||||
|
|
||||||
|
|||||||
@@ -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、状态增量更新和屏幕空间 picking;BGP 事件的向外扩散圈、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()` 显示当前缩放比例:
|
||||||
|
|||||||
270
docs/technical/zh/earth-interactable-usage.md
Normal 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 和动画层级。
|
||||||
@@ -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 事件主图标,与算力中心同层 |
|
||||||
|
|
||||||
## 天体与星空
|
## 天体与星空
|
||||||
|
|
||||||
|
|||||||
@@ -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-tested,Group renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
|
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested,Group 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` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||||
|
|||||||
@@ -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 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 |
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
3
frontend/public/earth/assets/icons/bgp-broadcast-pin.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
3
frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg
Executable 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 |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
2
frontend/public/earth/js/controls.js
vendored
@@ -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`);
|
||||||
|
|||||||
899
frontend/public/earth/js/interactable.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||