release: bump version to 0.66.0
This commit is contained in:
@@ -552,6 +552,7 @@
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -577,6 +578,37 @@
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button.is-websearch-blocked,
|
||||
.info-card-compute-candidate-preview.is-websearch-blocked {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button.is-loading,
|
||||
.info-card-compute-candidate-preview.is-loading {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button.is-loading::before,
|
||||
.info-card-compute-candidate-preview.is-loading::before,
|
||||
.info-card-unresolved-item.is-locating .info-card-unresolved-index::before {
|
||||
content: "";
|
||||
width: calc(10px * var(--hud-scale));
|
||||
height: calc(10px * var(--hud-scale));
|
||||
border: 1.5px solid rgba(201, 220, 255, 0.35);
|
||||
border-top-color: #c9dcff;
|
||||
border-radius: 999px;
|
||||
animation: info-card-location-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.info-card-unresolved-item.is-locating .info-card-unresolved-index {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.info-card-unresolved-item.is-locating .info-card-unresolved-index::before {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.info-card-compute-collect-button .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
}
|
||||
@@ -620,6 +652,7 @@
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
color: #c9dcff;
|
||||
border: 1px solid rgba(214, 229, 245, 0.18);
|
||||
@@ -633,6 +666,11 @@
|
||||
background: rgba(72, 138, 255, 0.18);
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.info-card-unresolved-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -670,6 +708,7 @@
|
||||
}
|
||||
|
||||
.info-card-unresolved-index {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -683,6 +722,12 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes info-card-location-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.info-card-unresolved-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1151,7 +1151,7 @@
|
||||
<div class="earth-about-heading">
|
||||
<span class="earth-about-kicker">About</span>
|
||||
<strong>智能星球计划</strong>
|
||||
<span>v0.61.0</span>
|
||||
<span>v0.65.2</span>
|
||||
</div>
|
||||
<p class="earth-about-copy">
|
||||
面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。
|
||||
@@ -1162,7 +1162,7 @@
|
||||
</div>
|
||||
<div class="earth-about-meta">
|
||||
<span>策划人</span>
|
||||
<strong>黄柳青</strong>
|
||||
<strong>方兴东、黄柳青</strong>
|
||||
</div>
|
||||
<div class="earth-about-meta">
|
||||
<span>产品兼开发者</span>
|
||||
@@ -1667,6 +1667,21 @@
|
||||
<span class="material-symbols-rounded">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="earth-settings-item earth-settings-link"
|
||||
href="/docs/manual"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Docs</span>
|
||||
<span class="earth-settings-item-subtitle">查看使用手册与开发文档</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
<span class="material-symbols-rounded">article</span>
|
||||
<span class="material-symbols-rounded">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="about" hidden>
|
||||
@@ -1679,7 +1694,7 @@
|
||||
<div class="earth-about-heading">
|
||||
<span class="earth-about-kicker">About</span>
|
||||
<strong>智能星球计划</strong>
|
||||
<span>v0.61.0</span>
|
||||
<span>v0.65.2</span>
|
||||
</div>
|
||||
<p class="earth-about-copy">
|
||||
面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。
|
||||
@@ -1691,7 +1706,7 @@
|
||||
</div>
|
||||
<div class="earth-about-meta">
|
||||
<span>策划人</span>
|
||||
<strong>黄柳青</strong>
|
||||
<strong>方兴东、黄柳青</strong>
|
||||
</div>
|
||||
<div class="earth-about-meta">
|
||||
<span>产品兼开发者</span>
|
||||
|
||||
@@ -4,12 +4,12 @@ const DEFAULT_ABOUT = {
|
||||
logo_src: "./assets/brand/lim-logo.png",
|
||||
kicker: "About",
|
||||
title: "智能星球计划",
|
||||
version: "v0.64.0",
|
||||
version: "v0.65.2",
|
||||
description:
|
||||
"面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
|
||||
meta: [
|
||||
{ label: "出品方", value: "浙江大学临空智能媒体研究院" },
|
||||
{ label: "策划人", value: "黄柳青" },
|
||||
{ label: "策划人", value: "方兴东、黄柳青" },
|
||||
{ label: "产品兼开发者", value: "钱坤、张鸽、齐鹏" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1332,7 +1332,7 @@ function applyCollectorCounts() {
|
||||
|
||||
async function fetchGeoJSONWithTimeout(url, timeoutMs, warningMessage, fallbackPayload) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
@@ -1368,7 +1368,7 @@ function selectBGPEventFeatures(incidentPayload, anomalyPayload) {
|
||||
}
|
||||
|
||||
export async function loadBGPAnomalies(scene, earth) {
|
||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi);
|
||||
const collectorsResponse = await fetch(PATHS.bgpCollectorsApi, { cache: "no-store" });
|
||||
if (!collectorsResponse.ok) {
|
||||
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
export let cableLines = [];
|
||||
export let landingPoints = [];
|
||||
export let lockedCable = null;
|
||||
let cableSourceFeatureCount = 0;
|
||||
let landingPointSourceFeatureCount = 0;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
@@ -302,6 +304,7 @@ function calculateGreatCirclePoints(
|
||||
export function clearCableLines(earthObj = null) {
|
||||
cableLines.forEach((line) => disposeObject(line, earthObj));
|
||||
cableLines = [];
|
||||
cableSourceFeatureCount = 0;
|
||||
cableIdMap = new Map();
|
||||
cableStates.clear();
|
||||
}
|
||||
@@ -309,6 +312,7 @@ export function clearCableLines(earthObj = null) {
|
||||
export function clearLandingPoints(earthObj = null) {
|
||||
landingPoints.forEach((point) => disposeObject(point, earthObj));
|
||||
landingPoints = [];
|
||||
landingPointSourceFeatureCount = 0;
|
||||
}
|
||||
|
||||
export function clearCableData(earthObj = null) {
|
||||
@@ -319,12 +323,11 @@ export function clearCableData(earthObj = null) {
|
||||
|
||||
export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载电缆数据...");
|
||||
if (!silent) {
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
}
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
const response = await fetch(PATHS.cablesApi, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`电缆接口返回 HTTP ${response.status}`);
|
||||
}
|
||||
@@ -335,6 +338,7 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
}
|
||||
|
||||
clearCableLines(earthObj);
|
||||
cableSourceFeatureCount = data.features.length;
|
||||
|
||||
for (const feature of data.features) {
|
||||
const geometry = feature.geometry;
|
||||
@@ -426,9 +430,8 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
const { silent = false } = options;
|
||||
console.log("正在加载登陆点数据...");
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
const response = await fetch(PATHS.landingPointsApi, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`登陆点接口返回 HTTP ${response.status}`);
|
||||
}
|
||||
@@ -439,6 +442,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
}
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
landingPointSourceFeatureCount = data.features.length;
|
||||
|
||||
const markerTexture = await getLandingPointTexture();
|
||||
|
||||
@@ -586,6 +590,10 @@ export function getLandingPoints() {
|
||||
return landingPoints;
|
||||
}
|
||||
|
||||
export function getCableSourceRecordCount() {
|
||||
return cableSourceFeatureCount + landingPointSourceFeatureCount;
|
||||
}
|
||||
|
||||
export function getCableState(cableId) {
|
||||
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ let previewRingB = null;
|
||||
let previewRingPulseOffset = 0;
|
||||
|
||||
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
|
||||
const LOCATION_CAPABILITY_API = "/api/v1/visualization/compute-centers/location-capability";
|
||||
let computeCenterLocationCapabilityPromise = null;
|
||||
|
||||
function getPreviewRingTexture() {
|
||||
if (previewRingTexture) return previewRingTexture;
|
||||
@@ -520,13 +522,48 @@ export async function collectLocationCandidates(endpoint, payload = {}) {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
let detail = "";
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
detail = payload?.detail?.reason || payload?.detail?.message || payload?.detail || "";
|
||||
} catch {
|
||||
detail = text;
|
||||
}
|
||||
throw new Error(
|
||||
`Collect location failed: HTTP ${response.status} ${text}`.trim(),
|
||||
`Collect location failed: HTTP ${response.status} ${detail}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getComputeCenterLocationCapability({ force = false } = {}) {
|
||||
if (!computeCenterLocationCapabilityPromise || force) {
|
||||
computeCenterLocationCapabilityPromise = fetch(LOCATION_CAPABILITY_API, { cache: "no-store" })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
reason: `WebSearch 状态检查失败:HTTP ${response.status} ${text}`.trim(),
|
||||
};
|
||||
}
|
||||
const payload = await response.json();
|
||||
return {
|
||||
enabled: payload?.enabled === true,
|
||||
provider: payload?.provider || "",
|
||||
reason: payload?.reason || "",
|
||||
};
|
||||
})
|
||||
.catch((error) => ({
|
||||
enabled: false,
|
||||
provider: null,
|
||||
reason: `WebSearch 状态检查失败:${error?.message || error}`,
|
||||
}));
|
||||
}
|
||||
return computeCenterLocationCapabilityPromise;
|
||||
}
|
||||
|
||||
export async function collectComputeCenterLocation(sourceId, context = {}) {
|
||||
if (!sourceId) {
|
||||
throw new Error("sourceId is required");
|
||||
|
||||
@@ -262,6 +262,7 @@ export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
interactablesApi: '/api/v1/interactables/geojson',
|
||||
vesselsApi: '/api/v1/vessels/snapshot',
|
||||
vesselTrackApi: (mmsi) => `/api/v1/visualization/vessels/${encodeURIComponent(mmsi)}/track`,
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
|
||||
13
frontend/public/earth/js/controls.js
vendored
13
frontend/public/earth/js/controls.js
vendored
@@ -3703,10 +3703,17 @@ function markRuntimeModeSection(selector, mode) {
|
||||
});
|
||||
}
|
||||
|
||||
function isRuntimeModeSectionVisible(section, mode = rotationMode) {
|
||||
const sectionModes = String(section?.dataset?.runtimeModeSection || "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
return sectionModes.includes(mode);
|
||||
}
|
||||
|
||||
function syncRuntimeModeSections() {
|
||||
document.querySelectorAll("[data-runtime-mode-section]").forEach((section) => {
|
||||
if (!(section instanceof HTMLElement)) return;
|
||||
section.hidden = section.dataset.runtimeModeSection !== rotationMode;
|
||||
section.hidden = !isRuntimeModeSectionVisible(section);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3747,8 +3754,8 @@ function integrateMotionSettingsIntoRuntime() {
|
||||
motionPanel.remove();
|
||||
});
|
||||
|
||||
markRuntimeModeSection("[data-cruise-module-toggle]", ROTATION_MODE.CRUISE);
|
||||
markRuntimeModeSection("[data-cruise-queue-mode]", ROTATION_MODE.CRUISE);
|
||||
markRuntimeModeSection("[data-cruise-module-toggle]", `${ROTATION_MODE.CRUISE} ${ROTATION_MODE.MOTION}`);
|
||||
markRuntimeModeSection("[data-cruise-queue-mode]", `${ROTATION_MODE.CRUISE} ${ROTATION_MODE.MOTION}`);
|
||||
markRuntimeModeSection("[data-auto-rotation-speed-slider]", ROTATION_MODE.ROTATE);
|
||||
markRuntimeModeSection("[data-motion-debug-toggle]", ROTATION_MODE.MOTION);
|
||||
markRuntimeModeSection("[data-motion-provider]", ROTATION_MODE.MOTION);
|
||||
|
||||
165
frontend/public/earth/js/earth-interactables.js
Normal file
165
frontend/public/earth/js/earth-interactables.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { PATHS } from "./constants.js";
|
||||
import { createInteractableLayer, SURFACE_AVOIDANCE_PROFILES } from "./interactable.js";
|
||||
|
||||
let showEarthInteractables = true;
|
||||
const interactableRevisions = new Map();
|
||||
|
||||
function featureToInteractableItem(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
const longitude = Number(props.longitude ?? coordinates[0]);
|
||||
const latitude = Number(props.latitude ?? coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return {
|
||||
...props,
|
||||
id: String(props.id ?? feature.id ?? ""),
|
||||
latitude,
|
||||
longitude,
|
||||
kind: props.kind || "default",
|
||||
label: props.label || "",
|
||||
revision: Number(props.revision || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInteractableItem(item) {
|
||||
const latitude = Number(item?.latitude ?? item?.lat);
|
||||
const longitude = Number(item?.longitude ?? item?.lon ?? item?.lng);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
const id = String(item?.id || "").trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
kind: item.kind || "default",
|
||||
label: item.label || "",
|
||||
revision: Number(item.revision || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function drawInteractableIcon(context, { glow = false, color = "#7dd3fc" }) {
|
||||
if (glow) {
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = 16;
|
||||
}
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = "rgba(8, 15, 27, 0.92)";
|
||||
context.lineWidth = 7;
|
||||
context.beginPath();
|
||||
context.arc(0, 0, 27, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "rgba(255,255,255,0.95)";
|
||||
context.beginPath();
|
||||
context.arc(0, 0, 9, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
const earthInteractableLayer = createInteractableLayer({
|
||||
id: "earthInteractables",
|
||||
objectType: "earth_interactable",
|
||||
renderOrder: 4.55,
|
||||
altitudeOffset: 0.28,
|
||||
pointSize: 30,
|
||||
colors: {
|
||||
normal: "#7dd3fc",
|
||||
default: "#7dd3fc",
|
||||
note: "#facc15",
|
||||
alert: "#fb7185",
|
||||
place: "#86efac",
|
||||
},
|
||||
opacity: {
|
||||
normal: 0.9,
|
||||
dimmed: 0.3,
|
||||
hover: 1,
|
||||
locked: 1,
|
||||
},
|
||||
stateScale: {
|
||||
hover: 1.18,
|
||||
locked: 1.34,
|
||||
dimmed: 0.82,
|
||||
},
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
icon: {
|
||||
draw: drawInteractableIcon,
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
}),
|
||||
getKind: (item) => item.kind || "default",
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
type: "earth_interactable",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function loadEarthInteractables(earth, { silent = false } = {}) {
|
||||
if (!earth) return { totalCount: 0 };
|
||||
const response = await fetch(PATHS.interactablesApi, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load Earth interactables: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const items = (payload.features || [])
|
||||
.map(featureToInteractableItem)
|
||||
.filter(Boolean);
|
||||
interactableRevisions.clear();
|
||||
items.forEach((item) => interactableRevisions.set(item.id, Number(item.revision || 0)));
|
||||
earthInteractableLayer.setData(items);
|
||||
earthInteractableLayer.attach(earth);
|
||||
earthInteractableLayer.setVisible(showEarthInteractables);
|
||||
if (!silent) {
|
||||
console.info("Earth interactables loaded", { count: items.length });
|
||||
}
|
||||
return { totalCount: earthInteractableLayer.getCount() };
|
||||
}
|
||||
|
||||
export function clearEarthInteractables(earth) {
|
||||
interactableRevisions.clear();
|
||||
earthInteractableLayer.setData([]);
|
||||
if (earth) earthInteractableLayer.attach(earth);
|
||||
}
|
||||
|
||||
export function refreshEarthInteractables(earth) {
|
||||
return loadEarthInteractables(earth, { silent: true });
|
||||
}
|
||||
|
||||
export function applyEarthInteractableEvent(earth, payload = {}) {
|
||||
if (payload.entity !== "interactable") return false;
|
||||
const action = payload.action;
|
||||
const item = normalizeInteractableItem(payload.item);
|
||||
const ids = Array.isArray(payload.ids) ? payload.ids.map(String) : [];
|
||||
const id = item?.id || ids[0];
|
||||
if (!id) return false;
|
||||
|
||||
const nextRevision = Number(payload.revision ?? item?.revision ?? 0);
|
||||
const currentRevision = Number(interactableRevisions.get(id) || 0);
|
||||
if (nextRevision && currentRevision && nextRevision < currentRevision) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action === "deleted") {
|
||||
const changed = earthInteractableLayer.removeItem(id);
|
||||
interactableRevisions.set(id, nextRevision || currentRevision);
|
||||
return changed;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
refreshEarthInteractables(earth).catch((error) => {
|
||||
console.warn("刷新 Earth interactables 失败:", error);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const changed = earthInteractableLayer.upsertItem(item);
|
||||
interactableRevisions.set(id, nextRevision || Number(item.revision || 0));
|
||||
earthInteractableLayer.attach(earth);
|
||||
earthInteractableLayer.setVisible(showEarthInteractables);
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function getEarthInteractableMarkers() {
|
||||
return earthInteractableLayer.getMarkers();
|
||||
}
|
||||
@@ -14,6 +14,16 @@ let mobileDetailsListenerBound = false;
|
||||
let renderedMobileDetailKey = null;
|
||||
const locationCollectStateCache = new Map();
|
||||
const locationCollectContextCache = new Map();
|
||||
let computeCenterLocationCapability = null;
|
||||
let computeCenterLocationCapabilityPromise = null;
|
||||
let computeCenterUnresolvedBatchState = {
|
||||
running: false,
|
||||
statusText: '',
|
||||
total: 0,
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
missed: 0,
|
||||
};
|
||||
// Latest candidate list per cache-key. Populated whenever state.candidates is
|
||||
// updated, and read by the click handler via `data-candidate-index` so we
|
||||
// never have to round-trip a candidate object through an HTML attribute.
|
||||
@@ -74,6 +84,82 @@ function clearLocationCollectState(contextOrKey) {
|
||||
updateLocationCollectDomFromState(key);
|
||||
}
|
||||
|
||||
function getWebSearchDisabledTitle(capability = computeCenterLocationCapability) {
|
||||
if (!capability) return '正在检查 WebSearch 状态,稍候即可定位。';
|
||||
return capability.reason || 'WebSearch 未开启,无法进行事实核查定位。';
|
||||
}
|
||||
|
||||
function isComputeCenterLocationBlocked() {
|
||||
return computeCenterLocationCapability?.enabled !== true;
|
||||
}
|
||||
|
||||
async function ensureComputeCenterLocationCapability() {
|
||||
if (computeCenterLocationCapability) return computeCenterLocationCapability;
|
||||
if (!computeCenterLocationCapabilityPromise) {
|
||||
computeCenterLocationCapabilityPromise = import('./compute-centers.js')
|
||||
.then((mod) => mod.getComputeCenterLocationCapability())
|
||||
.then((capability) => {
|
||||
computeCenterLocationCapability = capability;
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
return capability;
|
||||
})
|
||||
.catch((error) => {
|
||||
computeCenterLocationCapability = {
|
||||
enabled: false,
|
||||
reason: `WebSearch 状态检查失败:${error?.message || error}`,
|
||||
};
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
return computeCenterLocationCapability;
|
||||
});
|
||||
}
|
||||
return computeCenterLocationCapabilityPromise;
|
||||
}
|
||||
|
||||
function updateComputeCenterLocationCapabilityDom() {
|
||||
const blocked = isComputeCenterLocationBlocked();
|
||||
const title = getWebSearchDisabledTitle();
|
||||
document
|
||||
.querySelectorAll('[data-requires-web-search="true"]')
|
||||
.forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.disabled = blocked || button.classList.contains('is-loading');
|
||||
button.classList.toggle('is-websearch-blocked', blocked);
|
||||
button.title = blocked ? title : button.dataset.readyTitle || button.title || '';
|
||||
button.setAttribute('aria-disabled', blocked ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function setLocationButtonLoading(button, loading, label) {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.classList.toggle('is-loading', Boolean(loading));
|
||||
button.toggleAttribute('aria-busy', Boolean(loading));
|
||||
if (label) {
|
||||
button.dataset.loadingLabel = label;
|
||||
}
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
|
||||
function updateUnresolvedBatchDom() {
|
||||
document.querySelectorAll('[data-unresolved-batch-status]').forEach((statusEl) => {
|
||||
statusEl.textContent = computeCenterUnresolvedBatchState.statusText || '';
|
||||
});
|
||||
document.querySelectorAll('[data-unresolved-adopt-all]').forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) return;
|
||||
button.classList.toggle('is-loading', computeCenterUnresolvedBatchState.running);
|
||||
button.toggleAttribute('aria-busy', computeCenterUnresolvedBatchState.running);
|
||||
});
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
|
||||
function setUnresolvedBatchState(patch = {}) {
|
||||
computeCenterUnresolvedBatchState = {
|
||||
...computeCenterUnresolvedBatchState,
|
||||
...patch,
|
||||
};
|
||||
updateUnresolvedBatchDom();
|
||||
return computeCenterUnresolvedBatchState;
|
||||
}
|
||||
|
||||
function getCandidateForButton(button) {
|
||||
if (!(button instanceof HTMLElement)) return null;
|
||||
const root = button.closest('[data-collect-cache-key]');
|
||||
@@ -404,9 +490,17 @@ function renderLocationCollectSection(context) {
|
||||
: '自动采集坐标候选';
|
||||
const cacheKey = getLocationCollectCacheKey(context);
|
||||
const cached = getLocationCollectState(cacheKey);
|
||||
ensureComputeCenterLocationCapability();
|
||||
const blocked = isComputeCenterLocationBlocked();
|
||||
const disabledTitle = getWebSearchDisabledTitle();
|
||||
return `
|
||||
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}" data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
|
||||
<button type="button" class="info-card-compute-collect-button" data-collect-action="run">
|
||||
<button type="button" class="info-card-compute-collect-button"
|
||||
data-collect-action="run"
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="${escapeInfoCardHtml(buttonLabel)}"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : buttonLabel)}"
|
||||
${blocked ? 'disabled aria-disabled="true"' : ''}>
|
||||
<span class="material-symbols-rounded" aria-hidden="true">explore</span>
|
||||
<span>${buttonLabel}</span>
|
||||
</button>
|
||||
@@ -434,7 +528,12 @@ function hydrateLocationCollectRoot(root, state) {
|
||||
const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]');
|
||||
if (statusEl) statusEl.textContent = state?.statusText || '';
|
||||
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
|
||||
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.classList.toggle('is-loading', state?.loading === true);
|
||||
button.toggleAttribute('aria-busy', state?.loading === true);
|
||||
}
|
||||
root.classList.toggle('is-locating', state?.loading === true);
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
|
||||
function rememberLocationCollectContext(context) {
|
||||
@@ -588,7 +687,12 @@ function bindLocationCollectControls(content, context) {
|
||||
}
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
button.disabled = true;
|
||||
const capability = await ensureComputeCenterLocationCapability();
|
||||
if (!capability?.enabled) {
|
||||
button.title = getWebSearchDisabledTitle(capability);
|
||||
return;
|
||||
}
|
||||
setLocationButtonLoading(button, true, '正在定位');
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: '正在采集坐标候选...',
|
||||
@@ -620,7 +724,7 @@ function bindLocationCollectControls(content, context) {
|
||||
candidates: [],
|
||||
});
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
setLocationButtonLoading(button, false);
|
||||
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
||||
}
|
||||
}, { once: false });
|
||||
@@ -678,6 +782,9 @@ function getUnresolvedComputeCenterContext(item) {
|
||||
|
||||
function renderComputeCenterUnresolvedContent(content, data) {
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
ensureComputeCenterLocationCapability();
|
||||
const blocked = isComputeCenterLocationBlocked();
|
||||
const disabledTitle = getWebSearchDisabledTitle();
|
||||
if (!items.length) {
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-empty">
|
||||
@@ -704,7 +811,12 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
<div class="info-card-unresolved-name">${escapeInfoCardHtml(context.name)}</div>
|
||||
<div class="info-card-unresolved-meta">${escapeInfoCardHtml(meta)}</div>
|
||||
</div>
|
||||
<button type="button" class="info-card-compute-candidate-preview" data-unresolved-collect
|
||||
<button type="button" class="info-card-compute-candidate-preview"
|
||||
data-unresolved-collect
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="采集坐标候选"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : '采集坐标候选')}"
|
||||
${blocked ? 'disabled aria-disabled="true"' : ''}
|
||||
data-context-json="${contextJson}">采集</button>
|
||||
</div>
|
||||
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
|
||||
@@ -719,15 +831,22 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-summary">
|
||||
<span data-unresolved-summary-text>${items.length} 个算力中心没有可信坐标</span>
|
||||
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt" data-unresolved-adopt-all>
|
||||
一键采用
|
||||
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt"
|
||||
data-unresolved-adopt-all
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="一键定位并采用最高置信候选"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : '一键定位并采用最高置信候选')}"
|
||||
${blocked ? 'disabled aria-disabled="true"' : ''}>
|
||||
一键定位
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-card-compute-collect-status" data-unresolved-batch-status></div>
|
||||
<div class="info-card-compute-collect-status" data-unresolved-batch-status>${escapeInfoCardHtml(computeCenterUnresolvedBatchState.statusText || '')}</div>
|
||||
<div class="info-card-unresolved-list">
|
||||
${rows}
|
||||
</div>
|
||||
`;
|
||||
updateUnresolvedBatchDom();
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
bindComputeCenterUnresolvedControls(content);
|
||||
}
|
||||
|
||||
@@ -793,6 +912,58 @@ async function collectUnresolvedComputeCenterCandidates(context, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel = '') {
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: progressLabel || '正在一键定位并采用最高置信候选...',
|
||||
candidates: [],
|
||||
});
|
||||
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(
|
||||
context,
|
||||
{ useCached: true },
|
||||
);
|
||||
if (!result?.success) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `未找到可采用候选:${formatLocationCollectFailure(result)}`,
|
||||
candidates: [],
|
||||
result,
|
||||
});
|
||||
return { saved: false, result, reason: 'no_result' };
|
||||
}
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: `找到 ${candidates.length} 个候选,正在保存最高置信位置...`,
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
const bestCandidate = getBestLocationCandidate(candidates);
|
||||
if (!bestCandidate) {
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: '未找到包含有效经纬度的候选',
|
||||
candidates,
|
||||
result,
|
||||
});
|
||||
return { saved: false, result, reason: 'no_valid_candidate' };
|
||||
}
|
||||
const saveResult = await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: '坐标已保存,等待图层刷新',
|
||||
candidates,
|
||||
result,
|
||||
savedCandidate: bestCandidate,
|
||||
saveResult,
|
||||
});
|
||||
return {
|
||||
saved: true,
|
||||
candidate: bestCandidate,
|
||||
result,
|
||||
saveResult,
|
||||
};
|
||||
}
|
||||
|
||||
function getBestLocationCandidate(candidates) {
|
||||
return candidates
|
||||
.filter((candidate) => (
|
||||
@@ -833,13 +1004,19 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const capability = await ensureComputeCenterLocationCapability();
|
||||
if (!capability?.enabled) {
|
||||
button.title = getWebSearchDisabledTitle(capability);
|
||||
return;
|
||||
}
|
||||
const itemRoot = button.closest('[data-unresolved-item]');
|
||||
const statusEl = itemRoot?.querySelector('[data-unresolved-status]');
|
||||
const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]');
|
||||
const context = JSON.parse(button.dataset.contextJson || '{}');
|
||||
if (!context.sourceId || !statusEl || !candidatesEl) return;
|
||||
|
||||
button.disabled = true;
|
||||
itemRoot?.classList.add('is-locating');
|
||||
setLocationButtonLoading(button, true, '正在定位');
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: '正在采集坐标候选...',
|
||||
@@ -878,7 +1055,8 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
candidates: [],
|
||||
});
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
itemRoot?.classList.remove('is-locating');
|
||||
setLocationButtonLoading(button, false);
|
||||
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
||||
}
|
||||
});
|
||||
@@ -888,6 +1066,15 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
if (adoptAllButton instanceof HTMLButtonElement) {
|
||||
adoptAllButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const capability = await ensureComputeCenterLocationCapability();
|
||||
if (!capability?.enabled) {
|
||||
adoptAllButton.title = getWebSearchDisabledTitle(capability);
|
||||
return;
|
||||
}
|
||||
if (computeCenterUnresolvedBatchState.running) {
|
||||
updateUnresolvedBatchDom();
|
||||
return;
|
||||
}
|
||||
const statusEl = content.querySelector('[data-unresolved-batch-status]');
|
||||
const buttons = Array.from(content.querySelectorAll('button'));
|
||||
const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]'))
|
||||
@@ -900,55 +1087,72 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
|
||||
if (!pendingItems.length) return;
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
setLocationButtonLoading(adoptAllButton, true, '正在定位');
|
||||
setUnresolvedBatchState({
|
||||
running: true,
|
||||
total: pendingItems.length,
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
missed: 0,
|
||||
statusText: `一键定位进行中 0/${pendingItems.length}...`,
|
||||
});
|
||||
|
||||
let savedCount = 0;
|
||||
let missedCount = 0;
|
||||
try {
|
||||
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
|
||||
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
|
||||
}
|
||||
itemRoot.classList.add('is-locating');
|
||||
const progressText = `正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
|
||||
setUnresolvedBatchState({
|
||||
statusText: progressText,
|
||||
processed: index,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
});
|
||||
if (statusEl) statusEl.textContent = progressText;
|
||||
try {
|
||||
const { mod, result, candidates, fromCache } = await collectUnresolvedComputeCenterCandidates(
|
||||
context,
|
||||
{ useCached: true },
|
||||
);
|
||||
if (!result?.success) {
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = `未找到可采用候选:${formatLocationCollectFailure(result)}`;
|
||||
}
|
||||
const saveOutcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
|
||||
if (!saveOutcome.saved) {
|
||||
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || '未找到可采用候选';
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const bestCandidate = getBestLocationCandidate(candidates);
|
||||
if (!bestCandidate) {
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = '未找到包含有效经纬度的候选';
|
||||
}
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
|
||||
if (fromCache) {
|
||||
clearLocationCollectState(context);
|
||||
}
|
||||
savedCount += 1;
|
||||
removeResolvedUnresolvedItem(content, itemRoot);
|
||||
} catch (error) {
|
||||
console.error('adopt unresolved compute-center location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: `一键定位失败:${error?.message || error}`,
|
||||
candidates: [],
|
||||
});
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = `一键采用失败:${error?.message || error}`;
|
||||
}
|
||||
missedCount += 1;
|
||||
} finally {
|
||||
itemRoot.classList.remove('is-locating');
|
||||
setUnresolvedBatchState({
|
||||
processed: index + 1,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: `一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount} 个${missedCount ? `,失败 ${missedCount} 个` : ''}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = savedCount > 0
|
||||
? `已采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`
|
||||
: `${missedCount} 个都没有可自动采用的候选,需要手动处理`;
|
||||
}
|
||||
const finalStatus = savedCount > 0
|
||||
? `已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`
|
||||
: `${missedCount} 个都没有可自动采用的候选,需要手动处理`;
|
||||
if (statusEl) statusEl.textContent = finalStatus;
|
||||
setUnresolvedBatchState({
|
||||
running: false,
|
||||
processed: pendingItems.length,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: finalStatus,
|
||||
});
|
||||
if (savedCount > 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:compute-center-location-saved', {
|
||||
@@ -962,7 +1166,10 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setUnresolvedBatchState({ running: false });
|
||||
setLocationButtonLoading(adoptAllButton, false);
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -506,6 +506,7 @@ export function createInteractableLayer(options = {}) {
|
||||
getKind = (item) => item?.type || "default",
|
||||
getRotationBin = () => 0,
|
||||
getBucketKey = (marker) => String(getRotationBin(marker)),
|
||||
getItemId = (item) => item?.id ?? item?.source_id ?? item?.entity_key,
|
||||
getPointSizeMultiplier = () => 1,
|
||||
getPointOpacity = null,
|
||||
getUserData = (item) => item,
|
||||
@@ -1020,6 +1021,43 @@ export function createInteractableLayer(options = {}) {
|
||||
group.visible = visible;
|
||||
}
|
||||
|
||||
function rebuildFromMarkerData(items) {
|
||||
const wasVisible = visible;
|
||||
setData(items);
|
||||
group.visible = wasVisible;
|
||||
}
|
||||
|
||||
function upsertItem(item) {
|
||||
const itemId = getItemId(item);
|
||||
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
|
||||
return false;
|
||||
}
|
||||
let replaced = false;
|
||||
const nextItems = markers.map((marker) => {
|
||||
const markerItem = marker.userData || {};
|
||||
if (String(getItemId(markerItem)) !== String(itemId)) return { ...markerItem };
|
||||
replaced = true;
|
||||
return { ...markerItem, ...item };
|
||||
});
|
||||
if (!replaced) {
|
||||
nextItems.push(item);
|
||||
}
|
||||
rebuildFromMarkerData(nextItems);
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeItem(itemId) {
|
||||
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
|
||||
return false;
|
||||
}
|
||||
const nextItems = markers
|
||||
.map((marker) => ({ ...(marker.userData || {}) }))
|
||||
.filter((item) => String(getItemId(item)) !== String(itemId));
|
||||
if (nextItems.length === markers.length) return false;
|
||||
rebuildFromMarkerData(nextItems);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function preloadAssets(items = []) {
|
||||
if (icon.draw && !icon.source && !icon.getSource && !icon.stateSources) {
|
||||
return;
|
||||
@@ -1300,6 +1338,8 @@ export function createInteractableLayer(options = {}) {
|
||||
getCount: () => markers.length,
|
||||
isVisible: () => visible,
|
||||
setData,
|
||||
upsertItem,
|
||||
removeItem,
|
||||
preloadAssets,
|
||||
clearData,
|
||||
attach,
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
clearAllCableStates,
|
||||
applyLandingPointVisualState,
|
||||
resetLandingPointVisualState,
|
||||
getCableSourceRecordCount,
|
||||
getShowCables,
|
||||
clearCableData,
|
||||
getLandingPoints,
|
||||
@@ -201,6 +202,12 @@ import {
|
||||
toggleVessels,
|
||||
updateVesselVisualState,
|
||||
} from "./vessels.js";
|
||||
import {
|
||||
applyEarthInteractableEvent,
|
||||
clearEarthInteractables,
|
||||
loadEarthInteractables,
|
||||
refreshEarthInteractables,
|
||||
} from "./earth-interactables.js";
|
||||
import {
|
||||
setupControls,
|
||||
getAutoRotate,
|
||||
@@ -340,10 +347,18 @@ let cruiseRandomQueueSignature = "";
|
||||
let cruiseRandomQueueItems = [];
|
||||
let earthUpdatesSocket = null;
|
||||
let earthUpdatesReconnectTimer = null;
|
||||
let earthDataReconcileTimer = null;
|
||||
let earthDataReconcileInFlight = false;
|
||||
let earthUpdateFlushTimer = null;
|
||||
let earthUpdateFlushPromise = null;
|
||||
const pendingEarthUpdateLayers = new Set();
|
||||
const pendingEarthUpdateStrategies = new Map();
|
||||
let pendingEarthUpdateIsDatabaseChange = false;
|
||||
let presentationController = null;
|
||||
let motionControlAdapter = null;
|
||||
let motionCruiseAdapter = null;
|
||||
let motionCruiseSequencer = null;
|
||||
let motionSharedCruiseSequencer = null;
|
||||
let motionFocusCandidates = [];
|
||||
let motionFocusIndex = 0;
|
||||
let motionFocusedCandidate = null;
|
||||
@@ -362,6 +377,8 @@ let shouldRefreshSatellitesAfterVisibilityResume = false;
|
||||
|
||||
const EARTH_UPDATES_CHANNEL = "earth_updates";
|
||||
const EARTH_UPDATES_RECONNECT_DELAY_MS = 5000;
|
||||
const EARTH_DATA_RECONCILE_INTERVAL_MS = 60000;
|
||||
const EARTH_UPDATE_CLIENT_DEBOUNCE_MS = 250;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const interactionRaycaster = new THREE.Raycaster();
|
||||
@@ -821,7 +838,10 @@ function isMotionControlActive() {
|
||||
}
|
||||
|
||||
function isMotionPresentationActive() {
|
||||
return presentationController?.isActive?.("motion") === true;
|
||||
return (
|
||||
presentationController?.isActive?.("motion") === true ||
|
||||
motionSharedCruiseSequencer?.isPresentationPinned?.() === true
|
||||
);
|
||||
}
|
||||
|
||||
function getMotionCenterPointer() {
|
||||
@@ -1100,10 +1120,20 @@ function ensureMotionCruiseAdapter() {
|
||||
}
|
||||
|
||||
function repositionMotionConnector() {
|
||||
if (!motionCruiseSequencer?.isPresentationPinned?.()) return;
|
||||
const item = motionCruiseSequencer.getCurrentItem?.();
|
||||
if (motionCruiseSequencer?.isPresentationPinned?.()) {
|
||||
const item = motionCruiseSequencer.getCurrentItem?.();
|
||||
if (item) {
|
||||
ensureMotionCruiseAdapter().repositionConnector();
|
||||
}
|
||||
}
|
||||
repositionMotionSharedCruiseConnector();
|
||||
}
|
||||
|
||||
function repositionMotionSharedCruiseConnector() {
|
||||
if (!motionSharedCruiseSequencer?.isPresentationPinned?.()) return;
|
||||
const item = motionSharedCruiseSequencer.getCurrentItem?.();
|
||||
if (!item) return;
|
||||
ensureMotionCruiseAdapter().repositionConnector();
|
||||
getCruiseModuleForItem(item)?.repositionPresentation?.(item);
|
||||
}
|
||||
|
||||
function ensureMotionCruiseSequencer() {
|
||||
@@ -1132,6 +1162,81 @@ function ensureMotionCruiseSequencer() {
|
||||
return motionCruiseSequencer;
|
||||
}
|
||||
|
||||
function ensureMotionSharedCruiseSequencer() {
|
||||
if (motionSharedCruiseSequencer) return motionSharedCruiseSequencer;
|
||||
|
||||
motionSharedCruiseSequencer = new CruiseSequencer({
|
||||
presentationMode: "pinned",
|
||||
isActive: isMotionControlActive,
|
||||
getItems: () => getCruiseQueueItemsSorted(),
|
||||
getItemId: (item) => item?.id || null,
|
||||
clearCurrent: () => {
|
||||
clearCruiseHighlights();
|
||||
clearLockedObject();
|
||||
if (!shouldPreserveInfoCardOnCruiseInterrupt()) {
|
||||
hideInfoCard();
|
||||
}
|
||||
setCruisePresentationVisible(false);
|
||||
},
|
||||
onStop: ({ preservePresentation }) => {
|
||||
clearBGPSelection();
|
||||
if (!preservePresentation && !lockedObject) {
|
||||
hideInfoCard();
|
||||
}
|
||||
},
|
||||
focusItem: async (item, { interrupt }) =>
|
||||
getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }),
|
||||
presentItem: async (item, { context }) => {
|
||||
setCruisePresentationVisible(true);
|
||||
const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, {
|
||||
context,
|
||||
});
|
||||
if (!presented) {
|
||||
setCruisePresentationVisible(false);
|
||||
}
|
||||
return presented;
|
||||
},
|
||||
hideItem: async (item, { context }) => {
|
||||
await getCruiseModuleForItem(item)?.hidePresentation?.({ context });
|
||||
setCruisePresentationVisible(false);
|
||||
},
|
||||
});
|
||||
|
||||
return motionSharedCruiseSequencer;
|
||||
}
|
||||
|
||||
async function applyMotionSharedCruiseFocus(direction = "next") {
|
||||
await syncCruiseModuleKnownEventIds();
|
||||
const items = getCruiseQueueItemsSorted();
|
||||
if (!Array.isArray(items) || items.length === 0) return false;
|
||||
|
||||
const sequencer = ensureMotionSharedCruiseSequencer();
|
||||
const currentId =
|
||||
sequencer.getCurrentItemId?.() ||
|
||||
cruiseSequencer?.getCurrentItemId?.() ||
|
||||
"";
|
||||
const currentIndex = items.findIndex((item) => item?.id === currentId);
|
||||
const delta = direction === "prev" ? -1 : 1;
|
||||
const nextIndex =
|
||||
currentIndex >= 0
|
||||
? (currentIndex + delta + items.length) % items.length
|
||||
: direction === "prev"
|
||||
? items.length - 1
|
||||
: 0;
|
||||
const targetItem = items[nextIndex] || null;
|
||||
if (!targetItem) return false;
|
||||
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionFocusedCandidate = null;
|
||||
clearMotionFocusVisual();
|
||||
|
||||
const presented = await sequencer.presentSpecificItem(targetItem, { interrupt: true });
|
||||
if (presented) {
|
||||
showStatusMessage("动捕: 已切换巡航目标", "info");
|
||||
}
|
||||
return Boolean(presented);
|
||||
}
|
||||
|
||||
async function presentMotionCandidate(candidate, { interrupt = true } = {}) {
|
||||
const item = createMotionCruiseItem(candidate);
|
||||
if (!item) return false;
|
||||
@@ -1246,6 +1351,10 @@ export async function applyMotionFocus(direction = "next") {
|
||||
const releaseGate = beginMotionActionGate("focus");
|
||||
if (!releaseGate) return false;
|
||||
try {
|
||||
if (await applyMotionSharedCruiseFocus(direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
refreshMotionFocusCandidates({ force: true });
|
||||
if (motionFocusCandidates.length === 0) {
|
||||
const layer = getCurrentMotionFocusLayer();
|
||||
@@ -2297,9 +2406,18 @@ function formatBGPStatusFromSummary(summary) {
|
||||
return "当前无活跃事件";
|
||||
}
|
||||
|
||||
async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
|
||||
async function loadEarthStatsSummary({
|
||||
shouldApply = () => true,
|
||||
cacheBust = false,
|
||||
} = {}) {
|
||||
try {
|
||||
const response = await fetch(PATHS.earthSummaryApi);
|
||||
const url = new URL(PATHS.earthSummaryApi, window.location.origin);
|
||||
if (cacheBust) {
|
||||
url.searchParams.set("_", String(Date.now()));
|
||||
}
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: cacheBust ? "no-store" : "default",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Earth summary HTTP ${response.status}`);
|
||||
}
|
||||
@@ -2466,6 +2584,7 @@ function prepareToolInfoCard(type) {
|
||||
} else if (hadMotionPresentation) {
|
||||
presentationController?.dismiss?.("tool_card_open");
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionSharedCruiseSequencer?.stop?.();
|
||||
showStatusMessage("已暂停动捕目标展示,正在打开候选列表", "info");
|
||||
}
|
||||
|
||||
@@ -2963,6 +3082,11 @@ function getCurrentCruiseBGPMarker() {
|
||||
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
|
||||
}
|
||||
|
||||
function getCurrentMotionSharedCruiseBGPMarker() {
|
||||
const item = motionSharedCruiseSequencer?.getCurrentItem?.() ?? null;
|
||||
return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null;
|
||||
}
|
||||
|
||||
async function syncCruiseModuleKnownEventIds() {
|
||||
const modules = getEnabledCruiseModuleDefinitions();
|
||||
await Promise.all(
|
||||
@@ -3138,6 +3262,7 @@ function handleRotationModeChange(event) {
|
||||
setupMotionControl();
|
||||
if (detailMode !== ROTATION_MODE.MOTION) {
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionSharedCruiseSequencer?.stop?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3162,6 +3287,7 @@ function handleRotationModeChange(event) {
|
||||
|
||||
function handleCruiseModulesChange() {
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
motionSharedCruiseSequencer?.interruptPresentation?.({ resetLoop: true });
|
||||
clearBGPSelection();
|
||||
cruiseRandomQueueSignature = "";
|
||||
cruiseRandomQueueItems = [];
|
||||
@@ -3188,6 +3314,7 @@ function handleCruiseModulesChange() {
|
||||
|
||||
function handleCruiseQueueSettingsChange() {
|
||||
interruptCruisePresentation({ resetLoop: true });
|
||||
motionSharedCruiseSequencer?.interruptPresentation?.({ resetLoop: true });
|
||||
cruiseRandomQueueSignature = "";
|
||||
cruiseRandomQueueItems = [];
|
||||
|
||||
@@ -3468,10 +3595,79 @@ function updateSatelliteToggleUi(enabled, satelliteCount = getSatelliteCount())
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedCount = satelliteCount || earthStatsSummary?.satelliteCount || 0;
|
||||
const resolvedCount = getAuthoritativeSatelliteCount(satelliteCount);
|
||||
setEarthStatValue("satellite-count", `${resolvedCount} 颗`);
|
||||
}
|
||||
|
||||
function getAuthoritativeSatelliteCount(fallback = getSatelliteCount()) {
|
||||
return getAuthoritativeCount("satelliteCount", fallback);
|
||||
}
|
||||
|
||||
function getAuthoritativeCount(summaryKey, fallback = 0) {
|
||||
const summaryCount = Number(earthStatsSummary?.[summaryKey]);
|
||||
if (Number.isFinite(summaryCount)) {
|
||||
return summaryCount;
|
||||
}
|
||||
const fallbackCount = Number(fallback);
|
||||
return Number.isFinite(fallbackCount) ? fallbackCount : 0;
|
||||
}
|
||||
|
||||
function hasAuthoritativeZeroSummary(...summaryKeys) {
|
||||
if (!earthStatsSummary || summaryKeys.length === 0) return false;
|
||||
return summaryKeys.every((summaryKey) => {
|
||||
const summaryCount = Number(earthStatsSummary?.[summaryKey]);
|
||||
return Number.isFinite(summaryCount) && summaryCount <= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function applySatelliteEmptyState() {
|
||||
satelliteHydrationToken += 1;
|
||||
clearSatelliteData();
|
||||
updateSatelliteToggleUi(satellitesEnabled || getShowSatellites(), 0);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
function applyCableEmptyState() {
|
||||
const earth = getEarth();
|
||||
clearCableData(earth);
|
||||
updateCableToggleUi(cablesEnabled || getShowCables());
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
function applyComputeCenterEmptyState() {
|
||||
const earth = getEarth();
|
||||
clearComputeCenterData(earth);
|
||||
updateComputeCenterHud({
|
||||
totalCount: 0,
|
||||
unresolvedCount: 0,
|
||||
});
|
||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
function applyVesselEmptyState() {
|
||||
const earth = getEarth();
|
||||
clearVesselData(earth);
|
||||
updateVesselToggleUi(vesselsEnabled || getShowVessels(), 0);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
function applyBGPEmptyState() {
|
||||
const earth = getEarth();
|
||||
clearBGPData(earth);
|
||||
updateBGPHud({
|
||||
totalCount: 0,
|
||||
anomalyCount: 0,
|
||||
collectorCount: 0,
|
||||
});
|
||||
setLegendItems("bgp", getBGPLegendItems());
|
||||
refreshLegend();
|
||||
ensureBGPCruiseAdapter().syncKnownEventIds();
|
||||
}
|
||||
|
||||
function updateVesselHud(result = {}) {
|
||||
const count = Number(result.totalCount ?? getVesselCount() ?? 0);
|
||||
setEarthStatValue("vessel-count", `${count} 艘`);
|
||||
@@ -3516,7 +3712,7 @@ function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
|
||||
tooltip: enabled ? "隐藏船只" : "显示船只",
|
||||
});
|
||||
}
|
||||
setEarthStatValue("vessel-count", `${vesselCount || 0} 艘`);
|
||||
setEarthStatValue("vessel-count", `${getAuthoritativeCount("vesselCount", vesselCount)} 艘`);
|
||||
setEarthStatValue("vessel-live-summary", formatVesselLiveSummary());
|
||||
}
|
||||
|
||||
@@ -3530,9 +3726,11 @@ function updateCableToggleUi(enabled) {
|
||||
});
|
||||
}
|
||||
|
||||
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
|
||||
const landingPointCount =
|
||||
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
|
||||
const cableCount = getAuthoritativeCount("cableCount", getCableLines().length);
|
||||
const landingPointCount = getAuthoritativeCount(
|
||||
"landingPointCount",
|
||||
getLandingPoints().length,
|
||||
);
|
||||
setEarthStatValue("cable-count", `${cableCount}个`);
|
||||
setEarthStatValue("landing-point-count", `${landingPointCount}个`);
|
||||
}
|
||||
@@ -3600,6 +3798,7 @@ async function ensureSatellitesEnabled() {
|
||||
clearSatelliteData();
|
||||
const loadResult = await loadSatellites({
|
||||
limit: getInitialSatelliteLoadLimit(),
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -3680,16 +3879,22 @@ function disableSatellites() {
|
||||
}
|
||||
|
||||
function updateStatsSummary() {
|
||||
const cableCount = getCableLines().length || earthStatsSummary?.cableCount || 0;
|
||||
const landingPointCount =
|
||||
getLandingPoints().length || earthStatsSummary?.landingPointCount || 0;
|
||||
const satelliteCount = getSatelliteCount() || earthStatsSummary?.satelliteCount || 0;
|
||||
const vesselCount = getVesselCount() || earthStatsSummary?.vesselCount || 0;
|
||||
const computeCenterCount =
|
||||
getComputeCenterCount() || earthStatsSummary?.computeCenterCount || 0;
|
||||
const bgpEventCount = getBGPCount() || earthStatsSummary?.bgpEventCount || 0;
|
||||
const bgpCollectorCount =
|
||||
getBGPCollectorCount() || earthStatsSummary?.bgpCollectorCount || 0;
|
||||
const cableCount = getAuthoritativeCount("cableCount", getCableLines().length);
|
||||
const landingPointCount = getAuthoritativeCount(
|
||||
"landingPointCount",
|
||||
getLandingPoints().length,
|
||||
);
|
||||
const satelliteCount = getAuthoritativeSatelliteCount();
|
||||
const vesselCount = getAuthoritativeCount("vesselCount", getVesselCount());
|
||||
const computeCenterCount = getAuthoritativeCount(
|
||||
"computeCenterCount",
|
||||
getComputeCenterCount(),
|
||||
);
|
||||
const bgpEventCount = getAuthoritativeCount("bgpEventCount", getBGPCount());
|
||||
const bgpCollectorCount = getAuthoritativeCount(
|
||||
"bgpCollectorCount",
|
||||
getBGPCollectorCount(),
|
||||
);
|
||||
updateEarthStats({
|
||||
cableCount: `${cableCount}个`,
|
||||
landingPointCount: `${landingPointCount}个`,
|
||||
@@ -3743,6 +3948,7 @@ export function init() {
|
||||
initEarthOobe();
|
||||
initNewsPanel();
|
||||
connectEarthUpdatesRealtime();
|
||||
startEarthDataReconciliation();
|
||||
initSearchPanel({
|
||||
resolveResults: resolveEarthSearchResults,
|
||||
onSelectResult: handleSearchSelection,
|
||||
@@ -3836,6 +4042,7 @@ function setupMotionControl() {
|
||||
setMotionDebugPanelSkeletonOnly(getMotionDebugSkeletonOnly());
|
||||
if (!motionEnabled) {
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionSharedCruiseSequencer?.stop?.();
|
||||
}
|
||||
motionControlAdapter = createMotionControlAdapter({
|
||||
enabled: motionEnabled,
|
||||
@@ -4022,7 +4229,7 @@ function shouldHydrateFullSatelliteSet(loadResult) {
|
||||
|
||||
async function hydrateAllSatellitesInBackground(guardFn) {
|
||||
try {
|
||||
const loadResult = await loadSatellites({ limit: null });
|
||||
const loadResult = await loadSatellites({ limit: null, cacheBust: true });
|
||||
if (!guardFn()) return;
|
||||
updateSatelliteToggleUi(true, loadResult.count);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
@@ -4059,8 +4266,8 @@ async function refreshCablesRealtimeLayer() {
|
||||
const earth = getEarth();
|
||||
if (!scene || !earth) return;
|
||||
const wasEnabled = cablesEnabled || getShowCables();
|
||||
clearCableData(earth);
|
||||
if (!wasEnabled) {
|
||||
clearCableData(earth);
|
||||
updateCableToggleUi(false);
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
refreshLegend();
|
||||
@@ -4075,48 +4282,240 @@ async function refreshCablesRealtimeLayer() {
|
||||
refreshLegend();
|
||||
}
|
||||
|
||||
async function refreshSatellitesRealtimeLayer() {
|
||||
if (!satellitesEnabled && !getShowSatellites()) return;
|
||||
await ensureSatellitesEnabled();
|
||||
function shouldRefreshLayerFromServerState(handler) {
|
||||
return (
|
||||
handler.isVisible?.() ||
|
||||
handler.localCount?.() > 0 ||
|
||||
handler.summaryKeys?.some((summaryKey) => getAuthoritativeCount(summaryKey, 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshVesselsRealtimeLayer() {
|
||||
if (!vesselsEnabled && !getShowVessels()) return;
|
||||
await ensureVesselsEnabled();
|
||||
function shouldClearLayerFromServerState(handler) {
|
||||
return hasAuthoritativeZeroSummary(...(handler.summaryKeys || []));
|
||||
}
|
||||
|
||||
async function refreshEarthLayerFromUpdate(layer) {
|
||||
if (layer === "bgp") {
|
||||
await refreshBGPRealtimeLayer();
|
||||
} else if (layer === "computeCenters") {
|
||||
await refreshComputeCentersAfterLocationSave();
|
||||
} else if (layer === "cables") {
|
||||
await refreshCablesRealtimeLayer();
|
||||
} else if (layer === "satellites") {
|
||||
await refreshSatellitesRealtimeLayer();
|
||||
} else if (layer === "vessels") {
|
||||
await refreshVesselsRealtimeLayer();
|
||||
function getLayerSummaryTotal(handler) {
|
||||
if (!earthStatsSummary || !Array.isArray(handler.summaryKeys)) return null;
|
||||
let total = 0;
|
||||
for (const summaryKey of handler.summaryKeys) {
|
||||
const count = Number(earthStatsSummary?.[summaryKey]);
|
||||
if (!Number.isFinite(count)) return null;
|
||||
total += count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function shouldRefreshLayerForCountMismatch(handler) {
|
||||
if (!handler.refreshOnCountMismatch || !handler.isVisible?.()) return false;
|
||||
const summaryTotal = getLayerSummaryTotal(handler);
|
||||
if (summaryTotal === null) return false;
|
||||
return summaryTotal !== (handler.localCount?.() || 0);
|
||||
}
|
||||
|
||||
const EARTH_DATA_LAYER_HANDLERS = {
|
||||
bgp: {
|
||||
summaryKeys: ["bgpEventCount", "bgpCollectorCount"],
|
||||
isVisible: () => getShowBGP(),
|
||||
localCount: () => getBGPCount() + getBGPCollectorCount(),
|
||||
clear: applyBGPEmptyState,
|
||||
refresh: refreshBGPRealtimeLayer,
|
||||
},
|
||||
cables: {
|
||||
summaryKeys: ["cableCount", "landingPointCount"],
|
||||
isVisible: () => cablesEnabled || getShowCables(),
|
||||
localCount: () => getCableSourceRecordCount(),
|
||||
clear: applyCableEmptyState,
|
||||
refresh: refreshCablesRealtimeLayer,
|
||||
refreshOnCountMismatch: true,
|
||||
},
|
||||
computeCenters: {
|
||||
summaryKeys: ["computeCenterCount"],
|
||||
isVisible: () => getShowComputeCenters(),
|
||||
localCount: () => getComputeCenterCount() + getUnresolvedComputeCenters().length,
|
||||
clear: applyComputeCenterEmptyState,
|
||||
refresh: refreshComputeCentersAfterLocationSave,
|
||||
refreshOnCountMismatch: true,
|
||||
},
|
||||
satellites: {
|
||||
summaryKeys: ["satelliteCount"],
|
||||
isVisible: () => satellitesEnabled || getShowSatellites(),
|
||||
localCount: () => getSatelliteCount(),
|
||||
clear: applySatelliteEmptyState,
|
||||
refresh: ensureSatellitesEnabled,
|
||||
refreshOnCountMismatch: true,
|
||||
},
|
||||
vessels: {
|
||||
summaryKeys: ["vesselCount"],
|
||||
isVisible: () => vesselsEnabled || getShowVessels(),
|
||||
localCount: () => getVesselCount(),
|
||||
clear: applyVesselEmptyState,
|
||||
refresh: ensureVesselsEnabled,
|
||||
},
|
||||
};
|
||||
|
||||
async function refreshDataBackedEarthLayer(layer, options = {}) {
|
||||
const handler = EARTH_DATA_LAYER_HANDLERS[layer];
|
||||
if (!handler) return false;
|
||||
const shouldClearBeforeReload = options.strategy === "clear_then_reload";
|
||||
if (shouldClearBeforeReload) {
|
||||
handler.clear?.();
|
||||
}
|
||||
if (!shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
|
||||
handler.clear?.();
|
||||
return true;
|
||||
}
|
||||
if (!options.force && !shouldRefreshLayerFromServerState(handler)) {
|
||||
return false;
|
||||
}
|
||||
await handler.refresh?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function refreshInteractablesRealtimeLayer() {
|
||||
const earth = getEarth();
|
||||
if (!earth) return;
|
||||
await refreshEarthInteractables(earth);
|
||||
}
|
||||
|
||||
async function refreshEarthLayerFromUpdate(layer, options = {}) {
|
||||
if (EARTH_DATA_LAYER_HANDLERS[layer]) {
|
||||
await refreshDataBackedEarthLayer(layer, options);
|
||||
} else if (layer === "news") {
|
||||
await refreshEarthNews({ silent: true });
|
||||
} else if (layer === "interactables") {
|
||||
await refreshInteractablesRealtimeLayer();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEarthUpdateFrame(payload = {}) {
|
||||
const layers = Array.isArray(payload.layers) ? payload.layers : [];
|
||||
if (layers.length === 0) return;
|
||||
Promise.allSettled(layers.map((layer) => refreshEarthLayerFromUpdate(layer)))
|
||||
.then((results) => {
|
||||
const failed = results.some((result) => result.status === "rejected");
|
||||
if (failed) {
|
||||
console.warn("部分 Earth 图层实时刷新失败:", { payload, results });
|
||||
}
|
||||
updateStatsSummary();
|
||||
if (isCruiseModeActive()) {
|
||||
syncCruiseModuleKnownEventIds().catch((error) => {
|
||||
console.warn("实时刷新后同步巡航模块失败:", error);
|
||||
});
|
||||
async function flushPendingEarthUpdates() {
|
||||
if (earthUpdateFlushPromise) return earthUpdateFlushPromise;
|
||||
const layers = Array.from(pendingEarthUpdateLayers);
|
||||
const strategies = new Map(pendingEarthUpdateStrategies);
|
||||
const isDatabaseChange = pendingEarthUpdateIsDatabaseChange;
|
||||
pendingEarthUpdateLayers.clear();
|
||||
pendingEarthUpdateStrategies.clear();
|
||||
pendingEarthUpdateIsDatabaseChange = false;
|
||||
if (layers.length === 0) return Promise.resolve();
|
||||
|
||||
earthUpdateFlushPromise = (async () => {
|
||||
if (isDatabaseChange) {
|
||||
await loadEarthStatsSummary({
|
||||
shouldApply: () => !destroyed,
|
||||
cacheBust: true,
|
||||
});
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
layers.map((layer) => refreshEarthLayerFromUpdate(layer, {
|
||||
force: isDatabaseChange,
|
||||
strategy: strategies.get(layer),
|
||||
})),
|
||||
);
|
||||
const failed = results.some((result) => result.status === "rejected");
|
||||
if (failed) {
|
||||
console.warn("部分 Earth 图层实时刷新失败:", { layers, results });
|
||||
}
|
||||
updateStatsSummary();
|
||||
if (isCruiseModeActive()) {
|
||||
syncCruiseModuleKnownEventIds().catch((error) => {
|
||||
console.warn("实时刷新后同步巡航模块失败:", error);
|
||||
});
|
||||
}
|
||||
})()
|
||||
.catch((error) => {
|
||||
console.warn("Earth 实时刷新失败:", { layers, error });
|
||||
})
|
||||
.finally(() => {
|
||||
earthUpdateFlushPromise = null;
|
||||
if (pendingEarthUpdateLayers.size > 0 && !earthUpdateFlushTimer) {
|
||||
earthUpdateFlushTimer = window.setTimeout(() => {
|
||||
earthUpdateFlushTimer = null;
|
||||
flushPendingEarthUpdates();
|
||||
}, EARTH_UPDATE_CLIENT_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
return earthUpdateFlushPromise;
|
||||
}
|
||||
|
||||
function scheduleEarthUpdateFlush() {
|
||||
if (earthUpdateFlushTimer || earthUpdateFlushPromise) return;
|
||||
earthUpdateFlushTimer = window.setTimeout(() => {
|
||||
earthUpdateFlushTimer = null;
|
||||
flushPendingEarthUpdates();
|
||||
}, EARTH_UPDATE_CLIENT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleEarthUpdateFrame(payload = {}) {
|
||||
if (payload.entity === "interactable") {
|
||||
const earth = getEarth();
|
||||
const applied = applyEarthInteractableEvent(earth, payload);
|
||||
if (applied) {
|
||||
updateStatsSummary();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const layers = Array.isArray(payload.layers) ? payload.layers : [];
|
||||
if (layers.length === 0) return;
|
||||
const isDatabaseChange = payload.action === "database_changed";
|
||||
const strategy = typeof payload.refresh_strategy === "string" ? payload.refresh_strategy : "";
|
||||
layers.forEach((layer) => {
|
||||
pendingEarthUpdateLayers.add(layer);
|
||||
if (strategy === "clear_then_reload" || !pendingEarthUpdateStrategies.has(layer)) {
|
||||
pendingEarthUpdateStrategies.set(layer, strategy || "reload");
|
||||
}
|
||||
});
|
||||
pendingEarthUpdateIsDatabaseChange = pendingEarthUpdateIsDatabaseChange || isDatabaseChange;
|
||||
scheduleEarthUpdateFlush();
|
||||
}
|
||||
|
||||
async function reconcileEarthDataFromServer() {
|
||||
if (destroyed || earthDataReconcileInFlight) return;
|
||||
if (document.visibilityState === "hidden") return;
|
||||
earthDataReconcileInFlight = true;
|
||||
try {
|
||||
await loadEarthStatsSummary({
|
||||
shouldApply: () => !destroyed,
|
||||
cacheBust: true,
|
||||
});
|
||||
if (destroyed) return;
|
||||
|
||||
let changed = false;
|
||||
for (const [layer, handler] of Object.entries(EARTH_DATA_LAYER_HANDLERS)) {
|
||||
if (shouldClearLayerFromServerState(handler) && handler.localCount?.() > 0) {
|
||||
handler.clear?.();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const serverHasData = handler.summaryKeys?.some(
|
||||
(summaryKey) => getAuthoritativeCount(summaryKey, 0) > 0,
|
||||
);
|
||||
if (
|
||||
(handler.isVisible?.() && serverHasData && handler.localCount?.() === 0) ||
|
||||
shouldRefreshLayerForCountMismatch(handler)
|
||||
) {
|
||||
await refreshDataBackedEarthLayer(layer, { force: true });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
updateStatsSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Earth 数据一致性巡检失败:", error);
|
||||
} finally {
|
||||
earthDataReconcileInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEarthDataReconciliation() {
|
||||
if (earthDataReconcileTimer || destroyed) return;
|
||||
earthDataReconcileTimer = window.setInterval(
|
||||
() => {
|
||||
reconcileEarthDataFromServer();
|
||||
},
|
||||
EARTH_DATA_RECONCILE_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function connectEarthUpdatesRealtime() {
|
||||
@@ -4178,6 +4577,7 @@ async function loadData() {
|
||||
clearCableData(earth);
|
||||
clearComputeCenterData(earth);
|
||||
clearVesselData(earth);
|
||||
clearEarthInteractables(earth);
|
||||
clearSatelliteData();
|
||||
clearCountryBoundaryHover();
|
||||
|
||||
@@ -4248,6 +4648,10 @@ async function loadData() {
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
}
|
||||
|
||||
loadEarthInteractables(earth, { silent: true }).catch((error) => {
|
||||
console.warn("Earth interactables 加载失败:", error);
|
||||
});
|
||||
|
||||
// Step 6 — Terrain (if enabled)
|
||||
if (getShowTerrain()) {
|
||||
const terrainLayer = startupLayers.find((layer) => layer.id === "terrain");
|
||||
@@ -5500,6 +5904,8 @@ function animate() {
|
||||
const activeCruiseMarker =
|
||||
isCruiseModeActive() && isCruisePresentationPinned()
|
||||
? getCurrentCruiseBGPMarker()
|
||||
: getRotationMode() === ROTATION_MODE.MOTION && motionSharedCruiseSequencer?.isPresentationPinned?.()
|
||||
? getCurrentMotionSharedCruiseBGPMarker()
|
||||
: null;
|
||||
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
|
||||
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
|
||||
@@ -5586,12 +5992,25 @@ export function destroy() {
|
||||
window.clearTimeout(earthUpdatesReconnectTimer);
|
||||
earthUpdatesReconnectTimer = null;
|
||||
}
|
||||
if (earthDataReconcileTimer) {
|
||||
window.clearInterval(earthDataReconcileTimer);
|
||||
earthDataReconcileTimer = null;
|
||||
}
|
||||
if (earthUpdateFlushTimer) {
|
||||
window.clearTimeout(earthUpdateFlushTimer);
|
||||
earthUpdateFlushTimer = null;
|
||||
}
|
||||
pendingEarthUpdateLayers.clear();
|
||||
pendingEarthUpdateStrategies.clear();
|
||||
pendingEarthUpdateIsDatabaseChange = false;
|
||||
if (earthUpdatesSocket) {
|
||||
const socket = earthUpdatesSocket;
|
||||
earthUpdatesSocket = null;
|
||||
socket.close();
|
||||
}
|
||||
motionControlAdapter?.stop?.();
|
||||
motionCruiseSequencer?.stop?.();
|
||||
motionSharedCruiseSequencer?.stop?.();
|
||||
motionControlAdapter = null;
|
||||
presentationController?.dismiss?.("destroy");
|
||||
while (cleanupFns.length) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./callout-connector.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
getVisibleNewsItems,
|
||||
getCruiseNewsItems,
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
@@ -237,7 +237,7 @@ export function createNewsCruiseAdapter({ camera, earth, connector, focusView })
|
||||
}
|
||||
|
||||
function getSortedItems() {
|
||||
return getVisibleNewsItems()
|
||||
return getCruiseNewsItems()
|
||||
.map(mapNewsItemToCruiseEvent)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -496,19 +496,23 @@ function applyNewsRealtimePatch(updatePayload) {
|
||||
const patch = updatePayload?.patch;
|
||||
if (!payload || !itemId || !patch || typeof patch !== "object") return;
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
const cruiseItems = Array.isArray(payload.cruise_items) ? payload.cruise_items : [];
|
||||
let changed = false;
|
||||
const nextItems = items.map((item) => {
|
||||
const patchItem = (item) => {
|
||||
if (item?.id !== itemId) return item;
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
};
|
||||
const nextItems = items.map(patchItem);
|
||||
const nextCruiseItems = cruiseItems.map(patchItem);
|
||||
if (!changed) return;
|
||||
renderPayload({
|
||||
...payload,
|
||||
items: nextItems,
|
||||
cruise_items: Array.isArray(payload.cruise_items) ? nextCruiseItems : payload.cruise_items,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -678,6 +682,12 @@ export function getVisibleNewsItems() {
|
||||
return getDisplayableNewsItems(payload?.items);
|
||||
}
|
||||
|
||||
export function getCruiseNewsItems() {
|
||||
return getDisplayableNewsItems(
|
||||
Array.isArray(payload?.cruise_items) ? payload.cruise_items : payload?.items,
|
||||
);
|
||||
}
|
||||
|
||||
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
|
||||
if (!(board instanceof HTMLElement)) return;
|
||||
const cards = board.querySelectorAll("[data-news-id]");
|
||||
|
||||
@@ -48,21 +48,29 @@ async function fetchOobeStatus() {
|
||||
}
|
||||
|
||||
function statusSteps(status) {
|
||||
return [
|
||||
const steps = [
|
||||
{ label: "后端服务在线", done: true },
|
||||
{ label: "登录控制台", done: Boolean(status.authenticated), warn: !status.authenticated },
|
||||
{ label: "配置或确认数据源", done: Number(status.datasource_count || 0) > 0 || Number(status.custom_config_count || 0) > 0 },
|
||||
{ label: "触发首次采集", done: Boolean(status.has_collected_data), warn: !status.has_collected_data },
|
||||
{ label: "回到 Earth 查看结果", done: Boolean(status.ready) },
|
||||
];
|
||||
if (status.demo_mode) {
|
||||
steps.unshift({ label: "演示模式已开启", done: true });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function renderOobe(status) {
|
||||
const authenticated = Boolean(status.authenticated);
|
||||
const demoMode = Boolean(status.demo_mode);
|
||||
const primaryHref = authenticated ? status.datasources_url || "/datasources" : status.login_url || "/login?next=/datasources";
|
||||
const primaryText = authenticated ? "去采集数据" : "登录并采集数据";
|
||||
const secondaryHref = status.collection_url || "/collection-management";
|
||||
const subtitle = authenticated
|
||||
const docsHref = status.docs_url || "/docs/manual";
|
||||
const subtitle = demoMode
|
||||
? "演示模式已开启,本次访问将直接展示初始化引导,不受现有采集数据影响。"
|
||||
: authenticated
|
||||
? "当前还没有检测到可展示的数据,可以直接进入后台触发首次采集。"
|
||||
: "登录控制台并完成首次采集后,Earth 将显示实时数据层。";
|
||||
const steps = statusSteps(status)
|
||||
@@ -99,6 +107,7 @@ function renderOobe(status) {
|
||||
<div class="earth-oobe__actions">
|
||||
<a class="earth-oobe__primary" href="${escapeAttribute(primaryHref)}">${primaryText}</a>
|
||||
${authenticated ? `<a class="earth-oobe__secondary" href="${escapeAttribute(secondaryHref)}">进入采集管理</a>` : ""}
|
||||
${authenticated ? `<a class="earth-oobe__secondary" href="${escapeAttribute(docsHref)}">查看文档</a>` : ""}
|
||||
<button type="button" class="earth-oobe__ghost" data-oobe-close>先浏览 Earth</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -118,7 +127,8 @@ function renderOobe(status) {
|
||||
export async function initEarthOobe() {
|
||||
try {
|
||||
const status = await fetchOobeStatus();
|
||||
if (status?.ready || isTemporarilySkipped()) return;
|
||||
const demoMode = Boolean(status?.demo_mode);
|
||||
if ((status?.ready && !demoMode) || (isTemporarilySkipped() && !demoMode)) return;
|
||||
document.body.appendChild(renderOobe(status));
|
||||
} catch (error) {
|
||||
console.warn("Earth OOBE status unavailable.", error);
|
||||
|
||||
@@ -1234,8 +1234,13 @@ export async function loadSatellites(options = {}) {
|
||||
if (limit !== null) {
|
||||
url.searchParams.set("limit", String(limit));
|
||||
}
|
||||
if (options.cacheBust) {
|
||||
url.searchParams.set("_", String(Date.now()));
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: options.cache === "default" ? "default" : "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`卫星接口返回 HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
params.set("limit", String(requestedLimit));
|
||||
}
|
||||
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`);
|
||||
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vessels HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user