release: bump version to 0.31.2
This commit is contained in:
@@ -10,6 +10,25 @@ This project follows the repository versioning rule:
|
|||||||
|
|
||||||
## [0.31.0] — 2026-04-21
|
## [0.31.0] — 2026-04-21
|
||||||
|
|
||||||
|
## [0.31.2] — 2026-04-21
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- Earth 巡航模式重构为“通用巡航队列 + 通用连线动画 + BGP 业务适配”三层结构,后续扩到海缆、卫星或新闻巡航时不必再复制一套 `main.js` 状态机
|
||||||
|
- 修复巡航重构后的交互回归:空白点击重新稳定切到下一项,连线按“起点 → 引导线 → 终点”顺序入场
|
||||||
|
|
||||||
|
### 🔧 Improvements
|
||||||
|
- 新增 [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js) 统一管理队列推进、停留时长、打断与恢复
|
||||||
|
- 新增 [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js) 统一管理 SVG 连线、折线路径与描边动画
|
||||||
|
- 新增 [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 收口 BGP 巡航目标排序、卡片落点、轮询去重与连线适配
|
||||||
|
- 更新 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md) 说明新的巡航分层与复用边界
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
- 修复巡航模式下点击空白处无法稳定跳转到下一项、切回旋转再切回巡航后直接卡住的问题
|
||||||
|
- 修复巡航连线被实时重定位覆盖导致“直接出现”而非绘制动画的问题
|
||||||
|
- 修复连线动画节点入场节奏不对的问题,改为先出现起点,再绘制连线,最后出现终点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.31.1] — 2026-04-21
|
## [0.31.1] — 2026-04-21
|
||||||
|
|
||||||
### ✨ Highlights
|
### ✨ Highlights
|
||||||
|
|||||||
@@ -96,8 +96,11 @@ React 路由入口:
|
|||||||
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||||
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||||
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
||||||
|
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||||
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
- [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||||
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
- [tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js)
|
||||||
|
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||||
|
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
|
|
||||||
@@ -106,6 +109,17 @@ React 路由入口:
|
|||||||
- 面板内容
|
- 面板内容
|
||||||
- hover/lock/selection 语义
|
- hover/lock/selection 语义
|
||||||
|
|
||||||
|
其中巡航模式现在已经拆成两层:
|
||||||
|
|
||||||
|
- `cruise-sequencer.js`
|
||||||
|
- 负责目标队列顺序、停留时长、切换节奏、打断与恢复
|
||||||
|
- `callout-connector.js`
|
||||||
|
- 负责卡片连线 SVG、路径计算与绘制动画
|
||||||
|
- `bgp-cruise-adapter.js`
|
||||||
|
- 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序
|
||||||
|
|
||||||
|
当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。
|
||||||
|
|
||||||
## 当前样式分层
|
## 当前样式分层
|
||||||
|
|
||||||
Earth 的 CSS 不是一份大样式表,而是分层管理:
|
Earth 的 CSS 不是一份大样式表,而是分层管理:
|
||||||
@@ -233,6 +247,27 @@ Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易
|
|||||||
|
|
||||||
每次大功能完成后,都要做一次 cleanup pass。
|
每次大功能完成后,都要做一次 cleanup pass。
|
||||||
|
|
||||||
|
### 4. 巡航与业务事件不要再深度耦合
|
||||||
|
|
||||||
|
当前正确边界应该是:
|
||||||
|
|
||||||
|
- 通用巡航层只知道:
|
||||||
|
- 当前目标
|
||||||
|
- 队列顺序
|
||||||
|
- 相机 focus
|
||||||
|
- 停留 / 隐藏 / 切换
|
||||||
|
- 业务模块只负责:
|
||||||
|
- 提供目标队列
|
||||||
|
- 提供 focus 坐标
|
||||||
|
- 提供卡片内容
|
||||||
|
- 提供高亮/图层副作用
|
||||||
|
|
||||||
|
如果以后再给海缆、卫星或新闻做巡航,不应复制一套新的 `main.js` 状态变量,而应复用:
|
||||||
|
|
||||||
|
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||||
|
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||||
|
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js) 这种业务适配层模式
|
||||||
|
|
||||||
## 当前推荐改动方式
|
## 当前推荐改动方式
|
||||||
|
|
||||||
如果后续继续改 Earth,建议按这个顺序:
|
如果后续继续改 Earth,建议按这个顺序:
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.31.1`
|
- `dev` 当前开发分支历史推导到:`0.31.2`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.31.2` | bugfix | `dev` | `pending` | 将 Earth 巡航模式拆成通用 sequencer、通用连线和 BGP 巡航适配层,并修复空白点击推进与连线动画回归 |
|
||||||
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
|
| `0.31.1` | bugfix | `dev` | `pending` | Earth 图层开关统一 loading 状态机,卫星首次加载可见化,并将文档按 technical / plans / deprecated 重构归档 |
|
||||||
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
| `0.31.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 |
|
||||||
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
| `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.31.1",
|
"version": "0.31.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -206,11 +206,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-card-cruise-link.is-animating circle {
|
.info-card-cruise-link.is-animating circle {
|
||||||
animation: cruiseConnectorNodeIn 0.22s ease forwards;
|
|
||||||
animation-delay: 0.22s;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating circle:first-of-type {
|
||||||
|
animation: cruiseConnectorNodeIn 0.14s ease forwards;
|
||||||
|
animation-delay: 0.02s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-cruise-link.is-animating circle:last-of-type {
|
||||||
|
animation: cruiseConnectorNodeIn 0.16s ease forwards;
|
||||||
|
animation-delay: 0.34s;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes cruiseConnectorDraw {
|
@keyframes cruiseConnectorDraw {
|
||||||
from {
|
from {
|
||||||
stroke-dashoffset: var(--connector-length, 0px);
|
stroke-dashoffset: var(--connector-length, 0px);
|
||||||
|
|||||||
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
300
frontend/public/earth/js/bgp-cruise-adapter.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { CRUISE_CONFIG, PATHS } from "./constants.js";
|
||||||
|
import { createElbowConnectorPoints } from "./callout-connector.js";
|
||||||
|
|
||||||
|
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||||
|
const CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||||
|
const CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
||||||
|
const CRUISE_CARD_VIEWPORT_PADDING_PX = 32;
|
||||||
|
const CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||||
|
const CRUISE_CARD_ANCHOR_OFFSET_PX = 18;
|
||||||
|
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||||
|
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
||||||
|
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||||
|
|
||||||
|
function getMarkerTimestamp(marker) {
|
||||||
|
const rawValue = marker?.userData?.created_at_raw;
|
||||||
|
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
|
||||||
|
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBGPCruiseAdapter({
|
||||||
|
camera,
|
||||||
|
getMarkers,
|
||||||
|
connector,
|
||||||
|
focusView,
|
||||||
|
setMarkerLocked,
|
||||||
|
clearMarkerState,
|
||||||
|
showMarkerOverlay,
|
||||||
|
applySatelliteHighlights,
|
||||||
|
showMarkerInfo,
|
||||||
|
hideInfo,
|
||||||
|
isInfoVisible,
|
||||||
|
getLockedObject,
|
||||||
|
refreshMarkers,
|
||||||
|
}) {
|
||||||
|
let currentMarkerId = null;
|
||||||
|
let cardPlacement = null;
|
||||||
|
let knownEventIds = new Set();
|
||||||
|
|
||||||
|
function getCurrentMarker() {
|
||||||
|
if (!currentMarkerId) return null;
|
||||||
|
return getMarkers().find((marker) => marker?.userData?.id === currentMarkerId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedMarkers() {
|
||||||
|
return getMarkers()
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => getMarkerTimestamp(b) - getMarkerTimestamp(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkerScreenCoords(marker) {
|
||||||
|
if (!marker || !camera) return null;
|
||||||
|
scratchBGPWorldPosition.copy(marker.position);
|
||||||
|
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
||||||
|
const projected = scratchBGPWorldPosition.clone().project(camera);
|
||||||
|
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
||||||
|
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCardScreenCoords(marker) {
|
||||||
|
const markerCoords = getMarkerScreenCoords(marker);
|
||||||
|
if (!markerCoords) return null;
|
||||||
|
|
||||||
|
const hudScale =
|
||||||
|
Number.parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
||||||
|
) || 1;
|
||||||
|
const estimatedCardHeight = Math.min(
|
||||||
|
CRUISE_CARD_ESTIMATED_HEIGHT_PX * hudScale,
|
||||||
|
window.innerHeight * 0.7,
|
||||||
|
);
|
||||||
|
const estimatedCardWidth = Math.min(
|
||||||
|
CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale,
|
||||||
|
window.innerWidth - CRUISE_CARD_VIEWPORT_PADDING_PX,
|
||||||
|
);
|
||||||
|
|
||||||
|
const x =
|
||||||
|
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
|
||||||
|
const y =
|
||||||
|
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
|
||||||
|
const margin = CRUISE_CARD_SCREEN_MARGIN_PX;
|
||||||
|
const clampedX = Math.min(
|
||||||
|
Math.max(margin, x),
|
||||||
|
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
|
||||||
|
);
|
||||||
|
const clampedY = Math.min(
|
||||||
|
Math.max(margin, y),
|
||||||
|
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
||||||
|
);
|
||||||
|
const anchorY = clampedY + Math.max(
|
||||||
|
CRUISE_CARD_ANCHOR_OFFSET_PX * hudScale,
|
||||||
|
estimatedCardHeight * 0.18,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clampedX,
|
||||||
|
y: clampedY,
|
||||||
|
width: estimatedCardWidth,
|
||||||
|
height: estimatedCardHeight,
|
||||||
|
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
|
||||||
|
anchorY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectorPath(marker) {
|
||||||
|
const markerCoords = getMarkerScreenCoords(marker);
|
||||||
|
const targetCardCoords = cardPlacement || getCardScreenCoords(marker);
|
||||||
|
if (!markerCoords || !targetCardCoords) return null;
|
||||||
|
|
||||||
|
return createElbowConnectorPoints(
|
||||||
|
markerCoords,
|
||||||
|
{
|
||||||
|
x: targetCardCoords.anchorX,
|
||||||
|
y: targetCardCoords.anchorY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startFrom: "source",
|
||||||
|
sourceGapPx: CRUISE_CONFIG.linkMarkerGapPx,
|
||||||
|
targetGapPx: CRUISE_CONFIG.linkPanelGapPx,
|
||||||
|
elbowOffsetPx: CRUISE_CONFIG.linkElbowOffsetPx,
|
||||||
|
elbowDropPx: CRUISE_CONFIG.linkElbowDropPx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnector(marker, { animate = false } = {}) {
|
||||||
|
const path = getConnectorPath(marker);
|
||||||
|
if (!path) return false;
|
||||||
|
return connector.render(path, { animate });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFeatureIds(features = []) {
|
||||||
|
return features
|
||||||
|
.map((feature) => {
|
||||||
|
const properties = feature?.properties || {};
|
||||||
|
const coords = feature?.geometry?.coordinates || [];
|
||||||
|
return (
|
||||||
|
properties.id ||
|
||||||
|
properties.incident_key ||
|
||||||
|
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSortedMarkers,
|
||||||
|
getCurrentMarker,
|
||||||
|
isPresentationVisible() {
|
||||||
|
return cardPlacement != null;
|
||||||
|
},
|
||||||
|
clearCurrentHighlight() {
|
||||||
|
const marker = getCurrentMarker();
|
||||||
|
if (marker && getLockedObject() !== marker) {
|
||||||
|
clearMarkerState(marker);
|
||||||
|
}
|
||||||
|
currentMarkerId = null;
|
||||||
|
},
|
||||||
|
async focusMarker(marker, { interrupt = false } = {}) {
|
||||||
|
if (!marker) return;
|
||||||
|
currentMarkerId = marker.userData?.id || null;
|
||||||
|
cardPlacement = getCardScreenCoords(marker);
|
||||||
|
setMarkerLocked(marker);
|
||||||
|
showMarkerOverlay(marker);
|
||||||
|
applySatelliteHighlights(marker);
|
||||||
|
|
||||||
|
await focusView({
|
||||||
|
lat: marker.userData?.latitude ?? 0,
|
||||||
|
lon: marker.userData?.longitude ?? 0,
|
||||||
|
rotLon: (marker.userData?.longitude ?? 0) - 270,
|
||||||
|
zoom: 1.0,
|
||||||
|
duration: interrupt
|
||||||
|
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78)
|
||||||
|
: CRUISE_CONFIG.focusDurationMs,
|
||||||
|
suppressStatus: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async presentMarker(marker, { context }) {
|
||||||
|
if (!marker) return false;
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let connectorReady = false;
|
||||||
|
while (context.isCurrent()) {
|
||||||
|
connectorReady = renderConnector(marker, { animate: !connectorReady });
|
||||||
|
if (connectorReady) break;
|
||||||
|
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await context.nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectorReady || !context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector.hide();
|
||||||
|
hideInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS);
|
||||||
|
if (!connectorDelayCompleted || !context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector.hide();
|
||||||
|
hideInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMarkerInfo(marker, {
|
||||||
|
x: cardPlacement?.x,
|
||||||
|
y: cardPlacement?.y,
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
await context.nextFrame();
|
||||||
|
if (!isInfoVisible()) {
|
||||||
|
showMarkerInfo(marker, {
|
||||||
|
x: cardPlacement?.x,
|
||||||
|
y: cardPlacement?.y,
|
||||||
|
absolute: true,
|
||||||
|
});
|
||||||
|
await context.nextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInfoVisible() || !context.isCurrent()) {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector.hide();
|
||||||
|
hideInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async hidePresentation({ context }) {
|
||||||
|
if (!getLockedObject()) {
|
||||||
|
hideInfo();
|
||||||
|
}
|
||||||
|
connector.hide();
|
||||||
|
const hideDelayCompleted = await context.wait(CRUISE_PRESENTATION_HIDE_MS, {
|
||||||
|
secondary: true,
|
||||||
|
});
|
||||||
|
if (!hideDelayCompleted) return;
|
||||||
|
cardPlacement = null;
|
||||||
|
},
|
||||||
|
repositionConnector(marker) {
|
||||||
|
if (!cardPlacement || !marker || !connector.isVisible() || connector.isAnimating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderConnector(marker, { animate: false });
|
||||||
|
},
|
||||||
|
resetPresentation() {
|
||||||
|
cardPlacement = null;
|
||||||
|
connector.hide();
|
||||||
|
},
|
||||||
|
syncKnownEventIds() {
|
||||||
|
knownEventIds = new Set(
|
||||||
|
getMarkers()
|
||||||
|
.map((marker) => marker?.userData?.id)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return knownEventIds;
|
||||||
|
},
|
||||||
|
async pollForNewMarkerIds() {
|
||||||
|
const [incidentResponse, anomalyResponse] = await Promise.all([
|
||||||
|
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||||
|
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!incidentResponse.ok || !anomalyResponse.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [incidentPayload, anomalyPayload] = await Promise.all([
|
||||||
|
incidentResponse.json(),
|
||||||
|
anomalyResponse.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const incidentFeatures = Array.isArray(incidentPayload?.features)
|
||||||
|
? incidentPayload.features
|
||||||
|
: [];
|
||||||
|
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
|
||||||
|
? anomalyPayload.features
|
||||||
|
: [];
|
||||||
|
const selectedFeatures =
|
||||||
|
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
|
||||||
|
|
||||||
|
const nextIds = extractFeatureIds(selectedFeatures);
|
||||||
|
const newIds = nextIds.filter((id) => !knownEventIds.has(id));
|
||||||
|
if (newIds.length === 0) return [];
|
||||||
|
|
||||||
|
await refreshMarkers();
|
||||||
|
this.syncKnownEventIds();
|
||||||
|
return newIds;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
185
frontend/public/earth/js/callout-connector.js
Normal file
185
frontend/public/earth/js/callout-connector.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
const DEFAULT_CLASS_NAME = "info-card-cruise-link";
|
||||||
|
const DEFAULT_DRAW_ANIMATION_NAME = "cruiseConnectorDraw";
|
||||||
|
|
||||||
|
function createSvgElement(tagName) {
|
||||||
|
return document.createElementNS(SVG_NS, tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createElbowConnectorPoints(source, target, options = {}) {
|
||||||
|
if (!source || !target) return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
startFrom = "source",
|
||||||
|
sourceGapPx = 12,
|
||||||
|
targetGapPx = 8,
|
||||||
|
elbowOffsetPx = 18,
|
||||||
|
elbowDropPx = 14,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const sourcePoint = { x: Number(source.x), y: Number(source.y) };
|
||||||
|
const targetPoint = { x: Number(target.x), y: Number(target.y) };
|
||||||
|
if (
|
||||||
|
!Number.isFinite(sourcePoint.x) ||
|
||||||
|
!Number.isFinite(sourcePoint.y) ||
|
||||||
|
!Number.isFinite(targetPoint.x) ||
|
||||||
|
!Number.isFinite(targetPoint.y)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
|
||||||
|
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
|
||||||
|
const startY = sourcePoint.y;
|
||||||
|
const endX = targetPoint.x - horizontalDirection * targetGapPx;
|
||||||
|
const endY = targetPoint.y;
|
||||||
|
const elbowX = endX - horizontalDirection * elbowOffsetPx;
|
||||||
|
const elbowY = Math.min(startY, endY) + elbowDropPx;
|
||||||
|
|
||||||
|
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedPoints = [
|
||||||
|
{ x: startX, y: startY },
|
||||||
|
{ x: elbowX, y: elbowY },
|
||||||
|
{ x: endX, y: endY },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
|
||||||
|
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
|
||||||
|
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalloutConnector {
|
||||||
|
constructor({
|
||||||
|
container = null,
|
||||||
|
containerId = "container",
|
||||||
|
className = DEFAULT_CLASS_NAME,
|
||||||
|
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
|
||||||
|
} = {}) {
|
||||||
|
this.container = container;
|
||||||
|
this.containerId = containerId;
|
||||||
|
this.className = className;
|
||||||
|
this.drawAnimationName = drawAnimationName;
|
||||||
|
this.connectorEl = null;
|
||||||
|
this.polylineEl = null;
|
||||||
|
this.startpointEl = null;
|
||||||
|
this.endpointEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveContainer() {
|
||||||
|
if (this.container instanceof HTMLElement) return this.container;
|
||||||
|
this.container = document.getElementById(this.containerId);
|
||||||
|
return this.container instanceof HTMLElement ? this.container : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure() {
|
||||||
|
if (this.connectorEl instanceof SVGSVGElement) {
|
||||||
|
return this.connectorEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = this.resolveContainer();
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
const connector = createSvgElement("svg");
|
||||||
|
connector.setAttribute("class", this.className);
|
||||||
|
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
||||||
|
connector.setAttribute("preserveAspectRatio", "none");
|
||||||
|
|
||||||
|
const polyline = createSvgElement("polyline");
|
||||||
|
const startpoint = createSvgElement("circle");
|
||||||
|
const endpoint = createSvgElement("circle");
|
||||||
|
startpoint.setAttribute("r", "4");
|
||||||
|
endpoint.setAttribute("r", "4");
|
||||||
|
|
||||||
|
connector.append(startpoint, polyline, endpoint);
|
||||||
|
container.appendChild(connector);
|
||||||
|
|
||||||
|
connector.addEventListener("animationend", (event) => {
|
||||||
|
if (
|
||||||
|
event.animationName === this.drawAnimationName &&
|
||||||
|
this.connectorEl?.classList.contains("is-visible")
|
||||||
|
) {
|
||||||
|
if (this.polylineEl) {
|
||||||
|
this.polylineEl.style.strokeDashoffset = "0";
|
||||||
|
}
|
||||||
|
this.connectorEl?.classList.remove("is-animating");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connectorEl = connector;
|
||||||
|
this.polylineEl = polyline;
|
||||||
|
this.startpointEl = startpoint;
|
||||||
|
this.endpointEl = endpoint;
|
||||||
|
return connector;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVisible() {
|
||||||
|
return this.connectorEl?.classList.contains("is-visible") === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnimating() {
|
||||||
|
return this.connectorEl?.classList.contains("is-animating") === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
const connector = this.ensure();
|
||||||
|
if (!connector) return;
|
||||||
|
connector.classList.remove("is-visible", "is-animating");
|
||||||
|
}
|
||||||
|
|
||||||
|
render(path, { animate = false } = {}) {
|
||||||
|
const connector = this.ensure();
|
||||||
|
if (
|
||||||
|
!connector ||
|
||||||
|
!this.polylineEl ||
|
||||||
|
!this.startpointEl ||
|
||||||
|
!this.endpointEl ||
|
||||||
|
!Array.isArray(path?.points) ||
|
||||||
|
path.points.length < 2
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewWidth = window.innerWidth;
|
||||||
|
const viewHeight = window.innerHeight;
|
||||||
|
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
|
||||||
|
|
||||||
|
const pointsText = path.points
|
||||||
|
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
|
||||||
|
.join(" ");
|
||||||
|
this.polylineEl.setAttribute("points", pointsText);
|
||||||
|
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
|
||||||
|
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
|
||||||
|
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
|
||||||
|
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
|
||||||
|
|
||||||
|
const totalLength =
|
||||||
|
typeof this.polylineEl.getTotalLength === "function"
|
||||||
|
? this.polylineEl.getTotalLength()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
|
||||||
|
this.polylineEl.style.strokeDashoffset =
|
||||||
|
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
|
||||||
|
connector.style.setProperty(
|
||||||
|
"--connector-length",
|
||||||
|
totalLength > 0 ? `${totalLength}` : "0px",
|
||||||
|
);
|
||||||
|
connector.classList.add("is-visible");
|
||||||
|
|
||||||
|
if (animate && totalLength > 0) {
|
||||||
|
connector.classList.remove("is-animating");
|
||||||
|
void connector.getBoundingClientRect();
|
||||||
|
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
|
||||||
|
connector.classList.add("is-animating");
|
||||||
|
} else {
|
||||||
|
connector.classList.remove("is-animating");
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalLength > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
229
frontend/public/earth/js/cruise-sequencer.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
function nextAnimationFrame() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CruiseSequencer {
|
||||||
|
constructor({
|
||||||
|
isActive,
|
||||||
|
getItems,
|
||||||
|
getItemId,
|
||||||
|
focusItem,
|
||||||
|
presentItem,
|
||||||
|
hideItem,
|
||||||
|
clearCurrent,
|
||||||
|
onStop,
|
||||||
|
dwellMs = 2400,
|
||||||
|
transitionGapMs = 24,
|
||||||
|
}) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
this.getItems = getItems;
|
||||||
|
this.getItemId = getItemId;
|
||||||
|
this.focusItem = focusItem;
|
||||||
|
this.presentItem = presentItem;
|
||||||
|
this.hideItem = hideItem;
|
||||||
|
this.clearCurrent = clearCurrent;
|
||||||
|
this.onStop = onStop;
|
||||||
|
this.dwellMs = dwellMs;
|
||||||
|
this.transitionGapMs = transitionGapMs;
|
||||||
|
|
||||||
|
this.currentItemId = null;
|
||||||
|
this.currentIndex = -1;
|
||||||
|
this.queuedItemIds = [];
|
||||||
|
this.sequenceToken = 0;
|
||||||
|
this.advanceQueued = false;
|
||||||
|
this.advanceInterrupt = false;
|
||||||
|
this.advanceInFlight = false;
|
||||||
|
this.advanceLoopToken = 0;
|
||||||
|
this.primaryTimerId = null;
|
||||||
|
this.secondaryTimerId = null;
|
||||||
|
this.presentationVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentItem() {
|
||||||
|
if (!this.currentItemId) return null;
|
||||||
|
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentItemId() {
|
||||||
|
return this.currentItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPresentationPinned() {
|
||||||
|
return this.presentationVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBusy() {
|
||||||
|
return this.advanceInFlight || this.presentationVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(itemIds = []) {
|
||||||
|
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
|
||||||
|
this.queuedItemIds = Array.from(
|
||||||
|
new Set([...itemIds.filter(Boolean), ...this.queuedItemIds]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPresentationVisible(visible) {
|
||||||
|
this.presentationVisible = Boolean(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimers() {
|
||||||
|
if (this.primaryTimerId) {
|
||||||
|
clearTimeout(this.primaryTimerId);
|
||||||
|
this.primaryTimerId = null;
|
||||||
|
}
|
||||||
|
if (this.secondaryTimerId) {
|
||||||
|
clearTimeout(this.secondaryTimerId);
|
||||||
|
this.secondaryTimerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interruptPresentation({ preservePresentation = false, resetLoop = false } = {}) {
|
||||||
|
this.sequenceToken += 1;
|
||||||
|
this.clearTimers();
|
||||||
|
this.advanceQueued = false;
|
||||||
|
this.advanceInterrupt = false;
|
||||||
|
if (resetLoop) {
|
||||||
|
this.advanceLoopToken += 1;
|
||||||
|
this.advanceInFlight = false;
|
||||||
|
}
|
||||||
|
if (!preservePresentation) {
|
||||||
|
this.presentationVisible = false;
|
||||||
|
this.clearCurrent?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop({ preservePresentation = false } = {}) {
|
||||||
|
this.interruptPresentation({ preservePresentation });
|
||||||
|
this.currentItemId = preservePresentation ? this.currentItemId : null;
|
||||||
|
this.currentIndex = preservePresentation ? this.currentIndex : -1;
|
||||||
|
this.queuedItemIds = [];
|
||||||
|
this.onStop?.({ preservePresentation });
|
||||||
|
}
|
||||||
|
|
||||||
|
createContext(token) {
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
isCurrent: () => token === this.sequenceToken && this.isActive(),
|
||||||
|
wait: (durationMs, { secondary = false } = {}) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
if (secondary) {
|
||||||
|
if (this.secondaryTimerId === timerId) this.secondaryTimerId = null;
|
||||||
|
} else if (this.primaryTimerId === timerId) {
|
||||||
|
this.primaryTimerId = null;
|
||||||
|
}
|
||||||
|
resolve(token === this.sequenceToken && this.isActive());
|
||||||
|
}, durationMs);
|
||||||
|
|
||||||
|
if (secondary) {
|
||||||
|
this.secondaryTimerId = timerId;
|
||||||
|
} else {
|
||||||
|
this.primaryTimerId = timerId;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
nextFrame: nextAnimationFrame,
|
||||||
|
setPresentationVisible: (visible) => {
|
||||||
|
if (token !== this.sequenceToken) return;
|
||||||
|
this.presentationVisible = Boolean(visible);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveNextItem(items) {
|
||||||
|
let targetItem = null;
|
||||||
|
while (this.queuedItemIds.length > 0 && !targetItem) {
|
||||||
|
const queuedId = this.queuedItemIds.shift();
|
||||||
|
targetItem = items.find((item) => this.getItemId(item) === queuedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetItem) return targetItem;
|
||||||
|
|
||||||
|
const nextIndex = this.currentIndex >= 0 ? (this.currentIndex + 1) % items.length : 0;
|
||||||
|
return items[nextIndex] || items[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async performAdvance({ interrupt = false } = {}) {
|
||||||
|
if (!this.isActive()) return;
|
||||||
|
|
||||||
|
const items = this.getItems();
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return;
|
||||||
|
|
||||||
|
const targetItem = this.resolveNextItem(items);
|
||||||
|
if (!targetItem) return;
|
||||||
|
|
||||||
|
const token = ++this.sequenceToken;
|
||||||
|
const context = this.createContext(token);
|
||||||
|
|
||||||
|
this.clearTimers();
|
||||||
|
this.presentationVisible = false;
|
||||||
|
this.clearCurrent?.();
|
||||||
|
|
||||||
|
this.currentItemId = this.getItemId(targetItem);
|
||||||
|
this.currentIndex = items.findIndex(
|
||||||
|
(item) => this.getItemId(item) === this.currentItemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.focusItem?.(targetItem, { interrupt, context });
|
||||||
|
if (!context.isCurrent()) {
|
||||||
|
this.presentationVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presented = await this.presentItem?.(targetItem, { interrupt, context });
|
||||||
|
if (!presented || !context.isCurrent()) {
|
||||||
|
this.presentationVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.presentationVisible = true;
|
||||||
|
const dwellCompleted = await context.wait(this.dwellMs);
|
||||||
|
if (!dwellCompleted || !context.isCurrent()) {
|
||||||
|
this.presentationVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.hideItem?.(targetItem, { context });
|
||||||
|
if (!context.isCurrent()) {
|
||||||
|
this.presentationVisible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.presentationVisible = false;
|
||||||
|
const gapCompleted = await context.wait(this.transitionGapMs, { secondary: true });
|
||||||
|
if (!gapCompleted || !context.isCurrent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
async advance({ interrupt = false } = {}) {
|
||||||
|
if (!this.isActive()) return;
|
||||||
|
|
||||||
|
this.advanceQueued = true;
|
||||||
|
this.advanceInterrupt = this.advanceInterrupt || interrupt;
|
||||||
|
if (this.advanceInFlight) return;
|
||||||
|
|
||||||
|
const activeLoopToken = ++this.advanceLoopToken;
|
||||||
|
this.advanceInFlight = true;
|
||||||
|
try {
|
||||||
|
while (
|
||||||
|
this.advanceQueued &&
|
||||||
|
this.isActive() &&
|
||||||
|
this.advanceLoopToken === activeLoopToken
|
||||||
|
) {
|
||||||
|
const nextInterrupt = this.advanceInterrupt;
|
||||||
|
this.advanceQueued = false;
|
||||||
|
this.advanceInterrupt = false;
|
||||||
|
await this.performAdvance({ interrupt: nextInterrupt });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.advanceLoopToken === activeLoopToken) {
|
||||||
|
this.advanceInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
setLayerButtonState,
|
setLayerButtonState,
|
||||||
} from "./layer-button-state.js";
|
} from "./layer-button-state.js";
|
||||||
|
import { CalloutConnector } from "./callout-connector.js";
|
||||||
|
import { CruiseSequencer } from "./cruise-sequencer.js";
|
||||||
|
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
|
||||||
import {
|
import {
|
||||||
initInfoCard,
|
initInfoCard,
|
||||||
showInfoCard,
|
showInfoCard,
|
||||||
@@ -190,43 +193,10 @@ let cableToggleToken = 0;
|
|||||||
let satelliteToggleToken = 0;
|
let satelliteToggleToken = 0;
|
||||||
let satelliteHydrationToken = 0;
|
let satelliteHydrationToken = 0;
|
||||||
let sceneLights = null;
|
let sceneLights = null;
|
||||||
let cruiseTimerId = null;
|
|
||||||
let cruiseHideCardTimerId = null;
|
|
||||||
let cruisePollTimerId = null;
|
let cruisePollTimerId = null;
|
||||||
let cruiseCurrentMarkerId = null;
|
let cruiseConnector = null;
|
||||||
let cruiseCurrentIndex = -1;
|
let cruiseBGPAdapter = null;
|
||||||
let cruiseQueuedMarkerIds = [];
|
let cruiseSequencer = null;
|
||||||
let cruiseKnownEventIds = new Set();
|
|
||||||
let cruiseCardPinned = false;
|
|
||||||
let cruiseConnectorEl = null;
|
|
||||||
let cruiseConnectorPolyline = null;
|
|
||||||
let cruiseConnectorStartpoint = null;
|
|
||||||
let cruiseConnectorEndpoint = null;
|
|
||||||
let cruiseConnectorNeedsAnimation = false;
|
|
||||||
let cruiseCardPlacement = null;
|
|
||||||
let cruiseSequenceToken = 0;
|
|
||||||
let cruisePresentationPhase = "hidden";
|
|
||||||
let cruiseAdvanceInFlight = false;
|
|
||||||
let cruiseAdvanceQueued = false;
|
|
||||||
let cruiseAdvanceInterrupt = false;
|
|
||||||
let cruiseCancelNotifier = null;
|
|
||||||
|
|
||||||
function createCruisePresentationTimer() {
|
|
||||||
let endsAt = 0;
|
|
||||||
return {
|
|
||||||
start(durationMs) {
|
|
||||||
endsAt = Date.now() + Math.max(0, durationMs);
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
endsAt = 0;
|
|
||||||
},
|
|
||||||
isActive() {
|
|
||||||
return endsAt > 0 && Date.now() < endsAt;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cruisePresentationTimer = createCruisePresentationTimer();
|
|
||||||
|
|
||||||
const clock = new THREE.Clock();
|
const clock = new THREE.Clock();
|
||||||
const interactionRaycaster = new THREE.Raycaster();
|
const interactionRaycaster = new THREE.Raycaster();
|
||||||
@@ -242,10 +212,7 @@ const cleanupFns = [];
|
|||||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||||
const INERTIA_DAMPING = 0.92;
|
const INERTIA_DAMPING = 0.92;
|
||||||
const INERTIA_MIN_VELOCITY = 0.00008;
|
const INERTIA_MIN_VELOCITY = 0.00008;
|
||||||
const CRUISE_CONNECTOR_DRAW_MS = 420;
|
|
||||||
const CRUISE_TRANSITION_GAP_MS = 24;
|
const CRUISE_TRANSITION_GAP_MS = 24;
|
||||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
|
||||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
|
||||||
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
|
const ACTIVE_BGP_TOOLTIP_TEXT = "隐藏BGP观测";
|
||||||
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
|
const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
|
||||||
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
|
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
|
||||||
@@ -273,69 +240,10 @@ function bindListener(target, eventName, handler, options) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForCruiseDelay(durationMs, sequenceToken, { useHideTimer = false } = {}) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const settle = (ok) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
if (cruiseCancelNotifier === onCancel) cruiseCancelNotifier = null;
|
|
||||||
resolve(ok);
|
|
||||||
};
|
|
||||||
const onCancel = () => settle(false);
|
|
||||||
|
|
||||||
const timerId = window.setTimeout(() => {
|
|
||||||
if (useHideTimer) {
|
|
||||||
if (cruiseHideCardTimerId === timerId) cruiseHideCardTimerId = null;
|
|
||||||
} else if (cruiseTimerId === timerId) {
|
|
||||||
cruiseTimerId = null;
|
|
||||||
}
|
|
||||||
settle(sequenceToken === cruiseSequenceToken);
|
|
||||||
}, durationMs);
|
|
||||||
|
|
||||||
if (useHideTimer) {
|
|
||||||
cruiseHideCardTimerId = timerId;
|
|
||||||
} else {
|
|
||||||
cruiseTimerId = timerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
cruiseCancelNotifier = onCancel;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForCruiseConnectorReady(sequenceToken) {
|
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
while (sequenceToken === cruiseSequenceToken) {
|
|
||||||
if (!isCruiseModeActive() || !getAutoRotate()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = updateCruiseConnector();
|
|
||||||
if (ready) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextAnimationFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getViewportAspect() {
|
function getViewportAspect() {
|
||||||
return window.innerWidth / window.innerHeight;
|
return window.innerWidth / window.innerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextAnimationFrame() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window.requestAnimationFrame(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncRendererViewport() {
|
function syncRendererViewport() {
|
||||||
if (!camera || !renderer) return;
|
if (!camera || !renderer) return;
|
||||||
camera.aspect = getViewportAspect();
|
camera.aspect = getViewportAspect();
|
||||||
@@ -730,568 +638,150 @@ function updateBGPHud(bgpResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCruiseTimers() {
|
|
||||||
if (cruiseTimerId) {
|
|
||||||
clearTimeout(cruiseTimerId);
|
|
||||||
cruiseTimerId = null;
|
|
||||||
}
|
|
||||||
if (cruiseHideCardTimerId) {
|
|
||||||
clearTimeout(cruiseHideCardTimerId);
|
|
||||||
cruiseHideCardTimerId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureCruiseConnector() {
|
function ensureCruiseConnector() {
|
||||||
if (cruiseConnectorEl instanceof SVGSVGElement) {
|
if (!cruiseConnector) {
|
||||||
return cruiseConnectorEl;
|
cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" });
|
||||||
}
|
}
|
||||||
|
return cruiseConnector;
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById("container");
|
function ensureBGPCruiseAdapter() {
|
||||||
if (!(container instanceof HTMLElement)) return null;
|
if (cruiseBGPAdapter) return cruiseBGPAdapter;
|
||||||
|
|
||||||
const connector = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
cruiseBGPAdapter = createBGPCruiseAdapter({
|
||||||
connector.id = "info-card-cruise-link";
|
camera,
|
||||||
connector.setAttribute("class", "info-card-cruise-link");
|
getMarkers: () => getBGPAnomalyMarkers(),
|
||||||
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
connector: ensureCruiseConnector(),
|
||||||
connector.setAttribute("preserveAspectRatio", "none");
|
focusView: (options) => focusEarthView(camera, options),
|
||||||
|
setMarkerLocked: (marker) => {
|
||||||
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
setLegendMode("bgp");
|
||||||
const startpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
setBGPMarkerState(marker, "locked");
|
||||||
const endpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
},
|
||||||
startpoint.setAttribute("r", "4");
|
clearMarkerState: (marker) => setBGPMarkerState(marker, "normal"),
|
||||||
endpoint.setAttribute("r", "4");
|
showMarkerOverlay: (marker) => {
|
||||||
connector.appendChild(startpoint);
|
const earth = getEarth();
|
||||||
connector.appendChild(polyline);
|
if (!marker || !earth) return;
|
||||||
connector.appendChild(endpoint);
|
showBGPEventOverlay(marker, earth);
|
||||||
|
},
|
||||||
container.appendChild(connector);
|
applySatelliteHighlights: (marker) => {
|
||||||
cruiseConnectorEl = connector;
|
if (!marker) return;
|
||||||
cruiseConnectorPolyline = polyline;
|
applyBGPEventSatelliteHighlights(marker);
|
||||||
cruiseConnectorStartpoint = startpoint;
|
},
|
||||||
cruiseConnectorEndpoint = endpoint;
|
showMarkerInfo: showBGPInfo,
|
||||||
connector.addEventListener("animationend", (event) => {
|
hideInfo: hideInfoCard,
|
||||||
if (
|
isInfoVisible: () =>
|
||||||
event.animationName === "cruiseConnectorDraw" &&
|
document.getElementById("info-panel")?.classList.contains("is-visible") === true,
|
||||||
cruiseConnectorEl?.classList.contains("is-visible")
|
getLockedObject: () => lockedObject,
|
||||||
) {
|
refreshMarkers: async () => {
|
||||||
if (cruiseConnectorPolyline) {
|
const bgpResult = await loadBGPAnomalies(scene, getEarth());
|
||||||
cruiseConnectorPolyline.style.strokeDashoffset = "0";
|
updateBGPHud(bgpResult);
|
||||||
}
|
setLegendItems("bgp", getBGPLegendItems());
|
||||||
cruiseConnectorEl.classList.remove("is-animating");
|
refreshLegend();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
return connector;
|
|
||||||
|
return cruiseBGPAdapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCruiseConnector() {
|
function isCruisePresentationPinned() {
|
||||||
const connector = ensureCruiseConnector();
|
return cruiseSequencer?.isPresentationPinned() === true;
|
||||||
if (!connector) return;
|
|
||||||
connector.classList.remove("is-visible");
|
|
||||||
connector.classList.remove("is-animating");
|
|
||||||
cruiseCardPlacement = null;
|
|
||||||
cruisePresentationPhase = "hidden";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCruiseConnectorVisual() {
|
function setCruisePresentationVisible(visible) {
|
||||||
const connector = ensureCruiseConnector();
|
if (cruiseSequencer) {
|
||||||
if (!connector) return;
|
cruiseSequencer.setPresentationVisible(visible);
|
||||||
connector.classList.remove("is-visible");
|
|
||||||
connector.classList.remove("is-animating");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCruiseConnectorVisual() {
|
|
||||||
const connector = ensureCruiseConnector();
|
|
||||||
if (!connector) return;
|
|
||||||
connector.classList.add("is-visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCruiseCardPinned(pinned) {
|
|
||||||
cruiseCardPinned = pinned;
|
|
||||||
if (!pinned) {
|
|
||||||
cruisePresentationTimer.stop();
|
|
||||||
hideCruiseConnector();
|
|
||||||
}
|
}
|
||||||
}
|
if (!visible) {
|
||||||
|
ensureBGPCruiseAdapter().resetPresentation();
|
||||||
function interruptCruisePresentation() {
|
|
||||||
++cruiseSequenceToken;
|
|
||||||
clearCruiseTimers();
|
|
||||||
const notifier = cruiseCancelNotifier;
|
|
||||||
cruiseCancelNotifier = null;
|
|
||||||
notifier?.();
|
|
||||||
setCruiseCardPinned(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginCruisePresentationHide() {
|
|
||||||
if (!lockedObject) {
|
|
||||||
hideInfoCard();
|
|
||||||
}
|
|
||||||
cruisePresentationPhase = "hidden";
|
|
||||||
const connector = ensureCruiseConnector();
|
|
||||||
connector?.classList.remove("is-visible");
|
|
||||||
connector?.classList.remove("is-animating");
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleCruiseCardHide() {
|
|
||||||
if (cruiseHideCardTimerId) {
|
|
||||||
clearTimeout(cruiseHideCardTimerId);
|
|
||||||
cruiseHideCardTimerId = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCruiseMarkerHighlight() {
|
function clearCruiseMarkerHighlight() {
|
||||||
if (!cruiseCurrentMarkerId) return;
|
ensureBGPCruiseAdapter().clearCurrentHighlight();
|
||||||
const marker = getBGPAnomalyMarkers().find(
|
|
||||||
(item) => item.userData?.id === cruiseCurrentMarkerId,
|
|
||||||
);
|
|
||||||
if (marker && lockedObject !== marker) {
|
|
||||||
setBGPMarkerState(marker, "normal");
|
|
||||||
}
|
|
||||||
cruiseCurrentMarkerId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBGPMarkerTimestamp(marker) {
|
|
||||||
const rawValue = marker?.userData?.created_at_raw;
|
|
||||||
const parsedValue = rawValue ? new Date(rawValue).getTime() : 0;
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCruiseMarkersSorted() {
|
function getCruiseMarkersSorted() {
|
||||||
return getBGPAnomalyMarkers()
|
return ensureBGPCruiseAdapter().getSortedMarkers();
|
||||||
.slice()
|
|
||||||
.sort((a, b) => getBGPMarkerTimestamp(b) - getBGPMarkerTimestamp(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCruiseMarkerScreenCoords(marker) {
|
|
||||||
if (!marker || !camera) return null;
|
|
||||||
scratchBGPWorldPosition.copy(marker.position);
|
|
||||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
|
||||||
const projected = scratchBGPWorldPosition.clone().project(camera);
|
|
||||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: ((projected.x + 1) * 0.5) * window.innerWidth,
|
|
||||||
y: ((1 - projected.y) * 0.5) * window.innerHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCruiseCardScreenCoords(marker) {
|
|
||||||
const markerCoords = getCruiseMarkerScreenCoords(marker);
|
|
||||||
if (!markerCoords) return null;
|
|
||||||
|
|
||||||
const hudScale =
|
|
||||||
Number.parseFloat(
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
|
|
||||||
) || 1;
|
|
||||||
const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7);
|
|
||||||
const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32);
|
|
||||||
|
|
||||||
const x =
|
|
||||||
window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5;
|
|
||||||
const y =
|
|
||||||
window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5;
|
|
||||||
const margin = 12;
|
|
||||||
const clampedX = Math.min(
|
|
||||||
Math.max(margin, x),
|
|
||||||
Math.max(margin, window.innerWidth - estimatedCardWidth - margin),
|
|
||||||
);
|
|
||||||
const clampedY = Math.min(
|
|
||||||
Math.max(margin, y),
|
|
||||||
Math.max(margin, window.innerHeight - estimatedCardHeight - margin),
|
|
||||||
);
|
|
||||||
const anchorY = clampedY + Math.max(18 * hudScale, estimatedCardHeight * 0.18);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: clampedX,
|
|
||||||
y: clampedY,
|
|
||||||
width: estimatedCardWidth,
|
|
||||||
height: estimatedCardHeight,
|
|
||||||
anchorX: clampedX - CRUISE_CONFIG.linkPanelGapPx,
|
|
||||||
anchorY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCruiseEventCard(marker) {
|
|
||||||
const coords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
|
|
||||||
if (!coords) return;
|
|
||||||
showBGPInfo(marker, {
|
|
||||||
x: coords.x,
|
|
||||||
y: coords.y,
|
|
||||||
absolute: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCruiseInfoCardVisible() {
|
|
||||||
return document.getElementById("info-panel")?.classList.contains("is-visible") === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeCruiseConnectorPoints(marker) {
|
|
||||||
const markerCoords = getCruiseMarkerScreenCoords(marker);
|
|
||||||
if (!markerCoords) return null;
|
|
||||||
|
|
||||||
const targetCardCoords = cruiseCardPlacement || getCruiseCardScreenCoords(marker);
|
|
||||||
if (!targetCardCoords) return null;
|
|
||||||
|
|
||||||
const panelAnchorX = targetCardCoords.anchorX;
|
|
||||||
const panelAnchorY = targetCardCoords.anchorY;
|
|
||||||
const horizontalDirection = markerCoords.x <= panelAnchorX ? 1 : -1;
|
|
||||||
const startX = markerCoords.x + horizontalDirection * CRUISE_CONFIG.linkMarkerGapPx;
|
|
||||||
const startY = markerCoords.y;
|
|
||||||
const elbowX =
|
|
||||||
panelAnchorX -
|
|
||||||
horizontalDirection * (CRUISE_CONFIG.linkElbowOffsetPx + CRUISE_CONFIG.linkPanelGapPx);
|
|
||||||
const elbowY = Math.min(startY, panelAnchorY) + CRUISE_CONFIG.linkElbowDropPx;
|
|
||||||
|
|
||||||
if (Math.abs(panelAnchorX - startX) < 8 && Math.abs(panelAnchorY - startY) < 8) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startX, startY, elbowX, elbowY, panelAnchorX, panelAnchorY };
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCruiseConnectorPoints(pts) {
|
|
||||||
const polyline = cruiseConnectorPolyline;
|
|
||||||
const startpoint = cruiseConnectorStartpoint;
|
|
||||||
const endpoint = cruiseConnectorEndpoint;
|
|
||||||
if (!polyline || !startpoint || !endpoint) return 0;
|
|
||||||
|
|
||||||
polyline.setAttribute(
|
|
||||||
"points",
|
|
||||||
`${pts.startX.toFixed(2)},${pts.startY.toFixed(2)} ` +
|
|
||||||
`${pts.elbowX.toFixed(2)},${pts.elbowY.toFixed(2)} ` +
|
|
||||||
`${pts.panelAnchorX.toFixed(2)},${pts.panelAnchorY.toFixed(2)}`,
|
|
||||||
);
|
|
||||||
startpoint.setAttribute("cx", pts.startX.toFixed(2));
|
|
||||||
startpoint.setAttribute("cy", pts.startY.toFixed(2));
|
|
||||||
endpoint.setAttribute("cx", pts.panelAnchorX.toFixed(2));
|
|
||||||
endpoint.setAttribute("cy", pts.panelAnchorY.toFixed(2));
|
|
||||||
|
|
||||||
const totalLength =
|
|
||||||
typeof polyline.getTotalLength === "function" ? polyline.getTotalLength() : 0;
|
|
||||||
return totalLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCruiseConnector() {
|
|
||||||
const connector = ensureCruiseConnector();
|
|
||||||
if (
|
|
||||||
!connector ||
|
|
||||||
!cruiseConnectorPolyline ||
|
|
||||||
!cruiseConnectorStartpoint ||
|
|
||||||
!cruiseConnectorEndpoint ||
|
|
||||||
!cruiseCardPinned ||
|
|
||||||
cruisePresentationPhase === "hidden" ||
|
|
||||||
!cruiseCurrentMarkerId
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = getBGPAnomalyMarkers().find(
|
|
||||||
(item) => item.userData?.id === cruiseCurrentMarkerId,
|
|
||||||
);
|
|
||||||
if (!marker) return false;
|
|
||||||
|
|
||||||
const pts = computeCruiseConnectorPoints(marker);
|
|
||||||
if (!pts) return false;
|
|
||||||
|
|
||||||
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
|
||||||
const totalLength = applyCruiseConnectorPoints(pts);
|
|
||||||
const polyline = cruiseConnectorPolyline;
|
|
||||||
|
|
||||||
polyline.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
|
|
||||||
polyline.style.strokeDashoffset =
|
|
||||||
totalLength > 0 ? `${cruiseConnectorNeedsAnimation ? totalLength : 0}` : "";
|
|
||||||
connector.style.setProperty(
|
|
||||||
"--connector-length",
|
|
||||||
totalLength > 0 ? `${totalLength}` : "0px",
|
|
||||||
);
|
|
||||||
connector.classList.add("is-visible");
|
|
||||||
|
|
||||||
if (cruiseConnectorNeedsAnimation && totalLength > 0) {
|
|
||||||
connector.classList.remove("is-animating");
|
|
||||||
void connector.getBoundingClientRect();
|
|
||||||
polyline.style.strokeDashoffset = `${totalLength}`;
|
|
||||||
connector.classList.add("is-animating");
|
|
||||||
cruiseConnectorNeedsAnimation = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalLength > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function repositionCruiseConnector() {
|
function repositionCruiseConnector() {
|
||||||
if (!cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId) return;
|
if (!isCruisePresentationPinned()) return;
|
||||||
const connector = cruiseConnectorEl;
|
const marker = cruiseSequencer?.getCurrentItem() ?? null;
|
||||||
if (!connector || !connector.classList.contains("is-visible")) return;
|
ensureBGPCruiseAdapter().repositionConnector(marker);
|
||||||
|
|
||||||
const marker = getBGPAnomalyMarkers().find(
|
|
||||||
(item) => item.userData?.id === cruiseCurrentMarkerId,
|
|
||||||
);
|
|
||||||
if (!marker) return;
|
|
||||||
|
|
||||||
const pts = computeCruiseConnectorPoints(marker);
|
|
||||||
if (!pts) return;
|
|
||||||
|
|
||||||
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
|
||||||
applyCruiseConnectorPoints(pts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCruiseKnownEventIds() {
|
|
||||||
cruiseKnownEventIds = new Set(
|
|
||||||
getBGPAnomalyMarkers()
|
|
||||||
.map((marker) => marker.userData?.id)
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCruiseModeActive() {
|
function isCruiseModeActive() {
|
||||||
return getRotationMode() === ROTATION_MODE.CRUISE;
|
return getRotationMode() === ROTATION_MODE.CRUISE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCruiseMode({ preserveCard = false } = {}) {
|
function ensureCruiseSequencer() {
|
||||||
clearCruiseTimers();
|
if (cruiseSequencer) return cruiseSequencer;
|
||||||
clearCruiseMarkerHighlight();
|
|
||||||
clearBGPSelection();
|
|
||||||
cruiseCurrentIndex = -1;
|
|
||||||
cruiseQueuedMarkerIds = [];
|
|
||||||
cruiseAdvanceQueued = false;
|
|
||||||
cruiseAdvanceInterrupt = false;
|
|
||||||
if (!preserveCard) {
|
|
||||||
setCruiseCardPinned(false);
|
|
||||||
}
|
|
||||||
if (!preserveCard && !lockedObject) {
|
|
||||||
hideInfoCard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function focusCruiseMarker(marker, { interrupt = false } = {}) {
|
cruiseSequencer = new CruiseSequencer({
|
||||||
const earth = getEarth();
|
isActive: () => isCruiseModeActive() && getAutoRotate(),
|
||||||
if (!marker || !earth || !isCruiseModeActive() || !getAutoRotate()) return;
|
getItems: () => getCruiseMarkersSorted(),
|
||||||
const sequenceToken = ++cruiseSequenceToken;
|
getItemId: (marker) => marker?.userData?.id || null,
|
||||||
const abortPresentation = () => {
|
dwellMs: CRUISE_CONFIG.dwellMs,
|
||||||
hideInfoCard();
|
transitionGapMs: CRUISE_TRANSITION_GAP_MS,
|
||||||
setCruiseCardPinned(false);
|
clearCurrent: () => {
|
||||||
};
|
clearCruiseMarkerHighlight();
|
||||||
const shouldAbortSequence = () =>
|
clearLockedObject();
|
||||||
sequenceToken !== cruiseSequenceToken ||
|
hideInfoCard();
|
||||||
!isCruiseModeActive() ||
|
setCruisePresentationVisible(false);
|
||||||
!getAutoRotate();
|
},
|
||||||
|
onStop: ({ preservePresentation }) => {
|
||||||
clearCruiseTimers();
|
clearBGPSelection();
|
||||||
clearCruiseMarkerHighlight();
|
if (!preservePresentation && !lockedObject) {
|
||||||
clearLockedObject();
|
hideInfoCard();
|
||||||
hideInfoCard();
|
}
|
||||||
setCruiseCardPinned(false);
|
},
|
||||||
|
focusItem: async (marker, { interrupt }) =>
|
||||||
cruiseCurrentMarkerId = marker.userData?.id || null;
|
ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }),
|
||||||
cruiseConnectorNeedsAnimation = true;
|
presentItem: async (marker, { context }) => {
|
||||||
cruiseCardPlacement = getCruiseCardScreenCoords(marker);
|
setCruisePresentationVisible(true);
|
||||||
cruiseCurrentIndex = getCruiseMarkersSorted().findIndex(
|
const presented = await ensureBGPCruiseAdapter().presentMarker(marker, {
|
||||||
(item) => item.userData?.id === cruiseCurrentMarkerId,
|
context,
|
||||||
);
|
});
|
||||||
setLegendMode("bgp");
|
if (!presented) {
|
||||||
setBGPMarkerState(marker, "locked");
|
setCruisePresentationVisible(false);
|
||||||
showBGPEventOverlay(marker, earth);
|
}
|
||||||
applyBGPEventSatelliteHighlights(marker);
|
return presented;
|
||||||
|
},
|
||||||
await focusEarthView(camera, {
|
hideItem: async (_marker, { context }) => {
|
||||||
lat: marker.userData?.latitude ?? 0,
|
await ensureBGPCruiseAdapter().hidePresentation({ context });
|
||||||
lon: marker.userData?.longitude ?? 0,
|
setCruisePresentationVisible(false);
|
||||||
rotLon: (marker.userData?.longitude ?? 0) - 270,
|
},
|
||||||
zoom: 1.0,
|
|
||||||
duration: interrupt ? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78) : CRUISE_CONFIG.focusDurationMs,
|
|
||||||
suppressStatus: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
return cruiseSequencer;
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCruiseCardPinned(true);
|
|
||||||
cruisePresentationPhase = "connector";
|
|
||||||
showCruiseConnectorVisual();
|
|
||||||
|
|
||||||
const connectorReady = await waitForCruiseConnectorReady(sequenceToken);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!connectorReady ||
|
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectorDelayCompleted = await waitForCruiseDelay(
|
|
||||||
CRUISE_CONNECTOR_DRAW_MS,
|
|
||||||
sequenceToken,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!connectorDelayCompleted ||
|
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cruisePresentationPhase = "card";
|
|
||||||
showCruiseEventCard(marker);
|
|
||||||
await nextAnimationFrame();
|
|
||||||
if (!isCruiseInfoCardVisible()) {
|
|
||||||
showCruiseEventCard(marker);
|
|
||||||
await nextAnimationFrame();
|
|
||||||
}
|
|
||||||
if (!isCruiseInfoCardVisible() || shouldAbortSequence()) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cruisePresentationTimer.start(CRUISE_CONFIG.dwellMs);
|
|
||||||
|
|
||||||
const dwellDelayCompleted = await waitForCruiseDelay(
|
|
||||||
CRUISE_CONFIG.dwellMs,
|
|
||||||
sequenceToken,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!dwellDelayCompleted ||
|
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
beginCruisePresentationHide();
|
|
||||||
const hideDelayCompleted = await waitForCruiseDelay(
|
|
||||||
CRUISE_PRESENTATION_HIDE_MS,
|
|
||||||
sequenceToken,
|
|
||||||
{ useHideTimer: true },
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!hideDelayCompleted ||
|
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCruiseCardPinned(false);
|
|
||||||
const transitionGapCompleted = await waitForCruiseDelay(
|
|
||||||
CRUISE_TRANSITION_GAP_MS,
|
|
||||||
sequenceToken,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!transitionGapCompleted ||
|
|
||||||
shouldAbortSequence()
|
|
||||||
) {
|
|
||||||
abortPresentation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void advanceCruiseEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performCruiseAdvance({ interrupt = false } = {}) {
|
function interruptCruisePresentation({ resetLoop = false } = {}) {
|
||||||
if (!isCruiseModeActive() || !getAutoRotate()) return;
|
ensureCruiseSequencer().interruptPresentation({ resetLoop });
|
||||||
|
setCruisePresentationVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
const markers = getCruiseMarkersSorted();
|
function stopCruiseMode({ preserveCard = false } = {}) {
|
||||||
if (markers.length === 0) return;
|
ensureCruiseSequencer().stop({ preservePresentation: preserveCard });
|
||||||
|
if (!preserveCard) {
|
||||||
let targetMarker = null;
|
setCruisePresentationVisible(false);
|
||||||
|
|
||||||
while (cruiseQueuedMarkerIds.length > 0 && !targetMarker) {
|
|
||||||
const queuedId = cruiseQueuedMarkerIds.shift();
|
|
||||||
targetMarker = markers.find((marker) => marker.userData?.id === queuedId) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetMarker) {
|
|
||||||
const nextIndex =
|
|
||||||
cruiseCurrentIndex >= 0
|
|
||||||
? (cruiseCurrentIndex + 1) % markers.length
|
|
||||||
: 0;
|
|
||||||
targetMarker = markers[nextIndex] || markers[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
await focusCruiseMarker(targetMarker, { interrupt });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function advanceCruiseEvent({ interrupt = false } = {}) {
|
async function advanceCruiseEvent({ interrupt = false } = {}) {
|
||||||
if (!isCruiseModeActive() || !getAutoRotate()) return;
|
if (!isCruiseModeActive() || !getAutoRotate()) return;
|
||||||
|
await ensureCruiseSequencer().advance({ interrupt });
|
||||||
cruiseAdvanceQueued = true;
|
|
||||||
cruiseAdvanceInterrupt = cruiseAdvanceInterrupt || interrupt;
|
|
||||||
|
|
||||||
if (cruiseAdvanceInFlight) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cruiseAdvanceInFlight = true;
|
|
||||||
try {
|
|
||||||
while (cruiseAdvanceQueued && isCruiseModeActive() && getAutoRotate()) {
|
|
||||||
const nextInterrupt = cruiseAdvanceInterrupt;
|
|
||||||
cruiseAdvanceQueued = false;
|
|
||||||
cruiseAdvanceInterrupt = false;
|
|
||||||
await performCruiseAdvance({ interrupt: nextInterrupt });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cruiseAdvanceInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollCruiseEventsIfNeeded() {
|
async function pollCruiseEventsIfNeeded() {
|
||||||
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
|
if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [incidentResponse, anomalyResponse] = await Promise.all([
|
const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds();
|
||||||
fetch(`${PATHS.bgpIncidentsApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
|
||||||
fetch(`${PATHS.bgpApi}?limit=${CRUISE_CONFIG.maxPolledEvents}`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!incidentResponse.ok || !anomalyResponse.ok) return;
|
|
||||||
|
|
||||||
const [incidentPayload, anomalyPayload] = await Promise.all([
|
|
||||||
incidentResponse.json(),
|
|
||||||
anomalyResponse.json(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const incidentFeatures = Array.isArray(incidentPayload?.features)
|
|
||||||
? incidentPayload.features
|
|
||||||
: [];
|
|
||||||
const anomalyFeatures = Array.isArray(anomalyPayload?.features)
|
|
||||||
? anomalyPayload.features
|
|
||||||
: [];
|
|
||||||
const selectedFeatures =
|
|
||||||
incidentFeatures.length > 0 ? incidentFeatures : anomalyFeatures;
|
|
||||||
|
|
||||||
const nextIds = selectedFeatures
|
|
||||||
.map((feature) => {
|
|
||||||
const properties = feature?.properties || {};
|
|
||||||
const coords = feature?.geometry?.coordinates || [];
|
|
||||||
return (
|
|
||||||
properties.id ||
|
|
||||||
properties.incident_key ||
|
|
||||||
`${properties.collector || properties.incident_type || properties.anomaly_type || "event"}-${coords[1]}-${coords[0]}`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const newIds = nextIds.filter((id) => !cruiseKnownEventIds.has(id));
|
|
||||||
if (newIds.length === 0) return;
|
if (newIds.length === 0) return;
|
||||||
|
|
||||||
const bgpResult = await loadBGPAnomalies(scene, getEarth());
|
ensureCruiseSequencer().enqueue(newIds);
|
||||||
updateBGPHud(bgpResult);
|
if (!ensureCruiseSequencer().isBusy()) {
|
||||||
setLegendItems("bgp", getBGPLegendItems());
|
|
||||||
refreshLegend();
|
|
||||||
syncCruiseKnownEventIds();
|
|
||||||
cruiseQueuedMarkerIds = Array.from(
|
|
||||||
new Set([...newIds, ...cruiseQueuedMarkerIds]),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
cruisePresentationPhase === "hidden" &&
|
|
||||||
!cruiseCardPinned &&
|
|
||||||
!cruisePresentationTimer.isActive()
|
|
||||||
) {
|
|
||||||
await advanceCruiseEvent({ interrupt: true });
|
await advanceCruiseEvent({ interrupt: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1301,14 +791,11 @@ async function pollCruiseEventsIfNeeded() {
|
|||||||
|
|
||||||
function ensureCruisePolling() {
|
function ensureCruisePolling() {
|
||||||
if (cruisePollTimerId) return;
|
if (cruisePollTimerId) return;
|
||||||
cruisePollTimerId = window.setInterval(
|
cruisePollTimerId = window.setInterval(() => {
|
||||||
() => {
|
pollCruiseEventsIfNeeded().catch((error) => {
|
||||||
pollCruiseEventsIfNeeded().catch((error) => {
|
console.warn("巡航轮询失败:", error);
|
||||||
console.warn("巡航轮询失败:", error);
|
});
|
||||||
});
|
}, CRUISE_CONFIG.pollIntervalMs);
|
||||||
},
|
|
||||||
CRUISE_CONFIG.pollIntervalMs,
|
|
||||||
);
|
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
if (cruisePollTimerId) {
|
if (cruisePollTimerId) {
|
||||||
clearInterval(cruisePollTimerId);
|
clearInterval(cruisePollTimerId);
|
||||||
@@ -1330,7 +817,7 @@ function handleRotationModeChange(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureCruisePolling();
|
ensureCruisePolling();
|
||||||
syncCruiseKnownEventIds();
|
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||||
|
|
||||||
if (!detailActive) {
|
if (!detailActive) {
|
||||||
stopCruiseMode({ preserveCard: true });
|
stopCruiseMode({ preserveCard: true });
|
||||||
@@ -1456,7 +943,7 @@ function applyCableVisualState() {
|
|||||||
(lockedObjectType === "cable" && lockedObject) ||
|
(lockedObjectType === "cable" && lockedObject) ||
|
||||||
(lockedObjectType === "satellite" && lockedSatellite) ||
|
(lockedObjectType === "satellite" && lockedSatellite) ||
|
||||||
(lockedObjectType === "bgp" && lockedObject) ||
|
(lockedObjectType === "bgp" && lockedObject) ||
|
||||||
(isCruiseModeActive() && cruiseCardPinned);
|
(isCruiseModeActive() && isCruisePresentationPinned());
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case CABLE_STATE.LOCKED:
|
case CABLE_STATE.LOCKED:
|
||||||
@@ -1960,7 +1447,7 @@ async function loadData() {
|
|||||||
if (loadToken === currentLoadToken) {
|
if (loadToken === currentLoadToken) {
|
||||||
toggleBGP(true);
|
toggleBGP(true);
|
||||||
updateBGPHud(bgpResult);
|
updateBGPHud(bgpResult);
|
||||||
syncCruiseKnownEventIds();
|
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ label: "BGP态势", reason: err });
|
errors.push({ label: "BGP态势", reason: err });
|
||||||
@@ -2162,7 +1649,7 @@ function onMouseMove(event) {
|
|||||||
applyBGPHoverState(lockedObject);
|
applyBGPHoverState(lockedObject);
|
||||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||||
applyBGPHoverState(lockedObject);
|
applyBGPHoverState(lockedObject);
|
||||||
} else if (!lockedObject && !lockedSatellite && !cruiseCardPinned) {
|
} else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) {
|
||||||
hideInfoCard();
|
hideInfoCard();
|
||||||
}
|
}
|
||||||
hideTooltip();
|
hideTooltip();
|
||||||
@@ -2288,7 +1775,7 @@ function onMouseMove(event) {
|
|||||||
applyBGPHoverState(lockedObject);
|
applyBGPHoverState(lockedObject);
|
||||||
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
|
||||||
applyBGPHoverState(lockedObject);
|
applyBGPHoverState(lockedObject);
|
||||||
} else if (!lockedObjectType && !cruiseCardPinned) {
|
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
|
||||||
resetTransientBGPStates();
|
resetTransientBGPStates();
|
||||||
hideInfoCard();
|
hideInfoCard();
|
||||||
}
|
}
|
||||||
@@ -2512,7 +1999,15 @@ function onClick(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isLongDrag) {
|
if (!isLongDrag) {
|
||||||
interruptCruisePresentation();
|
if (isCruiseModeActive()) {
|
||||||
|
interruptCruisePresentation({ resetLoop: true });
|
||||||
|
clearLockedObject();
|
||||||
|
hideInfoCard();
|
||||||
|
setAutoRotate(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
interruptCruisePresentation({ resetLoop: true });
|
||||||
clearLockedObject();
|
clearLockedObject();
|
||||||
hideInfoCard();
|
hideInfoCard();
|
||||||
setAutoRotate(true);
|
setAutoRotate(true);
|
||||||
@@ -2570,9 +2065,10 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyCableVisualState();
|
applyCableVisualState();
|
||||||
const activeCruiseMarker = (isCruiseModeActive() && cruiseCardPinned && cruiseCurrentMarkerId)
|
const activeCruiseMarker =
|
||||||
? getBGPAnomalyMarkers().find((m) => m.userData?.id === cruiseCurrentMarkerId) ?? null
|
isCruiseModeActive() && isCruisePresentationPinned()
|
||||||
: null;
|
? cruiseSequencer?.getCurrentItem() ?? null
|
||||||
|
: null;
|
||||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||||
|
|
||||||
if (lockedObjectType === "cable" && lockedObject) {
|
if (lockedObjectType === "cable" && lockedObject) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.31.1"
|
version = "0.31.2"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user