From 003a46ac3092be2aaec1639e82365ca0a8f695d3 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Tue, 21 Apr 2026 23:50:35 +0800 Subject: [PATCH] release: bump version to 0.31.2 --- VERSION | 2 +- docs/CHANGELOG.md | 19 + docs/technical/earth-frontend-context.md | 35 + docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/info-panel.css | 12 +- .../public/earth/js/bgp-cruise-adapter.js | 300 +++++++ frontend/public/earth/js/callout-connector.js | 185 +++++ frontend/public/earth/js/cruise-sequencer.js | 229 ++++++ frontend/public/earth/js/main.js | 760 +++--------------- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 912 insertions(+), 639 deletions(-) create mode 100644 frontend/public/earth/js/bgp-cruise-adapter.js create mode 100644 frontend/public/earth/js/callout-connector.js create mode 100644 frontend/public/earth/js/cruise-sequencer.js diff --git a/VERSION b/VERSION index f176c944..c415e1c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.1 +0.31.2 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4de72e27..9c9c8701 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,25 @@ This project follows the repository versioning rule: ## [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 ### ✨ Highlights diff --git a/docs/technical/earth-frontend-context.md b/docs/technical/earth-frontend-context.md index e7f6a12d..a9ed753b 100644 --- a/docs/technical/earth-frontend-context.md +++ b/docs/technical/earth-frontend-context.md @@ -96,8 +96,11 @@ React 路由入口: - [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) - [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) - [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 语义 +其中巡航模式现在已经拆成两层: + +- `cruise-sequencer.js` + - 负责目标队列顺序、停留时长、切换节奏、打断与恢复 +- `callout-connector.js` + - 负责卡片连线 SVG、路径计算与绘制动画 +- `bgp-cruise-adapter.js` + - 负责 BGP 巡航展示适配:目标排序、卡片落点、连线路径、focus/overlay/info-card 时序 + +当前 BGP 巡航只是这套能力的一个调用方,不应再把“按队列巡航”和“BGP 事件展示”混写在同一个状态机里。 + ## 当前样式分层 Earth 的 CSS 不是一份大样式表,而是分层管理: @@ -233,6 +247,27 @@ Earth 已经经历过多轮 HUD、toolbar、media panel 重构,所以最容易 每次大功能完成后,都要做一次 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,建议按这个顺序: diff --git a/docs/version-history.md b/docs/version-history.md index 930ea810..433c0a7b 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.31.1` +- `dev` 当前开发分支历史推导到:`0.31.2` ## Timeline | 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.0` | feature | `dev` | `pending` | Earth 巡航展示模式:自动轮播 BGP 事件,连线逐帧追踪,卫星/海缆联动高亮,视觉状态全面统一 | | `0.30.0` | feature | `dev` | `pending` | Earth 新增真实地形图层(Terrarium DEM 代理 + 前端瓦片解码着色),设置弹窗支持地形透明度滑块 | diff --git a/frontend/package.json b/frontend/package.json index 01771325..540783b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.31.1", + "version": "0.31.2", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 0558cd91..f415dc0d 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -206,11 +206,19 @@ } .info-card-cruise-link.is-animating circle { - animation: cruiseConnectorNodeIn 0.22s ease forwards; - animation-delay: 0.22s; 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 { from { stroke-dashoffset: var(--connector-length, 0px); diff --git a/frontend/public/earth/js/bgp-cruise-adapter.js b/frontend/public/earth/js/bgp-cruise-adapter.js new file mode 100644 index 00000000..679b3b48 --- /dev/null +++ b/frontend/public/earth/js/bgp-cruise-adapter.js @@ -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; + }, + }; +} diff --git a/frontend/public/earth/js/callout-connector.js b/frontend/public/earth/js/callout-connector.js new file mode 100644 index 00000000..d5af33f7 --- /dev/null +++ b/frontend/public/earth/js/callout-connector.js @@ -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; + } +} diff --git a/frontend/public/earth/js/cruise-sequencer.js b/frontend/public/earth/js/cruise-sequencer.js new file mode 100644 index 00000000..b031ead2 --- /dev/null +++ b/frontend/public/earth/js/cruise-sequencer.js @@ -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; + } + } + } +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 63cb1a05..2bddd4a3 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -138,6 +138,9 @@ import { import { setLayerButtonState, } 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 { initInfoCard, showInfoCard, @@ -190,43 +193,10 @@ let cableToggleToken = 0; let satelliteToggleToken = 0; let satelliteHydrationToken = 0; let sceneLights = null; -let cruiseTimerId = null; -let cruiseHideCardTimerId = null; let cruisePollTimerId = null; -let cruiseCurrentMarkerId = null; -let cruiseCurrentIndex = -1; -let cruiseQueuedMarkerIds = []; -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(); +let cruiseConnector = null; +let cruiseBGPAdapter = null; +let cruiseSequencer = null; const clock = new THREE.Clock(); const interactionRaycaster = new THREE.Raycaster(); @@ -242,10 +212,7 @@ const cleanupFns = []; const DRAG_SMOOTHING_FACTOR = 0.18; const INERTIA_DAMPING = 0.92; const INERTIA_MIN_VELOCITY = 0.00008; -const CRUISE_CONNECTOR_DRAW_MS = 420; 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 TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips 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() { return window.innerWidth / window.innerHeight; } -function nextAnimationFrame() { - return new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); - }); -} - function syncRendererViewport() { if (!camera || !renderer) return; 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() { - if (cruiseConnectorEl instanceof SVGSVGElement) { - return cruiseConnectorEl; + if (!cruiseConnector) { + cruiseConnector = new CalloutConnector({ className: "info-card-cruise-link" }); } + return cruiseConnector; +} - const container = document.getElementById("container"); - if (!(container instanceof HTMLElement)) return null; +function ensureBGPCruiseAdapter() { + if (cruiseBGPAdapter) return cruiseBGPAdapter; - const connector = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - connector.id = "info-card-cruise-link"; - connector.setAttribute("class", "info-card-cruise-link"); - connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); - connector.setAttribute("preserveAspectRatio", "none"); - - const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); - const startpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - const endpoint = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - startpoint.setAttribute("r", "4"); - endpoint.setAttribute("r", "4"); - connector.appendChild(startpoint); - connector.appendChild(polyline); - connector.appendChild(endpoint); - - container.appendChild(connector); - cruiseConnectorEl = connector; - cruiseConnectorPolyline = polyline; - cruiseConnectorStartpoint = startpoint; - cruiseConnectorEndpoint = endpoint; - connector.addEventListener("animationend", (event) => { - if ( - event.animationName === "cruiseConnectorDraw" && - cruiseConnectorEl?.classList.contains("is-visible") - ) { - if (cruiseConnectorPolyline) { - cruiseConnectorPolyline.style.strokeDashoffset = "0"; - } - cruiseConnectorEl.classList.remove("is-animating"); - } + cruiseBGPAdapter = createBGPCruiseAdapter({ + camera, + getMarkers: () => getBGPAnomalyMarkers(), + connector: ensureCruiseConnector(), + focusView: (options) => focusEarthView(camera, options), + setMarkerLocked: (marker) => { + setLegendMode("bgp"); + setBGPMarkerState(marker, "locked"); + }, + clearMarkerState: (marker) => setBGPMarkerState(marker, "normal"), + showMarkerOverlay: (marker) => { + const earth = getEarth(); + if (!marker || !earth) return; + showBGPEventOverlay(marker, earth); + }, + applySatelliteHighlights: (marker) => { + if (!marker) return; + applyBGPEventSatelliteHighlights(marker); + }, + showMarkerInfo: showBGPInfo, + hideInfo: hideInfoCard, + isInfoVisible: () => + document.getElementById("info-panel")?.classList.contains("is-visible") === true, + getLockedObject: () => lockedObject, + refreshMarkers: async () => { + const bgpResult = await loadBGPAnomalies(scene, getEarth()); + updateBGPHud(bgpResult); + setLegendItems("bgp", getBGPLegendItems()); + refreshLegend(); + }, }); - return connector; + + return cruiseBGPAdapter; } -function hideCruiseConnector() { - const connector = ensureCruiseConnector(); - if (!connector) return; - connector.classList.remove("is-visible"); - connector.classList.remove("is-animating"); - cruiseCardPlacement = null; - cruisePresentationPhase = "hidden"; +function isCruisePresentationPinned() { + return cruiseSequencer?.isPresentationPinned() === true; } -function hideCruiseConnectorVisual() { - const connector = ensureCruiseConnector(); - if (!connector) return; - 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(); +function setCruisePresentationVisible(visible) { + if (cruiseSequencer) { + cruiseSequencer.setPresentationVisible(visible); } -} - -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; + if (!visible) { + ensureBGPCruiseAdapter().resetPresentation(); } } function clearCruiseMarkerHighlight() { - if (!cruiseCurrentMarkerId) return; - 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; + ensureBGPCruiseAdapter().clearCurrentHighlight(); } function getCruiseMarkersSorted() { - return getBGPAnomalyMarkers() - .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; + return ensureBGPCruiseAdapter().getSortedMarkers(); } function repositionCruiseConnector() { - if (!cruiseCardPinned || cruisePresentationPhase === "hidden" || !cruiseCurrentMarkerId) return; - const connector = cruiseConnectorEl; - if (!connector || !connector.classList.contains("is-visible")) return; - - 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), - ); + if (!isCruisePresentationPinned()) return; + const marker = cruiseSequencer?.getCurrentItem() ?? null; + ensureBGPCruiseAdapter().repositionConnector(marker); } function isCruiseModeActive() { return getRotationMode() === ROTATION_MODE.CRUISE; } -function stopCruiseMode({ preserveCard = false } = {}) { - clearCruiseTimers(); - clearCruiseMarkerHighlight(); - clearBGPSelection(); - cruiseCurrentIndex = -1; - cruiseQueuedMarkerIds = []; - cruiseAdvanceQueued = false; - cruiseAdvanceInterrupt = false; - if (!preserveCard) { - setCruiseCardPinned(false); - } - if (!preserveCard && !lockedObject) { - hideInfoCard(); - } -} +function ensureCruiseSequencer() { + if (cruiseSequencer) return cruiseSequencer; -async function focusCruiseMarker(marker, { interrupt = false } = {}) { - const earth = getEarth(); - if (!marker || !earth || !isCruiseModeActive() || !getAutoRotate()) return; - const sequenceToken = ++cruiseSequenceToken; - const abortPresentation = () => { - hideInfoCard(); - setCruiseCardPinned(false); - }; - const shouldAbortSequence = () => - sequenceToken !== cruiseSequenceToken || - !isCruiseModeActive() || - !getAutoRotate(); - - clearCruiseTimers(); - clearCruiseMarkerHighlight(); - clearLockedObject(); - hideInfoCard(); - setCruiseCardPinned(false); - - cruiseCurrentMarkerId = marker.userData?.id || null; - cruiseConnectorNeedsAnimation = true; - cruiseCardPlacement = getCruiseCardScreenCoords(marker); - cruiseCurrentIndex = getCruiseMarkersSorted().findIndex( - (item) => item.userData?.id === cruiseCurrentMarkerId, - ); - setLegendMode("bgp"); - setBGPMarkerState(marker, "locked"); - showBGPEventOverlay(marker, earth); - applyBGPEventSatelliteHighlights(marker); - - await focusEarthView(camera, { - 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, + cruiseSequencer = new CruiseSequencer({ + isActive: () => isCruiseModeActive() && getAutoRotate(), + getItems: () => getCruiseMarkersSorted(), + getItemId: (marker) => marker?.userData?.id || null, + dwellMs: CRUISE_CONFIG.dwellMs, + transitionGapMs: CRUISE_TRANSITION_GAP_MS, + clearCurrent: () => { + clearCruiseMarkerHighlight(); + clearLockedObject(); + hideInfoCard(); + setCruisePresentationVisible(false); + }, + onStop: ({ preservePresentation }) => { + clearBGPSelection(); + if (!preservePresentation && !lockedObject) { + hideInfoCard(); + } + }, + focusItem: async (marker, { interrupt }) => + ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }), + presentItem: async (marker, { context }) => { + setCruisePresentationVisible(true); + const presented = await ensureBGPCruiseAdapter().presentMarker(marker, { + context, + }); + if (!presented) { + setCruisePresentationVisible(false); + } + return presented; + }, + hideItem: async (_marker, { context }) => { + await ensureBGPCruiseAdapter().hidePresentation({ context }); + setCruisePresentationVisible(false); + }, }); - if ( - 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(); + return cruiseSequencer; } -async function performCruiseAdvance({ interrupt = false } = {}) { - if (!isCruiseModeActive() || !getAutoRotate()) return; +function interruptCruisePresentation({ resetLoop = false } = {}) { + ensureCruiseSequencer().interruptPresentation({ resetLoop }); + setCruisePresentationVisible(false); +} - const markers = getCruiseMarkersSorted(); - if (markers.length === 0) return; - - let targetMarker = null; - - while (cruiseQueuedMarkerIds.length > 0 && !targetMarker) { - const queuedId = cruiseQueuedMarkerIds.shift(); - targetMarker = markers.find((marker) => marker.userData?.id === queuedId) || null; +function stopCruiseMode({ preserveCard = false } = {}) { + ensureCruiseSequencer().stop({ preservePresentation: preserveCard }); + if (!preserveCard) { + setCruisePresentationVisible(false); } - - 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 } = {}) { if (!isCruiseModeActive() || !getAutoRotate()) return; - - 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; - } + await ensureCruiseSequencer().advance({ interrupt }); } async function pollCruiseEventsIfNeeded() { if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return; try { - 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 = 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)); + const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds(); if (newIds.length === 0) return; - const bgpResult = await loadBGPAnomalies(scene, getEarth()); - updateBGPHud(bgpResult); - setLegendItems("bgp", getBGPLegendItems()); - refreshLegend(); - syncCruiseKnownEventIds(); - cruiseQueuedMarkerIds = Array.from( - new Set([...newIds, ...cruiseQueuedMarkerIds]), - ); - if ( - cruisePresentationPhase === "hidden" && - !cruiseCardPinned && - !cruisePresentationTimer.isActive() - ) { + ensureCruiseSequencer().enqueue(newIds); + if (!ensureCruiseSequencer().isBusy()) { await advanceCruiseEvent({ interrupt: true }); } } catch (error) { @@ -1301,14 +791,11 @@ async function pollCruiseEventsIfNeeded() { function ensureCruisePolling() { if (cruisePollTimerId) return; - cruisePollTimerId = window.setInterval( - () => { - pollCruiseEventsIfNeeded().catch((error) => { - console.warn("巡航轮询失败:", error); - }); - }, - CRUISE_CONFIG.pollIntervalMs, - ); + cruisePollTimerId = window.setInterval(() => { + pollCruiseEventsIfNeeded().catch((error) => { + console.warn("巡航轮询失败:", error); + }); + }, CRUISE_CONFIG.pollIntervalMs); cleanupFns.push(() => { if (cruisePollTimerId) { clearInterval(cruisePollTimerId); @@ -1330,7 +817,7 @@ function handleRotationModeChange(event) { } ensureCruisePolling(); - syncCruiseKnownEventIds(); + ensureBGPCruiseAdapter().syncKnownEventIds(); if (!detailActive) { stopCruiseMode({ preserveCard: true }); @@ -1456,7 +943,7 @@ function applyCableVisualState() { (lockedObjectType === "cable" && lockedObject) || (lockedObjectType === "satellite" && lockedSatellite) || (lockedObjectType === "bgp" && lockedObject) || - (isCruiseModeActive() && cruiseCardPinned); + (isCruiseModeActive() && isCruisePresentationPinned()); switch (state) { case CABLE_STATE.LOCKED: @@ -1960,7 +1447,7 @@ async function loadData() { if (loadToken === currentLoadToken) { toggleBGP(true); updateBGPHud(bgpResult); - syncCruiseKnownEventIds(); + ensureBGPCruiseAdapter().syncKnownEventIds(); } } catch (err) { errors.push({ label: "BGP态势", reason: err }); @@ -2162,7 +1649,7 @@ function onMouseMove(event) { applyBGPHoverState(lockedObject); } else if (lockedObjectType === "bgp_collector" && lockedObject) { applyBGPHoverState(lockedObject); - } else if (!lockedObject && !lockedSatellite && !cruiseCardPinned) { + } else if (!lockedObject && !lockedSatellite && !isCruisePresentationPinned()) { hideInfoCard(); } hideTooltip(); @@ -2288,7 +1775,7 @@ function onMouseMove(event) { applyBGPHoverState(lockedObject); } else if (lockedObjectType === "bgp_collector" && lockedObject) { applyBGPHoverState(lockedObject); - } else if (!lockedObjectType && !cruiseCardPinned) { + } else if (!lockedObjectType && !isCruisePresentationPinned()) { resetTransientBGPStates(); hideInfoCard(); } @@ -2512,7 +1999,15 @@ function onClick(event) { } if (!isLongDrag) { - interruptCruisePresentation(); + if (isCruiseModeActive()) { + interruptCruisePresentation({ resetLoop: true }); + clearLockedObject(); + hideInfoCard(); + setAutoRotate(true); + return; + } + + interruptCruisePresentation({ resetLoop: true }); clearLockedObject(); hideInfoCard(); setAutoRotate(true); @@ -2570,9 +2065,10 @@ function animate() { } applyCableVisualState(); - const activeCruiseMarker = (isCruiseModeActive() && cruiseCardPinned && cruiseCurrentMarkerId) - ? getBGPAnomalyMarkers().find((m) => m.userData?.id === cruiseCurrentMarkerId) ?? null - : null; + const activeCruiseMarker = + isCruiseModeActive() && isCruisePresentationPinned() + ? cruiseSequencer?.getCurrentItem() ?? null + : null; updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker); if (lockedObjectType === "cable" && lockedObject) { diff --git a/pyproject.toml b/pyproject.toml index 5e3c7a3e..0b62bfb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.31.1" +version = "0.31.2" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 83b2c4e0..cdd8289d 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.31.1" +version = "0.31.2" source = { virtual = "." } dependencies = [ { name = "aiofiles" },