release: bump version to 0.66.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-26 03:41:47 +08:00
parent e65267fe21
commit 5bf5c73ca0
173 changed files with 8669 additions and 13210 deletions

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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: "钱坤、张鸽、齐鹏" },
],
};

View File

@@ -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}`);
}

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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',

View File

@@ -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);

View 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();
}

View File

@@ -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();
}
});
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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]");

View File

@@ -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);

View File

@@ -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}`);
}

View File

@@ -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}`);
}