release: bump version to 0.56.0
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
frontend/public/earth/js/controls.js
vendored
63
frontend/public/earth/js/controls.js
vendored
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user