release: bump version to 0.56.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:
linkong
2026-05-13 18:21:03 +08:00
parent 39854b9983
commit f14ff6ec0f
46 changed files with 1589 additions and 684 deletions

View File

@@ -414,6 +414,14 @@ function blendHexColors(fromHex, toHex, ratio) {
return colorScratchA.getHex();
}
function getHaloTintColor(baseColor) {
return blendHexColors(
BGP_CONFIG.halo.tintNeutralColor,
baseColor || BGP_CONFIG.collectorColor,
BGP_CONFIG.halo.tintBlend,
);
}
function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
@@ -1131,7 +1139,7 @@ function attachCollectorEffectSprites(marker) {
statusCore.renderOrder = 4;
const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: getHaloTintColor(activity.color),
opacity: 0.0,
scale: activity.coverageHaloScale * 0.7,
});
@@ -1524,6 +1532,9 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity * haloOpacityMul;
marker.userData.coverageHalo.material.color.setHex(
getHaloTintColor(marker.userData.baseColor),
);
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012) * haloScaleMul,
marker.userData.activity?.coverageHaloScale * 0.56 * (1 + pulse * 0.012) * haloScaleMul,
@@ -1753,8 +1764,9 @@ export function showBGPEventOverlay(marker, earth) {
const overlayItems = [];
validRegions.forEach((region) => {
const eventBaseColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: getHaloTintColor(eventBaseColor),
opacity: 0.24,
scale: BGP_CONFIG.regionScale,
});
@@ -1795,9 +1807,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const scaleBoost = Math.min(10, Math.log2(prefixCount + observationCount + 1) * 1.8);
const haloScale = BGP_CONFIG.regionScale * 0.7 + scaleBoost;
const pulseHaloScale = haloScale * 1.32;
const collectorBaseColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const collectorHaloColor = getHaloTintColor(collectorBaseColor);
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
color: collectorHaloColor,
opacity: 0.11,
scale: haloScale * 0.78,
});
@@ -1812,7 +1826,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
bgpCollectorRadarGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
color: collectorHaloColor,
opacity: 0.065,
scale: pulseHaloScale * 0.82,
});
@@ -1820,7 +1834,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
pulseHalo.renderOrder = 1;
bgpCollectorRadarGroup.add(pulseHalo);
const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
color: collectorBaseColor,
opacity: 0.12,
scale: Math.max(haloScale * 0.34, 5.5),
});
@@ -1840,7 +1854,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const sectorHalfWidth = Math.PI * 0.22;
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const coverageColor = collectorBaseColor;
const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
const leftBoundaryPoints = createRadialBoundaryPoints(

View File

@@ -344,7 +344,7 @@ export const SATELLITE_CONFIG = {
altitudeCompressionKm: 1200,
maxDisplayAltitudeKm: 40000,
minRealAltitudeOffset: 4,
maxRealAltitudeOffset: 55,
maxRealAltitudeOffset: 25,
frontFacingDotThreshold: 0.015,
overlayRenderOrder: 12,
dotBaseSize: 2.8,
@@ -440,6 +440,8 @@ export const BGP_CONFIG = {
collectorScale: 11.5,
collectorPulseScale: 16.5,
collectorCoverageScale: 22.5,
tintNeutralColor: 0xffffff,
tintBlend: 0.72,
},
sizeStabilization: {
enabled: true,

View File

@@ -1244,6 +1244,17 @@ function syncInteractableCompactDotsToggle() {
});
}
function syncSurfaceHoverInfoModeControls() {
const activeMode = getSurfaceHoverInfoMode();
document.querySelectorAll("[data-surface-hover-info-mode]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const mode = normalizeSurfaceHoverInfoMode(button.dataset.surfaceHoverInfoMode);
const active = mode === activeMode;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
export function getCruiseModules() {
const configuredModules = earthSettingsState?.shared?.cruiseModules;
return normalizeCruiseModules(configuredModules);
@@ -1373,6 +1384,42 @@ export function setInteractableCompactDotsEnabled(
return enabled;
}
export function getSurfaceHoverInfoMode() {
return normalizeSurfaceHoverInfoMode(earthSettingsState?.shared?.surfaceHoverInfoMode);
}
export function setSurfaceHoverInfoMode(
nextMode,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedMode = normalizeSurfaceHoverInfoMode(nextMode);
const previousMode = getSurfaceHoverInfoMode();
if (normalizedMode === previousMode) {
syncSurfaceHoverInfoModeControls();
return normalizedMode;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.surfaceHoverInfoMode = normalizedMode;
syncSurfaceHoverInfoModeControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const label =
normalizedMode === SURFACE_HOVER_INFO_MODES.COUNTRY
? "国家"
: normalizedMode === SURFACE_HOVER_INFO_MODES.POSITION
? "位置"
: "完整";
showStatusMessage(`悬停提示已切换为:${label}`, "info");
}
return normalizedMode;
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -1456,6 +1503,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setSurfaceHoverInfoMode(settings.shared.surfaceHoverInfoMode, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -2690,6 +2741,7 @@ function setupSettingsControls() {
const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]");
const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]");
const satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const surfaceHoverInfoModeButtons = document.querySelectorAll("[data-surface-hover-info-mode]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
@@ -2772,6 +2824,14 @@ function setupSettingsControls() {
});
});
surfaceHoverInfoModeButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
setSurfaceHoverInfoMode(target.dataset.surfaceHoverInfoMode);
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
@@ -2842,6 +2902,7 @@ function setupSettingsControls() {
syncSatelliteIdleBreathingToggle();
syncSatelliteRealAltitudeToggle();
syncInteractableCompactDotsToggle();
syncSurfaceHoverInfoModeControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
@@ -3034,7 +3095,7 @@ function setupDraggableHudPanels() {
bindListener(handle, "pointerdown", (event) => {
if (isMobileLayout()) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .media-panel-tab, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
if (event.target.closest(".hud-panel-close, .hud-panel__action, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-player, .tv-panel-edge, .earth-news-hud-edge, .legend-bar-btn, .news-story-card")) return;
event.preventDefault();
isDragging = true;
activePointerId = event.pointerId;

View File

@@ -1276,8 +1276,8 @@ const CARD_CONFIG = {
{ key: 'footprint_model', label: '覆盖模型' },
{ key: 'inclination', label: '倾角', unit: '°' },
{ key: 'period', label: '周期', unit: '分钟' },
{ key: 'perigee', label: '近地点', unit: 'km' },
{ key: 'apogee', label: '远地点', unit: 'km' }
{ key: 'perigee', label: '近地点高度', unit: 'km' },
{ key: 'apogee', label: '远地点高度', unit: 'km' }
]
},
bgp: {

View File

@@ -12,6 +12,7 @@ import {
CRUISE_CONFIG,
ROTATION_MODE,
SCENE_LIGHT_CONFIG,
SURFACE_HOVER_INFO_MODES,
} from "./constants.js";
import { vector3ToLatLon, screenToEarthCoords } from "./utils.js";
import {
@@ -224,6 +225,7 @@ import {
setDayNightInteractable,
applyDeferredLayerVisibilitySettings,
scheduleTerrainPrefetch,
getSurfaceHoverInfoMode,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -276,6 +278,10 @@ import {
setMotionDebugPanelVisible,
} from "./motion-debug-panel.js";
const EARTH_RADIUS_KM = 6371;
const EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 = 398600.4418;
const SECONDS_PER_DAY = 86400;
export let scene;
export let camera;
export let renderer;
@@ -391,6 +397,10 @@ const HUD_INTERACTIVE_SELECTORS = [
"#earth-stats *",
"#media-panel",
"#media-panel *",
"#desktop-news-ticker",
"#desktop-news-ticker *",
"#news-hud-panel",
"#news-hud-panel *",
"#motion-debug-panel",
"#motion-debug-panel *",
"#mobile-drawer-shell",
@@ -1402,9 +1412,10 @@ function getCableBriefHtml(cable) {
function showSatelliteInfo(props, coords) {
const meanMotion = props?.mean_motion || 0;
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : "-";
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
const altitudeRange = calculateSatelliteAltitudeRangeKm(
props?.mean_motion,
props?.eccentricity,
);
const presentation = getSatellitePresentationInfo(props);
let footprintModel = "不适用";
@@ -1426,11 +1437,39 @@ function showSatelliteInfo(props, coords) {
footprint_model: footprintModel,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee,
apogee,
perigee: altitudeRange?.perigee ?? "-",
apogee: altitudeRange?.apogee ?? "-",
}, coords);
}
function calculateSatelliteAltitudeRangeKm(meanMotion, eccentricity = 0) {
const revolutionsPerDay = Number(meanMotion);
const ecc = Number(eccentricity);
if (
!Number.isFinite(revolutionsPerDay) ||
revolutionsPerDay <= 0 ||
!Number.isFinite(ecc) ||
ecc < 0 ||
ecc >= 1
) {
return null;
}
const meanMotionRadPerSecond =
(revolutionsPerDay * Math.PI * 2) / SECONDS_PER_DAY;
const semiMajorAxisKm = Math.cbrt(
EARTH_GRAVITATIONAL_PARAMETER_KM3_S2 /
(meanMotionRadPerSecond * meanMotionRadPerSecond),
);
const perigeeKm = semiMajorAxisKm * (1 - ecc) - EARTH_RADIUS_KM;
const apogeeKm = semiMajorAxisKm * (1 + ecc) - EARTH_RADIUS_KM;
return {
perigee: Math.max(0, perigeeKm).toFixed(0),
apogee: Math.max(0, apogeeKm).toFixed(0),
};
}
function getSatelliteBriefHtml(props) {
const name = props?.name || "未知卫星";
const id = props?.norad_cat_id ? `NORAD: ${props.norad_cat_id}` : "";
@@ -1525,6 +1564,16 @@ function getCountryBoundaryBriefHtml(country) {
return `<strong>${name}</strong><br>ISO: ${code}<br>大洲: ${continent}`;
}
function getSurfacePositionBriefHtml(coords) {
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
return `纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
@@ -4648,9 +4697,33 @@ function onMouseMove(event) {
if (earthPoint) {
const coords = vector3ToLatLon(earthPoint);
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
const hoveredCountry = getShowCountryBoundaries()
const hoverInfoMode = getSurfaceHoverInfoMode();
const shouldShowCountry =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const shouldShowPosition =
hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION ||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
const hoveredCountry = shouldShowCountry && getShowCountryBoundaries()
? updateCountryBoundaryHover(coords)
: null;
const positionHtml = shouldShowPosition
? getSurfacePositionBriefHtml(coords)
: "";
if (!shouldShowCountry) {
clearCountryBoundaryHover();
}
if (hoveredCountry && shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
`${getCountryBoundaryBriefHtml(hoveredCountry)}<br>${positionHtml}`,
);
return;
}
if (hoveredCountry) {
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
@@ -4659,18 +4732,17 @@ function onMouseMove(event) {
);
return;
}
clearCountryBoundaryHover();
const elevMeters = sampleElevationAt(coords.lat, coords.lon);
const elevText = elevMeters !== null
? elevMeters >= 1000
? `${(elevMeters / 1000).toFixed(2)} km`
: `${Math.round(elevMeters)} m`
: "—";
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${elevText}`,
);
if (shouldShowPosition) {
showTooltip(
event.clientX + TOOLTIP_COORDS_OFFSET,
event.clientY + TOOLTIP_COORDS_OFFSET,
positionHtml,
);
} else {
hideTooltip();
}
} else {
clearCountryBoundaryHover();
hideTooltip();

View File

@@ -1,15 +1,19 @@
import { showStatusMessage } from "./ui.js";
import { getActiveTVTab, isTVPanelVisible } from "./tv.js";
// News aggregation now lives inside the shared media panel:
// - outer shell: #media-panel
// - this module renders into inner pane: #news-panel
// Desktop news has two surfaces:
// - a persistent top ticker
// - a center HUD that expands from the ticker
// Mobile keeps its existing drawer page.
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
const REQUEST_TIMEOUT_MS = 15000;
const NEWS_HUD_MORPH_MS = 300;
const NEWS_HUD_MIN_WIDTH_PX = 420;
const NEWS_HUD_MIN_HEIGHT_PX = 360;
const NEWS_HUD_RESIZE_MARGIN_PX = 12;
let initialized = false;
let refreshPromise = null;
@@ -18,6 +22,8 @@ let lastFocus = null;
let lastFetchAt = 0;
let lastRegionSwitchAt = 0;
let selectedCruiseStoryId = null;
let morphTimer = null;
function getElements() {
const isMobile = document.body.classList.contains("layout-mode-mobile");
return {
@@ -31,6 +37,11 @@ function getElements() {
board: document.getElementById(isMobile ? "mobile-news-board-list" : "news-board-list"),
empty: document.getElementById(isMobile ? "mobile-news-board-empty" : "news-board-empty"),
feedAnchor: document.getElementById(isMobile ? "mobile-news-feed-anchor" : "news-feed-anchor"),
ticker: document.getElementById("desktop-news-ticker"),
tickerRegion: document.getElementById("news-ticker-region"),
tickerTrack: document.getElementById("news-ticker-track"),
hud: document.getElementById("news-hud-panel"),
hudCloseBtn: document.getElementById("news-hud-close"),
};
}
@@ -57,6 +68,250 @@ export function updateNewsToggleUI(visible) {
void visible;
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function clearMorphTimer() {
if (!morphTimer) return;
window.clearTimeout(morphTimer);
morphTimer = null;
}
function setHudRect(hud, rect, opacity = 1) {
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.style.opacity = String(opacity);
}
function setHudRectWithoutTransition(hud, rect, opacity = 1) {
const previousTransition = hud.style.transition;
hud.style.transition = "none";
setHudRect(hud, rect, opacity);
void hud.offsetHeight;
hud.style.transition = previousTransition;
}
function getNewsHudTargetRect(hud) {
const wasHidden = hud.classList.contains("hud-panel-hidden");
const previousVisibility = hud.style.visibility;
if (wasHidden) hud.classList.remove("hud-panel-hidden");
hud.style.visibility = "hidden";
const rect = hud.getBoundingClientRect();
hud.style.visibility = previousVisibility;
if (wasHidden) hud.classList.add("hud-panel-hidden");
return rect;
}
function finishHudOpen(hud) {
hud.classList.remove("is-morphing");
hud.style.opacity = "";
revealSelectedCruiseStory();
}
function finishHudClose(hud, ticker, restoreRect = null) {
hud.classList.add("hud-panel-hidden");
hud.classList.remove("is-morphing");
hud.style.opacity = "";
if ((hud.dataset.dragged === "true" || hud.dataset.resized === "true") && restoreRect) {
setHudRect(hud, restoreRect, 1);
hud.style.opacity = "";
} else {
hud.style.left = "";
hud.style.top = "";
hud.style.width = "";
hud.style.height = "";
hud.style.transform = "";
}
ticker?.classList.remove("is-hidden");
}
function setNewsHudOpen(open, { highlightId = null } = {}) {
const { hud, ticker } = getElements();
if (!(hud instanceof HTMLElement)) return;
clearMorphTimer();
if (highlightId) {
selectedCruiseStoryId = highlightId;
applyCruiseStorySelection();
}
const tickerRect = ticker instanceof HTMLElement
? ticker.getBoundingClientRect()
: { left: window.innerWidth / 2 - 240, top: 20, width: 480, height: 38 };
if (open) {
const targetRect = getNewsHudTargetRect(hud);
hud.classList.remove("hud-panel-hidden");
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, tickerRect, 1);
ticker?.classList.add("is-hidden");
requestAnimationFrame(() => {
setHudRect(hud, targetRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudOpen(hud);
}, NEWS_HUD_MORPH_MS);
} else {
const currentRect = hud.getBoundingClientRect();
hud.classList.add("is-morphing");
setHudRectWithoutTransition(hud, currentRect, 1);
requestAnimationFrame(() => {
setHudRect(hud, tickerRect, 1);
});
morphTimer = window.setTimeout(() => {
morphTimer = null;
finishHudClose(hud, ticker, currentRect);
}, NEWS_HUD_MORPH_MS);
}
ticker?.setAttribute("aria-expanded", open ? "true" : "false");
}
function openNewsHud(options = {}) {
setNewsHudOpen(true, options);
}
function closeNewsHud() {
setNewsHudOpen(false);
}
function setupNewsHudResize() {
const { hud } = getElements();
const container = document.getElementById("container");
if (!(hud instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
let resizing = false;
let activeEdge = "";
const resizeStart = {
pointerX: 0,
pointerY: 0,
width: 0,
height: 0,
left: 0,
top: 0,
};
const clearPositioningForResize = () => {
const rect = hud.getBoundingClientRect();
hud.style.left = `${rect.left}px`;
hud.style.top = `${rect.top}px`;
hud.style.width = `${rect.width}px`;
hud.style.height = `${rect.height}px`;
hud.style.transform = "none";
hud.dataset.dragged = "true";
hud.dataset.resized = "true";
};
const stopResize = () => {
resizing = false;
activeEdge = "";
hud.classList.remove("is-resizing");
document.body.style.userSelect = "";
};
const onMove = (event) => {
if (!resizing) return;
const containerRect = container.getBoundingClientRect();
const hudScale = getHudScale();
const minWidth = Math.round(NEWS_HUD_MIN_WIDTH_PX * hudScale);
const minHeight = Math.round(NEWS_HUD_MIN_HEIGHT_PX * hudScale);
const dx = event.clientX - resizeStart.pointerX;
const dy = event.clientY - resizeStart.pointerY;
if (activeEdge.includes("r")) {
const maxW = containerRect.right - resizeStart.left - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
}
if (activeEdge.includes("l")) {
const newW = Math.max(minWidth, resizeStart.width - dx);
hud.style.width = `${newW}px`;
hud.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
}
if (activeEdge.includes("b")) {
const maxH = containerRect.bottom - resizeStart.top - NEWS_HUD_RESIZE_MARGIN_PX;
hud.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
}
};
hud.querySelectorAll(".earth-news-hud-edge[data-edge]").forEach((edgeEl) => {
edgeEl.addEventListener("pointerdown", (event) => {
if (document.body.classList.contains("layout-mode-mobile")) return;
if (hud.classList.contains("hud-panel-hidden")) return;
event.preventDefault();
event.stopPropagation();
clearPositioningForResize();
activeEdge = edgeEl.dataset.edge ?? "";
resizing = true;
resizeStart.pointerX = event.clientX;
resizeStart.pointerY = event.clientY;
const rect = hud.getBoundingClientRect();
resizeStart.width = rect.width;
resizeStart.height = rect.height;
resizeStart.left = rect.left;
resizeStart.top = rect.top;
hud.classList.add("is-resizing");
document.body.style.userSelect = "none";
edgeEl.setPointerCapture?.(event.pointerId);
});
edgeEl.addEventListener("pointermove", onMove);
edgeEl.addEventListener("pointerup", stopResize);
edgeEl.addEventListener("pointercancel", stopResize);
edgeEl.addEventListener("lostpointercapture", stopResize);
});
}
function escapeTickerText(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function renderTicker(nextPayload) {
const { ticker, tickerRegion, tickerTrack } = getElements();
if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return;
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
const focus = nextPayload?.focus || {};
if (tickerRegion instanceof HTMLElement) {
tickerRegion.textContent = (focus.region || "global").toUpperCase();
tickerRegion.style.color = focus.accent || "";
}
if (items.length === 0) {
tickerTrack.textContent = "正在准备全球态势新闻...";
tickerTrack.style.removeProperty("--news-ticker-duration");
return;
}
const visibleItems = items.slice(0, 6);
const tickerItems = [...visibleItems, ...visibleItems];
tickerTrack.innerHTML = tickerItems
.map((item) => `
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
<span>${escapeTickerText(item.title || "未命名新闻")}</span>
</span>
`)
.join("");
tickerTrack.style.setProperty("--news-ticker-duration", `${Math.max(22, visibleItems.length * 7)}s`);
}
function renderEmptyState(message) {
const { board, empty, status, openBtn } = getElements();
if (board) board.innerHTML = "";
@@ -68,6 +323,7 @@ function renderEmptyState(message) {
status.textContent = "等待聚合新闻源";
}
if (openBtn) openBtn.disabled = true;
renderTicker({ items: [], focus: payload?.focus || { region: "global" } });
}
function renderPayload(nextPayload) {
@@ -87,6 +343,8 @@ function renderPayload(nextPayload) {
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
const focus = nextPayload?.focus || {};
renderTicker(nextPayload);
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
if (document.body.classList.contains("layout-mode-mobile")) {
// Mobile page omits the region chip shell, but the rest of the page is still renderable.
@@ -338,21 +596,24 @@ export function initNewsPanel() {
if (initialized) return;
initialized = true;
updateNewsToggleUI(isTVPanelVisible());
updateNewsToggleUI(true);
renderEmptyState("正在准备全球态势新闻聚合源...");
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
if (getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
const { ticker, hudCloseBtn } = getElements();
ticker?.addEventListener("click", (event) => {
const itemEl = event.target instanceof Element
? event.target.closest("[data-news-id]")
: null;
const highlightId = itemEl?.getAttribute("data-news-id") || null;
openNewsHud({ highlightId });
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
if (event.detail?.visible && getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
ticker?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openNewsHud();
});
hudCloseBtn?.addEventListener("click", closeNewsHud);
setupNewsHudResize();
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);

View File

@@ -23,8 +23,6 @@ function waitForContext(context, durationMs) {
}
function getDefaultCardTarget() {
// TODO: replace the singleton card fallback with a presentation/card token check
// before BGP/News migrate here, so connectors only attach to their owning card.
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;

View File

@@ -1,7 +1,7 @@
// satellites.js - Satellite visualization module with real SGP4 positions and animations
import * as THREE from "three";
import { twoline2satrec, propagate } from "satellite.js";
import { twoline2satrec, propagate, eciToEcf, gstime } from "satellite.js";
import {
CONFIG,
DEFAULT_SATELLITE_DISPLAY_STYLE,
@@ -128,6 +128,7 @@ const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const FOOTPRINT_DIRECTION_SAMPLE_MS = 30000;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
@@ -954,30 +955,84 @@ function computeSatellitePosition(satellite, time) {
return null;
}
const x = positionAndVelocity.position.x;
const y = positionAndVelocity.position.y;
const z = positionAndVelocity.position.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
@@ -1952,14 +2007,15 @@ function getLockedSatelliteTrackDirection(groundNormal) {
const props = satellite?.properties;
if (!props?.norad_cat_id) return null;
const satrec = getOrBuildSatrec(props, new Date());
if (!satrec || satrec.error) return null;
const now = new Date();
const currentPosition = computeSatellitePosition(satellite, now);
const nextPosition = computeSatellitePosition(
satellite,
new Date(now.getTime() + FOOTPRINT_DIRECTION_SAMPLE_MS),
);
if (!currentPosition || !nextPosition) return null;
const propagation = propagate(satrec, new Date());
const velocity = propagation?.velocity;
if (!velocity) return null;
scratchFootprintVelocity.set(velocity.x, velocity.y, velocity.z);
scratchFootprintVelocity.subVectors(nextPosition, currentPosition);
if (!Number.isFinite(scratchFootprintVelocity.lengthSq())) return null;
scratchFootprintTangent
@@ -2592,32 +2648,65 @@ function calculatePredictedOrbit(
const points = [];
const samples = Math.ceil(periodSeconds / sampleInterval);
const now = new Date();
const fixedSiderealTime = gstime(now);
for (let i = 0; i <= samples; i++) {
const time = new Date(now.getTime() + i * sampleInterval * 1000);
const pos = computeSatellitePosition(satellite, time);
const pos = computeSatelliteInertialOrbitPosition(
satellite,
time,
fixedSiderealTime,
);
if (pos) points.push(pos);
}
if (points.length < samples * 0.5) {
points.length = 0;
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
for (let i = 0; i <= samples; i++) {
const theta = (i / samples) * Math.PI * 2;
const phi = (inclination * Math.PI) / 180;
const x =
radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180);
const y = radius * Math.cos(phi);
const z =
radius * Math.sin(phi) * Math.sin(theta + (raan * Math.PI) / 180);
points.push(new THREE.Vector3(x, y, z));
}
return calculateFallbackPredictedOrbit(satellite, samples);
}
closeOrbitLoop(points);
return points;
}
function closeOrbitLoop(points) {
if (points.length > 2) {
points.push(points[0].clone());
}
}
function calculateFallbackPredictedOrbit(satellite, samples) {
const points = [];
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const inclinationRad = THREE.MathUtils.degToRad(
satellite.properties?.inclination ?? 53,
);
const raanRad = THREE.MathUtils.degToRad(satellite.properties?.raan ?? 0);
const cosRaan = Math.cos(raanRad);
const sinRaan = Math.sin(raanRad);
const cosInclination = Math.cos(inclinationRad);
const sinInclination = Math.sin(inclinationRad);
for (let i = 0; i <= samples; i++) {
const argument = (i / samples) * Math.PI * 2;
const cosArgument = Math.cos(argument);
const sinArgument = Math.sin(argument);
const ecfX =
radius *
(cosRaan * cosArgument -
sinRaan * sinArgument * cosInclination);
const ecfY =
radius *
(sinRaan * cosArgument +
cosRaan * sinArgument * cosInclination);
const ecfZ = radius * sinArgument * sinInclination;
points.push(new THREE.Vector3(ecfX, ecfZ, -ecfY));
}
closeOrbitLoop(points);
return points;
}

View File

@@ -5,7 +5,6 @@ import { createHUDPanel } from "./hud-panels.js";
// Naming convention:
// - #media-panel is the outer HUD shell, responsible for drag/resize/show-hide
// - #tv-panel is the inner live tab pane
// - #news-panel is the inner aggregation-news tab pane
const TV_STREAMS_API = "/api/v1/tv/streams";
const TV_PROXY_API = "/api/v1/tv/proxy";
@@ -13,8 +12,8 @@ const TV_STATUS_MESSAGE = {
idle: "等待加载直播源",
syncing: "正在同步直播源...",
empty: "暂无可播放直播源",
iframeReady: "直播已加载",
videoReady: "视频流已加载",
iframeReady: "直播已加载",
videoReady: "直播已加载",
videoError: "当前视频流不可播放,请尝试其他频道",
externalOnly: "当前频道仅支持外部打开",
loadFailed: "电视直播源加载失败",
@@ -28,25 +27,15 @@ let hlsPlayer = null;
let hlsRecoveryAttempts = 0;
let metaAutoCollapseTimer = null;
let mediaPanel = null;
let activeTab = "live";
const failedSourceIds = new Set();
let probeTimer = null;
let reformCleanupTimer = null;
const tabPanelState = {
live: null,
news: null,
};
let mobileMetaCollapsed = false;
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
const DEFAULT_HUD_OFFSET_PX = 20;
const MIN_NEWS_TAB_HEIGHT_PX = 280;
const PANEL_RESIZE_MARGIN_PX = 12;
const TV_PANEL_MIN_WIDTH_PX = 360;
const TV_PANEL_MIN_HEIGHT_PX = 340;
const REFORM_CLEANUP_MS = 280;
const REFORM_RESTORE_ANCHOR_DATA_KEY = "reformRestoreAnchor";
const HLS_MAX_RECOVERY_ATTEMPTS = 3;
const HLS_RETRY_CONFIG = {
@@ -66,6 +55,7 @@ function getElements() {
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
origin: document.getElementById("tv-source-origin"),
status: document.getElementById(isMobile ? "mobile-tv-source-status" : "tv-source-status"),
notes: document.getElementById(isMobile ? "mobile-tv-source-notes" : "tv-source-notes"),
iframe: document.getElementById(isMobile ? "mobile-tv-iframe" : "tv-iframe"),
@@ -75,13 +65,8 @@ function getElements() {
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
newsTabBtn: document.getElementById("tv-tab-news"),
// Inner tab panes.
livePane: document.getElementById("tv-panel"),
newsPane: document.getElementById("news-panel"),
};
}
@@ -124,37 +109,13 @@ function setMetaCollapsed(collapsed) {
syncMetaToggleState(Boolean(collapsed));
}
function syncPanelActiveTab(tab = activeTab) {
function syncPanelActiveTab(tab = "live") {
const { panel } = getElements();
if (panel instanceof HTMLElement) {
panel.dataset.activeTab = tab;
}
}
function syncNewsDefaultMaxHeight() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
const earthStats = document.getElementById("earth-stats");
const hudOffset = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-offset"),
);
const resolvedOffset = Number.isFinite(hudOffset) ? hudOffset : DEFAULT_HUD_OFFSET_PX;
if (!(earthStats instanceof HTMLElement)) {
panel.style.removeProperty("--tv-news-default-max-height");
return;
}
const statsRect = earthStats.getBoundingClientRect();
const availableHeight = Math.max(
Math.round(MIN_NEWS_TAB_HEIGHT_PX * getHudScale()),
Math.floor(window.innerHeight - resolvedOffset - statsRect.bottom),
);
panel.style.setProperty("--tv-news-default-max-height", `${availableHeight}px`);
}
function autoExpandMeta() {
if (isMobileLayout()) {
clearTimeout(metaAutoCollapseTimer);
@@ -174,82 +135,6 @@ function clearPanelPositioningForResize(panel) {
panel.dataset.dragged = "true";
}
function readPanelLayoutState(panel) {
return {
width: panel.style.width || "",
height: panel.style.height || "",
resized: panel.dataset.resized === "true",
};
}
function resetPanelLayoutState(panel) {
panel.style.width = "";
panel.style.height = "";
delete panel.dataset.resized;
}
function captureTabState(tab = activeTab) {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? isMetaCollapsed() : null,
};
}
function restoreTabState(tab, panel, container, anchor = null) {
if (!(panel instanceof HTMLElement)) return;
const snapshot = tabPanelState[tab];
if (!snapshot?.layout) {
resetPanelLayoutState(panel);
return;
}
const { layout } = snapshot;
panel.style.width = layout.width;
panel.style.height = layout.height;
if (layout.resized) {
panel.dataset.resized = "true";
} else {
delete panel.dataset.resized;
}
if (tab === "live" && snapshot.metaCollapsed !== null) {
setMetaCollapsed(snapshot.metaCollapsed);
}
requestAnimationFrame(() => {
if (anchor) {
const panelRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
const targetLeft = anchor.right - containerRect.left - panelRect.width;
const targetTop = anchor.bottom - containerRect.top - panelRect.height;
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
const clampedLeft = Math.min(maxLeft, Math.max(0, targetLeft));
const clampedTop = Math.min(maxTop, Math.max(0, targetTop));
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.transform = "none";
panel.dataset.dragged = "true";
} else {
panel.style.right = "";
panel.style.bottom = "";
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
delete panel.dataset.dragged;
}
});
}
function getHudScale() {
const scale = Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"),
@@ -365,14 +250,11 @@ function updateToggleButton(visible) {
const { toggleBtn } = getElements();
if (!toggleBtn) return;
const icon = toggleBtn.querySelector(".material-symbols-rounded");
const isLiveTab = activeTab === "live";
toggleBtn.classList.toggle("active", visible);
if (icon) {
icon.textContent = isLiveTab ? "live_tv" : "newspaper";
icon.textContent = "live_tv";
}
const title = visible
? (isLiveTab ? "切换到态势新闻" : "切换到新闻直播")
: (isLiveTab ? "打开新闻直播" : "打开态势新闻");
const title = visible ? "关闭 Live 新闻" : "打开 Live 新闻";
toggleBtn.title = title;
toggleBtn.setAttribute("aria-label", title);
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
@@ -404,182 +286,16 @@ export function setTVPanelVisible(visible, options = {}) {
setPanelVisible(visible, options);
}
function clearReformState() {
const { panel } = getElements();
if (!(panel instanceof HTMLElement)) return;
panel.classList.remove("is-reforming");
panel.style.height = "";
if (panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] === "true") {
panel.style.top = "";
panel.style.bottom = "";
delete panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY];
}
if (reformCleanupTimer) {
clearTimeout(reformCleanupTimer);
reformCleanupTimer = null;
}
function setActiveTab() {
syncPanelActiveTab("live");
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: "live" },
}));
}
function animateTabReform(applyChange) {
const { panel } = getElements();
const container = document.getElementById("container");
if (!(panel instanceof HTMLElement)) {
applyChange();
return;
}
if (!(container instanceof HTMLElement)) {
applyChange();
return;
}
if (panel.dataset.resized === "true") {
applyChange();
requestAnimationFrame(() => {
clampPanelToContainer(panel, container);
});
return;
}
if (panel.classList.contains("is-dragging") || panel.classList.contains("is-resizing")) {
applyChange();
return;
}
clearReformState();
const reformStartRect = panel.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const reformSnapshot = {
height: reformStartRect.height,
anchoredBottom: reformStartRect.bottom - containerRect.top,
shouldRestoreDefaultAnchoring: panel.dataset.dragged !== "true",
};
if (reformSnapshot.shouldRestoreDefaultAnchoring) {
panel.dataset[REFORM_RESTORE_ANCHOR_DATA_KEY] = "true";
panel.style.bottom = "auto";
}
panel.style.top = `${reformSnapshot.anchoredBottom - reformSnapshot.height}px`;
panel.style.height = `${reformSnapshot.height}px`;
panel.classList.add("is-reforming");
void panel.offsetHeight;
applyChange();
panel.style.height = "auto";
const targetHeight = panel.getBoundingClientRect().height;
panel.style.height = `${reformSnapshot.height}px`;
void panel.offsetHeight;
const targetTop = reformSnapshot.anchoredBottom - targetHeight;
const finalizeReform = () => {
panel.removeEventListener("transitionend", handleReformTransitionEnd);
clearReformState();
};
const handleReformTransitionEnd = (event) => {
if (event.target === panel && event.propertyName === "height") {
finalizeReform();
}
};
panel.addEventListener("transitionend", handleReformTransitionEnd);
reformCleanupTimer = window.setTimeout(finalizeReform, REFORM_CLEANUP_MS);
requestAnimationFrame(() => {
panel.style.top = `${targetTop}px`;
panel.style.height = `${targetHeight}px`;
});
}
function updateTabState(target, isActive, activeClassName = "") {
if (!(target instanceof HTMLElement)) return;
target.hidden = !isActive;
if (activeClassName) {
target.classList.toggle(activeClassName, isActive);
}
}
function updateTabButtonState(button, isActive) {
if (!(button instanceof HTMLButtonElement)) return;
button.classList.toggle("media-panel-tab--active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
}
function setActiveTab(tab) {
const nextTab = tab === "news" ? "news" : "live";
if (activeTab === nextTab) return;
captureTabState(activeTab);
const { panel } = getElements();
const targetSnapshot = tabPanelState[nextTab];
const currentIsCustom =
panel instanceof HTMLElement && panel.dataset.resized === "true";
const targetIsCustom = Boolean(targetSnapshot?.layout?.resized);
const container = document.getElementById("container");
const currentAnchor =
panel instanceof HTMLElement && container instanceof HTMLElement
? (() => {
const panelRect = panel.getBoundingClientRect();
return {
right: panelRect.right,
bottom: panelRect.bottom,
};
})()
: null;
const applyTabSwitch = (restoreLayoutState = false) => {
activeTab = nextTab;
syncPanelActiveTab(nextTab);
syncNewsDefaultMaxHeight();
const {
liveTabBtn,
newsTabBtn,
liveHeaderControls,
newsHeaderControls,
livePane,
newsPane,
} = getElements();
updateTabButtonState(liveTabBtn, nextTab === "live");
updateTabButtonState(newsTabBtn, nextTab === "news");
updateTabState(liveHeaderControls, nextTab === "live");
updateTabState(newsHeaderControls, nextTab === "news");
updateTabState(livePane, nextTab === "live", "tv-tab-pane--active");
updateTabState(newsPane, nextTab === "news", "tv-tab-pane--active");
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
if (
restoreLayoutState &&
panel instanceof HTMLElement &&
container instanceof HTMLElement
) {
restoreTabState(nextTab, panel, container, currentAnchor);
}
requestAnimationFrame(() => {
captureTabState(nextTab);
});
window.dispatchEvent(new CustomEvent("earth:tv-tab-change", {
detail: { tab: nextTab },
}));
};
if (currentIsCustom || targetIsCustom) {
applyTabSwitch(true);
return;
}
animateTabReform(() => applyTabSwitch(false));
}
export function openTVPanelTab(tab = "live") {
export function openTVPanelTab() {
setPanelVisible(true);
if (activeTab === tab) return;
setActiveTab(tab);
setActiveTab("live");
}
export function setActiveTVTab(tab = "live") {
@@ -591,7 +307,7 @@ export function isTVPanelVisible() {
}
export function getActiveTVTab() {
return activeTab;
return "live";
}
function getEmbeddedUrl(source) {
@@ -913,6 +629,21 @@ function setPanelMessage(message) {
const { status } = getElements();
if (status) {
status.textContent = message || TV_STATUS_MESSAGE.idle;
if (status.id === "tv-source-status") {
const normalized = message || TV_STATUS_MESSAGE.idle;
status.classList.toggle(
"tv-panel-tag--error",
normalized === TV_STATUS_MESSAGE.videoError || normalized === TV_STATUS_MESSAGE.loadFailed,
);
status.classList.toggle(
"tv-panel-tag--warning",
normalized === TV_STATUS_MESSAGE.syncing ||
normalized === TV_STATUS_MESSAGE.externalOnly ||
normalized.includes("重试") ||
normalized.includes("恢复") ||
normalized.includes("回退"),
);
}
}
}
@@ -967,12 +698,11 @@ function renderSourceOptions() {
const fragment = document.createDocumentFragment();
sources.forEach((source) => {
const sourceOriginLabel = source.collector_source ? "[采集]" : "[内置]";
const defaultMark = source.id === tvPayload?.default_source_id ? " · 默认" : "";
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
const option = document.createElement("option");
option.value = source.id;
option.textContent = `${sourceOriginLabel} ${source.name}${defaultMark}${failMark}`;
option.textContent = `${source.name}${defaultMark}${failMark}`;
fragment.appendChild(option);
});
@@ -983,7 +713,7 @@ function renderSourceOptions() {
}
function renderSource(source) {
const { title, meta, catalog, notes, iframe, video, empty } = getElements();
const { title, meta, catalog, origin, notes, iframe, video, empty } = getElements();
const embeddedUrl = getEmbeddedUrl(source);
const videoUrl = getVideoUrl(source);
const externalUrl = getExternalUrl(source);
@@ -993,6 +723,10 @@ function renderSource(source) {
if (title) {
title.textContent = source?.name || "暂无可用频道";
}
if (origin instanceof HTMLElement) {
origin.textContent = source?.collector_source ? "采集" : source ? "内置" : "待加载";
origin.title = source?.collector_source ? `采集源:${source.collector_source}` : source ? "内置源" : "待加载";
}
if (meta) {
meta.textContent = source
? `${source.provider} · ${source.region} · ${source.language} · ${source.source_type}`
@@ -1004,12 +738,7 @@ function renderSource(source) {
const latestLabel = latestUpdatedAt
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString("zh-CN", { hour12: false })}`
: "尚未同步";
const sourceOriginLabel = source?.collector_source
? `采集源 ${source.collector_source}`
: source
? "内置源"
: "";
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}${sourceOriginLabel ? ` · ${sourceOriginLabel}` : ""}`;
catalog.textContent = `${sourceCount} 个频道 · ${latestLabel}`;
}
if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
@@ -1039,9 +768,7 @@ function renderSource(source) {
hideEmptyState(empty);
setPanelMessage(
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
);
setPanelMessage("直播加载中");
updateOpenButton(source);
autoExpandMeta();
}
@@ -1117,8 +844,6 @@ export function initTVPanel() {
toggleBtn,
panel,
metaToggle,
liveTabBtn,
newsTabBtn,
} = getElements();
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
@@ -1148,23 +873,13 @@ export function initTVPanel() {
const currentlyVisible = mediaPanel?.isVisible() ?? false;
if (!currentlyVisible) {
setPanelVisible(true);
if (activeTab === "live") {
await ensureTVPanelReady();
showStatusMessage("新闻直播窗口已打开", "info");
} else {
showStatusMessage("态势新闻窗口已打开", "info");
}
await ensureTVPanelReady();
showStatusMessage("Live 新闻窗口已打开", "info");
return;
}
const nextTab = activeTab === "live" ? "news" : "live";
setActiveTab(nextTab);
if (nextTab === "live") {
await ensureTVPanelReady();
showStatusMessage("已切换到新闻直播", "info");
return;
}
showStatusMessage("已切换到态势新闻", "info");
setPanelVisible(false);
showStatusMessage("Live 新闻窗口已关闭", "info");
});
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
@@ -1213,13 +928,6 @@ export function initTVPanel() {
});
});
liveTabBtn?.addEventListener("click", () => {
setActiveTab("live");
});
newsTabBtn?.addEventListener("click", () => {
setActiveTab("news");
});
[iframe, document.getElementById("mobile-tv-iframe"), document.getElementById("tv-iframe")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((iframeEl) => {
@@ -1249,10 +957,4 @@ export function initTVPanel() {
setupResizeHandle();
syncPanelActiveTab("live");
syncNewsDefaultMaxHeight();
updateTabButtonState(liveTabBtn, true);
updateTabButtonState(newsTabBtn, false);
captureTabState("live");
window.addEventListener("resize", syncNewsDefaultMaxHeight);
}

View File

@@ -3,6 +3,7 @@
let statusTimeoutId = null;
let statusHideTimeoutId = null;
const STATUS_BASE_CLASS = "earth-status-message";
const STATUS_TICKER_STACK_CLASS = "earth-status-message--ticker-stack";
const STATUS_DISPLAY_MS = 3000;
const STATUS_FADE_MS = 280;
const GESTURE_STATUS_DISPLAY_MS = 760;
@@ -87,6 +88,52 @@ function buildStatusContent(statusEl, message, type) {
statusEl.appendChild(text);
}
function getStatusSidePlacement(statusEl) {
if (!(statusEl instanceof HTMLElement)) return false;
if (document.body.classList.contains("layout-mode-mobile")) return false;
const ticker = document.getElementById("desktop-news-ticker");
const brand = document.getElementById("brand-panel");
if (!(ticker instanceof HTMLElement) || !(brand instanceof HTMLElement)) return false;
if (ticker.classList.contains("is-hidden") || ticker.offsetParent === null) return false;
const tickerRect = ticker.getBoundingClientRect();
const brandRect = brand.getBoundingClientRect();
const statusWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (!statusWidth || !tickerRect.width || !brandRect.width) return false;
const rootStyle = getComputedStyle(document.documentElement);
const hudScale = Number.parseFloat(rootStyle.getPropertyValue("--hud-scale")) || 1;
const requiredGap = Math.max(10, Math.round(12 * hudScale));
const availableWidth = tickerRect.left - brandRect.right - requiredGap * 2;
return {
shouldStack: availableWidth < statusWidth,
left: Math.round(brandRect.right + requiredGap),
maxWidth: Math.max(160, Math.floor(availableWidth)),
};
}
function syncStatusPlacement(statusEl) {
if (!(statusEl instanceof HTMLElement)) return;
const placement = getStatusSidePlacement(statusEl);
const shouldStack = !placement || placement.shouldStack;
statusEl.classList.toggle(STATUS_TICKER_STACK_CLASS, shouldStack);
if (!placement || shouldStack) {
statusEl.style.left = "";
statusEl.style.maxWidth = "";
return;
}
statusEl.style.left = `${placement.left}px`;
statusEl.style.maxWidth = `${placement.maxWidth}px`;
}
function syncVisibleStatusPlacement() {
const statusEl = getElement("status-message");
if (statusEl?.classList.contains("visible")) {
syncStatusPlacement(statusEl);
}
}
function buildPersistentErrorContent(errorEl, message) {
buildStatusContent(errorEl, message, "error");
}
@@ -97,6 +144,8 @@ function hideStatusElement(statusEl, onHidden) {
if (!statusEl.classList.contains("visible")) {
setElementDisplay(statusEl, false);
statusEl.className = STATUS_BASE_CLASS;
statusEl.style.left = "";
statusEl.style.maxWidth = "";
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;
@@ -123,6 +172,7 @@ function startTransientStatus(message, type = "info") {
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
@@ -159,6 +209,7 @@ export function showGestureStatusMessage(message, type = "info") {
buildStatusContent(statusEl, message, type);
statusEl.className = `${STATUS_BASE_CLASS} ${type} gesture`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
@@ -246,6 +297,7 @@ export function setLoading(loading) {
pendingLoadingMessage = "";
statusEl.className = `${STATUS_BASE_CLASS} loading`;
setElementDisplay(statusEl, true, "inline-flex");
syncStatusPlacement(statusEl);
statusEl.offsetHeight;
statusEl.classList.add("visible");
requestAnimationFrame(() => {
@@ -268,6 +320,8 @@ export function setLoading(loading) {
}
}
window.addEventListener("resize", syncVisibleStatusPlacement, { passive: true });
export function setLoadingMessage(title) {
const statusEl = getElement("status-message");
if (!statusEl || !statusEl.classList.contains("loading")) {
@@ -279,6 +333,7 @@ export function setLoadingMessage(title) {
textEl.textContent = title;
requestAnimationFrame(() => {
updateLoadingWidthLock(statusEl);
syncStatusPlacement(statusEl);
});
}
}
@@ -334,6 +389,8 @@ export function clearUiState() {
if (statusEl) {
statusEl.className = STATUS_BASE_CLASS;
setElementDisplay(statusEl, false);
statusEl.style.left = "";
statusEl.style.maxWidth = "";
statusEl.innerHTML = "";
clearLoadingWidthLock(statusEl);
}