From b1a5934b80a2c4d38de50dc208762a7baa941fcf Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 30 Apr 2026 04:42:29 +0800 Subject: [PATCH] release: bump version to 0.46.0 --- VERSION | 2 +- docs/CHANGELOG.md | 18 + docs/plans/README.md | 1 + docs/plans/earth-interactable-layer-plan.md | 312 ++++++ .../en/earth-layer-style-reference.md | 40 +- docs/technical/zh/README.md | 1 + docs/technical/zh/earth-frontend-context.md | 22 +- docs/technical/zh/earth-interactable-usage.md | 270 ++++++ .../zh/earth-layer-style-reference.md | 47 +- docs/technical/zh/earth-render-layer-order.md | 8 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- .../earth/assets/icons/bgp-broadcast-pin.svg | 3 + .../assets/icons/compute-gpu-cluster.svg | 20 +- .../assets/icons/compute-hdd-network.svg | 3 + .../assets/icons/compute-supercomputer.svg | 11 +- .../icons/landing-point-geo-alt-fill.svg | 3 + frontend/public/earth/css/toolbar.css | 22 +- frontend/public/earth/index.html | 81 ++ frontend/public/earth/js/bgp.js | 581 ++++++----- frontend/public/earth/js/cables.js | 216 ++--- frontend/public/earth/js/compute-centers.js | 298 ++---- frontend/public/earth/js/constants.js | 27 +- frontend/public/earth/js/controls.js | 2 + frontend/public/earth/js/interactable.js | 899 ++++++++++++++++++ frontend/public/earth/js/main.js | 164 +--- frontend/public/earth/js/vessels.js | 386 ++------ pyproject.toml | 2 +- uv.lock | 2 +- 29 files changed, 2351 insertions(+), 1095 deletions(-) create mode 100644 docs/plans/earth-interactable-layer-plan.md create mode 100644 docs/technical/zh/earth-interactable-usage.md create mode 100644 frontend/public/earth/assets/icons/bgp-broadcast-pin.svg create mode 100644 frontend/public/earth/assets/icons/compute-hdd-network.svg create mode 100755 frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg create mode 100644 frontend/public/earth/js/interactable.js diff --git a/VERSION b/VERSION index bcce5d06..30109231 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.0 +0.46.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e8ce9ccc..fe38d160 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `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 ### ✨ Highlights diff --git a/docs/plans/README.md b/docs/plans/README.md index 0737f095..1ca4f784 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -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-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-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-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) diff --git a/docs/plans/earth-interactable-layer-plan.md b/docs/plans/earth-interactable-layer-plan.md new file mode 100644 index 00000000..f6940a58 --- /dev/null +++ b/docs/plans/earth-interactable-layer-plan.md @@ -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 层级范围 | diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index f51f0c54..369a46f1 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -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 opacity | `CABLE_CONFIG.line.opacity` | `1.0` | Cable line opacity | | 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 icon texture size | `CABLE_CONFIG.landingPoint.textureSize` | `256` | Canvas size for solid map-pin icon | -| Landing point icon aspect ratio | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` | -| 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 base scale | `CABLE_CONFIG.landingPoint.baseScale` | `12` | Matches compute center sprite height | +| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | Same surface height as cable lines, avoiding a floating marker | +| Landing point sprite height | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` base height | +| Landing point reference FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | Matches the current Earth camera FOV | +| Landing point scale minimum | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | Minimum multiplier at maximum zoom; `3 * 0.36 = 1.08` | +| 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 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 | | 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 | ## 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 | | --- | --- | --- | --- | | 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` | -| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Supercomputer marker | -| GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker | -| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover state | -| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked state | +| Compute center point size | local `COMPUTE_CENTER_POINT_SIZE` | `36` | Shared Interactable base size for normal markers and hover / locked overlays | +| 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` | +| Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | Normal `PointsMaterial.opacity` | +| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Legacy Sprite scale; not directly used by the current Interactable path | +| 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 | | Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | 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 | | --- | --- | --- | --- | -| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `2.1` | Anomaly marker | -| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | Collector marker | -| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Anomaly sprite | -| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector plane | +| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP event Interactable marker | +| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP collector Interactable marker, aligned with the vessel layer | +| BGP event point size | local `BGP_EVENT_POINT_SIZE` | `34` | Event Interactable base size, adjusted by severity through `getPointSizeMultiplier()` | +| 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 | -| 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 | | Dimmed opacity | `BGP_CONFIG.opacity.dimmed` | `0.24` | Dim state | | Collector opacity | `BGP_CONFIG.opacity.collector` | `0.62` | Collector state | diff --git a/docs/technical/zh/README.md b/docs/technical/zh/README.md index 2ced26da..a1d62650 100644 --- a/docs/technical/zh/README.md +++ b/docs/technical/zh/README.md @@ -24,6 +24,7 @@ - [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 的完整使用手册 - [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` 的接口、生命周期和接入示例 不适合放入这里的内容: diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 2690cb36..3355b3d6 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -249,11 +249,13 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持: AIS 船只图层入口: - [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` - 将 BarentsWatch AIS GeoJSON 转为地球局部坐标 marker 数据 +- 通过 `createInteractableLayer()` 注册 Interactable 图标层 - 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker - 按船型映射颜色 - 根据航行/停泊状态绘制三角形或圆点纹理 @@ -271,7 +273,7 @@ AIS 船只图层入口: 方向标准以 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。 2. 对 hover picking 做轻量节流。 @@ -293,6 +295,24 @@ AIS 船只图层入口: 图例项颜色来自 `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()` 显示当前缩放比例: diff --git a/docs/technical/zh/earth-interactable-usage.md b/docs/technical/zh/earth-interactable-usage.md new file mode 100644 index 00000000..9605b156 --- /dev/null +++ b/docs/technical/zh/earth-interactable-usage.md @@ -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 和动画层级。 diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 2225a1e4..92c8115c 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -146,21 +146,18 @@ | 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` | | 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity | | 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 | -| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | 对齐算力中心贴地表 marker 高度 | -| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin,中间圆孔透明镂空 | -| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` | -| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 | -| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 | +| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 与海缆线同层贴地,避免凌空 | +| 登陆点 sprite 高度 | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` 基准高度 | +| 登陆点缩放参考 FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | 与当前 Earth 相机 FOV 一致 | +| 登陆点缩放下限 | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.36` | 地球放到最大时的最小倍率;`3 * 0.36 = 1.08` | +| 登陆点缩放上限 | 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` | -| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质;sprite 不使用 | -| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质;sprite 不使用 | | 登陆点透明度 | `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 状态颜色乘数 | | 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 | | 非相关登陆点颜色 | `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 混黑底 | ## 卫星、轨迹和 footprint @@ -212,11 +209,13 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。 | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | | 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 | -| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` | -| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker | -| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker | -| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 | -| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 | +| 算力中心点像素尺寸 | local `COMPUTE_CENTER_POINT_SIZE` | `36` | `Interactable` 普通 marker 与 hover / locked overlay 共享基准尺寸 | +| 算力中心 asset fit size | local `COMPUTE_CENTER_ICON_FIT_SIZE` | `60` | SVG asset 在 `128x128` atlas canvas 内的最大绘制尺寸,由 `icon.fitSize` 控制 | +| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | 普通 `PointsMaterial.opacity` | +| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 旧 Sprite 缩放参数;当前 Interactable 路径不再直接使用 | +| 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 状态 | | 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | 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 collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker | -| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite | -| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane | +| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP 事件 Interactable marker | +| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP 观测站 Interactable marker,与船只同层贴地 | +| BGP 事件点像素尺寸 | local `BGP_EVENT_POINT_SIZE` | `34` | 事件 icon 的 Interactable 基准尺寸,按严重级别通过 `getPointSizeMultiplier()` 调整 | +| 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` | 交互状态 | -| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite | +| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | BGP 事件 Interactable 普通态 | | hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 | | dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 | | collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 | @@ -244,8 +247,8 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。 | region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 | | BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring | | BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring | -| collector marker renderOrder | inline | `3` | `marker.renderOrder` | -| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` | +| collector marker renderOrder | local `BGP_COLLECTOR_RENDER_ORDER` | `4.4` | 观测站主图标,与船只同层 | +| anomaly marker renderOrder | local `BGP_EVENT_RENDER_ORDER` | `4.5` | BGP 事件主图标,与算力中心同层 | ## 天体与星空 diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index d83485d6..2e62d766 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -21,17 +21,17 @@ | 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 | | 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 | | 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 | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 | | 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.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 线;中国和中国(台湾)共享高亮组。 | | 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.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.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 | +| 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`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 | | 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 | | 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | | 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | diff --git a/docs/version-history.md b/docs/version-history.md index 613c4bd8..dc52951d 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.45.0` +- `dev` 当前开发分支历史推导到:`0.46.0` ## Timeline | 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.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 | | `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 | diff --git a/frontend/package.json b/frontend/package.json index a79f7de2..67657882 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.45.0", + "version": "0.46.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/icons/bgp-broadcast-pin.svg b/frontend/public/earth/assets/icons/bgp-broadcast-pin.svg new file mode 100644 index 00000000..df937883 --- /dev/null +++ b/frontend/public/earth/assets/icons/bgp-broadcast-pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/earth/assets/icons/compute-gpu-cluster.svg b/frontend/public/earth/assets/icons/compute-gpu-cluster.svg index 4b68e692..49f9913e 100644 --- a/frontend/public/earth/assets/icons/compute-gpu-cluster.svg +++ b/frontend/public/earth/assets/icons/compute-gpu-cluster.svg @@ -1,16 +1,6 @@ - - - - - - - - - - - - - - - + + + + + diff --git a/frontend/public/earth/assets/icons/compute-hdd-network.svg b/frontend/public/earth/assets/icons/compute-hdd-network.svg new file mode 100644 index 00000000..63daedf0 --- /dev/null +++ b/frontend/public/earth/assets/icons/compute-hdd-network.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/earth/assets/icons/compute-supercomputer.svg b/frontend/public/earth/assets/icons/compute-supercomputer.svg index f32769d0..1269f989 100644 --- a/frontend/public/earth/assets/icons/compute-supercomputer.svg +++ b/frontend/public/earth/assets/icons/compute-supercomputer.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + diff --git a/frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg b/frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg new file mode 100755 index 00000000..a53f2bdf --- /dev/null +++ b/frontend/public/earth/assets/icons/landing-point-geo-alt-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/earth/css/toolbar.css b/frontend/public/earth/css/toolbar.css index b9c59847..4e1fbb69 100644 --- a/frontend/public/earth/css/toolbar.css +++ b/frontend/public/earth/css/toolbar.css @@ -24,16 +24,16 @@ } .earth-toolbar { - --toolbar-scale: 1; - --toolbar-orb-size: calc(46px * var(--toolbar-scale)); - --toolbar-hub-size: calc(58px * var(--toolbar-scale)); - --toolbar-arc-width: calc(420px * var(--toolbar-scale)); - --toolbar-arc-height: calc(160px * var(--toolbar-scale)); - --toolbar-inner-arc-width: calc(260px * var(--toolbar-scale)); - --toolbar-inner-arc-height: calc(56px * var(--toolbar-scale)); + --toolbar-scale: var(--initial-toolbar-scale, 1); + --toolbar-orb-size: var(--initial-toolbar-orb-size, calc(46px * var(--toolbar-scale))); + --toolbar-hub-size: var(--initial-toolbar-hub-size, calc(58px * var(--toolbar-scale))); + --toolbar-arc-width: var(--initial-toolbar-arc-width, calc(420px * var(--toolbar-scale))); + --toolbar-arc-height: var(--initial-toolbar-arc-height, calc(160px * var(--toolbar-scale))); + --toolbar-inner-arc-width: var(--initial-toolbar-inner-arc-width, calc(260px * var(--toolbar-scale))); + --toolbar-inner-arc-height: var(--initial-toolbar-inner-arc-height, calc(56px * var(--toolbar-scale))); position: relative; width: min(620px, calc(100vw - 40px)); - height: calc(200px * var(--toolbar-scale)); + height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale))); display: flex; align-items: center; justify-content: center; @@ -155,7 +155,8 @@ height: var(--toolbar-orb-size); min-width: var(--toolbar-orb-size); min-height: var(--toolbar-orb-size); - border-radius: 50%; + aspect-ratio: 1 / 1; + border-radius: 9999px; overflow: hidden; } @@ -164,7 +165,8 @@ height: var(--toolbar-hub-size); min-width: var(--toolbar-hub-size); min-height: var(--toolbar-hub-size); - border-radius: 50%; + aspect-ratio: 1 / 1; + border-radius: 9999px; overflow: hidden; color: var(--hud-title); } diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 3c800a8f..c229c566 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -44,6 +44,87 @@ 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", + ); + })(); diff --git a/frontend/public/earth/js/bgp.js b/frontend/public/earth/js/bgp.js index 2e14f1b1..6bfd546b 100644 --- a/frontend/public/earth/js/bgp.js +++ b/frontend/public/earth/js/bgp.js @@ -1,10 +1,13 @@ import * as THREE from "three"; import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js"; +import { createInteractableLayer } from "./interactable.js"; import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js"; const bgpGroup = new THREE.Group(); const bgpOverlayGroup = new THREE.Group(); +const bgpEventOverlayGroup = new THREE.Group(); +const bgpCollectorRadarGroup = new THREE.Group(); const collectorMarkers = []; const anomalyMarkers = []; const activeEventCountByCollector = new Map(); @@ -14,7 +17,6 @@ let totalAnomalyCount = 0; let totalIncidentCount = 0; let textureCache = null; let eventRingTextureCache = null; -let collectorTextureCache = null; const eventTextureCache = new Map(); let activeEventOverlay = null; let activeCollectorOverlayContext = null; @@ -22,17 +24,27 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto", }); 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 colorScratchB = new THREE.Color(); const COLLECTOR_SCAN_SPEED_RAD = 0.00018; 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() { if (textureCache) return textureCache; @@ -85,48 +97,6 @@ function getEventRingTexture() { 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) { const value = String(anomalyType || "").toLowerCase(); if (value.includes("origin")) return "triangle"; @@ -266,6 +236,169 @@ function getSeverityScale(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) { 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; return getSurfaceMarkerCameraScale(camera, { - altitudeOffset: BGP_CONFIG.collectorAltitudeOffset, + altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET, referenceFov: 75, min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6), 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) { const recent24h = Number(markerData?.recent_24h_observation_count || 0); const recent7d = Number(markerData?.recent_7d_observation_count || 0); @@ -784,13 +891,26 @@ function clearMarkerArray(markers) { const marker = markers.pop(); while (marker.children.length > 0) { const child = marker.children.pop(); + child.geometry?.dispose?.(); child.material?.dispose(); } + disposeCollectorEffectSprites(marker); + disposeEventRingSprite(marker.userData?.ringA); + disposeEventRingSprite(marker.userData?.ringB); 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) { while (group.children.length > 0) { const child = group.children[group.children.length - 1]; @@ -979,46 +1099,14 @@ function createRadialBoundaryPoints( return points; } -function createCollectorMarker(markerData) { - const activity = getCollectorActivityProfile(markerData); - 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); - +function attachCollectorEffectSprites(marker) { + const activity = marker.userData.activity; const heatHalo = createOverlaySprite({ color: activity.color, opacity: 0.0, scale: activity.haloScale * 0.58, }); heatHalo.renderOrder = 1; - marker.add(heatHalo); const pulseHalo = createOverlaySprite({ color: activity.color, @@ -1026,7 +1114,6 @@ function createCollectorMarker(markerData) { scale: activity.pulseHaloScale * 0.48, }); pulseHalo.renderOrder = 0; - marker.add(pulseHalo); const statusCore = createOverlaySprite({ color: activity.color, @@ -1037,9 +1124,7 @@ function createCollectorMarker(markerData) { BGP_CONFIG.marker.collectorStatusCoreMinScale, ), }); - statusCore.position.set(0, 0, 0.02); statusCore.renderOrder = 4; - marker.add(statusCore); const coverageHalo = createOverlaySprite({ color: BGP_CONFIG.regionColor, @@ -1048,65 +1133,52 @@ function createCollectorMarker(markerData) { }); coverageHalo.renderOrder = 0; coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1); - marker.add(coverageHalo); - marker.userData = { - type: "bgp_collector", - state: "normal", - baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost, - baseColor, - idleColor, - pulseOffset: Math.random() * Math.PI * 2, - anomaly_count: 0, - activity, - heatHalo, - pulseHalo, - statusCore, - coverageHalo, - ...markerData, - }; + marker.userData.heatHalo = heatHalo; + marker.userData.pulseHalo = pulseHalo; + marker.userData.statusCore = statusCore; + marker.userData.coverageHalo = coverageHalo; - collectorMarkers.push(marker); - bgpGroup.add(marker); + [heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => { + sprite.position.copy(marker.position); + sprite.visible = showBGP; + bgpGroup.add(sprite); + }); } -function createAnomalyMarker(markerData) { - const sprite = new THREE.Sprite( - new THREE.SpriteMaterial({ - map: getEventTexture(markerData.incident_type || markerData.anomaly_type), - color: getSeverityColor(markerData.severity), - transparent: true, - opacity: BGP_CONFIG.opacity.normal, - depthWrite: false, - depthTest: true, - blending: THREE.NormalBlending, - }), - ); +function disposeCollectorEffectSprites(marker) { + [ + marker?.userData?.heatHalo, + marker?.userData?.pulseHalo, + marker?.userData?.statusCore, + marker?.userData?.coverageHalo, + ].forEach((sprite) => { + if (!sprite) return; + sprite.parent?.remove?.(sprite); + sprite.material?.dispose?.(); + sprite.geometry?.dispose?.(); + }); +} - const position = latLonToVector3( - markerData.latitude, - markerData.longitude, - CONFIG.earthRadius + BGP_CONFIG.altitudeOffset, - ); +async function setCollectorMarkers(markerData, earth) { + collectorMarkers.forEach(disposeCollectorEffectSprites); + collectorMarkers.length = 0; + await bgpCollectorIconLayer.preloadAssets(markerData); + bgpCollectorIconLayer.setData(markerData); + bgpCollectorIconLayer.attach(earth); + bgpCollectorIconLayer.setVisible(showBGP); - const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity); - sprite.position.copy(position); - sprite.scale.setScalar(baseScale); - sprite.renderOrder = 5; - sprite.visible = showBGP; - sprite.userData = { - type: "bgp", - state: "normal", - baseScale, - baseColor: getSeverityColor(markerData.severity), - pulseOffset: Math.random() * Math.PI * 2, - ...markerData, - }; + bgpCollectorIconLayer.getMarkers().forEach((marker) => { + attachCollectorEffectSprites(marker); + collectorMarkers.push(marker); + }); +} - const ringA = new THREE.Sprite( +function createEventRingSprite(marker) { + const ring = new THREE.Sprite( new THREE.SpriteMaterial({ map: getEventRingTexture(), - color: getSeverityColor(markerData.severity), + color: marker.userData.baseColor || getSeverityColor(marker.userData.severity), transparent: true, opacity: 0, depthWrite: false, @@ -1114,30 +1186,40 @@ function createAnomalyMarker(markerData) { blending: THREE.AdditiveBlending, }), ); - ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA); - ringA.position.set(0, 0, -0.01); - sprite.add(ringA); + ring.position.copy(marker.position); + ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05; + ring.visible = showBGP; + return ring; +} - const ringB = new THREE.Sprite( - new THREE.SpriteMaterial({ - map: getEventRingTexture(), - color: getSeverityColor(markerData.severity), - transparent: true, - 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); +function disposeEventRingSprite(ring) { + if (!ring) return; + ring.parent?.remove?.(ring); + ring.material?.dispose?.(); + ring.geometry?.dispose?.(); +} - sprite.userData.ringA = ringA; - sprite.userData.ringB = ringB; +function attachAnomalyRingSprites(marker) { + 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); - bgpGroup.add(sprite); +function setAnomalyMarkers(markerData, earth) { + 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) { @@ -1304,17 +1386,18 @@ export async function loadBGPAnomalies(scene, earth) { totalIncidentCount = selectedEventData.totalIncidentCount; activeEventCountByCollector.clear(); - spreadCollectorPositions( + const collectorMarkersData = spreadCollectorPositions( collectorFeatures .map(buildCollectorFeatureData) .filter(Boolean), - ).forEach(createCollectorMarker); + ); + await setCollectorMarkers(collectorMarkersData, earth); - if (selectedEventData.mode === "incident") { - dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker); - } else { - dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker); - } + const eventMarkers = + selectedEventData.mode === "incident" + ? dedupeIncidents(selectedEventData.features) + : dedupeAnomalies(selectedEventData.features); + setAnomalyMarkers(eventMarkers, earth); applyCollectorCounts(); if (!bgpGroup.parent) { @@ -1360,7 +1443,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru ); let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera); - let opacity = BGP_CONFIG.collectorIcon.idleOpacity; let haloOpacity = 0.0; let pulseOpacity = 0.0; let coverageOpacity = 0.0; @@ -1374,14 +1456,12 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru if (isLocked) { scale *= 1.1 + 0.14 * pulse; - opacity = 0.96; haloOpacity = 0.05; pulseOpacity = 0.024; coverageOpacity = 0.036; markerColor = 0xcff2ff; } else if (isHovered) { scale *= 1.08; - opacity = 0.88; haloOpacity = 0.03; pulseOpacity = 0.014; coverageOpacity = 0.02; @@ -1392,7 +1472,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru ); } else if (hasLockedLayer) { scale *= 0.98; - opacity = BGP_CONFIG.collectorIcon.idleOpacity; haloOpacity = 0.0; pulseOpacity = 0.0; coverageOpacity = 0.0; @@ -1401,12 +1480,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru 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) { + marker.userData.heatHalo.position.copy(marker.position); marker.userData.heatHalo.material.opacity = haloOpacity; marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.heatHalo.scale.setScalar( @@ -1414,6 +1489,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru ); } if (marker.userData.pulseHalo) { + marker.userData.pulseHalo.position.copy(marker.position); marker.userData.pulseHalo.material.opacity = pulseOpacity; marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor); marker.userData.pulseHalo.scale.setScalar( @@ -1421,6 +1497,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru ); } if (marker.userData.statusCore) { + marker.userData.statusCore.position.copy(marker.position); marker.userData.statusCore.material.opacity = isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18; 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) { + marker.userData.coverageHalo.position.copy(marker.position); marker.userData.coverageHalo.material.opacity = coverageOpacity; marker.userData.coverageHalo.scale.set( 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) => { const isLocked = lockedObjectType === "bgp" && lockedObject === marker; const isLinkedCollectorLocked = @@ -1459,7 +1551,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru const iconAnchorScale = marker.userData.baseScale * getEventDistanceScale(marker, camera); let scale = iconAnchorScale; - let opacity = BGP_CONFIG.opacity.normal; let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity); const isIncidentMarker = marker.userData.source === "bgp_incident"; let ringBaseOpacity = isIncidentMarker @@ -1468,49 +1559,38 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru if (isLocked || isLinkedCollectorLocked) { scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; - opacity = 0.9 + 0.1 * pulse; markerColor = 0xfff1a8; ringBaseOpacity *= 1.2; } else if (isCruise) { scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse; - opacity = 0.9 + 0.1 * pulse; ringBaseOpacity *= 1.2; } else if (isHovered) { scale *= BGP_CONFIG.marker.hoverScale; - opacity = 0.9; ringBaseOpacity *= 1.05; } else if (isOtherLocked) { scale *= BGP_CONFIG.marker.dimmedScale; - opacity = 0.22; markerColor = 0x7d8ca3; ringBaseOpacity = 0.02; } else { 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) => { if (!ring) return; const progress = Math.max(0, Math.min(1, phase)); const minScale = 1.28; const desiredWorldScale = - iconAnchorScale * (minScale + progress * (maxScale - minScale)); - const parentScale = Math.max(scale, 0.0001); - const localRingScale = desiredWorldScale / parentScale; + scale * (minScale + progress * (maxScale - minScale)); const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14)); const fadeOut = 1 - progress; const visibility = fadeIn * fadeOut; - ring.scale.setScalar(localRingScale); + ring.position.copy(marker.position); + ring.scale.setScalar(desiredWorldScale); ring.material.color.setHex(markerColor); ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0; 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); @@ -1519,6 +1599,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru marker.userData.ringB.visible = false; } }); + + bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera); } 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") { return; } - marker.userData.state = state; + if (marker.userData.type === "bgp") { + bgpEventIconLayer.setMarkerState(marker, state); + return; + } + bgpCollectorIconLayer.setMarkerState(marker, state); } export function clearBGPSelection() { collectorMarkers.forEach((marker) => { - marker.userData.state = "normal"; + bgpCollectorIconLayer.setMarkerState(marker, "normal"); }); anomalyMarkers.forEach((marker) => { - marker.userData.state = "normal"; + bgpEventIconLayer.setMarkerState(marker, "normal"); }); clearBGPEventOverlay(); } @@ -1542,6 +1628,8 @@ export function clearBGPSelection() { export function clearBGPData(earth) { clearMarkerArray(collectorMarkers); clearMarkerArray(anomalyMarkers); + bgpCollectorIconLayer.clearData(earth); + bgpEventIconLayer.clearData(earth); clearBGPEventOverlay(); activeEventCountByCollector.clear(); totalAnomalyCount = 0; @@ -1559,11 +1647,21 @@ export function toggleBGP(show) { showBGP = Boolean(show); bgpGroup.visible = showBGP; bgpOverlayGroup.visible = showBGP; + bgpCollectorIconLayer.setVisible(showBGP); + bgpEventIconLayer.setVisible(showBGP); 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) => { - 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; } +export function getBGPAnomalyPointerIntersections(options = {}) { + return bgpEventIconLayer.getPointerIntersections(options); +} + +export function getBGPCollectorPointerIntersections(options = {}) { + return bgpCollectorIconLayer.getPointerIntersections(options); +} + export function getBGPCollectorMarkers() { return collectorMarkers; } @@ -1644,11 +1750,11 @@ export function showBGPEventOverlay(marker, earth) { latLonToVector3( region.latitude, region.longitude, - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1, + CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1, ), ); halo.renderOrder = 2; - bgpOverlayGroup.add(halo); + bgpEventOverlayGroup.add(halo); overlayItems.push(halo); }); @@ -1687,11 +1793,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { latLonToVector3( marker.userData.displayLatitude ?? marker.userData.latitude, marker.userData.displayLongitude ?? marker.userData.longitude, - CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15, + CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.15, ), ); halo.renderOrder = 2; - bgpOverlayGroup.add(halo); + bgpCollectorRadarGroup.add(halo); const pulseHalo = createOverlaySprite({ color: BGP_CONFIG.collectorColor, @@ -1700,7 +1806,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { }); pulseHalo.position.copy(halo.position); pulseHalo.renderOrder = 1; - bgpOverlayGroup.add(pulseHalo); + bgpCollectorRadarGroup.add(pulseHalo); const innerRing = createOverlaySprite({ color: BGP_CONFIG.collectorColor, opacity: 0.12, @@ -1708,7 +1814,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { }); innerRing.position.copy(halo.position); innerRing.renderOrder = 3; - bgpOverlayGroup.add(innerRing); + bgpCollectorRadarGroup.add(innerRing); const overlayItems = [halo, pulseHalo, innerRing]; 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 endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI); const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor; - const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44; - const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4; + const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44; + const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4; const leftBoundaryPoints = createRadialBoundaryPoints( anchorLatitude, anchorLongitude, @@ -1765,7 +1871,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { 0.12, ); sectorFill.renderOrder = 2; - bgpOverlayGroup.add(sectorFill); + bgpCollectorRadarGroup.add(sectorFill); overlayItems.push(sectorFill); const outerArc = createCoverageBoundaryLine( @@ -1774,7 +1880,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { 0.9, ); outerArc.renderOrder = 3; - bgpOverlayGroup.add(outerArc); + bgpCollectorRadarGroup.add(outerArc); overlayItems.push(outerArc); const leftBoundary = createCoverageBoundaryLine( @@ -1783,7 +1889,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { 0.76, ); leftBoundary.renderOrder = 3; - bgpOverlayGroup.add(leftBoundary); + bgpCollectorRadarGroup.add(leftBoundary); overlayItems.push(leftBoundary); const rightBoundary = createCoverageBoundaryLine( @@ -1792,7 +1898,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { 0.76, ); rightBoundary.renderOrder = 3; - bgpOverlayGroup.add(rightBoundary); + bgpCollectorRadarGroup.add(rightBoundary); overlayItems.push(rightBoundary); activeEventOverlay = overlayItems; @@ -1808,7 +1914,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) { export function clearBGPEventOverlay() { activeEventOverlay = null; activeCollectorOverlayContext = null; - clearGroup(bgpOverlayGroup); + clearGroup(bgpEventOverlayGroup); + clearGroup(bgpCollectorRadarGroup); } function updateCollectorOverlayScan(lockedObjectType, lockedObject) { diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index 7b0d1836..fab6452d 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -20,80 +20,110 @@ export let lockedCable = null; let cableIdMap = new Map(); let cableStates = new Map(); let cablesVisible = true; -let landingPointTexture = null; const _lpEarthWorldPos = new THREE.Vector3(); const _lpWorldPos = new THREE.Vector3(); const _lpCameraRel = new THREE.Vector3(); const _lpPointRel = 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() { - const size = CABLE_CONFIG.landingPoint.textureSize; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - const iconPath = new Path2D( - [ - "M400 704", - "C386 704 375 697 367 684", - "L173 378", - "C117 290 144 173 229 111", - "C278 75 337 57 400 57", - "C463 57 522 75 571 111", - "C656 173 683 290 627 378", - "L433 684", - "C425 697 414 704 400 704", - "Z", - ].join(" "), +function getLandingPointPulse() { + return ( + Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1 + ) * 0.5; +} + +function getLandingPointDimColor() { + const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB; + const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness; + const color = new THREE.Color( + (dimColor.r * brightness) / 255, + (dimColor.g * brightness) / 255, + (dimColor.b * brightness) / 255, ); + return `#${color.getHexString()}`; +} - ctx.clearRect(0, 0, size, size); - ctx.save(); - ctx.translate(size * 0.12, size * 0.02); - ctx.scale(size / 1000, size / 1000); +function createLandingPointBallTexture() { + const canvas = document.createElement("canvas"); + canvas.width = LANDING_POINT_ATLAS_CELL_SIZE; + 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"; - ctx.fill(iconPath); + context.clearRect(0, 0, canvas.width, canvas.height); - ctx.globalCompositeOperation = "destination-out"; - ctx.beginPath(); - ctx.arc(400, 320, 86, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); + const shadow = context.createRadialGradient( + center - 16, + center - 18, + 8, + 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); texture.colorSpace = THREE.SRGBColorSpace; + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; texture.needsUpdate = true; return texture; } -function getLandingPointTexture() { - if (!landingPointTexture) { - landingPointTexture = createLandingPointTexture(); - } +async function getLandingPointTexture() { + if (landingPointTexture) return landingPointTexture; + landingPointTexture = createLandingPointBallTexture(); return landingPointTexture; } -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function getLandingPointDistanceScale(point, camera) { - if ( - !point || - !camera || - CABLE_CONFIG.landingPointSizeStabilization?.enabled === false - ) return 1; - +function getLandingPointDistanceScale(camera) { + if (!camera) return 1; return getSurfaceMarkerCameraScale(camera, { altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset, - referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75, - min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12, - max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0, + referenceFov: LANDING_POINT_SIZE_REFERENCE_FOV, + min: LANDING_POINT_SIZE_SCALE_MIN, + 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) { 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) { if (properties.color) { if ( @@ -426,7 +440,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) { clearLandingPoints(earthObj); - let validCount = 0; + const markerTexture = await getLandingPointTexture(); for (const feature of data.features) { if (!feature.geometry || !feature.geometry.coordinates) continue; @@ -460,20 +474,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) { const marker = new THREE.Sprite( new THREE.SpriteMaterial({ - map: getLandingPointTexture(), + map: markerTexture, color: CABLE_CONFIG.landingPoint.color, transparent: true, opacity: CABLE_CONFIG.landingPoint.opacity, depthTest: false, depthWrite: false, + alphaTest: 0.01, }), ); marker.material.userData.sharedMap = true; marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder; - marker.center.set( - CABLE_CONFIG.landingPoint.anchorX, - CABLE_CONFIG.landingPoint.anchorY, - ); + marker.center.set(0.5, 0.5); marker.position.copy(position); marker.userData = { type: "landingPoint", @@ -481,15 +493,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) { cableNames: properties.cable_names || [], country: properties.country || "未知国家", 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); landingPoints.push(marker); - validCount++; } + const validCount = landingPoints.length; + setEarthStatValue("landing-point-count", `${validCount}个`); if (!silent) { @@ -619,10 +634,8 @@ function isFacingCamera(lp, camera) { const distance = Math.sqrt(distanceSq); _lpCameraToPoint.multiplyScalar(1 / distance); - // The pin sprite is rendered without depth testing so its full shape does - // not get sliced by the globe. Instead, hide it when the camera-to-anchor - // segment is occluded by a slightly inflated globe, matching the behavior of - // the BGP and compute-center markers near the limb. + // Sprite rendering keeps the full pin visible. Hide it when the anchor point + // is occluded by the globe so back-side landing points do not bleed through. const occlusionRadius = CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45; const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint); @@ -638,75 +651,52 @@ function isFacingCamera(lp, camera) { } 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) ? lockedCableName.filter(Boolean) : lockedCableName ? [lockedCableName] : []; - landingPoints.forEach((lp) => { - lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true); + const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true); const isRelated = !dimAll && Array.isArray(lp.userData.cableNames) && lp.userData.cableNames.some((name) => relatedNames.includes(name)); + lp.visible = isVisible; + setLandingPointScale(lp, camera); + if (isRelated) { + const pulse = getLandingPointPulse(); setLandingPointMaterialState(lp, { color: 0xffd27a, - emissive: 0x7a4a00, - emissiveIntensity: - CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase + - 0.2 + - pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2), opacity: Math.max( 0.92, CABLE_CONFIG.landingPointVisual.related.opacityBase + pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse, ), }); - const distanceScale = getLandingPointDistanceScale(lp, camera); - const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; - setLandingPointScale( - lp, - (CABLE_CONFIG.landingPointVisual.related.scaleBase + - pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) * - baseScale * - distanceScale, - ); + lp.userData.landing_visual_state = "related"; } 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, { - color: new THREE.Color(r / 255, g / 255, b / 255), - emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive, - emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity, + color: getLandingPointDimColor(), opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity, }); - const distanceScale = getLandingPointDistanceScale(lp, camera); - const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; - setLandingPointScale(lp, baseScale * distanceScale); + lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden"; } }); } export function resetLandingPointVisualState(camera = null) { 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, { color: CABLE_CONFIG.landingPoint.color, - emissive: CABLE_CONFIG.landingPoint.emissive, - emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity, opacity: CABLE_CONFIG.landingPoint.opacity, }); - const distanceScale = getLandingPointDistanceScale(lp, camera); - const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale; - setLandingPointScale(lp, baseScale * distanceScale); + lp.userData.landing_visual_state = isVisible ? "normal" : "hidden"; }); } diff --git a/frontend/public/earth/js/compute-centers.js b/frontend/public/earth/js/compute-centers.js index 0ec4de4a..c7242da2 100644 --- a/frontend/public/earth/js/compute-centers.js +++ b/frontend/public/earth/js/compute-centers.js @@ -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 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 supercomputerCount = 0; let gpuClusterCount = 0; @@ -69,71 +72,13 @@ function spreadComputeCenterPositions(markers) { return markers; } -function createMarkerTexture(siteType, 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, - }); - } - +function drawComputeCenterEstimatedBadge(context, isEstimated = false) { if (isEstimated) { - fillPath(() => { - context.arc(94, 36, 12, 0, Math.PI * 2); - }, { - fillStyle: "rgba(15,23,42,0.92)", - }); 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.font = "bold 18px sans-serif"; context.textAlign = "center"; @@ -141,76 +86,74 @@ function createMarkerTexture(siteType, isEstimated = false) { context.fillText("?", 94, 36); context.restore(); } - - const texture = new THREE.CanvasTexture(canvas); - texture.needsUpdate = true; - textureCache.set(textureKey, texture); - return texture; } function normalizeSiteType(siteType) { return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster"; } -function getBaseScale(siteType) { - return siteType === "supercomputer" - ? COMPUTE_CENTER_CONFIG.marker.supercomputerScale - : COMPUTE_CENTER_CONFIG.marker.gpuClusterScale; -} - -function getDistanceScale(marker, camera) { - if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) { - return 1; - } - - return getSurfaceMarkerCameraScale(camera, { - altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset, - referenceFov: 75, - min: COMPUTE_CENTER_CONFIG.sizeStabilization.min, - max: COMPUTE_CENTER_CONFIG.sizeStabilization.max, - }); -} - -function clearGroup(group) { - for (let index = group.children.length - 1; index >= 0; index -= 1) { - const child = group.children[index]; - child.material?.dispose?.(); - group.remove(child); - } -} - -function createComputeCenterMarker(markerData) { - const siteType = markerData.site_type; - const material = new THREE.SpriteMaterial({ - map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)), - transparent: true, - depthWrite: false, - opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity, - }); - const marker = new THREE.Sprite(material); - const baseScale = getBaseScale(siteType); - marker.position.copy( - latLonToVector3( - markerData.displayLatitude, - markerData.displayLongitude, - CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset, - ), - ); - marker.scale.setScalar(baseScale); - marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER; - marker.visible = showComputeCenters; - marker.userData = { - ...markerData, - site_type: siteType, - type: "compute_center", - baseScale, - state: "normal", +const computeCenterIconLayer = createInteractableLayer({ + id: "computeCenters", + objectType: "compute_center", + renderOrder: COMPUTE_CENTER_RENDER_ORDER, + altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset, + pointSize: COMPUTE_CENTER_POINT_SIZE, + atlasCellSize: COMPUTE_CENTER_ATLAS_CELL_SIZE, + colors: { + byKind: COMPUTE_CENTER_CONFIG.colors, + normal: COMPUTE_CENTER_CONFIG.colors.gpu_cluster, + }, + opacity: { + normal: COMPUTE_CENTER_CONFIG.marker.baseOpacity, + dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedOpacity, + hover: 0.98, + locked: 1, + }, + stateScale: { + hover: COMPUTE_CENTER_CONFIG.marker.hoverScale, + locked: COMPUTE_CENTER_CONFIG.marker.lockedScale, + dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedScale, + }, + pulse: { + enabled: true, + speed: COMPUTE_CENTER_CONFIG.marker.pulseSpeed, + amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude, + }, + icon: { + coordinates: "canvas", + colorable: false, + fitSize: COMPUTE_CENTER_ICON_FIT_SIZE, + glowBlur: 16, + getSource({ marker, item }) { + const siteType = + marker?.userData?.site_type || item?.site_type || "gpu_cluster"; + return ( + COMPUTE_CENTER_ICON_SOURCES[siteType] || + COMPUTE_CENTER_ICON_SOURCES.infrastructure + ); + }, + afterDraw(context, { marker, item }) { + drawComputeCenterEstimatedBadge( + context, + Boolean(marker?.userData?.is_estimated ?? item?.is_estimated), + ); + }, + }, + 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, - }; - computeCenterGroup.add(marker); - computeCenterMarkers.push(marker); - return marker; -} + }), +}); export function formatComputeCenterTypeLabel(siteType) { return siteType === "supercomputer" ? "超算中心" : "GPU 集群"; @@ -252,11 +195,11 @@ export function getComputeCenterLegendItems() { } export function getComputeCenterMarkers() { - return computeCenterMarkers; + return computeCenterIconLayer.getMarkers(); } export function getComputeCenterCount() { - return computeCenterMarkers.length; + return computeCenterIconLayer.getCount(); } export function getComputeCenterSupercomputerCount() { @@ -268,35 +211,27 @@ export function getComputeCenterGPUClusterCount() { } export function getComputeCenterStatusSummary() { - if (computeCenterMarkers.length === 0) return "暂无算力中心数据"; + if (getComputeCenterCount() === 0) return "暂无算力中心数据"; return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`; } export function setComputeCenterMarkerState(marker, state = "normal") { - if (!marker || marker.userData?.type !== "compute_center") return; - marker.userData.state = state; + computeCenterIconLayer.setMarkerState(marker, state); } export function clearComputeCenterSelection() { - computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal")); + getComputeCenterMarkers().forEach((marker) => setComputeCenterMarkerState(marker, "normal")); } export function clearComputeCenterData(earth) { - computeCenterMarkers.length = 0; supercomputerCount = 0; gpuClusterCount = 0; - clearGroup(computeCenterGroup); - if (earth && computeCenterGroup.parent === earth) { - earth.remove(computeCenterGroup); - } + computeCenterIconLayer.clearData(earth); } export function toggleComputeCenters(show) { showComputeCenters = Boolean(show); - computeCenterGroup.visible = showComputeCenters; - computeCenterMarkers.forEach((marker) => { - marker.visible = showComputeCenters; - }); + computeCenterIconLayer.setVisible(showComputeCenters); } export function getShowComputeCenters() { @@ -313,64 +248,37 @@ export async function loadComputeCenters(_scene, earth) { clearComputeCenterData(earth); - spreadComputeCenterPositions( + const markerData = spreadComputeCenterPositions( features .map((feature) => buildComputeCenterMarkerData(feature)) .filter(Boolean), ) - .slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers) - .forEach((markerData) => { - const marker = createComputeCenterMarker(markerData); - if (!marker) return; - if (marker.userData.site_type === "supercomputer") { - supercomputerCount += 1; - } else { - gpuClusterCount += 1; - } - }); + .slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers); - if (earth && !computeCenterGroup.parent) { - earth.add(computeCenterGroup); - } - computeCenterGroup.visible = showComputeCenters; + markerData.forEach((item) => { + if (item.site_type === "supercomputer") { + supercomputerCount += 1; + } else { + gpuClusterCount += 1; + } + }); + await computeCenterIconLayer.preloadAssets(markerData); + computeCenterIconLayer.setData(markerData); + computeCenterIconLayer.attach(earth); + computeCenterIconLayer.setVisible(showComputeCenters); return { - totalCount: computeCenterMarkers.length, + totalCount: getComputeCenterCount(), supercomputerCount, gpuClusterCount, summary: getComputeCenterStatusSummary(), }; } -export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) { - const hasFocus = lockedObjectType === "compute_center" && lockedObject; - 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; - }); +export function getComputeCenterPointerIntersections(options) { + return computeCenterIconLayer.getPointerIntersections(options); +} + +export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) { + computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera); } diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index b8d6b46b..9ce0bbdb 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -293,39 +293,20 @@ export const CABLE_CONFIG = { renderOrder: 1, }, landingPoint: { - altitudeOffset: 0.48, - textureSize: 256, - iconAspectRatio: 0.82, - anchorX: 0.52, - anchorY: 0.276, - baseScale: 12, + altitudeOffset: 0.2, color: 0xffaa00, - emissive: 0x442200, - emissiveIntensity: 0.5, opacity: 1.0, - renderOrder: 4.5, - }, - landingPointSizeStabilization: { - enabled: true, - referenceFov: 75, - min: 0.12, - max: 3.0, + renderOrder: 1, }, landingPointVisual: { pulseSpeed: 0.003, dimBrightness: 0.62, related: { - emissiveIntensityBase: 0.5, - emissiveIntensityPulse: 0.5, opacityBase: 0.8, opacityPulse: 0.2, - scaleBase: 1.2, - scalePulse: 0.3, }, dimmed: { colorRGB: { r: 180, g: 116, b: 28 }, - emissive: 0x3a2200, - emissiveIntensity: 0.18, opacity: 0.78, }, }, @@ -364,8 +345,8 @@ export const SATELLITE_CONFIG = { export const BGP_CONFIG = { defaultFetchLimit: 200, maxRenderedMarkers: 200, - altitudeOffset: 2.1, - collectorAltitudeOffset: 1.6, + altitudeOffset: 0.48, + collectorAltitudeOffset: 0.2, marker: { eventBaseScale: 6.2, collectorBaseScale: 7.4, diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 11409f2e..cd65b760 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -3301,6 +3301,8 @@ function setupToolbarHubCluster() { 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-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.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`); diff --git a/frontend/public/earth/js/interactable.js b/frontend/public/earth/js/interactable.js new file mode 100644 index 00000000..98d8fddf --- /dev/null +++ b/frontend/public/earth/js/interactable.js @@ -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 ", + [ + "#include ", + "uniform vec2 interactableIconAnchor;", + "uniform vec2 interactableViewportSize;", + ].join("\n"), + ) + .replace( + "#include ", + [ + "#include ", + "#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, + }; +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index c48c2009..ace44dcd 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -123,6 +123,8 @@ import { import { loadBGPAnomalies, getBGPAnomalyMarkers, + getBGPAnomalyPointerIntersections as getBGPEventIconPointerIntersections, + getBGPCollectorPointerIntersections as getBGPCollectorIconPointerIntersections, getBGPCollectorMarkers, getBGPLegendItems, getBGPCount, @@ -162,6 +164,7 @@ import { getComputeCenterCount, getComputeCenterLegendItems, getComputeCenterMarkers, + getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections, getShowComputeCenters, loadComputeCenters, setComputeCenterMarkerState, @@ -175,6 +178,7 @@ import { getVesselCount, getVesselLegendItems, getVesselMarkers, + getVesselPointerIntersections as getVesselIconPointerIntersections, loadVessels, setVesselMarkerState, showVesselTrack, @@ -291,14 +295,6 @@ const interactionMouse = new THREE.Vector2(); const scratchCameraToEarth = new THREE.Vector3(); const scratchCableCenter = 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 scratchSatelliteScreenPosition = 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 VESSEL_HOVER_PICK_INTERVAL_MS = 100; const VESSEL_POINTER_RADIUS_PX = 22; +const INTERACTABLE_POINTER_RADIUS_PX = 24; const GLOBE_DRAGGING_CLASS = "is-globe-dragging"; const HUD_INTERACTIVE_SELECTORS = [ ".earth-left-column", @@ -548,70 +545,37 @@ function clearTransientHoverState() { setHoveredSatelliteIndex(null); } -function getFrontFacingVesselMarkers(markers) { +function getVesselPointerIntersections() { 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 - ); + return getVesselIconPointerIntersections({ + earth, + camera, + pointer: interactionMouse, + radiusPx: VESSEL_POINTER_RADIUS_PX, + frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold, }); } -function getVesselPointerIntersections() { +function getBGPEventPointerIntersections() { const earth = getEarth(); - if (!earth) return []; - scratchVesselCameraLocal.copy(camera.position); - earth.worldToLocal(scratchVesselCameraLocal); - scratchVesselCameraLocal.normalize(); - - const pointerX = ((interactionMouse.x + 1) / 2) * window.innerWidth; - 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); - earth.localToWorld(scratchVesselWorldPosition); - scratchVesselScreenPosition.copy(scratchVesselWorldPosition).project(camera); - if ( - scratchVesselScreenPosition.z < -1 || - scratchVesselScreenPosition.z > 1 - ) { - return; - } - - const screenX = (scratchVesselScreenPosition.x * 0.5 + 0.5) * window.innerWidth; - const screenY = (-scratchVesselScreenPosition.y * 0.5 + 0.5) * window.innerHeight; - const deltaX = screenX - pointerX; - const deltaY = screenY - pointerY; - const distancePxSq = deltaX * deltaX + deltaY * deltaY; - if (distancePxSq > radiusSq) return; - - intersections.push({ - object: marker, - point: scratchVesselWorldPosition.clone(), - distance: camera.position.distanceTo(scratchVesselWorldPosition), - distancePxSq, - }); + return getBGPEventIconPointerIntersections({ + earth, + camera, + pointer: interactionMouse, + radiusPx: INTERACTABLE_POINTER_RADIUS_PX, + frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold, }); +} - return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq); +function getBGPCollectorPointerIntersections() { + const earth = getEarth(); + return getBGPCollectorIconPointerIntersections({ + earth, + camera, + pointer: interactionMouse, + radiusPx: INTERACTABLE_POINTER_RADIUS_PX, + frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold, + }); } function shouldSkipVesselHoverPicking() { @@ -3115,41 +3079,14 @@ function getFrontFacingCables(cableLines) { }); } -function getFrontFacingBGPMarkers(markers) { +function getComputeCenterPointerIntersections() { const earth = getEarth(); - if (!earth) return markers; - - scratchCameraToEarth.subVectors(camera.position, earth.position).normalize(); - - return markers.filter((marker) => { - scratchBGPWorldPosition.copy(marker.position); - 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 - ); + return getComputeCenterIconPointerIntersections({ + earth, + camera, + pointer: interactionMouse, + radiusPx: INTERACTABLE_POINTER_RADIUS_PX, + frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold, }); } @@ -3199,23 +3136,14 @@ function onMouseMove(event) { const frontCables = getFrontFacingCables(getCableLines()); const cableIntersects = interactionRaycaster.intersectObjects(frontCables); - const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( - getBGPAnomalyMarkers(), - ); - const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( - getBGPCollectorMarkers(), - ); const bgpAnomalyIntersects = getShowBGP() - ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) + ? getBGPEventPointerIntersections() : []; const bgpCollectorIntersects = getShowBGP() - ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) + ? getBGPCollectorPointerIntersections() : []; - const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers( - getComputeCenterMarkers(), - ); const computeCenterIntersects = getShowComputeCenters() - ? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers) + ? getComputeCenterPointerIntersections() : []; const vesselPick = getVesselHoverIntersections(); const vesselIntersects = vesselPick.intersects; @@ -3552,22 +3480,14 @@ function onClick(event) { const cableIntersects = interactionRaycaster.intersectObjects( getFrontFacingCables(getCableLines()), ); - const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers( - getBGPAnomalyMarkers(), - ); - const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers( - getBGPCollectorMarkers(), - ); const bgpAnomalyIntersects = getShowBGP() - ? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers) + ? getBGPEventPointerIntersections() : []; const bgpCollectorIntersects = getShowBGP() - ? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers) + ? getBGPCollectorPointerIntersections() : []; const computeCenterIntersects = getShowComputeCenters() - ? interactionRaycaster.intersectObjects( - getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()), - ) + ? getComputeCenterPointerIntersections() : []; const vesselIntersects = getShowVessels() ? getVesselPointerIntersections() diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index ee5717bc..5325982b 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -1,30 +1,17 @@ import * as THREE from "three"; import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js"; +import { createInteractableLayer } from "./interactable.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 activeTrackLine = null; -let lastVisualStateKey = ""; -let visualStateVersion = 0; const VESSEL_RENDER_ORDER = 4.4; const VESSEL_POINT_SIZE = 34; const VESSEL_ATLAS_CELL_SIZE = 128; const VESSEL_COURSE_BINS = 32; -function invalidateVesselVisualState() { - visualStateVersion += 1; - lastVisualStateKey = ""; -} - function normalizeVesselType(value, code) { const type = String(value || "").trim().toLowerCase(); const numericCode = Number(code); @@ -56,72 +43,6 @@ function drawVesselShape(context, anchored, glow, color = "#ffffff") { 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) { const props = feature?.properties || {}; 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) { if (marker.userData.anchored) return 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; } -function buildVesselPoints() { - vesselPoints = new THREE.Group(); - vesselPoints.visible = showVessels; - vesselPoints.renderOrder = VESSEL_RENDER_ORDER; - vesselPoints.userData = { type: "vessel_points" }; - vesselPointObjects = []; - - const groups = new Map(); - vesselMarkers.forEach((marker, index) => { +const vesselIconLayer = createInteractableLayer({ + id: "vessels", + objectType: "vessel", + renderOrder: VESSEL_RENDER_ORDER, + altitudeOffset: VESSEL_CONFIG.altitudeOffset, + pointSize: VESSEL_POINT_SIZE, + atlasCellSize: VESSEL_ATLAS_CELL_SIZE, + colors: { + 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 courseBin = getCourseBin(marker); - const key = `${anchored ? "anchored" : "moving"}:${courseBin}`; - if (!groups.has(key)) { - groups.set(key, { - anchored, - courseBin, - markers: [], - 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; -} + return `${anchored ? "anchored" : "moving"}:${courseBin}`; + }, + getUserData: (item) => ({ + ...item, + vessel_kind: item.type, + baseScale: VESSEL_CONFIG.marker.baseScale, + }), +}); export function getVesselMarkers() { - return vesselMarkers; + return vesselIconLayer.getMarkers(); } export function getVesselCount() { - return vesselMarkers.length; + return vesselIconLayer.getCount(); } export function getShowVessels() { @@ -285,18 +135,14 @@ export function getShowVessels() { export function toggleVessels(show) { showVessels = Boolean(show); - invalidateVesselVisualState(); - vesselGroup.visible = showVessels; - if (vesselPoints) { - vesselPoints.visible = showVessels; - } + vesselIconLayer.setVisible(showVessels); if (activeTrackLine) { activeTrackLine.visible = showVessels; } } export function clearVesselSelection() { - vesselMarkers.forEach((marker) => setVesselMarkerState(marker, "normal")); + getVesselMarkers().forEach((marker) => setVesselMarkerState(marker, "normal")); clearVesselTrack(); } @@ -310,21 +156,16 @@ function clearVesselTrack() { } export function setVesselMarkerState(marker, state = "normal") { - if (!marker || marker.userData?.type !== "vessel") return; - if (marker.userData.state === state) return; - marker.userData.state = state; - invalidateVesselVisualState(); + vesselIconLayer.setMarkerState(marker, state); +} + +export function getVesselPointerIntersections(options) { + return vesselIconLayer.getPointerIntersections(options); } export function clearVesselData(earth) { - invalidateVesselVisualState(); clearVesselSelection(); - vesselMarkers.length = 0; - disposeVesselRenderObjects(); - clearGroup(vesselGroup); - if (earth && vesselGroup.parent === earth) { - earth.remove(vesselGroup); - } + vesselIconLayer.clearData(earth); } 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 : []; clearVesselData(earth); - features + const markerData = features .map((feature) => buildVesselMarkerData(feature)) .filter(Boolean) - .slice(0, VESSEL_CONFIG.maxRenderedMarkers) - .forEach((markerData) => createVesselMarker(markerData)); - buildVesselPoints(); + .slice(0, VESSEL_CONFIG.maxRenderedMarkers); + vesselIconLayer.setData(markerData); - if (earth && !vesselGroup.parent) { - earth.add(vesselGroup); - } - vesselGroup.visible = showVessels; + vesselIconLayer.attach(earth); + vesselIconLayer.setVisible(showVessels); return { - totalCount: vesselMarkers.length, + totalCount: getVesselCount(), 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) { - if (!showVessels || vesselMarkers.length === 0 || !vesselPoints) { - 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, - ); + vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera); } diff --git a/pyproject.toml b/pyproject.toml index 75519d98..9a06b954 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.45.0" +version = "0.46.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index d0a7c45c..cef6250e 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.45.0" +version = "0.46.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },