release: bump version to 0.48.0

This commit is contained in:
linkong
2026-05-07 18:06:06 +08:00
parent 421234301a
commit bb9183b8a4
51 changed files with 4609 additions and 400 deletions

View File

@@ -477,6 +477,10 @@
<span class="stats-footer-dot"></span>
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
</div>
<div class="stats-footer">
<span class="stats-footer-dot"></span>
<span id="vessel-live-summary" class="stats-footer-text" data-earth-stat="vessel-live-summary">AISStream 未连接</span>
</div>
<!-- hidden elements kept for JS compatibility -->
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
@@ -708,6 +712,7 @@
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">BGP 状态</div>
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
<div id="mobile-vessel-live-summary" class="earth-mobile-situation-status" data-earth-stat="vessel-live-summary">AISStream 未连接</div>
</div>
</div>
</section>

View File

@@ -271,7 +271,12 @@ function closeTransientMobileOverlays({ except = null } = {}) {
setMobileDrawerOpen("layer-toggles", false);
}
if (except !== "media" && except !== "search" && isTVPanelVisible()) {
if (
except !== "media"
&& except !== "search"
&& except !== "settings"
&& isTVPanelVisible()
) {
setTVPanelVisible(false);
}
}

View File

@@ -8,6 +8,30 @@ let typewriterToken = 0;
let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
'imo',
'imo_display',
'callsign',
]);
const MAX_VESSEL_MEDIA_TILES = 4;
function formatInfoCardValue(field, rawValue) {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '-';
}
let value = rawValue;
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
value = String(value);
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
}
return value;
}
function getNewsSummaryText(data) {
return (data?.summary || data?.title || '').trim() || '暂无摘要';
@@ -109,13 +133,7 @@ function renderMobileDetailContent(type, config, data) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') value = value + ' ' + field.unit;
const value = formatInfoCardValue(field, data[field.key]);
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
@@ -166,29 +184,95 @@ function ensureMobileDetailsListener() {
function renderDefaultCardContent(content, config, data) {
let html = '';
for (const field of config.fields) {
let value = data[field.key];
if (value === undefined || value === null || value === '') {
value = '-';
} else if (typeof value === 'number') {
value = value.toLocaleString();
}
if (field.unit && value !== '-') {
value = value + ' ' + field.unit;
}
const value = formatInfoCardValue(field, data[field.key]);
const sourceLabel = getFieldSourceLabel(data, field.key);
html += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}</span>
<span class="info-card-value">${value}${sourceLabel}</span>
</div>
`;
}
if (config.className === 'vessel') {
html += renderVesselEnrichmentSection(data?.enrichment);
}
content.innerHTML = html;
}
function getFieldSourceLabel(data, fieldKey) {
const sources = data && typeof data === 'object' ? data.field_sources : null;
if (!sources || typeof sources !== 'object') return '';
const source = sources[fieldKey];
if (!source) return '';
return ` <span class="info-card-source-tag" title="字段来源">${source}</span>`;
}
function renderVesselEnrichmentSection(enrichment) {
if (!enrichment || typeof enrichment !== 'object') return '';
const profile = enrichment.profile;
const media = enrichment.media;
if (!profile && !media) {
return `
<div class="info-card-enrichment info-card-enrichment--empty">
<div class="info-card-enrichment-title">船舶资料</div>
<div class="info-card-enrichment-status">资料缓存中</div>
</div>
`;
}
let inner = '';
if (profile?.payload && typeof profile.payload === 'object') {
inner += renderEnrichmentPayloadRows(profile.payload);
inner += renderEnrichmentMeta('资料', profile);
}
if (media?.payload && typeof media.payload === 'object') {
if (Array.isArray(media.payload.images) && media.payload.images.length > 0) {
const tiles = media.payload.images
.slice(0, MAX_VESSEL_MEDIA_TILES)
.map((url) => `<img class="info-card-enrichment-thumb" src="${String(url)}" alt="vessel media" />`)
.join('');
inner += `<div class="info-card-enrichment-media">${tiles}</div>`;
}
inner += renderEnrichmentMeta('媒体', media);
}
if (!inner) {
inner = '<div class="info-card-enrichment-status">资料缓存中</div>';
}
return `
<div class="info-card-enrichment">
<div class="info-card-enrichment-title">船舶资料</div>
${inner}
</div>
`;
}
function renderEnrichmentPayloadRows(payload) {
let rows = '';
for (const [key, value] of Object.entries(payload)) {
if (value === null || value === undefined || value === '') continue;
if (typeof value === 'object') continue;
rows += `
<div class="info-card-property">
<span class="info-card-label">${key}</span>
<span class="info-card-value">${String(value)}</span>
</div>
`;
}
return rows;
}
function renderEnrichmentMeta(label, record) {
const parts = [];
if (record.source) parts.push(`来源 ${record.source}`);
if (record.fetched_at) parts.push(`更新 ${record.fetched_at}`);
if (record.confidence !== null && record.confidence !== undefined) {
parts.push(`置信 ${Number(record.confidence).toFixed(2)}`);
}
if (!parts.length) return '';
return `<div class="info-card-enrichment-meta">${label}${parts.join(' · ')}</div>`;
}
// ── Mobile popup ─────────────────────────────────────────────
function getMobilePopupTitle(type, data) {

View File

@@ -98,6 +98,8 @@ function registerBuiltinLayerStartupTasks() {
function registerVesselStartupTask() {
registerLayerStartupTask("vessels", (context) => async (layer) => {
if (!context.getShowVessels()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载船只..."),
);

View File

@@ -180,9 +180,12 @@ import {
getVesselLegendItems,
getVesselMarkers,
getVesselPointerIntersections as getVesselIconPointerIntersections,
getVesselRealtimeStats,
loadVessels,
setVesselMarkerState,
showVesselTrack,
startVesselRealtime,
stopVesselRealtime,
toggleVessels,
updateVesselVisualState,
} from "./vessels.js";
@@ -1489,6 +1492,13 @@ async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
satelliteCount: toCount(stats.satellite_count),
computeCenterCount: toCount(stats.compute_center_count),
vesselCount: toCount(stats.vessel_count),
vesselRawUniqueMmsi: toCount(stats.vessel_raw_unique_mmsi),
vesselLegacyUniqueMmsi: toCount(stats.vessel_legacy_unique_mmsi),
aisstreamConnectionState: stats.aisstream_connection_state || null,
aisstreamLastSeenAt: stats.aisstream_last_seen_at || null,
aisstreamLagSeconds: Number.isFinite(Number(stats.aisstream_lag_seconds))
? Number(stats.aisstream_lag_seconds)
: null,
bgpEventCount: toCount(stats.bgp_event_count),
bgpIncidentCount: toCount(stats.bgp_incident_count),
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
@@ -2154,6 +2164,36 @@ function updateVesselHud(result = {}) {
setEarthStatValue("vessel-count", `${count}`);
}
function formatRelativeTime(value) {
const date = value instanceof Date ? value : value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return null;
const elapsedSeconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
if (elapsedSeconds < 5) return "刚刚";
if (elapsedSeconds < 60) return `${elapsedSeconds} 秒前`;
const elapsedMinutes = Math.round(elapsedSeconds / 60);
if (elapsedMinutes < 60) return `${elapsedMinutes} 分钟前`;
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
function formatVesselLiveSummary() {
const stream = getVesselRealtimeStats();
if (stream.connected) {
const lastUpdate = formatRelativeTime(stream.lastUpdateAt);
if (stream.updates > 0) {
return `AISStream 实时已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
}
return "AISStream 实时已连接 · 等待首批更新";
}
const state = earthStatsSummary?.aisstreamConnectionState;
if (state === "connected") {
const lastSeen = formatRelativeTime(earthStatsSummary?.aisstreamLastSeenAt);
return `AISStream 后台已连接${lastSeen ? ` · 最近 ${lastSeen}` : ""}`;
}
if (state === "reconnecting") return "AISStream 正在重连";
if (state === "connecting") return "AISStream 正在连接";
return "AISStream 未连接";
}
function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
const vesselBtn = document.getElementById("toggle-vessels");
if (vesselBtn) {
@@ -2164,6 +2204,7 @@ function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
});
}
setEarthStatValue("vessel-count", `${vesselCount || 0}`);
setEarthStatValue("vessel-live-summary", formatVesselLiveSummary());
}
function updateCableToggleUi(enabled) {
@@ -2292,6 +2333,12 @@ async function ensureVesselsEnabled() {
vesselsEnabled = true;
const result = await loadVessels(scene, earth);
toggleVessels(true);
startVesselRealtime(earth, {
onUpdate: ({ totalCount }) => {
updateVesselToggleUi(true, totalCount);
updateStatsSummary();
},
});
updateVesselToggleUi(true, result.totalCount);
setLegendItems("vessels", getVesselLegendItems());
refreshLegend();
@@ -2300,6 +2347,7 @@ async function ensureVesselsEnabled() {
function disableVessels() {
vesselsEnabled = false;
stopVesselRealtime();
toggleVessels(false);
clearVesselSelection();
updateVesselToggleUi(false, 0);
@@ -2334,6 +2382,7 @@ function updateStatsSummary() {
landingPointCount: `${landingPointCount}`,
satelliteCount: `${satelliteCount}`,
vesselCount: `${vesselCount}`,
vesselLiveSummary: formatVesselLiveSummary(),
computeCenterCount: `${computeCenterCount}`,
bgpAnomalyCount: `${bgpEventCount}`,
bgpCollectorCount: `${bgpCollectorCount}`,

View File

@@ -218,6 +218,7 @@ export function updateEarthStats(stats) {
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
}
if (has("vesselCount")) setEarthStatValue("vessel-count", String(stats.vesselCount || 0));
if (has("vesselLiveSummary")) setEarthStatValue("vessel-live-summary", stats.vesselLiveSummary || "-");
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
if (has("bgpCollectorCount")) {
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));

View File

@@ -6,6 +6,15 @@ import { latLonToVector3 } from "./utils.js";
let showVessels = false;
let activeTrackLine = null;
let vesselStreamSocket = null;
let vesselStreamReconnectTimer = null;
let vesselDataByKey = new Map();
let vesselRealtimeStats = {
connected: false,
updates: 0,
lastUpdateAt: null,
lastBatchSize: 0,
};
const VESSEL_RENDER_ORDER = 4.4;
const VESSEL_POINT_SIZE = 34;
@@ -13,6 +22,95 @@ const VESSEL_ATLAS_CELL_SIZE = 128;
const VESSEL_COURSE_BINS = 32;
const VESSEL_TRACK_ENDPOINT_EPSILON = 0.001;
function getVesselDedupeKey(feature, markerData) {
const props = feature?.properties || {};
const mmsi = props.mmsi ?? feature?.id ?? markerData?.mmsi;
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
return `mmsi:${String(mmsi).trim()}`;
}
return [
"position",
Number(markerData.latitude).toFixed(5),
Number(markerData.longitude).toFixed(5),
String(props.name || markerData.name || "").trim().toLowerCase(),
].join(":");
}
function dedupeVesselFeatures(features) {
const seen = new Set();
const markerData = [];
features.forEach((feature) => {
const marker = buildVesselMarkerData(feature);
if (!marker) return;
const key = getVesselDedupeKey(feature, marker);
if (seen.has(key)) return;
seen.add(key);
markerData.push(marker);
});
return markerData;
}
function markerDataToDedupeKey(item) {
const mmsi = item?.mmsi;
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
return `mmsi:${String(mmsi).trim()}`;
}
return [
"position",
Number(item.latitude).toFixed(5),
Number(item.longitude).toFixed(5),
String(item.name || "").trim().toLowerCase(),
].join(":");
}
function buildVesselFeatureFromDelta(item) {
const lat = Number(item?.lat ?? item?.latitude);
const lon = Number(item?.lon ?? item?.lng ?? item?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return {
type: "Feature",
id: item.mmsi,
geometry: {
type: "Point",
coordinates: [lon, lat],
},
properties: {
...item,
mmsi: item.mmsi,
mmsi_display: item.mmsi_display || (item.mmsi !== undefined && item.mmsi !== null ? String(item.mmsi) : undefined),
},
};
}
function rebuildVesselLayerFromCache(earth) {
if (!earth) return;
vesselIconLayer.setData(Array.from(vesselDataByKey.values()));
vesselIconLayer.attach(earth);
vesselIconLayer.setVisible(showVessels);
}
function applyVesselDeltas(earth, vessels = []) {
let changed = false;
vessels.forEach((item) => {
const feature = buildVesselFeatureFromDelta(item);
if (!feature) return;
const marker = buildVesselMarkerData(feature);
if (!marker) return;
vesselDataByKey.set(markerDataToDedupeKey(marker), marker);
changed = true;
});
if (changed) {
rebuildVesselLayerFromCache(earth);
}
return changed;
}
function getVesselStreamUrl() {
if (typeof window === "undefined") return "ws://localhost:8000/ws";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/ws`;
}
function normalizeVesselType(value, code) {
const type = String(value || "").trim().toLowerCase();
const numericCode = Number(code);
@@ -67,9 +165,14 @@ function buildVesselMarkerData(feature) {
const navStatus = Number(props.nav_status);
const speed = Number(props.sog);
const anchored = navStatus === 1 || navStatus === 5 || (Number.isFinite(speed) && speed < 0.5);
const mmsiString = props.mmsi !== undefined && props.mmsi !== null && String(props.mmsi).trim() !== ""
? String(props.mmsi)
: null;
return {
...props,
mmsi: mmsiString,
mmsi_display: props.mmsi_display ? String(props.mmsi_display) : mmsiString,
latitude,
longitude,
type,
@@ -163,6 +266,10 @@ export function getVesselCount() {
return vesselIconLayer.getCount();
}
export function getVesselRealtimeStats() {
return { ...vesselRealtimeStats };
}
export function getShowVessels() {
return showVessels;
}
@@ -199,6 +306,7 @@ export function getVesselPointerIntersections(options) {
export function clearVesselData(earth) {
clearVesselSelection();
vesselDataByKey.clear();
vesselIconLayer.clearData(earth);
}
@@ -216,12 +324,11 @@ export async function loadVessels(_scene, earth, options = {}) {
const features = Array.isArray(payload?.features) ? payload.features : [];
clearVesselData(earth);
let markerData = features
.map((feature) => buildVesselMarkerData(feature))
.filter(Boolean);
let markerData = dedupeVesselFeatures(features);
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
markerData = markerData.slice(0, requestedLimit);
}
vesselDataByKey = new Map(markerData.map((item) => [markerDataToDedupeKey(item), item]));
vesselIconLayer.setData(markerData);
vesselIconLayer.attach(earth);
@@ -233,6 +340,97 @@ export async function loadVessels(_scene, earth, options = {}) {
};
}
export function startVesselRealtime(earth, { onUpdate } = {}) {
if (vesselStreamSocket || typeof WebSocket === "undefined") return;
const connect = () => {
if (!showVessels || vesselStreamSocket) return;
const socket = new WebSocket(getVesselStreamUrl());
vesselStreamSocket = socket;
socket.onopen = () => {
vesselRealtimeStats = {
...vesselRealtimeStats,
connected: true,
};
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } }));
};
socket.onmessage = (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "heartbeat" && message.data?.action === "ping") {
socket.send(JSON.stringify({ type: "heartbeat" }));
return;
}
if (message.type !== "data_frame" || message.channel !== "vessels") return;
const payload = message.payload || {};
if (payload.action === "reload") {
loadVessels(null, earth)
.then((result) => {
vesselRealtimeStats = {
...vesselRealtimeStats,
connected: true,
updates: vesselRealtimeStats.updates + 1,
lastUpdateAt: new Date(),
lastBatchSize: 0,
};
onUpdate?.({ totalCount: result?.totalCount ?? getVesselCount(), payload, stream: getVesselRealtimeStats() });
})
.catch(() => {});
return;
}
if (payload.action !== "upsert" || !Array.isArray(payload.vessels)) return;
if (applyVesselDeltas(earth, payload.vessels)) {
vesselRealtimeStats = {
connected: true,
updates: vesselRealtimeStats.updates + 1,
lastUpdateAt: new Date(),
lastBatchSize: payload.vessels.length,
};
onUpdate?.({ totalCount: getVesselCount(), payload, stream: getVesselRealtimeStats() });
}
};
socket.onclose = () => {
if (vesselStreamSocket === socket) {
vesselStreamSocket = null;
}
vesselRealtimeStats = {
...vesselRealtimeStats,
connected: false,
};
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
if (showVessels) {
vesselStreamReconnectTimer = window.setTimeout(connect, 3000);
}
};
socket.onerror = () => {
socket.close();
};
};
connect();
}
export function stopVesselRealtime() {
if (vesselStreamReconnectTimer) {
window.clearTimeout(vesselStreamReconnectTimer);
vesselStreamReconnectTimer = null;
}
if (vesselStreamSocket) {
const socket = vesselStreamSocket;
vesselStreamSocket = null;
socket.close();
}
vesselRealtimeStats = {
connected: false,
updates: 0,
lastUpdateAt: null,
lastBatchSize: 0,
};
}
export async function showVesselTrack(marker, earth) {
clearVesselTrack();
if (!marker?.userData?.mmsi || !earth) return null;