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

This commit is contained in:
linkong
2026-06-04 17:16:23 +08:00
parent acbbfdf9e2
commit 8c204717cd
78 changed files with 1762 additions and 703 deletions

View File

@@ -14,12 +14,12 @@
border-radius: 0;
padding: 0 calc(18px * var(--hud-scale));
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-columns: minmax(calc(112px * var(--hud-scale)), calc(152px * var(--hud-scale))) minmax(0, 1fr);
align-items: center;
gap: calc(12px * var(--hud-scale));
color: var(--hud-text);
background:
linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.82) 11%, rgba(7, 17, 31, 0.78) 89%, transparent 100%),
linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.28) 4%, rgba(17, 31, 53, 0.82) 12%, rgba(7, 17, 31, 0.78) 88%, rgba(7, 17, 31, 0.26) 96%, transparent 100%),
radial-gradient(circle at 50% -80%, rgba(145, 186, 255, 0.18), transparent 58%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
@@ -30,6 +30,8 @@
cursor: pointer;
z-index: 24;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%);
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.84) 0%, #000 3%, #000 97%, rgba(0, 0, 0, 0.84) 100%);
transition: opacity 0.18s ease, transform 0.24s ease;
}
@@ -40,41 +42,30 @@
.earth-news-ticker::before,
.earth-news-ticker::after {
content: "";
position: absolute;
top: -1px;
bottom: -1px;
width: calc(96px * var(--hud-scale));
pointer-events: none;
z-index: 2;
}
.earth-news-ticker::before {
left: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
}
.earth-news-ticker::after {
right: 0;
background: linear-gradient(270deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08));
content: none;
}
.earth-news-ticker__region {
position: relative;
z-index: 3;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
color: var(--hud-accent-strong);
font-size: calc(0.66rem * var(--hud-scale));
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
text-align: left;
}
.earth-news-ticker__viewport {
min-width: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
-webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%);
mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.22) 4%, rgba(0, 0, 0, 0.72) 11%, #000 18%, #000 82%, rgba(0, 0, 0, 0.72) 89%, rgba(0, 0, 0, 0.22) 96%, transparent 100%);
}
.earth-news-ticker__track {
@@ -490,6 +481,54 @@
0 0 18px rgba(255, 184, 77, 0.12);
}
.earth-news-hud.has-breaking-watch,
.earth-news-ticker.has-breaking-watch,
.earth-mobile-news-board.has-breaking-watch {
box-shadow:
0 0 0 1px rgba(255, 213, 128, 0.08) inset,
0 18px 46px rgba(156, 106, 20, 0.12);
}
.earth-news-hud.has-breaking-breaking,
.earth-news-ticker.has-breaking-breaking,
.earth-mobile-news-board.has-breaking-breaking {
box-shadow:
0 0 0 1px rgba(255, 166, 102, 0.14) inset,
0 18px 48px rgba(185, 89, 32, 0.16);
}
.earth-news-hud.has-breaking-critical,
.earth-news-ticker.has-breaking-critical,
.earth-mobile-news-board.has-breaking-critical {
box-shadow:
0 0 0 1px rgba(255, 118, 118, 0.18) inset,
0 18px 52px rgba(172, 43, 43, 0.18);
}
.news-story-card--breaking-watch {
border-color: rgba(255, 213, 128, 0.28);
background:
linear-gradient(180deg, rgba(255, 248, 220, 0.08), rgba(255, 184, 77, 0.05));
}
.news-story-card--breaking-breaking {
border-color: rgba(255, 166, 102, 0.36);
background:
linear-gradient(180deg, rgba(255, 205, 150, 0.1), rgba(255, 135, 80, 0.07));
}
.news-story-card--breaking-critical {
border-color: rgba(255, 118, 118, 0.42);
background:
linear-gradient(180deg, rgba(255, 155, 155, 0.11), rgba(205, 58, 58, 0.08));
}
.news-story-card--breaking-global {
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
0 0 18px rgba(255, 168, 96, 0.1);
}
.news-story-meta,
.news-story-tags {
display: flex;
@@ -548,6 +587,12 @@
background: rgba(255, 255, 255, 0.04);
}
.news-story-tag--breaking {
color: rgba(255, 232, 205, 0.96);
border: 1px solid rgba(255, 184, 96, 0.24);
background: rgba(255, 151, 78, 0.11);
}
.news-board-empty {
color: var(--hud-text-muted);
font-size: calc(0.82rem * var(--hud-scale));
@@ -555,6 +600,14 @@
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
}
@media (max-width: 1180px) {
.earth-news-ticker {
--news-ticker-width: min(calc(760px * var(--hud-scale)), 66vw);
grid-template-columns: minmax(calc(86px * var(--hud-scale)), calc(118px * var(--hud-scale))) minmax(0, 1fr);
padding-inline: calc(14px * var(--hud-scale));
}
}
.layout-mode-mobile .earth-news-ticker,
.layout-mode-mobile .earth-news-hud {
display: none !important;

View File

@@ -280,7 +280,7 @@ export const PATHS = {
export const VESSEL_CONFIG = {
altitudeOffset: 0.2,
maxRenderedMarkers: 0,
maxRenderedMarkers: 3000,
marker: {
baseScale: 7.5,
baseOpacity: 0.88,

View File

@@ -3896,7 +3896,8 @@ async function ensureVesselsEnabled() {
if (!earth) return 0;
vesselsEnabled = true;
const result = await loadVessels(scene, earth);
const zoom = getZoomLevel();
const result = await loadVessels(scene, earth, { zoom });
toggleVessels(true);
startVesselRealtime(earth, {
onUpdate: ({ totalCount }) => {

View File

@@ -31,6 +31,17 @@ const CATEGORY_LABELS = {
other: "其他",
};
const BREAKING_LEVEL_LABELS = {
watch: "关注",
breaking: "突发",
critical: "严重突发",
};
const BREAKING_SCOPE_LABELS = {
regional: "区域",
global: "全球",
};
const SOURCE_TYPE_LABELS = {
rss: "RSS",
atom: "Atom",
@@ -131,6 +142,14 @@ export function getNewsCategoryLabel(category) {
return CATEGORY_LABELS[category] || category || CATEGORY_LABELS.other;
}
export function getNewsBreakingLabel(level, scope = "regional") {
const normalizedLevel = normalizeText(level).toLowerCase();
if (!normalizedLevel || normalizedLevel === "none") return "";
const levelLabel = BREAKING_LEVEL_LABELS[normalizedLevel] || level;
const scopeLabel = BREAKING_SCOPE_LABELS[normalizeText(scope).toLowerCase()] || BREAKING_SCOPE_LABELS.regional;
return `${scopeLabel}${levelLabel}`;
}
export function getNewsSourceTypeLabel(sourceType) {
const normalized = normalizeText(sourceType).toLowerCase();
return SOURCE_TYPE_LABELS[normalized] || sourceType || "RSS";

View File

@@ -3,6 +3,7 @@ import {
getNewsDisplaySummary,
getNewsDisplayTitle,
getNewsCategoryLabel,
getNewsBreakingLabel,
getNewsEnrichmentStatusLabel,
getNewsFetchChannelLabel,
getNewsRegionLabel,
@@ -371,6 +372,54 @@ function getDisplayableNewsItems(items) {
: [];
}
function normalizeBreakingLevel(level) {
const normalized = String(level ?? "").trim().toLowerCase();
return ["watch", "breaking", "critical"].includes(normalized) ? normalized : "none";
}
function normalizeBreakingScope(scope) {
const normalized = String(scope ?? "").trim().toLowerCase();
return normalized === "global" ? "global" : "regional";
}
function isBreakingActive(item) {
const level = normalizeBreakingLevel(item?.breaking_level);
if (level === "none") return false;
const expiresAt = item?.breaking_expires_at ? new Date(item.breaking_expires_at) : null;
return !expiresAt || Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() > Date.now();
}
function getHighestBreakingLevel(items) {
const ranks = { none: 0, watch: 1, breaking: 2, critical: 3 };
return (Array.isArray(items) ? items : []).reduce((highest, item) => {
if (!isBreakingActive(item)) return highest;
const level = normalizeBreakingLevel(item?.breaking_level);
return ranks[level] > ranks[highest] ? level : highest;
}, "none");
}
function applyNewsBreakingShellState(nextPayload) {
const filtersLevel = normalizeBreakingLevel(nextPayload?.filters?.highest_breaking_level);
const computedLevel = getHighestBreakingLevel([
...(Array.isArray(nextPayload?.items) ? nextPayload.items : []),
...(Array.isArray(nextPayload?.cruise_items) ? nextPayload.cruise_items : []),
]);
const level = filtersLevel !== "none" ? filtersLevel : computedLevel;
document
.querySelectorAll(".earth-news-hud, .earth-news-ticker, .earth-mobile-news-board")
.forEach((element) => {
if (!(element instanceof HTMLElement)) return;
element.classList.remove(
"has-breaking-watch",
"has-breaking-breaking",
"has-breaking-critical",
);
if (level !== "none") {
element.classList.add(`has-breaking-${level}`);
}
});
}
function normalizeNewsSourceType(value) {
const normalized = String(value ?? "").trim().toLowerCase();
return normalized || "";
@@ -662,6 +711,7 @@ function renderPayload(nextPayload) {
renderTicker(nextPayload);
syncFilterSummaries(nextPayload);
applyNewsBreakingShellState(nextPayload);
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
@@ -729,19 +779,36 @@ function renderPayload(nextPayload) {
board.innerHTML = displayItems
.map((item) => {
const cardClass = item.is_focus_match
? "news-story-card news-story-card--focus"
: "news-story-card";
const breakingLevel = isBreakingActive(item) ? normalizeBreakingLevel(item.breaking_level) : "none";
const breakingScope = normalizeBreakingScope(item.breaking_scope);
const cardClass = [
"news-story-card",
item.is_focus_match ? "news-story-card--focus" : "",
breakingLevel !== "none" ? `news-story-card--breaking-${breakingLevel}` : "",
breakingLevel !== "none" && breakingScope === "global" ? "news-story-card--breaking-global" : "",
].filter(Boolean).join(" ");
const title = getNewsDisplayTitle(item);
const summaryText = getNewsDisplaySummary(item);
const leadText = summaryText || title;
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
const categoryLabel = getNewsCategoryLabel(item.category);
const statusLabel = getNewsEnrichmentStatusLabel(item);
const breakingLabel = getNewsBreakingLabel(breakingLevel, breakingScope);
const sourceDescriptor = getNewsSourceDescriptor(item, sourcesByName, sourcesById);
const summary = title && title !== leadText
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
: "";
const tagHtml = breakingLevel !== "none"
? `
<span class="news-story-tag news-story-tag--breaking">${escapeNewsHtml(breakingLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
`
: `
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
`;
return `
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener" title="${escapeNewsHtml(sourceDescriptor.tooltip)}">
<div class="news-story-meta">
@@ -752,9 +819,7 @@ function renderPayload(nextPayload) {
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
${summary}
<div class="news-story-tags">
<span class="news-story-tag">${escapeNewsHtml(categoryLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
${tagHtml}
</div>
</a>
`;

View File

@@ -2,20 +2,12 @@ import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import {
canAttemptEarthRealtime,
getEarthRealtimeCooldownMs,
getEarthRealtimeUrl,
recordEarthRealtimeFailure,
recordEarthRealtimeOpen,
} from "./realtime.js";
import { latLonToVector3 } from "./utils.js";
let showVessels = false;
let activeTrackLine = null;
let vesselStreamSocket = null;
let vesselStreamReconnectTimer = null;
let vesselDataByKey = new Map();
let vesselSnapshotGeneration = 0;
let vesselRealtimeStats = {
connected: false,
updates: 0,
@@ -70,48 +62,6 @@ function markerDataToDedupeKey(item) {
].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 normalizeVesselType(value, code) {
const type = String(value || "").trim().toLowerCase();
const numericCode = Number(code);
@@ -258,18 +208,13 @@ const vesselIconLayer = createInteractableLayer({
vessel_kind: item.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
}),
cluster: {
strategy: "dynamic-screen",
enabled: true,
maxMarkersPerDot: 10,
},
cluster: false,
});
const DEFAULT_VESSEL_VIEWPORT = {
bbox: [-10, 50, 35, 75],
const GLOBAL_VESSEL_VIEWPORT = {
bbox: [-180, -85.05112878, 180, 85.05112878],
zoom: 4,
};
const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500;
export function getVesselMarkers() {
return vesselIconLayer.getMarkers();
@@ -324,25 +269,25 @@ export function clearVesselData(earth) {
}
export async function loadVessels(_scene, earth, options = {}) {
const params = new URLSearchParams();
const requestGeneration = ++vesselSnapshotGeneration;
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
? options.bbox
: DEFAULT_VESSEL_VIEWPORT.bbox;
: GLOBAL_VESSEL_VIEWPORT.bbox;
const zoom = Number.isFinite(Number(options.zoom))
? Number(options.zoom)
: DEFAULT_VESSEL_VIEWPORT.zoom;
params.set("bbox", bbox.join(","));
params.set("zoom", String(zoom));
: GLOBAL_VESSEL_VIEWPORT.zoom;
const params = new URLSearchParams({ bbox: bbox.join(","), zoom: String(zoom) });
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
params.set("limit", String(requestedLimit));
}
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Vessels HTTP ${response.status}`);
}
if (!response.ok) throw new Error(`Vessels HTTP ${response.status}`);
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
if (requestGeneration !== vesselSnapshotGeneration) {
return { totalCount: getVesselCount(), stats: {} };
}
clearVesselData(earth);
let markerData = dedupeVesselFeatures(features);
@@ -361,132 +306,12 @@ export async function loadVessels(_scene, earth, options = {}) {
};
}
function normalizeVesselViewportOptions(options = {}) {
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
? options.bbox.map(Number)
: DEFAULT_VESSEL_VIEWPORT.bbox;
const [lonA, latA, lonB, latB] = bbox;
const normalizedBbox = [
Math.max(-180, Math.min(lonA, lonB)),
Math.max(-90, Math.min(latA, latB)),
Math.min(180, Math.max(lonA, lonB)),
Math.min(90, Math.max(latA, latB)),
];
const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]);
const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA
? normalizedBbox
: DEFAULT_VESSEL_VIEWPORT.bbox;
const zoom = Number.isFinite(Number(options.zoom))
? Number(options.zoom)
: DEFAULT_VESSEL_VIEWPORT.zoom;
return {
bbox: safeBbox,
zoom: Math.max(1, Math.min(20, Math.round(zoom))),
limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers,
};
}
export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) {
if (vesselStreamSocket || vesselStreamReconnectTimer || typeof WebSocket === "undefined") return;
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
const connect = () => {
if (!showVessels || vesselStreamSocket) return;
if (!canAttemptEarthRealtime()) {
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
return;
}
const socket = new WebSocket(getEarthRealtimeUrl());
vesselStreamSocket = socket;
socket.onopen = () => {
socket.__planetOpened = true;
recordEarthRealtimeOpen();
vesselRealtimeStats = {
...vesselRealtimeStats,
connected: true,
};
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
socket.send(JSON.stringify({
type: "subscribe",
data: {
channel: "vessels",
bbox: subscriptionOptions.bbox,
zoom: subscriptionOptions.zoom,
limit: subscriptionOptions.limit,
},
}));
};
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) {
if (!socket.__planetOpened) {
recordEarthRealtimeFailure();
}
vesselStreamReconnectTimer = window.setTimeout(connect, Math.max(3000, getEarthRealtimeCooldownMs()));
}
};
socket.onerror = () => {
socket.close();
};
};
connect();
export function startVesselRealtime(_earth, { onUpdate } = {}) {
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
}
export function stopVesselRealtime() {
if (vesselStreamReconnectTimer) {
window.clearTimeout(vesselStreamReconnectTimer);
vesselStreamReconnectTimer = null;
}
if (vesselStreamSocket) {
const socket = vesselStreamSocket;
vesselStreamSocket = null;
socket.close();
}
vesselSnapshotGeneration += 1;
vesselRealtimeStats = {
connected: false,
updates: 0,