Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d0332fba | ||
|
|
fe45a99cbd | ||
|
|
ae77b06c3c | ||
|
|
b5dd4f12f8 | ||
|
|
75cb214f23 | ||
|
|
4c21973197 | ||
|
|
51ae5e6ec9 | ||
|
|
1cf1f32ddd | ||
|
|
8f3ab88743 | ||
|
|
f8b43a995b |
10
README.md
10
README.md
@@ -328,11 +328,11 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
|
||||
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md)
|
||||
- [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
@@ -346,7 +346,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
当前推荐参考实现:
|
||||
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
完整使用说明见:
|
||||
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
当前支持:
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.v1 import (
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
news,
|
||||
system_control,
|
||||
tv,
|
||||
)
|
||||
@@ -35,3 +36,4 @@ api_router.include_router(system_control.router, prefix="/system", tags=["system
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
13
backend/app/api/v1/news.py
Normal file
13
backend/app/api/v1/news.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/earth-feed")
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
490
backend/app/services/earth_news.py
Normal file
490
backend/app/services/earth_news.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegionProfile:
|
||||
key: str
|
||||
label: str
|
||||
query: str
|
||||
accent: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsFeedSource:
|
||||
id: str
|
||||
name: str
|
||||
region: str
|
||||
feed_url: str
|
||||
homepage_url: str
|
||||
source_type: str = "rss"
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
source: str
|
||||
feed_name: str
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedRegionFeed:
|
||||
region: str
|
||||
fetched_at: datetime
|
||||
items: list[ParsedNewsItem]
|
||||
sources: list[NewsFeedSource]
|
||||
|
||||
|
||||
REGION_PROFILES: dict[str, RegionProfile] = {
|
||||
"americas": RegionProfile(
|
||||
key="americas",
|
||||
label="美洲焦点",
|
||||
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
|
||||
accent="#79d3ff",
|
||||
),
|
||||
"europe": RegionProfile(
|
||||
key="europe",
|
||||
label="欧洲焦点",
|
||||
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
|
||||
accent="#8fd4ff",
|
||||
),
|
||||
"middle-east-africa": RegionProfile(
|
||||
key="middle-east-africa",
|
||||
label="中东与非洲焦点",
|
||||
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
|
||||
accent="#ffb56a",
|
||||
),
|
||||
"asia-pacific": RegionProfile(
|
||||
key="asia-pacific",
|
||||
label="亚太焦点",
|
||||
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
|
||||
accent="#78f2cf",
|
||||
),
|
||||
"global": RegionProfile(
|
||||
key="global",
|
||||
label="全球焦点",
|
||||
query='"world news" OR geopolitics OR "global affairs"',
|
||||
accent="#d6e6ff",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
|
||||
return (
|
||||
"https://news.google.com/rss/search?q="
|
||||
+ quote(query, safe="")
|
||||
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
|
||||
)
|
||||
|
||||
|
||||
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
NewsFeedSource(
|
||||
id="bbc-world",
|
||||
name="BBC World",
|
||||
region="global",
|
||||
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
priority=10,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="dw-top",
|
||||
name="DW Top Stories",
|
||||
region="europe",
|
||||
feed_url="https://rss.dw.com/rdf/rss-en-top",
|
||||
homepage_url="https://www.dw.com/en/top-stories/s-9097",
|
||||
priority=20,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="global-scan",
|
||||
name="Global Monitor / World",
|
||||
region="global",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["global"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=30,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-americas",
|
||||
name="Global Monitor / Americas",
|
||||
region="americas",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["americas"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-europe",
|
||||
name="Global Monitor / Europe",
|
||||
region="europe",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["europe"].query,
|
||||
hl="en-GB",
|
||||
gl="GB",
|
||||
ceid="GB:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-mea",
|
||||
name="Global Monitor / MEA",
|
||||
region="middle-east-africa",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["middle-east-africa"].query,
|
||||
hl="en-US",
|
||||
gl="US",
|
||||
ceid="US:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
NewsFeedSource(
|
||||
id="google-apac",
|
||||
name="Global Monitor / APAC",
|
||||
region="asia-pacific",
|
||||
feed_url=_google_news_feed(
|
||||
REGION_PROFILES["asia-pacific"].query,
|
||||
hl="en-SG",
|
||||
gl="SG",
|
||||
ceid="SG:en",
|
||||
),
|
||||
homepage_url="https://news.google.com/",
|
||||
source_type="aggregated",
|
||||
priority=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
if lat is None or lon is None:
|
||||
return "global"
|
||||
if -170 <= lon <= -30:
|
||||
return "americas"
|
||||
if -30 < lon <= 45:
|
||||
return "europe" if lat >= 30 else "middle-east-africa"
|
||||
if 45 < lon <= 150:
|
||||
return "middle-east-africa" if lat < 10 else "asia-pacific"
|
||||
return "asia-pacific"
|
||||
|
||||
|
||||
def get_region_profile(region: str) -> RegionProfile:
|
||||
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
key=lambda source: (source.priority, source.name),
|
||||
)
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
soup = BeautifulSoup(value, "html.parser")
|
||||
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def _truncate(value: str, limit: int = 180) -> str:
|
||||
text = value.strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _normalize_source_name(raw: str, fallback: str) -> str:
|
||||
text = html.unescape((raw or "").strip())
|
||||
if " - " in text:
|
||||
return text.split(" - ")[-1].strip() or fallback
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _parse_datetime(raw: str | None) -> datetime | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
for parser in (
|
||||
lambda value: parsedate_to_datetime(value),
|
||||
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
|
||||
):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_item_text(element: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
node = element.find(name)
|
||||
if node is not None and node.text:
|
||||
return node.text.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
|
||||
root = ET.fromstring(xml_text)
|
||||
items: list[ParsedNewsItem] = []
|
||||
|
||||
rss_items = root.findall("./channel/item")
|
||||
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
|
||||
nodes = rss_items or atom_entries
|
||||
|
||||
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
|
||||
if node.tag.endswith("entry"):
|
||||
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
|
||||
summary = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}summary",
|
||||
"{http://www.w3.org/2005/Atom}content",
|
||||
)
|
||||
link_node = node.find("{http://www.w3.org/2005/Atom}link")
|
||||
link = link_node.get("href", "").strip() if link_node is not None else ""
|
||||
published = _extract_item_text(
|
||||
node,
|
||||
"{http://www.w3.org/2005/Atom}updated",
|
||||
"{http://www.w3.org/2005/Atom}published",
|
||||
)
|
||||
else:
|
||||
title = _extract_item_text(node, "title")
|
||||
summary = _extract_item_text(node, "description", "content")
|
||||
link = _extract_item_text(node, "link")
|
||||
published = _extract_item_text(node, "pubDate", "published", "updated")
|
||||
|
||||
clean_title = html.unescape(title).strip()
|
||||
clean_summary = _truncate(_strip_html(summary), 180)
|
||||
if not clean_title or not link:
|
||||
continue
|
||||
|
||||
item_source = _normalize_source_name(clean_title, source.name)
|
||||
display_title = clean_title
|
||||
if source.source_type == "aggregated" and " - " in clean_title:
|
||||
parts = clean_title.rsplit(" - ", 1)
|
||||
display_title = parts[0].strip()
|
||||
item_source = _normalize_source_name(parts[1], source.name)
|
||||
|
||||
items.append(
|
||||
ParsedNewsItem(
|
||||
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
|
||||
title=display_title,
|
||||
summary=clean_summary,
|
||||
url=link,
|
||||
source=item_source,
|
||||
feed_name=source.name,
|
||||
feed_region=source.region,
|
||||
homepage_url=source.homepage_url,
|
||||
published_at=_parse_datetime(published),
|
||||
)
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": source.id,
|
||||
"name": source.name,
|
||||
"region": source.region,
|
||||
"homepage_url": source.homepage_url,
|
||||
}
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
items: list[ParsedNewsItem],
|
||||
sources: list[NewsFeedSource],
|
||||
errors: list[str],
|
||||
stale: bool,
|
||||
generated_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
profile = get_region_profile(active_region)
|
||||
timestamp = generated_at or datetime.now(UTC)
|
||||
return {
|
||||
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
|
||||
"focus": {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
"items": [_serialize_item(item, active_region=active_region) for item in items],
|
||||
"errors": errors,
|
||||
"stale": stale,
|
||||
}
|
||||
|
||||
|
||||
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
|
||||
deduped: dict[str, ParsedNewsItem] = {}
|
||||
for item in items:
|
||||
key = item.url.strip() or item.title.strip().lower()
|
||||
if key not in deduped:
|
||||
deduped[key] = item
|
||||
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (
|
||||
item.feed_region != active_region,
|
||||
item.published_at is None,
|
||||
-(item.published_at.timestamp() if item.published_at else 0),
|
||||
item.feed_name,
|
||||
),
|
||||
)[:MAX_ITEMS_TOTAL]
|
||||
|
||||
|
||||
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
|
||||
cached = _REGION_CACHE.get(region)
|
||||
if not cached:
|
||||
return None
|
||||
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
|
||||
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
|
||||
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
|
||||
_REGION_CACHE[region] = CachedRegionFeed(
|
||||
region=region,
|
||||
fetched_at=datetime.now(UTC),
|
||||
items=list(items),
|
||||
sources=list(sources),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
|
||||
try:
|
||||
response = await client.get(source.feed_url)
|
||||
response.raise_for_status()
|
||||
return source, _parse_feed_entries(response.text, source), None
|
||||
except Exception as exc:
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
) as client:
|
||||
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
|
||||
|
||||
fetched_items: list[ParsedNewsItem] = []
|
||||
for source, items, error in results:
|
||||
if error:
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=ranked_items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=cached.items,
|
||||
sources=cached.sources,
|
||||
errors=errors,
|
||||
stale=True,
|
||||
generated_at=cached.fetched_at,
|
||||
)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=[],
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
@@ -8,6 +8,132 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.29.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 加载状态条改成单一队列式通知面板,加载阶段不再因为步骤文案变化而回缩,也不会被其他通知打断
|
||||
- 调整 brand panel 的呈现方式与昼夜/选中态可读性,让品牌区更自然、交互高亮在白天和黑夜里都更稳定
|
||||
|
||||
### 🔧 Improvements
|
||||
- 移除旧的地球加载浮层结构,统一由 HUD 状态消息承载三点脉冲加载过程
|
||||
- brand panel 改为无边框品牌层,仅保留轻微氛围光,不再因为非常规尺寸显得像第五块功能面板
|
||||
- 温和收敛地球昼夜材质与主背光强度,保留昼夜辨识度的同时提升白天地表纹理和夜面交互可见性
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复加载地球时通知条在步骤切换中反复缩短、其他状态消息抢占加载流程的问题
|
||||
- 修复海缆、登陆点和 BGP 选中高亮在黑夜中过暗、在高光中过亮导致难以辨识的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.29.0] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- Earth 新增天球层第一版:引入真实全天星图、亮星层与太阳/月亮位置计算,地球场景首次具备可校准的天文背景
|
||||
- 地球昼夜分隔升级为更明显的日夜增强效果,夜面、晨昏带和太阳方向联动更容易直接读出来
|
||||
|
||||
### 🔧 Improvements
|
||||
- 新增 `celestial.js` 模块和 `assets/celestial/` 资源目录,统一管理星图、亮星数据以及太阳/月亮与光照同步
|
||||
- 卫星图例改为按倾角分组,严格固定为“赤道轨道 → 低倾角轨道 → 中倾角轨道 → 高倾角轨道 → 逆行轨道”顺序,并全部中文化
|
||||
- 图层面板补齐关闭按钮,拖拽脱离左列后不再被流布局 margin 影响,能够真正贴到品牌面板下沿
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复天球球壳放大后被相机 far plane 裁剪导致的外层黑环问题
|
||||
- 修复图层面板在左侧上移时始终与 brand panel 保持额外间距的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.28.2] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 修正媒体情报面板在 `电视直播 / 态势聚合` tab 间切换时的尺寸记忆逻辑,切回原 tab 后可恢复各自大小状态
|
||||
- 清理 `docs/` 根目录遗留的旧路径文档,只保留新的分组目录和归档目录,结束同一文档双路径并存状态
|
||||
|
||||
### 🔧 Improvements
|
||||
- `media-panel` 切换逻辑改成按 tab 分别记忆尺寸状态,避免 `A -> B -> A` 时继续共用同一套外层尺寸
|
||||
- 目录整理真正完成收尾:旧的 `docs/*.md` 平铺计划文档删除,继续以 `docs/agents / earth / backend / frontend / ops / ue5 / deprecated` 为唯一入口
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复拉伸 `media-panel` 后切换 tab 时,`news-panel` 高度回退到旧默认值的问题
|
||||
- 修复拉伸后切换 tab 导致面板视觉锚点异常的问题,切换时改为围绕当前卡片自身右下角进行尺寸恢复
|
||||
|
||||
---
|
||||
|
||||
## [0.28.1] — 2026-04-20
|
||||
|
||||
### ✨ Highlights
|
||||
- 收口 Earth 媒体情报面板命名,明确外层 `media-panel` 与内部 `tv-panel / news-panel` 的职责边界
|
||||
- 整理 `docs/` 目录分组,并将已完成或已废弃的计划文档归档到 `docs/deprecated`
|
||||
|
||||
### 🔧 Improvements
|
||||
- 底部 tab 语义统一为 `media-panel-tabs / media-panel-tab`,并将文案更新为“电视直播 / 态势聚合”
|
||||
- 补充媒体面板、聚合新闻模块的注释说明,减少 `tv-panel` 同时指代外层壳和内层直播 pane 的阅读歧义
|
||||
- 更新 README、AI Provider README 与历史文档互链,适配新的 `docs/agents / docs/earth / docs/frontend / docs/backend / docs/ops / docs/ue5` 分组结构
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板标题组在头部撑出多余空白的问题,去掉 `hud-panel__title-group` 的无效弹性占位
|
||||
- 修复聚合 tab 头部仍保留冗余固定标题的问题,现在仅显示区域标签
|
||||
|
||||
---
|
||||
|
||||
## [0.28.0] — 2026-04-20
|
||||
|
||||
### ✨ Features
|
||||
- 将 Earth 的“新闻直播”和“全球态势聚合”合并为统一的“媒体情报”面板,支持底部 tab 切换与共享标题栏操作区
|
||||
- 聚合新闻不再单独占据一个 HUD 面板,而是作为媒体情报面板内的第二视图与直播协同呈现
|
||||
|
||||
说明:
|
||||
- 当前结构中,外层 HUD 壳为 `media-panel`,内部 tab 内容区分别为 `tv-panel` 和 `news-panel`
|
||||
|
||||
### 🔧 Improvements
|
||||
- TV / News 面板切换加入底边锚定的 reform 动画,并继续保留拖拽、缩放和共享 HUD 行为收口
|
||||
- 聚合新闻视图新增默认高度约束与内部滚动填充逻辑,避免初始高度过度膨胀
|
||||
- `tv.js`、`news.js` 进一步清理共享 HUDPanel 迁移后的残留逻辑,收紧 tab / resize / reform 相关局部 helper
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复媒体情报面板右下角缩放时高度异常抬升、标题栏被顶出视口的问题
|
||||
- 修复直播/聚合 tab 切换时按钮高亮、内容切换和底边基准表现不一致的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
## [0.27.8] — 2026-04-20
|
||||
|
||||
### 🔧 Improvements
|
||||
- Earth HUD 共享 `HUDPanel` 默认展开/收缩逻辑继续收口,图例与图层面板统一使用同一套边缘阈值与箭头状态机
|
||||
- 保持新闻直播面板现有特例折叠行为不变,避免播放器区域被默认折叠逻辑影响
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复图例与图层面板展开/收缩箭头方向和实际动作不一致的问题
|
||||
- 修复拖动到屏幕底边附近时初始箭头、拖动中箭头和点击后动作不同步的问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.7] — 2026-04-16
|
||||
|
||||
### 🔧 Improvements
|
||||
- 用户管理、数据源配置、电视直播源表格统一接入可折叠操作列,窄宽度下自动收起到下拉菜单,减少操作区挤压
|
||||
- 电视直播设置改为表格总览 + 弹窗编辑模式,主表内容更紧凑,适合控制台一屏浏览
|
||||
- Earth TV 面板新增失败源探测与自动回退恢复标记,便于值班时快速识别异常直播源
|
||||
|
||||
### 🐛 Fixes
|
||||
- 修复 Settings 电视直播源新增后取消编辑会残留未保存草稿的问题
|
||||
- 修复 Settings 删除直播源只改本地状态、刷新后恢复的问题,删除现在会立即持久化
|
||||
- 修复 Users / DataSources / Settings 表格“备注/状态”和“操作”之间的空白占位列问题
|
||||
- 修复 `useCollapsedActions` 未释放 `ResizeObserver` 导致的潜在内存泄漏与重复回调问题
|
||||
|
||||
---
|
||||
|
||||
## [0.27.6] — 2026-04-15
|
||||
|
||||
### 🔧 Improvements
|
||||
- BGP 告警页表格纵向 overflow 修复:补全 flex 布局链,tabs content-holder 正确撑满剩余高度
|
||||
- 用户管理表格横向滚动修复:采用 flex-fill 方案替换 `height: auto !important`,自定义滚动条 X 轨道位置对齐表格底部
|
||||
- Playground 宽布局隐藏"服务状态"按钮:侧边栏可见时不显示冗余入口
|
||||
- AI Chatbox 输入框失焦收起为单行,聚焦或有内容时展开完整 composer
|
||||
|
||||
---
|
||||
|
||||
## [0.27.4] — 2026-04-14
|
||||
|
||||
### 🔧 Improvements
|
||||
@@ -137,7 +263,7 @@ Released: 2026-04-12
|
||||
|
||||
- Added [backend/app/api/v1/tv.py](/home/ray/dev/linkong/planet/backend/app/api/v1/tv.py), [backend/app/services/tv_streams.py](/home/ray/dev/linkong/planet/backend/app/services/tv_streams.py), and [backend/app/services/collectors/news_live_streams.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/news_live_streams.py) to provide TV source configuration, public stream payloads, a guarded HLS proxy path, and a collector entry point for future world-news live-source ingestion.
|
||||
- Added the Earth TV HUD workspace through [frontend/public/earth/index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), [frontend/public/earth/js/tv.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), and [frontend/public/earth/css/tv-panel.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/tv-panel.css), including toolbar access, draggable/closable behavior, resize support, direct video/HLS playback, iframe fallback, and per-channel external-open handling.
|
||||
- Added [docs/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-tv-live-module-plan.md) and [docs/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
- Added [docs/deprecated/earth-tv-live-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-tv-live-module-plan.md) and [docs/earth/news-live-streams-collector-format.md](/home/ray/dev/linkong/planet/docs/earth/news-live-streams-collector-format.md) to document the TV module rollout plan and the expected collector payload format for future curated live-channel ingestion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -223,7 +349,7 @@ Released: 2026-04-10
|
||||
|
||||
- Improved [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx) and [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css) by rebuilding Playground into a true chatbox workflow with persistent history, edit-and-resend behavior, grounded message actions, responsive composer behavior, bottom-stick scrolling, and tighter mobile layout handling.
|
||||
- Improved [frontend/src/components/AppLayout/AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx), [frontend/src/App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx), and [frontend/src/pages/Alerts/Alerts.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Alerts/Alerts.tsx) by reorganizing navigation around `采集与数据`, `专题观测`, and split alert entries so the app can scale to more observability and situational modules without turning the top-level UI into a single overloaded page.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
- Improved [README.md](/home/ray/dev/linkong/planet/README.md) and [docs/agents/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/agents/situational-awareness-foundation-plan.md) by documenting the current AI/alerts base, planned situational-awareness direction, and the new persistent Playground foundation.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -261,7 +387,7 @@ Released: 2026-04-10
|
||||
### Improved
|
||||
|
||||
- Improved [rules.md](/home/ray/dev/linkong/planet/rules.md) by adding mandatory release-workflow requirements and a new frontend layout constraint section covering single-screen workspaces, overflow ownership, tab-pane behavior, compact-mode expectations, and readable-card fallbacks.
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
- Improved [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md) by summarizing the recurring Earth, Playground, BGP, and admin-layout regressions into concrete constraints for future frontend work, including “prefer scrollbars over unreadable compression” and “do not treat every tab as a table pane.”
|
||||
|
||||
## 0.24.6
|
||||
|
||||
@@ -278,7 +404,7 @@ Released: 2026-04-10
|
||||
- Improved [backend/app/services/bgp_incidents.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_incidents.py) and [backend/app/services/bgp_enrichment.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_enrichment.py) by avoiding historical full-table infrastructure scans, narrowing observation baseline payloads to required columns, and pushing more ASN filtering into the database.
|
||||
- Improved [backend/app/api/v1/alerts.py](/home/ray/dev/linkong/planet/backend/app/api/v1/alerts.py), [backend/app/api/v1/dashboard.py](/home/ray/dev/linkong/planet/backend/app/api/v1/dashboard.py), and [backend/app/api/v1/settings.py](/home/ray/dev/linkong/planet/backend/app/api/v1/settings.py) by collapsing several repeated count and settings queries into fewer aggregate or batched reads.
|
||||
- Improved [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx), [frontend/src/index.css](/home/ray/dev/linkong/planet/frontend/src/index.css), and [frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx](/home/ray/dev/linkong/planet/frontend/src/components/MarkdownRenderer/MarkdownRenderer.tsx) by rebuilding the `AI 简报` tab layout, fixing saved brief scrolling behavior, and extending the renderer to handle tables, separators, and stored metadata comments more gracefully.
|
||||
- Improved [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
- Improved [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md) by explicitly recording that the current BGP brief is only the first-stage summary flow and that regional prefix-geography analysis remains a planned Phase B follow-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -384,8 +510,8 @@ Released: 2026-04-09
|
||||
### Added
|
||||
|
||||
- Added [frontend/src/pages/Playground/Playground.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Playground/Playground.tsx), introducing the first dedicated AI testing workspace with provider status visibility, prompt/result tabs, and collapsible operator guidance.
|
||||
- Added [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
- Added [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md), documenting the repository standard for one-screen admin workspaces and module-local overflow handling.
|
||||
- Added [docs/frontend/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/frontend/ai-playground-development-plan.md), capturing the completed AI gateway/UI work and the next delivery phases for BGP briefs, evidence-first inputs, and future agent runtime expansion.
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -490,7 +616,7 @@ Released: 2026-04-07
|
||||
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
|
||||
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
|
||||
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
|
||||
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
|
||||
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
|
||||
### Improved
|
||||
@@ -616,7 +742,7 @@ Released: 2026-04-02
|
||||
|
||||
- Added a new `IPtoASN Prefix Geography` collector in [iptoasn.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/iptoasn.py) and registered it through [data_sources.yaml](/home/ray/dev/linkong/planet/backend/app/core/data_sources.yaml), [data_sources.py](/home/ray/dev/linkong/planet/backend/app/core/data_sources.py), [datasource_defaults.py](/home/ray/dev/linkong/planet/backend/app/core/datasource_defaults.py), and [collectors/__init__.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/__init__.py).
|
||||
- Added country centroid helpers in [countries.py](/home/ray/dev/linkong/planet/backend/app/core/countries.py) so country-level prefix geography can produce map coordinates instead of only labels.
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/prefix-geography-plan.md).
|
||||
- Added a dedicated prefix-geography implementation note in [prefix-geography-plan.md](/home/ray/dev/linkong/planet/docs/earth/prefix-geography-plan.md).
|
||||
- Added recent `15m` collector activity dimensions to BGP coverage output in [bgp_collectors.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collectors.py) and [visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py).
|
||||
- Added additional BGP detector coverage for `route_leak_candidate` and `path_flap` flows in [test_bgp.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp.py).
|
||||
- Added a local Earth cloud texture at [earth_clouds_1024.png](/home/ray/dev/linkong/planet/frontend/public/earth/assets/earth_clouds_1024.png) to avoid remote cloud-map dependency failures.
|
||||
@@ -631,7 +757,7 @@ Released: 2026-04-02
|
||||
- Improved Earth event animation semantics in [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by separating icon pulse from ring expansion so the center marker can breathe while the ring expands independently.
|
||||
- Improved Earth texture reliability in [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) by switching clouds back to a local static asset under the restored `public/earth` runtime.
|
||||
- Improved frontend boot noise in [frontend/index.html](/home/ray/dev/linkong/planet/frontend/index.html) by removing the default Vite favicon request that was generating irrelevant `vite.svg` timeouts during Earth debugging.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
- Improved project planning docs in [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md) and [TODO.md](/home/ray/dev/linkong/planet/TODO.md) so the roadmap now explicitly prioritizes `activity layer`, `prefix-centric geography`, and follow-up geofeed/whois work.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -789,7 +915,7 @@ Released: 2026-03-31
|
||||
- Added restart-task Redis helpers and whitelist command mapping in [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py).
|
||||
- Added detached restart runner orchestration in [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
|
||||
- Added `-d` / `--database` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for database-only restarts.
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/system-service-control.md).
|
||||
- Added restart control documentation in [system-service-control.md](/home/ray/dev/linkong/planet/docs/backend/system-service-control.md).
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -992,7 +1118,7 @@ Released: 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
|
||||
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/deprecated/earth-module-plan.md).
|
||||
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
|
||||
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ This document connects three existing planning threads into one implementation r
|
||||
|
||||
Related documents:
|
||||
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agent-architecture-plan.md)
|
||||
- [aiprovider](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [datasource-health-plan](/home/ray/dev/linkong/planet/docs/agents/datasource-health-plan.md)
|
||||
- [agent-architecture-plan](/home/ray/dev/linkong/planet/docs/agents/agent-architecture-plan.md)
|
||||
|
||||
|
||||
## Big Picture
|
||||
17
docs/deprecated/README.md
Normal file
17
docs/deprecated/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Deprecated Docs
|
||||
|
||||
这个目录用于存放两类文档:
|
||||
|
||||
1. 已经完成、主要保留为历史记录的实施计划
|
||||
2. 已被现有实现或新方案替代的旧计划
|
||||
|
||||
放到这里并不代表这些文档“错误”,而是表示:
|
||||
|
||||
- 它们不再适合作为当前开发的主指导文档
|
||||
- 如果要了解历史决策、演进路径或旧设计背景,仍然可以参考
|
||||
|
||||
当前归档原则:
|
||||
|
||||
- 明确写明“已完成”的计划,优先归档
|
||||
- 已被正式实现替代、继续放在 `docs/` 根目录会误导后续开发的计划,归档
|
||||
- 仍然指导未来开发、尚未完成或仍有明确执行价值的文档,继续保留在 `docs/`
|
||||
165
docs/deprecated/hud-panel-component-plan.md
Normal file
165
docs/deprecated/hud-panel-component-plan.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HUD Panel Component Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Unify Earth HUD panels into a reusable component layer so new panels can share:
|
||||
|
||||
- a consistent shell
|
||||
- a consistent header
|
||||
- a consistent action-button system
|
||||
- a consistent body and collapse pattern
|
||||
|
||||
## Scope
|
||||
|
||||
Target panels:
|
||||
|
||||
- `tv-panel`
|
||||
- `news-panel`
|
||||
- `legend`
|
||||
- `layer-panel`
|
||||
- `earth-stats`
|
||||
- `info-card`
|
||||
- settings modal header/actions
|
||||
|
||||
## Component Model
|
||||
|
||||
### Base shell
|
||||
|
||||
- `.hud-panel`
|
||||
- `.hud-panel--compact`
|
||||
- `.hud-panel--media`
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel-hidden`
|
||||
- `.hud-panel.is-dragging`
|
||||
- `.hud-panel.is-layout-animating`
|
||||
|
||||
### Header
|
||||
|
||||
- `.hud-panel__header`
|
||||
- `.hud-panel__title-group`
|
||||
- `.hud-panel__title`
|
||||
- `.hud-panel__subtitle`
|
||||
- `.hud-panel__chip`
|
||||
- `.hud-panel__actions`
|
||||
|
||||
Header baseline rule:
|
||||
|
||||
- Header title styling is fixed by the component layer and should not drift per panel
|
||||
- Title font size, font weight, letter spacing, line height, text color, and vertical alignment come from the shared header tokens and structure
|
||||
- Header divider, border treatment, inner spacing, and title-to-actions alignment are part of the same shared baseline
|
||||
- Panel-specific header differences should be limited to explicit variants such as `compact` or `media`, or token overrides with documented intent
|
||||
- “Looks close enough” local header overrides should be treated as temporary compatibility code and removed during migration
|
||||
|
||||
### Actions
|
||||
|
||||
- `.hud-panel__action`
|
||||
- `.hud-panel__action--icon`
|
||||
- `.hud-panel__action--collapse`
|
||||
- `.hud-panel__action--close`
|
||||
- `.hud-panel__action--refresh`
|
||||
- `.hud-panel__action--external`
|
||||
|
||||
Action-button baseline rule:
|
||||
|
||||
- Header action buttons must have one fixed default style baseline across all HUD panels
|
||||
- Default width behavior, padding, icon size, radius, alignment, hover, and active feedback all come from `.hud-panel__action`
|
||||
- Panel-specific differences must be expressed through explicit variants or token overrides, not ad-hoc local button rewrites
|
||||
- `close` buttons are part of the same default action system and must not silently fall back to a separate legacy box model
|
||||
|
||||
### Body
|
||||
|
||||
- `.hud-panel__body`
|
||||
- `.hud-panel__body--scroll`
|
||||
- `.hud-panel__body--collapsible`
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
- `.hud-panel--collapsed`
|
||||
- `.hud-panel--expand-up`
|
||||
- `.hud-panel--expand-down`
|
||||
|
||||
Adaptive collapse / expand rule:
|
||||
|
||||
- HUD panels support two expansion directions:
|
||||
- top-to-bottom expansion
|
||||
- bottom-to-top expansion
|
||||
- Expansion direction should be decided at runtime from available viewport space rather than hardcoded per panel
|
||||
- Use:
|
||||
- `d` = available distance from the header anchor to the viewport bottom edge
|
||||
- `h` = expected expanded panel height
|
||||
- buffer = `20px`
|
||||
- Collapsed-state direction rule:
|
||||
- if `d > h + 20px`, the next action direction is `expand-up`
|
||||
- if `d <= h + 20px`, the next action direction is `expand-down`
|
||||
- To avoid jitter around the threshold, the shared controller should keep a small hysteresis band:
|
||||
- if the current direction is already `up`, keep it until `d <= h`
|
||||
- if the current direction is already `down`, keep it until `d > h + 20px`
|
||||
- The opposite edge is still a safety guard:
|
||||
- if the chosen side cannot fit at all, fall back to the other side if it can fit
|
||||
- if neither side fully fits, choose the side with more space and let the body scroll
|
||||
- If neither direction fully fits, choose the direction with more available space and let the body scroll
|
||||
- Collapse icon direction must match the active expansion direction so the icon always describes the real open/close motion
|
||||
- The collapse icon describes the next action, not the current state
|
||||
- This mapping is fixed component behavior and must not drift per panel:
|
||||
- collapsed + expand-down => `expand_more`
|
||||
- expanded + expand-down => `expand_less`
|
||||
- collapsed + expand-up => `expand_less`
|
||||
- expanded + expand-up => `expand_more`
|
||||
- Panels must not combine icon-name swapping with extra CSS rotation for the same collapse control
|
||||
- Expansion direction and icon direction must come from one shared source of truth in the component controller
|
||||
- The direction decision should be recomputed when opening, resizing the viewport, or restoring a dragged panel near another edge
|
||||
|
||||
## Tokens
|
||||
|
||||
Promote panel differences into CSS variables instead of duplicating selectors:
|
||||
|
||||
- `--hud-panel-padding`
|
||||
- `--hud-header-padding`
|
||||
- `--hud-header-gap`
|
||||
- `--hud-action-padding`
|
||||
- `--hud-action-gap`
|
||||
- `--hud-action-icon-size`
|
||||
- `--hud-body-gap`
|
||||
- `--hud-body-max-height`
|
||||
- `--hud-chip-radius`
|
||||
- `--hud-title-font-size`
|
||||
- `--hud-title-font-weight`
|
||||
- `--hud-title-letter-spacing`
|
||||
- `--hud-title-line-height`
|
||||
- `--hud-title-color`
|
||||
- `--hud-header-border-color`
|
||||
- `--hud-header-divider-opacity`
|
||||
- `--hud-expand-direction`
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Build the shared component layer in `frontend/public/earth/css/hud.css`
|
||||
2. Migrate `tv-panel` and `news-panel` first as the reference implementation
|
||||
3. Migrate `legend` and `layer-panel` into a compact variant
|
||||
4. Migrate `earth-stats` and `info-card`
|
||||
5. Align settings modal header/actions with the same action system
|
||||
6. Remove legacy one-off button selectors after verification
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change panel behavior and data flow during the first pass
|
||||
- Keep old class names temporarily as compatibility hooks
|
||||
- Prefer variable overrides over per-panel reimplementation
|
||||
- Treat header action-button default styling as fixed component API, not per-panel design space
|
||||
- Treat header title typography, border, and divider styling as fixed component API, not per-panel design space
|
||||
- Treat collapse direction as a component behavior contract, not a one-off panel trick
|
||||
- Treat collapse icon semantics as a component behavior contract, not a per-panel visual preference
|
||||
- Verify header alignment and drag/collapse behavior after each migration batch
|
||||
|
||||
## First Implementation Batch
|
||||
|
||||
Batch 1 should only do:
|
||||
|
||||
- shared header structure
|
||||
- shared action-button system
|
||||
- shared title typography and header border/divider baseline
|
||||
- shared collapsible body pattern
|
||||
- adaptive collapse direction logic and direction-aware collapse icons
|
||||
- migration of `tv-panel` and `news-panel`
|
||||
|
||||
That keeps risk low while giving the rest of the HUD a stable target to migrate toward.
|
||||
@@ -187,7 +187,7 @@ Current reality:
|
||||
- that is expected, because incidents are aggregated and de-noised
|
||||
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
|
||||
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/bgp-region-aggregation-plan.md).
|
||||
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-region-aggregation-plan.md).
|
||||
|
||||
So the immediate next milestone is:
|
||||
|
||||
@@ -17,7 +17,7 @@ It is an aggregation/view-model layer:
|
||||
|
||||
## Why This Layer Exists
|
||||
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/bgp-context.md):
|
||||
Current product gap from [bgp-context.md](/home/ray/dev/linkong/planet/docs/earth/bgp-context.md):
|
||||
|
||||
- incident density is naturally low
|
||||
- anomaly density is higher, but still not enough to keep the globe expressive all the time
|
||||
@@ -290,7 +290,7 @@ Each feature should include:
|
||||
|
||||
## Earth Rendering Plan
|
||||
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/bgp-earth-rendering-plan.md).
|
||||
Detailed visual layering guidance is expanded in [bgp-earth-rendering-plan.md](/home/ray/dev/linkong/planet/docs/earth/bgp-earth-rendering-plan.md).
|
||||
|
||||
### Layer Relationship
|
||||
|
||||
715
docs/earth/earth-celestial-background-plan.md
Normal file
715
docs/earth/earth-celestial-background-plan.md
Normal file
@@ -0,0 +1,715 @@
|
||||
# Earth 天球背景与日月位置实施方案
|
||||
|
||||
## 目标
|
||||
|
||||
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
|
||||
|
||||
1. 用真实天球背景替换当前随机星点
|
||||
2. 在当前时间下显示太阳与月亮的相对位置
|
||||
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
|
||||
|
||||
本方案优先追求:
|
||||
|
||||
- 与当前 Three.js Earth 架构兼容
|
||||
- 风险可控
|
||||
- 先落地一版真实感明显提升的 V1
|
||||
- 为后续更严格的天文参考系升级预留余地
|
||||
|
||||
## 当前现状
|
||||
|
||||
当前 Earth 的基础条件已经具备:
|
||||
|
||||
- 地球、云层、地形、网格都基于 Three.js,主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
|
||||
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不需要重写 Earth
|
||||
- 可以在现有 scene/world 层新增一个 celestial layer
|
||||
- 第一阶段不必拆 Earth / satellite / cable 的参考系
|
||||
|
||||
## 总体策略
|
||||
|
||||
采用“两层现实”设计:
|
||||
|
||||
### 1. 世界层(world-space celestial layer)
|
||||
|
||||
用于放置:
|
||||
|
||||
- 天球背景
|
||||
- 太阳
|
||||
- 月亮
|
||||
- 太阳光方向
|
||||
|
||||
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
|
||||
|
||||
### 2. 地球层(earth-fixed layer)
|
||||
|
||||
继续保持当前结构:
|
||||
|
||||
- 海缆
|
||||
- 登陆点
|
||||
- 卫星点与轨迹
|
||||
- BGP 覆盖
|
||||
- 地球纹理、云层、地形
|
||||
|
||||
这些对象继续挂在 `earthObj` 下,不打断现有交互。
|
||||
|
||||
## 为什么先这样做
|
||||
|
||||
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
|
||||
如果现在直接做严格惯性参考系改造,会同时影响:
|
||||
|
||||
- `earthObj.rotation`
|
||||
- 卫星轨迹与锁定逻辑
|
||||
- 海缆与登陆点附着关系
|
||||
- resetView / autoRotate / hover / click 等交互链路
|
||||
|
||||
所以第一阶段只做:
|
||||
|
||||
- 真正的天空
|
||||
- 真正的日月方向
|
||||
- 不碰现有 Earth 附着对象的语义
|
||||
|
||||
## 推荐技术选型
|
||||
|
||||
### 天文计算库
|
||||
|
||||
推荐:
|
||||
|
||||
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
|
||||
原因:
|
||||
|
||||
- 有 JavaScript 版本
|
||||
- 支持 Sun / Moon 的矢量与坐标变换
|
||||
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
|
||||
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
|
||||
|
||||
不作为主选的库:
|
||||
|
||||
- [SunCalc](https://github.com/mourner/suncalc)
|
||||
|
||||
原因:
|
||||
|
||||
- 更偏本地观察者视角的太阳/月亮高度角
|
||||
- 用于“地面日出日落”很好
|
||||
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
|
||||
|
||||
### Three.js 表现层
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 天球:内翻球壳 + 星图纹理
|
||||
- 太阳:`THREE.Sprite`
|
||||
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
|
||||
- 太阳光:`THREE.DirectionalLight`
|
||||
|
||||
参考:
|
||||
|
||||
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
|
||||
|
||||
## 天球背景资源与星体数据来源
|
||||
|
||||
为避免把“视觉背景”和“可计算天体位置”混为一谈,本方案明确分成两类资源:
|
||||
|
||||
### 1. 背景资源:全天星图贴图
|
||||
|
||||
用于 Phase 1 的“真实天空背景”。
|
||||
|
||||
推荐优先来源:
|
||||
|
||||
- NASA SVS 的 Tycho 全天星图
|
||||
- [The Tycho Catalog Skymap - Version 2.0](https://svs.gsfc.nasa.gov/3572/)
|
||||
- NASA Deep Star Maps 2020
|
||||
- SatelliteMap.space 在 credits 中明确提到其使用了 `NASA Deep Star Maps 2020 - High-resolution star field (1.7 billion stars from Gaia DR2)` 作为星空视觉资源
|
||||
- 这说明行业内成熟实现并不一定直接渲染全部星表点,而很可能先使用一张高质量官方深空星图作为背景层
|
||||
- 如需后续替换,也可评估 ESA / Gaia 的全天 sky map 资源
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议要求:
|
||||
|
||||
- 使用官方来源或官方衍生可复用资源
|
||||
- 等距矩形投影(equirectangular)
|
||||
- 坐标定义尽量明确为赤道坐标展开
|
||||
- 分辨率建议至少 `4k`
|
||||
- 颜色不要过亮,避免压过 Earth HUD 前景
|
||||
- 尽量优先选择官方天文机构已经生产好的深空图,而不是自行拼接低质量星空纹理
|
||||
|
||||
建议本地资源目录:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
### 2. 位置数据:星表与天体计算
|
||||
|
||||
用于 Phase 2+ 的“位置正确的星体”。
|
||||
|
||||
推荐来源分两层:
|
||||
|
||||
- 太阳、月亮位置
|
||||
- 使用 [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
- 恒星位置
|
||||
- 第一优先:Hipparcos / Tycho
|
||||
- [Hipparcos overview](https://www.cosmos.esa.int/web/Hipparcos)
|
||||
- [Hipparcos catalogues](https://www.cosmos.esa.int/web/hipparcos/catalogues)
|
||||
- 第二优先:Gaia
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议策略:
|
||||
|
||||
- V1:背景球壳只用全天星图,不立即生成全量恒星点
|
||||
- V2:只挑选亮星(例如星等 `< 5.5`)生成恒星点层
|
||||
- V3:如果确实需要更丰富的星场,再逐步扩展到更深星等
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 背景球壳负责“天球真实感”
|
||||
- 亮星点负责“位置正确、可后续标注和高亮”
|
||||
- 不需要一开始就处理数十万甚至数百万颗星
|
||||
|
||||
### 3. 对外部成熟实现的参考结论
|
||||
|
||||
`SatelliteMap.space` 的公开 credits 提供了一个很有价值的参考样板:
|
||||
|
||||
- 图形渲染使用 `TWGL.js`
|
||||
- 天文计算使用 `Skyfield` 与 `Astronomia`
|
||||
- 星空/天球视觉资源使用 `NASA Deep Star Maps 2020`
|
||||
|
||||
这给本项目的启发是:
|
||||
|
||||
- “真实感强的天球背景”完全可以先依赖官方高质量深空图
|
||||
- “位置正确的动态天体”则应依赖单独的天文计算链路
|
||||
- 没有必要在第一版就直接渲染完整星表
|
||||
|
||||
因此本项目推荐继续坚持两层拆分:
|
||||
|
||||
- 背景层:官方深空图 / 全天星图
|
||||
- 计算层:太阳、月亮与后续亮星点
|
||||
|
||||
## 如何保证星体位置正确
|
||||
|
||||
位置正确不是只看“图看起来像”,而是要统一参考系和转换链路。
|
||||
|
||||
### 1. 统一坐标基准
|
||||
|
||||
本方案推荐统一使用:
|
||||
|
||||
- `J2000` 赤道坐标系作为恒星位置基准
|
||||
|
||||
原因:
|
||||
|
||||
- Hipparcos / Tycho 资料和大量天文可视化都容易映射到该基准
|
||||
- 太阳、月亮也可以通过 Astronomy Engine 转到同一坐标系
|
||||
- 这样背景、恒星点、太阳、月亮就能共用一套 sky orientation
|
||||
|
||||
### 2. 背景贴图与点位必须使用同一展开逻辑
|
||||
|
||||
如果背景球壳使用赤道坐标全天图,那么:
|
||||
|
||||
- 亮星点也必须按赤道坐标贴到同一球面方向
|
||||
- 太阳/月亮 sprite 也必须按赤道坐标转换后落到同一 world-space
|
||||
|
||||
否则会出现:
|
||||
|
||||
- 背景银河带是对的
|
||||
- 但太阳/月亮或亮星点飘到不匹配的位置
|
||||
|
||||
### 3. RA / Dec 到 Three.js 坐标的落点方式
|
||||
|
||||
亮星点和日月方向最终都要转成单位球面向量。
|
||||
|
||||
概念步骤:
|
||||
|
||||
1. 读取赤经 `RA`
|
||||
2. 读取赤纬 `Dec`
|
||||
3. 转成弧度
|
||||
4. 映射到单位球面向量
|
||||
5. 再根据 Three.js 当前世界坐标定义做轴向映射
|
||||
|
||||
参考公式:
|
||||
|
||||
```text
|
||||
x = cos(dec) * cos(ra)
|
||||
y = sin(dec)
|
||||
z = cos(dec) * sin(ra)
|
||||
```
|
||||
|
||||
实际接入 Three.js 时,需要做一次项目内坐标轴校准:
|
||||
|
||||
- 验证 `RA = 0h`
|
||||
- 验证 `RA = 6h`
|
||||
- 验证北天极
|
||||
- 验证银河带主方向
|
||||
|
||||
然后确定最终的:
|
||||
|
||||
- `x/y/z` 对应 Three.js 哪个轴
|
||||
- 是否需要 `z` 取反
|
||||
- 是否需要整体再做一个固定 `rotation`
|
||||
|
||||
建议把这层显式封装在:
|
||||
|
||||
```js
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
不要把轴映射散落在不同模块里。
|
||||
|
||||
### 4. 背景球壳与恒星点的关系
|
||||
|
||||
推荐最终组合:
|
||||
|
||||
- 背景层:全天星图球壳
|
||||
- 点位层:亮星点
|
||||
- 动态层:太阳 / 月亮
|
||||
|
||||
这样有三个好处:
|
||||
|
||||
- 背景层提供密集真实的天空纹理
|
||||
- 亮星点提供位置正确、可扩展的标注基础
|
||||
- 太阳/月亮提供与时间相关的真实动态对象
|
||||
|
||||
## 数据与资源建议清单
|
||||
|
||||
### 推荐首批引入资源
|
||||
|
||||
1. 全天星图
|
||||
- 来源:NASA Tycho all-sky map
|
||||
- 用途:背景球壳纹理
|
||||
|
||||
2. 月亮纹理
|
||||
- 用途:Phase 4 月相表现
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/moon_albedo_2k.jpg`
|
||||
|
||||
3. 太阳 glow 贴图
|
||||
- 用途:太阳 sprite halo
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/sun_glow.png`
|
||||
|
||||
### 推荐首批数据文件
|
||||
|
||||
如果要上亮星层,建议新增一个预处理后的轻量数据文件:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/bright-stars.json`
|
||||
|
||||
建议字段:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 32349,
|
||||
"name": "Sirius",
|
||||
"raDeg": 101.2875,
|
||||
"decDeg": -16.7161,
|
||||
"mag": -1.46,
|
||||
"colorIndex": 0.00
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
建议不要在浏览器里直接吞原始 Gaia 大表,而是先离线裁剪成:
|
||||
|
||||
- 只保留亮星
|
||||
- 只保留渲染必需字段
|
||||
- JSON 或二进制轻量格式
|
||||
|
||||
## 资源与数据实施路线
|
||||
|
||||
### 路线 A:先做可用版本(推荐)
|
||||
|
||||
1. 引入 NASA Tycho 全天图
|
||||
- 或评估替换为更接近 SatelliteMap.space 路线的 `NASA Deep Star Maps 2020`
|
||||
2. 实现背景球壳
|
||||
3. 用 Astronomy Engine 计算太阳/月亮方向
|
||||
4. 暂不做亮星点
|
||||
|
||||
优点:
|
||||
|
||||
- 最快见效
|
||||
- 风险最低
|
||||
- 就能明显提升天球真实感
|
||||
|
||||
### 路线 B:在 A 基础上增强
|
||||
|
||||
1. 离线生成 `bright-stars.json`
|
||||
2. 浏览器端渲染亮星点
|
||||
3. 后续可加:
|
||||
- 星座线
|
||||
- 亮星名称
|
||||
- 特定星体高亮
|
||||
|
||||
优点:
|
||||
|
||||
- 背景真实感和“位置正确的可交互星体”同时兼顾
|
||||
|
||||
## 代码模块建议细化
|
||||
|
||||
### 新增模块
|
||||
|
||||
- `frontend/public/earth/js/celestial.js`
|
||||
- 管理天球背景
|
||||
- 管理太阳/月亮
|
||||
- 管理亮星层(后续)
|
||||
|
||||
- `frontend/public/earth/js/celestial-data.js`
|
||||
- 资源路径
|
||||
- 星图方向配置
|
||||
- 亮星数据加载(后续)
|
||||
|
||||
### 建议函数设计
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date)
|
||||
export function setCelestialVisibility(visible)
|
||||
export function disposeCelestialLayer()
|
||||
|
||||
function loadStarMapTexture()
|
||||
function createSkySphere(texture)
|
||||
function createSunSprite()
|
||||
function createMoonSprite()
|
||||
function getSunEquatorialPosition(date)
|
||||
function getMoonEquatorialPosition(date)
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
### 推荐后续预处理脚本
|
||||
|
||||
如要引入亮星层,建议单独做离线脚本:
|
||||
|
||||
- `scripts/build_bright_stars.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 Hipparcos / Tycho 源数据读取
|
||||
- 过滤亮星
|
||||
- 生成 `bright-stars.json`
|
||||
|
||||
这样浏览器端只消费轻量结果,不承担大表解析成本。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
## Phase 1:真实天球背景
|
||||
|
||||
### 目标
|
||||
|
||||
用真实全天星图替换当前随机星点背景。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 新增一张全天星图纹理
|
||||
|
||||
建议路径:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
纹理要求:
|
||||
|
||||
- 等距矩形投影
|
||||
- 赤经/赤纬坐标展开
|
||||
- 无地平线、无地景遮挡
|
||||
- 尽量深色、弱干扰,适合大屏 HUD 叠加
|
||||
|
||||
2. 新增天球球壳
|
||||
|
||||
新增模块:
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
建议接口:
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date, camera, earth)
|
||||
export function disposeCelestialLayer()
|
||||
```
|
||||
|
||||
3. 实现一个大半径内翻球体
|
||||
|
||||
建议参数:
|
||||
|
||||
- 半径:`600 ~ 900`
|
||||
- 材质:`MeshBasicMaterial`
|
||||
- `side: THREE.BackSide`
|
||||
- 不受场景光照影响
|
||||
- 始终围绕场景中心
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 初始加载后背景不再是随机星点
|
||||
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
|
||||
- 不明显干扰海缆/卫星/BGP 的前景识别
|
||||
|
||||
## Phase 2:太阳与月亮真实位置
|
||||
|
||||
### 目标
|
||||
|
||||
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 在 `celestial.js` 内封装天体位置计算
|
||||
|
||||
建议函数:
|
||||
|
||||
```js
|
||||
function getSunDirection(date)
|
||||
function getMoonDirection(date)
|
||||
```
|
||||
|
||||
输出统一为 world-space `THREE.Vector3`
|
||||
|
||||
2. 太阳显示
|
||||
|
||||
- 一个暖色发光 sprite
|
||||
- 比月亮更大、更亮
|
||||
- 可选添加柔和 halo
|
||||
|
||||
3. 月亮显示
|
||||
|
||||
- 一个较小 sprite 或 sphere
|
||||
- 灰白偏冷色
|
||||
- 后续 Phase 3 再做月相
|
||||
|
||||
4. 更新频率
|
||||
|
||||
不要每帧重新做完整天文计算,建议:
|
||||
|
||||
- 每 30 秒或 60 秒重算一次真实位置
|
||||
- 渲染帧内做平滑过渡
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面可见太阳与月亮两个对象
|
||||
- 时间变化时位置会更新
|
||||
- 日月不会跟随地球局部旋转而错误附着
|
||||
|
||||
## Phase 3:太阳驱动地球受光
|
||||
|
||||
### 目标
|
||||
|
||||
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 替换或接管当前主定向光
|
||||
|
||||
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 保留环境补光
|
||||
- 主太阳光方向由 `sunDirection` 决定
|
||||
|
||||
2. 太阳光参数建议
|
||||
|
||||
- `DirectionalLight` 颜色偏暖白
|
||||
- 强度略高于当前主光
|
||||
- 保留一个弱背光作为氛围补偿,避免背面过死黑
|
||||
|
||||
3. 先不做物理级大气散射
|
||||
|
||||
第一版只要求:
|
||||
|
||||
- 亮面与暗面方向真实
|
||||
- 云层和大气仍保持当前风格
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地球明暗面会随太阳方向改变
|
||||
- 太阳 sprite 和地球亮面方向一致
|
||||
- 不破坏现有海缆、卫星、BGP 的可见性
|
||||
|
||||
## Phase 4:月相与天文细节增强
|
||||
|
||||
### 目标
|
||||
|
||||
在日月真实位置基础上增加更强的“天文可信度”。
|
||||
|
||||
### 可选项
|
||||
|
||||
1. 月相
|
||||
|
||||
- 根据日月夹角计算 illuminated fraction
|
||||
- 用月相纹理或 shader 表达盈亏
|
||||
|
||||
2. 赤道/黄道辅助线
|
||||
|
||||
- 可作为开发调试层,不默认显示
|
||||
|
||||
3. 太阳 terminator 增强
|
||||
|
||||
- 给地球夜面加入更自然的 night tint
|
||||
- 未来可叠加城市夜光纹理
|
||||
|
||||
4. 天文时间入口
|
||||
|
||||
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 月亮不再只是一个静态圆点
|
||||
- 后续扩展行星或观测模式时无需推倒重来
|
||||
|
||||
## Phase 5:严格参考系升级(可选,不作为 V1 必做)
|
||||
|
||||
### 目标
|
||||
|
||||
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
|
||||
|
||||
### 需要处理的问题
|
||||
|
||||
- 地球自转角与 UTC 的一致性
|
||||
- 赤道坐标系、地固坐标系、相机交互层分离
|
||||
- 卫星轨道显示与 Earth 旋转同步关系
|
||||
- resetView 和 autoRotate 的语义重定
|
||||
|
||||
### 风险
|
||||
|
||||
这一步会影响:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
因此不建议与 V1 同时推进。
|
||||
|
||||
## 代码改造清单
|
||||
|
||||
## 1. 新增文件
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理天球背景、太阳、月亮
|
||||
- 对外暴露 init/update/dispose
|
||||
|
||||
## 2. 修改 `constants.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
export const CELESTIAL_CONFIG = {
|
||||
sphereRadius: 800,
|
||||
updateIntervalMs: 60000,
|
||||
sunSpriteScale: 28,
|
||||
moonSpriteScale: 16,
|
||||
sunLightIntensity: 1.25,
|
||||
ambientIntensity: 0.28,
|
||||
backLightIntensity: 0.18,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 修改 `main.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
1. `init()` 中:
|
||||
- 初始化 celestial layer
|
||||
2. `addLights()` 中:
|
||||
- 把固定太阳光改成可更新的 celestial sun light
|
||||
3. `animate()` 中:
|
||||
- 每帧调 `updateCelestialLayer()`
|
||||
4. `destroy()` 中:
|
||||
- 清理 celestial 资源
|
||||
|
||||
## 4. 修改 `earth.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
- `createStars()` 逐步退役
|
||||
- 第一阶段可先保留作为 fallback
|
||||
- 当真实星图加载成功后,不再显示随机星点
|
||||
|
||||
## 5. 新增资源
|
||||
|
||||
目录建议:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/`
|
||||
|
||||
建议至少包含:
|
||||
|
||||
- `starmap_equatorial_4k.jpg`
|
||||
- `sun_glow.png`
|
||||
- `moon_albedo_2k.jpg`
|
||||
|
||||
## 数据流设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
|
||||
B --> C["创建天球球壳"]
|
||||
B --> D["创建太阳 sprite + 主定向光"]
|
||||
B --> E["创建月亮 sprite"]
|
||||
|
||||
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
|
||||
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
|
||||
H --> I["更新 sun sprite / moon sprite 位置"]
|
||||
H --> J["更新太阳 DirectionalLight 方向"]
|
||||
J --> K["地球昼夜方向变化"]
|
||||
```
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
### 1. 星图投影方向容易反
|
||||
|
||||
这会表现为:
|
||||
|
||||
- 星图左右镜像
|
||||
- 赤经方向颠倒
|
||||
- 日月位置和背景对不上
|
||||
|
||||
建议:
|
||||
|
||||
- 先做一个开发调试模式
|
||||
- 显示赤经/赤纬参考点,快速校正纹理朝向
|
||||
|
||||
### 2. 不要让天球跟随 Earth 旋转
|
||||
|
||||
天球背景和日月必须属于 scene/world,而不是 `earthObj`。
|
||||
|
||||
### 3. 不要每帧做重型天文计算
|
||||
|
||||
真实位置更新应节流,否则会浪费 CPU。
|
||||
|
||||
### 4. 月亮先求“方向正确”,再求“月相精致”
|
||||
|
||||
月相属于第二步优化,不应阻塞 V1 上线。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 新建 `celestial.js`
|
||||
2. 用星图球壳替换随机星点
|
||||
3. 接入 Astronomy Engine
|
||||
4. 加太阳/月亮 sprite
|
||||
5. 用太阳方向驱动主光
|
||||
6. 再决定要不要做月相和更严格参考系
|
||||
|
||||
## 最终建议
|
||||
|
||||
对于当前 Planet Earth,最稳妥的方案是:
|
||||
|
||||
- 先做真实天球背景
|
||||
- 再做真实太阳/月亮方向
|
||||
- 再让太阳驱动地球受光
|
||||
- 暂时不做 Earth 参考系重构
|
||||
|
||||
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。
|
||||
@@ -30,7 +30,7 @@
|
||||
- [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py)
|
||||
- [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py)
|
||||
- [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py)
|
||||
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
|
||||
### 2. 本地运行与配置打通
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
相关文件:
|
||||
|
||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||
- [docs/frontend/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend/frontend-layout-guidelines.md)
|
||||
- [frontend/src/pages/BGP/BGP.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/BGP/BGP.tsx)
|
||||
|
||||
## 当前限制
|
||||
@@ -16,12 +16,20 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.27.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.29.1`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.29.1` | bugfix | `dev` | `pending` | Earth 加载通知条改为队列式单面板显示,brand panel 去框并收敛昼夜与选中态可读性 |
|
||||
| `0.29.0` | feature | `dev` | `pending` | Earth 新增天球背景与太阳/月亮位置层,强化昼夜分隔并收口卫星图例与图层面板交互 |
|
||||
| `0.28.2` | bugfix | `dev` | `pending` | 修正媒体情报 tab 尺寸记忆与切换锚点逻辑,并清理 docs 根目录遗留旧路径文档 |
|
||||
| `0.28.1` | bugfix | `dev` | `pending` | 收口 Earth 媒体情报面板命名与 tab 文案,整理 docs 分组并归档已完成/废弃计划文档 |
|
||||
| `0.28.0` | feature | `dev` | `pending` | 合并 Earth 媒体情报面板,整合新闻直播与态势聚合 tab,并稳定 TV/news 的 reform、resize 与共享 HUD 行为 |
|
||||
| `0.27.8` | bugfix | `dev` | `pending` | 统一 Earth HUD 默认折叠逻辑,修复图例与图层面板箭头和底边阈值行为 |
|
||||
| `0.27.7` | bugfix | `dev` | `pending` | 修复电视直播源编辑持久化问题,清理表格空白占位列并统一可折叠操作列 |
|
||||
| `0.27.6` | improvement | `dev` | `pending` | BGP/用户表格滚动条修复,Playground 响应式按钮与输入框收起优化 |
|
||||
| `0.27.5` | bugfix | `dev` | `pending` | 统一控制台自定义滚动条,修复 alerts/BGP 响应式滚动与采集进度完成态显示 |
|
||||
| `0.27.4` | improvement | `dev` | — | info-card 懒加载动态挂载,页面初始不再有隐藏节点 |
|
||||
| `0.27.3` | improvement | `dev` | — | TV panel 折叠方向稳定、视频跳动修复、图例折叠按钮修复、搜索图标调整 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.27.5",
|
||||
"version": "0.29.1",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal file
30
frontend/public/earth/assets/celestial/bright-stars.json
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{ "id": 32349, "name": "Sirius", "raDeg": 101.2875, "decDeg": -16.7161, "mag": -1.46, "colorIndex": 0.00 },
|
||||
{ "id": 30438, "name": "Canopus", "raDeg": 95.9879, "decDeg": -52.6957, "mag": -0.74, "colorIndex": 0.15 },
|
||||
{ "id": 69673, "name": "Arcturus", "raDeg": 213.9153, "decDeg": 19.1824, "mag": -0.05, "colorIndex": 1.23 },
|
||||
{ "id": 71683, "name": "Alpha Centauri", "raDeg": 219.9021, "decDeg": -60.8339, "mag": -0.01, "colorIndex": 0.71 },
|
||||
{ "id": 91262, "name": "Vega", "raDeg": 279.2347, "decDeg": 38.7837, "mag": 0.03, "colorIndex": 0.00 },
|
||||
{ "id": 24608, "name": "Capella", "raDeg": 79.1723, "decDeg": 45.9979, "mag": 0.08, "colorIndex": 0.80 },
|
||||
{ "id": 24436, "name": "Rigel", "raDeg": 78.6345, "decDeg": -8.2016, "mag": 0.12, "colorIndex": -0.03 },
|
||||
{ "id": 37279, "name": "Procyon", "raDeg": 114.8255, "decDeg": 5.2250, "mag": 0.34, "colorIndex": 0.42 },
|
||||
{ "id": 7588, "name": "Achernar", "raDeg": 24.4286, "decDeg": -57.2368, "mag": 0.46, "colorIndex": -0.16 },
|
||||
{ "id": 27989, "name": "Betelgeuse", "raDeg": 88.7929, "decDeg": 7.4071, "mag": 0.50, "colorIndex": 1.85 },
|
||||
{ "id": 68702, "name": "Hadar", "raDeg": 210.9559, "decDeg": -60.3731, "mag": 0.61, "colorIndex": -0.23 },
|
||||
{ "id": 97649, "name": "Altair", "raDeg": 297.6958, "decDeg": 8.8683, "mag": 0.76, "colorIndex": 0.22 },
|
||||
{ "id": 60718, "name": "Acrux", "raDeg": 186.6490, "decDeg": -63.0991, "mag": 0.77, "colorIndex": -0.24 },
|
||||
{ "id": 21421, "name": "Aldebaran", "raDeg": 68.9800, "decDeg": 16.5093, "mag": 0.85, "colorIndex": 1.54 },
|
||||
{ "id": 65474, "name": "Spica", "raDeg": 201.2983, "decDeg": -11.1614, "mag": 0.98, "colorIndex": -0.23 },
|
||||
{ "id": 80763, "name": "Antares", "raDeg": 247.3519, "decDeg": -26.4320, "mag": 1.06, "colorIndex": 1.83 },
|
||||
{ "id": 37826, "name": "Pollux", "raDeg": 116.3289, "decDeg": 28.0262, "mag": 1.14, "colorIndex": 1.00 },
|
||||
{ "id": 113368, "name": "Fomalhaut", "raDeg": 344.4128, "decDeg": -29.6222, "mag": 1.16, "colorIndex": 0.09 },
|
||||
{ "id": 102098, "name": "Deneb", "raDeg": 310.3579, "decDeg": 45.2803, "mag": 1.25, "colorIndex": 0.09 },
|
||||
{ "id": 49669, "name": "Regulus", "raDeg": 152.0929, "decDeg": 11.9672, "mag": 1.35, "colorIndex": -0.11 },
|
||||
{ "id": 65477, "name": "Mimosa", "raDeg": 191.9303, "decDeg": -59.6888, "mag": 1.25, "colorIndex": -0.23 },
|
||||
{ "id": 33579, "name": "Alphard", "raDeg": 141.8969, "decDeg": -8.6586, "mag": 1.98, "colorIndex": 1.44 },
|
||||
{ "id": 21444, "name": "Bellatrix", "raDeg": 81.2828, "decDeg": 6.3497, "mag": 1.64, "colorIndex": -0.22 },
|
||||
{ "id": 25336, "name": "Elnath", "raDeg": 81.5729, "decDeg": 28.6075, "mag": 1.65, "colorIndex": -0.13 },
|
||||
{ "id": 26311, "name": "Alnilam", "raDeg": 84.0534, "decDeg": -1.2019, "mag": 1.69, "colorIndex": -0.19 },
|
||||
{ "id": 26727, "name": "Alnitak", "raDeg": 85.1897, "decDeg": -1.9426, "mag": 1.77, "colorIndex": -0.19 },
|
||||
{ "id": 25930, "name": "Saiph", "raDeg": 86.9391, "decDeg": -9.6696, "mag": 2.06, "colorIndex": -0.20 },
|
||||
{ "id": 58001, "name": "Alioth", "raDeg": 193.5073, "decDeg": 55.9598, "mag": 1.76, "colorIndex": -0.02 }
|
||||
]
|
||||
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_deep_8k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
BIN
frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -19,6 +19,7 @@
|
||||
--hud-font-size: calc(0.88rem * var(--hud-scale));
|
||||
--hud-font-size-sm: calc(0.75rem * var(--hud-scale));
|
||||
--hud-title-size: calc(1.02rem * var(--hud-scale));
|
||||
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
|
||||
--hud-kicker-size: calc(0.68rem * var(--hud-scale));
|
||||
--hud-surface-top: rgba(17, 31, 53, 0.84);
|
||||
--hud-surface-bottom: rgba(7, 17, 31, 0.76);
|
||||
@@ -82,59 +83,17 @@ body.earth-page {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 240;
|
||||
min-width: min(calc(320px * var(--hud-scale)), 78vw);
|
||||
padding: calc(26px * var(--hud-scale));
|
||||
border-radius: calc(18px * var(--hud-scale));
|
||||
border: 1px solid rgba(77, 184, 255, 0.34);
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.12), transparent 35%),
|
||||
linear-gradient(180deg, rgba(13, 24, 46, 0.95), rgba(7, 14, 28, 0.94));
|
||||
box-shadow:
|
||||
0 0 30px rgba(77, 184, 255, 0.22),
|
||||
0 16px 40px rgba(0, 0, 0, 0.28);
|
||||
text-align: center;
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
.earth-loading-text {
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
.earth-loading-title {
|
||||
font-size: calc(1.15rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-loading-subtitle {
|
||||
margin-top: calc(10px * var(--hud-scale));
|
||||
color: #9ab7d4;
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.earth-loading-spinner {
|
||||
width: calc(40px * var(--hud-scale));
|
||||
height: calc(40px * var(--hud-scale));
|
||||
margin: 0 auto calc(15px * var(--hud-scale));
|
||||
border: 4px solid rgba(77, 184, 255, 0.28);
|
||||
border-top: 4px solid #4db8ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@keyframes earthLoadingPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
transform: scale(0.78);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,20 +28,22 @@
|
||||
|
||||
.stats-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.64rem * var(--hud-scale));
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Reuse hud-panel-close — just override size to match kicker line */
|
||||
.stats-drag-bar .hud-panel-close {
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.stats-drag-bar .hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(12px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── 2-column KPI grid ────────────────────────────────────────── */
|
||||
|
||||
@@ -65,22 +65,6 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hud-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-gap-sm);
|
||||
margin-bottom: var(--hud-gap-sm);
|
||||
padding-bottom: var(--hud-gap-sm);
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.hud-panel-header .hud-panel-title {
|
||||
margin-bottom: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.hud-panel-drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
@@ -90,19 +74,72 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel__header,
|
||||
.hud-panel-header {
|
||||
--hud-header-padding: 0 0 var(--hud-gap-sm);
|
||||
--hud-header-gap: var(--hud-gap-sm);
|
||||
--hud-header-border-color: var(--hud-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-header-gap);
|
||||
margin-bottom: var(--hud-gap-sm);
|
||||
padding: var(--hud-header-padding);
|
||||
border-bottom: 1px solid var(--hud-header-border-color);
|
||||
}
|
||||
|
||||
.hud-panel__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hud-panel__title,
|
||||
.hud-panel__header .hud-panel-title,
|
||||
.hud-panel-header .hud-panel-title {
|
||||
margin: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hud-panel__subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hud-panel__chip {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hud-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hud-panel__action,
|
||||
.hud-panel-close {
|
||||
align-self: flex-start;
|
||||
width: calc(var(--hud-title-size) * 1.24);
|
||||
height: calc(var(--hud-title-size) * 1.24);
|
||||
min-width: calc(var(--hud-title-size) * 1.24);
|
||||
padding: 0;
|
||||
--hud-action-padding: calc(7px * var(--hud-scale));
|
||||
--hud-action-icon-size: calc(16px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
padding: var(--hud-action-padding);
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: auto;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
@@ -112,17 +149,51 @@
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hud-panel__action .material-symbols-rounded,
|
||||
.hud-panel-close .material-symbols-rounded {
|
||||
font-size: calc(var(--hud-title-size) * 0.8);
|
||||
font-size: var(--hud-action-icon-size);
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-panel-close:hover {
|
||||
.hud-panel__action:hover:not(:disabled),
|
||||
.hud-panel-close:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.hud-panel__action:disabled,
|
||||
.hud-panel-close:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hud-panel__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hud-panel__body--collapsible {
|
||||
--hud-body-collapse-gap: var(--hud-gap-sm);
|
||||
--hud-body-max-height: 1000px;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
max-height: var(--hud-body-max-height);
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease,
|
||||
margin 0.22s ease;
|
||||
}
|
||||
|
||||
.hud-panel--collapsed .hud-panel__body--collapsible {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-top: calc(-1 * var(--hud-body-collapse-gap));
|
||||
}
|
||||
|
||||
.hud-panel.is-dragging {
|
||||
transition: none !important;
|
||||
box-shadow:
|
||||
@@ -172,21 +243,32 @@
|
||||
|
||||
.earth-status-message {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: calc(20px * var(--hud-scale));
|
||||
left: 50%;
|
||||
transform: translate(-50%, -18px);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.92), rgba(8, 18, 32, 0.9));
|
||||
border-radius: 14px;
|
||||
padding: 11px 15px;
|
||||
z-index: 210;
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
border: 1px solid var(--hud-border);
|
||||
font-size: 0.9rem;
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
text-align: center;
|
||||
min-width: 180px;
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
background:
|
||||
linear-gradient(90deg, rgba(145, 186, 255, 0.07) 0%, transparent 44%),
|
||||
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--hud-border);
|
||||
border-left-color: rgba(145, 186, 255, 0.32);
|
||||
padding: calc(8px * var(--hud-scale)) calc(20px * var(--hud-scale)) calc(8px * var(--hud-scale)) calc(16px * var(--hud-scale));
|
||||
z-index: 210;
|
||||
box-shadow:
|
||||
var(--hud-shadow-soft),
|
||||
0 0 18px rgba(145, 186, 255, 0.06);
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
text-align: left;
|
||||
min-width: min(calc(160px * var(--hud-scale)), 58vw);
|
||||
max-width: min(calc(440px * var(--hud-scale)), 74vw);
|
||||
color: var(--hud-text);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 0.28s ease,
|
||||
@@ -198,19 +280,110 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Indicator: single dot (transient) or three dots (loading) ── */
|
||||
|
||||
.earth-status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(5px * var(--hud-scale));
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.earth-status-dot {
|
||||
width: calc(7px * var(--hud-scale));
|
||||
height: calc(7px * var(--hud-scale));
|
||||
border-radius: 50%;
|
||||
background: rgba(145, 186, 255, 0.95);
|
||||
box-shadow:
|
||||
0 0 8px rgba(145, 186, 255, 0.7),
|
||||
0 0 20px rgba(145, 186, 255, 0.28);
|
||||
animation: statusDotPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.earth-status-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Loading: three-dot sequential pulse */
|
||||
.earth-status-message.loading .earth-status-dot {
|
||||
animation: earthLoadingPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.earth-status-message.loading .earth-status-dot:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.earth-status-message.loading .earth-status-dot:nth-child(3) {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
/* ── Status color variants ───────────────────────────────────── */
|
||||
|
||||
.earth-status-message.success {
|
||||
color: #d8f7df;
|
||||
border-left: 3px solid #66d18f;
|
||||
color: #dff8e7;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(102, 209, 143, 0.08) 0%, transparent 44%),
|
||||
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
|
||||
border-left-color: rgba(102, 209, 143, 0.38);
|
||||
box-shadow:
|
||||
var(--hud-shadow-soft),
|
||||
0 0 18px rgba(102, 209, 143, 0.06);
|
||||
}
|
||||
|
||||
.earth-status-message.success .earth-status-dot {
|
||||
background: #66d18f;
|
||||
box-shadow:
|
||||
0 0 8px rgba(102, 209, 143, 0.8),
|
||||
0 0 20px rgba(102, 209, 143, 0.3);
|
||||
}
|
||||
|
||||
.earth-status-message.warning {
|
||||
color: #fff2c3;
|
||||
border-left: 3px solid #e4c464;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(228, 196, 100, 0.08) 0%, transparent 44%),
|
||||
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
|
||||
border-left-color: rgba(228, 196, 100, 0.38);
|
||||
box-shadow:
|
||||
var(--hud-shadow-soft),
|
||||
0 0 18px rgba(228, 196, 100, 0.06);
|
||||
}
|
||||
|
||||
.earth-status-message.warning .earth-status-dot {
|
||||
background: #e4c464;
|
||||
box-shadow:
|
||||
0 0 8px rgba(228, 196, 100, 0.8),
|
||||
0 0 20px rgba(228, 196, 100, 0.3);
|
||||
}
|
||||
|
||||
.earth-status-message.error {
|
||||
color: #ffd4d7;
|
||||
border-left: 3px solid #ff7b86;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 123, 134, 0.08) 0%, transparent 44%),
|
||||
linear-gradient(180deg, rgba(22, 36, 58, 0.95), rgba(8, 18, 32, 0.93));
|
||||
border-left-color: rgba(255, 123, 134, 0.38);
|
||||
box-shadow:
|
||||
var(--hud-shadow-soft),
|
||||
0 0 18px rgba(255, 123, 134, 0.06);
|
||||
}
|
||||
|
||||
.earth-status-message.error .earth-status-dot {
|
||||
background: #ff7b86;
|
||||
box-shadow:
|
||||
0 0 8px rgba(255, 123, 134, 0.8),
|
||||
0 0 20px rgba(255, 123, 134, 0.34);
|
||||
}
|
||||
|
||||
@keyframes statusDotPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.82;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.earth-tooltip {
|
||||
@@ -313,10 +486,22 @@
|
||||
.earth-settings-title {
|
||||
margin: 4px 0 0;
|
||||
color: var(--hud-title);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.earth-settings-close {
|
||||
margin-top: 4px;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
@@ -458,4 +643,4 @@
|
||||
/* .hud-panel-legend layout-expanded rule lives in legend.css */
|
||||
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
|
||||
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
|
||||
/* .hud-panel-tv layout-expanded rule lives in tv-panel.css */
|
||||
/* .hud-panel-media layout-expanded rule lives in tv-panel.css */
|
||||
|
||||
@@ -26,13 +26,23 @@
|
||||
.hud-panel-brand {
|
||||
--brand-scale: 0.88;
|
||||
--brand-copy-width: 160px;
|
||||
border-radius: 0;
|
||||
padding: calc(18px * var(--hud-scale)) calc(20px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
/* Reserve full panel height before brand images load */
|
||||
min-height: calc(66px * var(--hud-scale));
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hud-panel-brand::before,
|
||||
.hud-panel-brand::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand {
|
||||
@@ -40,6 +50,20 @@
|
||||
align-items: center;
|
||||
gap: calc(10px * var(--hud-scale) * var(--brand-scale));
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(4px * var(--hud-scale)) calc(-10px * var(--hud-scale)) calc(6px * var(--hud-scale)) calc(-10px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 18% 50%, rgba(145, 186, 255, 0.14), transparent 32%),
|
||||
linear-gradient(90deg, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
filter: blur(14px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__logo {
|
||||
@@ -160,7 +184,7 @@
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: calc(0.92rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
color: var(--hud-title);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
@@ -170,6 +194,15 @@
|
||||
|
||||
.info-card-close {
|
||||
flex-shrink: 0;
|
||||
align-self: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-close .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
@@ -51,38 +51,33 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(22px * var(--hud-scale));
|
||||
height: calc(22px * var(--hud-scale));
|
||||
min-width: calc(22px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.layer-panel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
/* Chevron:展开时朝上(可折叠),折叠时朝下(可展开) */
|
||||
.layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.layer-panel--collapsed .layer-panel-btn .material-symbols-rounded {
|
||||
transform: rotate(0deg);
|
||||
transition: color 0.18s ease;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
/* ── Search bar ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -27,38 +27,38 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Mode tabs ────────────────────────────────────────────────── */
|
||||
/* ── Current mode label ───────────────────────────────────────── */
|
||||
|
||||
.legend-tabs {
|
||||
.legend-current {
|
||||
display: flex;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
align-items: center;
|
||||
gap: calc(6px * var(--hud-scale));
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-tab {
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
.legend-title {
|
||||
flex: 0 0 auto;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: var(--hud-panel-header-title-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-tab--active {
|
||||
.legend-current-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
border: 1px solid rgba(120, 180, 255, 0.2);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
border-color: rgba(120, 180, 255, 0.2);
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Bar action buttons ───────────────────────────────────────── */
|
||||
@@ -66,7 +66,7 @@
|
||||
.legend-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(2px * var(--hud-scale));
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -74,54 +74,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(20px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
min-width: calc(20px * var(--hud-scale));
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
|
||||
.legend-bar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--hud-text);
|
||||
}
|
||||
|
||||
.legend-bar-btn .material-symbols-rounded {
|
||||
font-size: calc(13px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Collapse chevron */
|
||||
#legend-collapse .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
}
|
||||
|
||||
.legend--collapsed #legend-collapse .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ── Collapsible list body ────────────────────────────────────── */
|
||||
|
||||
.legend-body {
|
||||
max-height: calc(220px * var(--hud-scale));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend--collapsed .legend-body {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
--hud-body-collapse-gap: calc(4px * var(--hud-scale));
|
||||
--hud-body-max-height: calc(220px * var(--hud-scale));
|
||||
}
|
||||
|
||||
/* ── Item list ────────────────────────────────────────────────── */
|
||||
|
||||
182
frontend/public/earth/css/news-panel.css
Normal file
182
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.news-region-chip {
|
||||
--news-accent: #d6e6ff;
|
||||
border: 1px solid color-mix(in srgb, var(--news-accent) 46%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(8px * var(--hud-scale));
|
||||
color: color-mix(in srgb, var(--news-accent) 82%, white);
|
||||
background: color-mix(in srgb, var(--news-accent) 12%, transparent);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-panel-subtitle {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-panel-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-panel-focus {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: calc(10px * var(--hud-scale));
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(123, 205, 255, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(104, 166, 232, 0.04));
|
||||
border: 1px solid rgba(205, 231, 255, 0.08);
|
||||
}
|
||||
|
||||
.news-focus-kicker,
|
||||
.news-board-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-focus-label {
|
||||
margin-top: calc(4px * var(--hud-scale));
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-focus-coords {
|
||||
margin-top: calc(3px * var(--hud-scale));
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-source-count {
|
||||
align-self: start;
|
||||
color: var(--hud-accent-strong);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-board {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-board-list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
padding-right: calc(4px * var(--hud-scale));
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 220, 255, 0.36) transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.news-board-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(210, 237, 255, 0.24), rgba(110, 176, 255, 0.28));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.news-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
text-decoration: none;
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
border: 1px solid rgba(201, 225, 247, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(92, 151, 218, 0.03));
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.news-story-card:hover {
|
||||
border-color: rgba(214, 235, 255, 0.16);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(92, 151, 218, 0.06));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.news-story-card--focus {
|
||||
border-color: rgba(127, 219, 255, 0.22);
|
||||
box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset;
|
||||
}
|
||||
|
||||
.news-story-meta,
|
||||
.news-story-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.news-story-source,
|
||||
.news-story-time,
|
||||
.news-story-tag {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.news-story-source {
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.news-story-title {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.news-story-summary {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.news-story-tag {
|
||||
border-radius: 999px;
|
||||
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.news-board-empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.82rem * var(--hud-scale));
|
||||
line-height: 1.5;
|
||||
padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale));
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
/* tv-panel */
|
||||
/* media-panel
|
||||
* Outer HUD shell: #media-panel
|
||||
* Inner live pane: #tv-panel
|
||||
* Inner news pane: #news-panel
|
||||
*/
|
||||
|
||||
.hud-panel-tv {
|
||||
.hud-panel-media {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - (2 * var(--hud-offset)));
|
||||
min-width: calc(300px * var(--hud-scale));
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
display: flex;
|
||||
@@ -13,14 +18,28 @@
|
||||
z-index: 18;
|
||||
}
|
||||
|
||||
/* header 内嵌 select + actions */
|
||||
.hud-panel-tv .hud-panel-header {
|
||||
.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) {
|
||||
max-height: min(
|
||||
var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))),
|
||||
calc(100vh - (2 * var(--hud-offset)))
|
||||
);
|
||||
}
|
||||
|
||||
.hud-panel-media.is-reforming {
|
||||
transition:
|
||||
height 0.24s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
top 0.24s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: height, top;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel-header .hud-panel-close {
|
||||
align-self: center;
|
||||
.hud-panel-media .hud-panel__title-group {
|
||||
flex: 0 0 auto;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
@@ -29,13 +48,53 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* select / action 在 drag-handle 内,恢复正常指针 */
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-select,
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-action {
|
||||
.hud-panel-media .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-media .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.hud-panel-media .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-media .hud-panel__header .media-panel-tab {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tv-panel-header-controls--news {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tv-panel-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-tab-pane {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tv-panel-toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tv-panel-status {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
@@ -63,58 +122,6 @@
|
||||
color: #eef5fc;
|
||||
}
|
||||
|
||||
.tv-panel-actions {
|
||||
display: flex;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.tv-panel-action {
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(4px * var(--hud-scale));
|
||||
background: transparent;
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(7px * var(--hud-scale));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.tv-panel-action--icon .material-symbols-rounded {
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
line-height: 1;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tv-panel-action:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(225, 239, 255, 0.14);
|
||||
color: var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-panel-action:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* toggle 箭头:展开时朝下,折叠时朝上 */
|
||||
.tv-panel-meta-toggle .material-symbols-rounded {
|
||||
transition: transform 0.22s ease;
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.tv-panel-meta-toggle.is-collapsed .material-symbols-rounded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* meta wrap:折叠时用负 margin 抵消 flex gap,无死区 */
|
||||
.tv-panel-meta-wrap {
|
||||
overflow: hidden;
|
||||
max-height: calc(120px * var(--hud-scale));
|
||||
@@ -196,6 +203,41 @@
|
||||
background: #050a14;
|
||||
}
|
||||
|
||||
.media-panel-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.media-panel-tab {
|
||||
border: 1px solid rgba(201, 225, 247, 0.12);
|
||||
border-radius: calc(12px * var(--hud-scale));
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--hud-text-muted);
|
||||
padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale));
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.media-panel-tab:hover {
|
||||
color: var(--hud-text);
|
||||
border-color: rgba(214, 235, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.media-panel-tab--active {
|
||||
color: var(--hud-accent-strong);
|
||||
border-color: rgba(120, 180, 255, 0.24);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.tv-tab-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Multi-edge resize handles ───────────────────────────────── */
|
||||
|
||||
.tv-panel-edge {
|
||||
@@ -257,35 +299,35 @@
|
||||
border-color: rgba(244, 249, 255, 0.75);
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-resizing {
|
||||
.hud-panel-media.is-resizing {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Whole panel is draggable; player overrides back to default */
|
||||
.hud-panel-tv:not(.is-resizing) {
|
||||
.hud-panel-media:not(.is-resizing) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.hud-panel-tv.is-dragging {
|
||||
.hud-panel-media.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.hud-panel-tv .tv-panel-player {
|
||||
.hud-panel-media .tv-panel-player {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disable iframe/video pointer capture while dragging so mouse events pass through */
|
||||
.hud-panel-tv.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-tv.is-dragging .tv-panel-video {
|
||||
.hud-panel-media.is-dragging .tv-panel-iframe,
|
||||
.hud-panel-media.is-dragging .tv-panel-video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-tv:not([data-dragged="true"]) {
|
||||
.earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) {
|
||||
bottom: var(--hud-offset);
|
||||
right: var(--hud-offset);
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset)));
|
||||
}
|
||||
|
||||
/* TV panel keeps its fixed width on all screen sizes.
|
||||
/* Media panel keeps its fixed width on all screen sizes.
|
||||
Responsive stretching removed — width only changes if user manually resizes. */
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15"
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15",
|
||||
"astronomy-engine": "https://esm.sh/astronomy-engine@2.1.19"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -35,6 +36,7 @@
|
||||
<link rel="stylesheet" href="css/legend.css">
|
||||
<link rel="stylesheet" href="css/earth-stats.css">
|
||||
<link rel="stylesheet" href="css/tv-panel.css">
|
||||
<link rel="stylesheet" href="css/news-panel.css">
|
||||
<link rel="stylesheet" href="css/layer-panel.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
|
||||
@@ -61,7 +63,10 @@
|
||||
<span class="material-symbols-rounded layer-panel-icon">layers</span>
|
||||
<span class="layer-panel-title">图层</span>
|
||||
<button id="layer-panel-collapse" class="layer-panel-btn" type="button" aria-label="折叠图层列表" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="layer-panel-btn hud-panel-close" type="button" data-close-panel="layer-toggles" aria-label="关闭图层面板" title="关闭">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +176,12 @@
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开新闻直播</span>
|
||||
</button>
|
||||
<button id="toggle-news" class="floating-btn liquid-glass-surface earth-toolbar-btn active" title="全球态势新闻">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">newspaper</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">打开态势新闻</span>
|
||||
</button>
|
||||
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -217,24 +228,23 @@
|
||||
|
||||
|
||||
<div id="legend" class="hud-panel hud-panel-legend hud-panel-draggable" data-panel-key="legend">
|
||||
<!-- Drag bar: mode tabs + collapse + close -->
|
||||
<!-- Drag bar: current mode + collapse + close -->
|
||||
<div class="legend-bar hud-panel-drag-handle">
|
||||
<div class="legend-tabs" id="legend-tabs">
|
||||
<button class="legend-tab legend-tab--active" data-legend-mode="cables">海缆</button>
|
||||
<button class="legend-tab" data-legend-mode="satellites">卫星</button>
|
||||
<button class="legend-tab" data-legend-mode="bgp">BGP</button>
|
||||
<div class="legend-current" id="legend-current">
|
||||
<span class="legend-title">图例</span>
|
||||
<span id="legend-current-label" class="legend-current-label">海缆</span>
|
||||
</div>
|
||||
<div class="legend-bar-actions">
|
||||
<button id="legend-collapse" class="legend-bar-btn" title="折叠">
|
||||
<button id="legend-collapse" class="legend-bar-btn hud-panel__action hud-panel__action--collapse" title="折叠">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="legend-bar-btn hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<button class="legend-bar-btn hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="legend" aria-label="关闭图例">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible list -->
|
||||
<div id="legend-body" class="legend-body">
|
||||
<div id="legend-body" class="legend-body hud-panel__body hud-panel__body--collapsible">
|
||||
<div class="legend-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,45 +298,97 @@
|
||||
<span id="camera-distance" hidden></span>
|
||||
</div>
|
||||
|
||||
<div id="tv-panel" class="hud-panel hud-panel-tv hud-panel-draggable" data-panel-key="tv-panel" data-drag-self="true">
|
||||
<div class="hud-panel-header hud-panel-drag-handle">
|
||||
<span class="hud-panel-title tv-panel-header-title">新闻直播</span>
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-actions">
|
||||
<button id="tv-refresh" class="tv-panel-action tv-panel-action--icon" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<div id="media-panel" class="hud-panel hud-panel-media hud-panel-draggable" data-panel-key="media-panel" data-drag-self="true">
|
||||
<div class="hud-panel__header hud-panel-drag-handle">
|
||||
<div class="hud-panel__title-group">
|
||||
<span class="hud-panel-title hud-panel__title tv-panel-header-title">媒体情报</span>
|
||||
</div>
|
||||
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="tv-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
<button id="tv-meta-toggle" class="hud-panel__action hud-panel__action--collapse tv-panel-meta-toggle" type="button" title="折叠新闻直播内容" aria-label="折叠新闻直播内容">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tv-header-controls-news" class="tv-panel-header-controls tv-panel-header-controls--news" hidden>
|
||||
<div class="news-panel-title-row">
|
||||
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
|
||||
</div>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="news-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新新闻源" aria-label="刷新新闻源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
<button id="news-open-external" class="hud-panel__action hud-panel__action--external" type="button" title="打开源站" aria-label="打开源站">
|
||||
<span class="material-symbols-rounded">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-panel__actions">
|
||||
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="media-panel" aria-label="关闭媒体情报面板">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="hud-panel-close" type="button" data-close-panel="tv-panel" aria-label="关闭电视直播">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-content">
|
||||
<section id="tv-panel" class="tv-tab-pane tv-tab-pane--active" aria-labelledby="tv-tab-live">
|
||||
<div class="tv-panel-meta-wrap" id="tv-meta-wrap">
|
||||
<div class="tv-panel-meta" id="tv-panel-meta">
|
||||
<span id="tv-source-status" class="tv-panel-status">等待加载直播源</span>
|
||||
<div id="tv-source-title" class="tv-panel-title">暂无可用频道</div>
|
||||
<div id="tv-source-meta" class="tv-panel-subtitle">当前未配置可播放新闻直播源</div>
|
||||
<div id="tv-source-catalog" class="tv-panel-catalog">频道目录待同步</div>
|
||||
<div id="tv-source-notes" class="tv-panel-notes">支持后台配置默认源与采集器补充源。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="news-panel" class="tv-tab-pane tv-tab-pane--news" aria-labelledby="tv-tab-news" hidden>
|
||||
<div id="news-panel-body" class="news-panel-body">
|
||||
<div class="news-panel-subtitle">跟随地球正面视角自动切换区域新闻</div>
|
||||
|
||||
<div class="news-panel-focus">
|
||||
<div>
|
||||
<div class="news-focus-kicker">当前关注区域</div>
|
||||
<div id="news-focus-label" class="news-focus-label">全球焦点</div>
|
||||
<div id="news-focus-coords" class="news-focus-coords">跟随当前视角自动聚焦</div>
|
||||
</div>
|
||||
<div id="news-source-count" class="news-source-count">0 路聚合源</div>
|
||||
</div>
|
||||
|
||||
<div class="news-board">
|
||||
<div id="news-board-status" class="news-board-status">正在准备全球态势新闻...</div>
|
||||
<div id="news-board-list" class="news-board-list"></div>
|
||||
<div id="news-board-empty" class="news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="news-feed-anchor" hidden rel="noreferrer noopener" target="_blank"></a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="tv-panel-player">
|
||||
<div id="tv-empty-state" class="tv-panel-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
id="tv-iframe"
|
||||
class="tv-panel-iframe"
|
||||
hidden
|
||||
title="新闻直播"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
></iframe>
|
||||
<video id="tv-video" class="tv-panel-video" hidden controls autoplay muted playsinline></video>
|
||||
|
||||
<div class="media-panel-tabs" role="tablist" aria-label="媒体情报切换">
|
||||
<button id="tv-tab-live" class="media-panel-tab media-panel-tab--active" type="button" role="tab" aria-selected="true" aria-controls="tv-panel">电视直播</button>
|
||||
<button id="tv-tab-news" class="media-panel-tab" type="button" role="tab" aria-selected="false" aria-controls="news-panel">态势聚合</button>
|
||||
</div>
|
||||
<div class="tv-panel-edge" data-edge="r"></div>
|
||||
<div class="tv-panel-edge" data-edge="b"></div>
|
||||
@@ -335,12 +397,7 @@
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="earth-loading">
|
||||
<div id="loading-spinner" class="earth-loading-spinner"></div>
|
||||
<div id="loading-title" class="earth-loading-title earth-loading-text">正在初始化全球态势数据...</div>
|
||||
<div id="loading-subtitle" class="earth-loading-subtitle">同步卫星、海底光缆、登陆点与BGP态势数据</div>
|
||||
</div>
|
||||
<div id="status-message" class="earth-status-message"></div>
|
||||
<div id="status-message" class="earth-status-message" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="tooltip" class="earth-tooltip"></div>
|
||||
<div id="settings-modal" class="earth-settings-modal" aria-hidden="true">
|
||||
<div id="settings-backdrop" class="earth-settings-backdrop"></div>
|
||||
@@ -394,7 +451,7 @@
|
||||
<span class="earth-settings-item-subtitle">控制新闻直播窗口显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="tv-panel">
|
||||
<input id="toggle-view-tv" type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -1386,21 +1386,17 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
|
||||
if (isLocked) {
|
||||
scale *= 1.1 + 0.14 * pulse;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.022;
|
||||
pulseOpacity = 0.012;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.lockedNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
BGP_CONFIG.collectorIcon.lockedBlend,
|
||||
);
|
||||
opacity = 0.96;
|
||||
haloOpacity = 0.05;
|
||||
pulseOpacity = 0.024;
|
||||
coverageOpacity = 0.036;
|
||||
markerColor = 0xcff2ff;
|
||||
} else if (isHovered) {
|
||||
scale *= 1.08;
|
||||
opacity = BGP_CONFIG.opacity.collectorHover;
|
||||
haloOpacity = 0.016;
|
||||
pulseOpacity = 0.008;
|
||||
coverageOpacity = 0.014;
|
||||
opacity = 0.88;
|
||||
haloOpacity = 0.03;
|
||||
pulseOpacity = 0.014;
|
||||
coverageOpacity = 0.02;
|
||||
markerColor = blendHexColors(
|
||||
BGP_CONFIG.collectorIcon.hoverNeutralColor,
|
||||
marker.userData.baseColor || BGP_CONFIG.collectorColor,
|
||||
@@ -1482,12 +1478,13 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera) {
|
||||
if (isLocked || isLinkedCollectorLocked) {
|
||||
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
|
||||
opacity =
|
||||
BGP_CONFIG.opacity.lockedMin +
|
||||
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
|
||||
0.9 +
|
||||
0.1 * pulse;
|
||||
markerColor = 0xfff1a8;
|
||||
ringBaseOpacity *= 1.2;
|
||||
} else if (isHovered) {
|
||||
scale *= BGP_CONFIG.marker.hoverScale;
|
||||
opacity = BGP_CONFIG.opacity.hover;
|
||||
opacity = 0.9;
|
||||
ringBaseOpacity *= 1.05;
|
||||
} else if (isOtherLocked) {
|
||||
scale *= BGP_CONFIG.marker.dimmedScale;
|
||||
|
||||
@@ -558,14 +558,18 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
if (isRelated) {
|
||||
lp.material.color.setHex(CABLE_CONFIG.landingPoint.color);
|
||||
lp.material.emissive.setHex(CABLE_CONFIG.landingPoint.emissive);
|
||||
lp.material.color.setHex(0xffd27a);
|
||||
lp.material.emissive.setHex(0x7a4a00);
|
||||
lp.material.emissiveIntensity =
|
||||
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse;
|
||||
0.2 +
|
||||
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2);
|
||||
lp.material.opacity =
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse;
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
);
|
||||
const distanceScale = getLandingPointDistanceScale(lp, camera);
|
||||
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
|
||||
lp.scale.setScalar(
|
||||
|
||||
487
frontend/public/earth/js/celestial.js
Normal file
487
frontend/public/earth/js/celestial.js
Normal file
@@ -0,0 +1,487 @@
|
||||
import * as THREE from "three";
|
||||
import * as Astronomy from "astronomy-engine";
|
||||
|
||||
import { CELESTIAL_CONFIG, EARTH_CONFIG } from "./constants.js";
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
|
||||
const defaultMoonDirection = new THREE.Vector3(-0.6, 0.45, -0.2).normalize();
|
||||
|
||||
let celestialRoot = null;
|
||||
let skySphere = null;
|
||||
let brightStarsGroup = null;
|
||||
let sunSprite = null;
|
||||
let moonSprite = null;
|
||||
let sunHaloSprite = null;
|
||||
let moonHaloSprite = null;
|
||||
let brightStarTexture = null;
|
||||
let sunDirection = defaultSunDirection.clone();
|
||||
let moonDirection = defaultMoonDirection.clone();
|
||||
let lastUpdatedAt = 0;
|
||||
let linkedSunLight = null;
|
||||
let linkedBackLight = null;
|
||||
let linkedEarth = null;
|
||||
let brightStarSprites = [];
|
||||
let celestialRotationQuaternion = new THREE.Quaternion();
|
||||
let celestialViewQuaternion = new THREE.Quaternion();
|
||||
let runtimeOrientationEuler = {
|
||||
...CELESTIAL_CONFIG.orientationEulerRad,
|
||||
};
|
||||
let runtimeFollowConfig = {
|
||||
...CELESTIAL_CONFIG.followEarthRotation,
|
||||
};
|
||||
const scratchEuler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||
|
||||
function getCelestialEuler() {
|
||||
const { x, y, z } = runtimeOrientationEuler;
|
||||
return new THREE.Euler(x, y, z, "YXZ");
|
||||
}
|
||||
|
||||
function refreshCelestialOrientation() {
|
||||
celestialRotationQuaternion.setFromEuler(getCelestialEuler());
|
||||
refreshCelestialView();
|
||||
}
|
||||
|
||||
function refreshCelestialView() {
|
||||
if (linkedEarth && runtimeFollowConfig.enabled) {
|
||||
const followX = runtimeFollowConfig.x
|
||||
? (linkedEarth.rotation.x - EARTH_CONFIG.tiltRad) * (runtimeFollowConfig.invertX ? -1 : 1)
|
||||
: 0;
|
||||
const followY = runtimeFollowConfig.y
|
||||
? linkedEarth.rotation.y * (runtimeFollowConfig.invertY ? -1 : 1)
|
||||
: 0;
|
||||
const followZ = runtimeFollowConfig.z
|
||||
? linkedEarth.rotation.z * (runtimeFollowConfig.invertZ ? -1 : 1)
|
||||
: 0;
|
||||
|
||||
scratchEuler.set(
|
||||
followX,
|
||||
followY,
|
||||
followZ,
|
||||
"YXZ",
|
||||
);
|
||||
celestialViewQuaternion.setFromEuler(scratchEuler);
|
||||
} else {
|
||||
celestialViewQuaternion.identity();
|
||||
}
|
||||
|
||||
if (celestialRoot) {
|
||||
celestialRoot.quaternion
|
||||
.copy(celestialRotationQuaternion)
|
||||
.multiply(celestialViewQuaternion);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCelestialOrientation(direction) {
|
||||
return direction
|
||||
.clone()
|
||||
.applyQuaternion(celestialRotationQuaternion)
|
||||
.applyQuaternion(celestialViewQuaternion)
|
||||
.normalize();
|
||||
}
|
||||
|
||||
function configureTextureEncoding(texture) {
|
||||
if (!texture) return;
|
||||
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
} else if ("encoding" in texture && THREE.sRGBEncoding) {
|
||||
texture.encoding = THREE.sRGBEncoding;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscTexture(stops, size = 256) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2,
|
||||
size / 2,
|
||||
0,
|
||||
size / 2,
|
||||
size / 2,
|
||||
size / 2,
|
||||
);
|
||||
|
||||
stops.forEach(([offset, color]) => gradient.addColorStop(offset, color));
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
configureTextureEncoding(texture);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createSprite({ texture, scale, opacity = 1, name, color = 0xffffff }) {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
toneMapped: false,
|
||||
color,
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.name = name;
|
||||
sprite.scale.setScalar(scale);
|
||||
sprite.renderOrder = 100;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function createSkySphere() {
|
||||
const geometry = new THREE.SphereGeometry(
|
||||
CELESTIAL_CONFIG.skyRadius,
|
||||
64,
|
||||
64,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
side: THREE.BackSide,
|
||||
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
|
||||
opacity: CELESTIAL_CONFIG.skyOpacity,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = "celestial-sky-sphere";
|
||||
mesh.renderOrder = -1000;
|
||||
mesh.raycast = () => {};
|
||||
|
||||
textureLoader.load(
|
||||
CELESTIAL_CONFIG.starMapUrl,
|
||||
(texture) => {
|
||||
configureTextureEncoding(texture);
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
console.warn("Failed to load celestial star map texture");
|
||||
},
|
||||
);
|
||||
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function geoVectorToWorldDirection(vector) {
|
||||
return new THREE.Vector3(vector.x, vector.z, vector.y).normalize();
|
||||
}
|
||||
|
||||
function raDecToWorldDirection(raDeg, decDeg) {
|
||||
const raRad = THREE.MathUtils.degToRad(raDeg);
|
||||
const decRad = THREE.MathUtils.degToRad(decDeg);
|
||||
|
||||
const x = Math.cos(decRad) * Math.cos(raRad);
|
||||
const y = Math.sin(decRad);
|
||||
const z = Math.cos(decRad) * Math.sin(raRad);
|
||||
|
||||
return new THREE.Vector3(x, z, y).normalize();
|
||||
}
|
||||
|
||||
function colorFromBvIndex(colorIndex) {
|
||||
if (colorIndex <= -0.2) return 0xa8c9ff;
|
||||
if (colorIndex <= 0.0) return 0xc8dcff;
|
||||
if (colorIndex <= 0.3) return 0xf4f7ff;
|
||||
if (colorIndex <= 0.7) return 0xfff4dc;
|
||||
if (colorIndex <= 1.1) return 0xffdfb0;
|
||||
if (colorIndex <= 1.5) return 0xffc47f;
|
||||
return 0xffa35e;
|
||||
}
|
||||
|
||||
function scaleFromMagnitude(mag) {
|
||||
const brightness = THREE.MathUtils.clamp(
|
||||
1 - (mag - (-1.5)) / (CELESTIAL_CONFIG.brightStarMinMag - (-1.5)),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
return THREE.MathUtils.lerp(
|
||||
CELESTIAL_CONFIG.brightStarMinScale,
|
||||
CELESTIAL_CONFIG.brightStarMaxScale,
|
||||
Math.pow(brightness, 0.72),
|
||||
);
|
||||
}
|
||||
|
||||
function createBrightStarTexture() {
|
||||
return createDiscTexture([
|
||||
[0, "rgba(255,255,255,1)"],
|
||||
[0.18, "rgba(255,255,255,0.98)"],
|
||||
[0.46, "rgba(215,230,255,0.42)"],
|
||||
[1, "rgba(128,168,255,0)"],
|
||||
], 128);
|
||||
}
|
||||
|
||||
function createBrightStarsGroup() {
|
||||
const group = new THREE.Group();
|
||||
group.name = "bright-stars-group";
|
||||
group.visible = true;
|
||||
return group;
|
||||
}
|
||||
|
||||
async function loadBrightStars() {
|
||||
try {
|
||||
const response = await fetch(CELESTIAL_CONFIG.brightStarsUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const stars = await response.json();
|
||||
const filteredStars = stars
|
||||
.filter((star) => Number.isFinite(star?.raDeg) && Number.isFinite(star?.decDeg))
|
||||
.filter((star) => star.mag <= CELESTIAL_CONFIG.brightStarMinMag)
|
||||
.sort((a, b) => a.mag - b.mag)
|
||||
.slice(0, CELESTIAL_CONFIG.brightStarMaxCount);
|
||||
|
||||
brightStarTexture = createBrightStarTexture();
|
||||
brightStarSprites = filteredStars.map((star) => {
|
||||
const sprite = createSprite({
|
||||
texture: brightStarTexture,
|
||||
scale: scaleFromMagnitude(star.mag),
|
||||
opacity: CELESTIAL_CONFIG.brightStarOpacity,
|
||||
name: `bright-star-${star.name}`,
|
||||
color: colorFromBvIndex(star.colorIndex ?? 0.4),
|
||||
});
|
||||
sprite.position
|
||||
.copy(raDecToWorldDirection(star.raDeg, star.decDeg))
|
||||
.multiplyScalar(CELESTIAL_CONFIG.brightStarDistance);
|
||||
sprite.userData.star = star;
|
||||
return sprite;
|
||||
});
|
||||
|
||||
brightStarSprites.forEach((sprite) => brightStarsGroup?.add(sprite));
|
||||
} catch (error) {
|
||||
console.warn("Failed to load bright star layer", error);
|
||||
}
|
||||
}
|
||||
|
||||
function computeBodyDirection(body, date) {
|
||||
const vector = Astronomy.GeoVector(body, date, false);
|
||||
return geoVectorToWorldDirection(vector);
|
||||
}
|
||||
|
||||
function updateSpritePositions() {
|
||||
if (sunSprite) {
|
||||
sunSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
|
||||
}
|
||||
if (sunHaloSprite) {
|
||||
sunHaloSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
|
||||
}
|
||||
|
||||
if (moonSprite) {
|
||||
moonSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
|
||||
}
|
||||
if (moonHaloSprite) {
|
||||
moonHaloSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLighting() {
|
||||
const calibratedSunDirection = applyCelestialOrientation(sunDirection);
|
||||
|
||||
if (linkedSunLight) {
|
||||
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
|
||||
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
|
||||
linkedSunLight.position
|
||||
.copy(calibratedSunDirection)
|
||||
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
|
||||
}
|
||||
|
||||
if (linkedBackLight) {
|
||||
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
|
||||
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
|
||||
linkedBackLight.position
|
||||
.copy(calibratedSunDirection)
|
||||
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
function computeCelestialState(date = new Date()) {
|
||||
sunDirection = computeBodyDirection(Astronomy.Body.Sun, date);
|
||||
moonDirection = computeBodyDirection(Astronomy.Body.Moon, date);
|
||||
updateSpritePositions();
|
||||
updateLighting();
|
||||
lastUpdatedAt = date.getTime();
|
||||
}
|
||||
|
||||
export function initCelestialLayer(
|
||||
scene,
|
||||
{ camera = null, sunLight = null, backLight = null, earth = null } = {},
|
||||
) {
|
||||
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
|
||||
|
||||
disposeCelestialLayer();
|
||||
|
||||
linkedSunLight = sunLight;
|
||||
linkedBackLight = backLight;
|
||||
linkedEarth = earth;
|
||||
|
||||
celestialRoot = new THREE.Group();
|
||||
celestialRoot.name = "celestial-root";
|
||||
celestialRoot.position.set(0, 0, 0);
|
||||
|
||||
refreshCelestialOrientation();
|
||||
|
||||
skySphere = createSkySphere();
|
||||
brightStarsGroup = createBrightStarsGroup();
|
||||
|
||||
const sunTexture = createDiscTexture([
|
||||
[0, "rgba(255,255,255,1)"],
|
||||
[0.08, "rgba(255,251,242,1)"],
|
||||
[0.22, "rgba(255,242,205,0.98)"],
|
||||
[0.52, "rgba(255,211,117,0.9)"],
|
||||
[0.82, "rgba(255,162,54,0.18)"],
|
||||
[1, "rgba(255,120,32,0)"],
|
||||
]);
|
||||
const sunHaloTexture = createDiscTexture([
|
||||
[0, "rgba(255,245,214,0.9)"],
|
||||
[0.2, "rgba(255,220,154,0.54)"],
|
||||
[0.52, "rgba(255,160,72,0.16)"],
|
||||
[1, "rgba(255,120,32,0)"],
|
||||
], 512);
|
||||
const moonTexture = createDiscTexture([
|
||||
[0, "rgba(255,255,255,0.98)"],
|
||||
[0.42, "rgba(229,236,248,0.92)"],
|
||||
[0.76, "rgba(164,178,202,0.34)"],
|
||||
[1, "rgba(80,92,118,0)"],
|
||||
]);
|
||||
const moonHaloTexture = createDiscTexture([
|
||||
[0, "rgba(226,235,250,0.42)"],
|
||||
[0.38, "rgba(188,203,230,0.16)"],
|
||||
[1, "rgba(120,136,170,0)"],
|
||||
], 384);
|
||||
|
||||
sunSprite = createSprite({
|
||||
texture: sunTexture,
|
||||
scale: CELESTIAL_CONFIG.sunScale,
|
||||
name: "sun-sprite",
|
||||
});
|
||||
sunHaloSprite = createSprite({
|
||||
texture: sunHaloTexture,
|
||||
scale: CELESTIAL_CONFIG.sunHaloScale,
|
||||
opacity: 0.78,
|
||||
name: "sun-halo-sprite",
|
||||
});
|
||||
moonSprite = createSprite({
|
||||
texture: moonTexture,
|
||||
scale: CELESTIAL_CONFIG.moonScale,
|
||||
opacity: 0.98,
|
||||
name: "moon-sprite",
|
||||
});
|
||||
moonHaloSprite = createSprite({
|
||||
texture: moonHaloTexture,
|
||||
scale: CELESTIAL_CONFIG.moonHaloScale,
|
||||
opacity: 0.52,
|
||||
name: "moon-halo-sprite",
|
||||
});
|
||||
|
||||
sunHaloSprite.renderOrder = 98;
|
||||
moonHaloSprite.renderOrder = 98;
|
||||
|
||||
celestialRoot.add(skySphere);
|
||||
celestialRoot.add(brightStarsGroup);
|
||||
celestialRoot.add(sunHaloSprite);
|
||||
celestialRoot.add(moonHaloSprite);
|
||||
celestialRoot.add(sunSprite);
|
||||
celestialRoot.add(moonSprite);
|
||||
scene.add(celestialRoot);
|
||||
|
||||
computeCelestialState(new Date());
|
||||
loadBrightStars();
|
||||
|
||||
return {
|
||||
root: celestialRoot,
|
||||
getSunDirection: () => sunDirection.clone(),
|
||||
getMoonDirection: () => moonDirection.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCelestialLayer(date = new Date(), camera = null) {
|
||||
if (!celestialRoot) return;
|
||||
|
||||
refreshCelestialView();
|
||||
|
||||
const now = date.getTime();
|
||||
if (!lastUpdatedAt || now - lastUpdatedAt >= CELESTIAL_CONFIG.updateIntervalMs) {
|
||||
computeCelestialState(date);
|
||||
} else {
|
||||
updateLighting();
|
||||
updateSpritePositions();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSunDirection() {
|
||||
return applyCelestialOrientation(sunDirection);
|
||||
}
|
||||
|
||||
export function getMoonDirection() {
|
||||
return applyCelestialOrientation(moonDirection);
|
||||
}
|
||||
|
||||
export function getCelestialDebugState() {
|
||||
return {
|
||||
orientationEulerRad: { ...runtimeOrientationEuler },
|
||||
followEarthRotation: { ...runtimeFollowConfig },
|
||||
};
|
||||
}
|
||||
|
||||
export function setCelestialOrientation(nextEuler = {}) {
|
||||
runtimeOrientationEuler = {
|
||||
...runtimeOrientationEuler,
|
||||
...nextEuler,
|
||||
};
|
||||
refreshCelestialOrientation();
|
||||
updateLighting();
|
||||
return getCelestialDebugState();
|
||||
}
|
||||
|
||||
export function setCelestialFollow(nextFollow = {}) {
|
||||
runtimeFollowConfig = {
|
||||
...runtimeFollowConfig,
|
||||
...nextFollow,
|
||||
};
|
||||
refreshCelestialView();
|
||||
updateLighting();
|
||||
return getCelestialDebugState();
|
||||
}
|
||||
|
||||
export function disposeCelestialLayer() {
|
||||
if (celestialRoot?.parent) {
|
||||
celestialRoot.parent.remove(celestialRoot);
|
||||
}
|
||||
|
||||
[skySphere, sunSprite, moonSprite, sunHaloSprite, moonHaloSprite, ...brightStarSprites].forEach((object) => {
|
||||
if (!object) return;
|
||||
if (object.geometry) object.geometry.dispose();
|
||||
if (object.material) {
|
||||
if (object.material.map) object.material.map.dispose?.();
|
||||
object.material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
skySphere = null;
|
||||
brightStarsGroup = null;
|
||||
sunSprite = null;
|
||||
moonSprite = null;
|
||||
sunHaloSprite = null;
|
||||
moonHaloSprite = null;
|
||||
celestialRoot = null;
|
||||
brightStarSprites = [];
|
||||
brightStarTexture?.dispose?.();
|
||||
brightStarTexture = null;
|
||||
|
||||
lastUpdatedAt = 0;
|
||||
linkedSunLight = null;
|
||||
linkedBackLight = null;
|
||||
linkedEarth = null;
|
||||
sunDirection.copy(defaultSunDirection);
|
||||
moonDirection.copy(defaultMoonDirection);
|
||||
runtimeOrientationEuler = {
|
||||
...CELESTIAL_CONFIG.orientationEulerRad,
|
||||
};
|
||||
runtimeFollowConfig = {
|
||||
...CELESTIAL_CONFIG.followEarthRotation,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,46 @@ export const EARTH_CONFIG = {
|
||||
latCoefficient: 0.5
|
||||
};
|
||||
|
||||
export const CELESTIAL_CONFIG = {
|
||||
enabled: true,
|
||||
updateIntervalMs: 10_000,
|
||||
skyRadius: 2600,
|
||||
skyOpacity: 1,
|
||||
starMapUrl: "./assets/celestial/starmap_equatorial_4k.jpg",
|
||||
brightStarsUrl: "./assets/celestial/bright-stars.json",
|
||||
orientationEulerRad: {
|
||||
x: 0.46,
|
||||
y: 1.18,
|
||||
z: -0.08,
|
||||
},
|
||||
followEarthRotation: {
|
||||
enabled: true,
|
||||
x: true,
|
||||
y: true,
|
||||
z: false,
|
||||
invertX: true,
|
||||
invertY: false,
|
||||
invertZ: false,
|
||||
},
|
||||
sunDistance: 2150,
|
||||
moonDistance: 2050,
|
||||
sunScale: 78,
|
||||
moonScale: 38,
|
||||
sunHaloScale: 136,
|
||||
moonHaloScale: 62,
|
||||
brightStarDistance: 2350,
|
||||
brightStarMinMag: 2.1,
|
||||
brightStarMaxCount: 36,
|
||||
brightStarMinScale: 2.4,
|
||||
brightStarMaxScale: 6.8,
|
||||
brightStarOpacity: 0.88,
|
||||
sunLightDistance: 460,
|
||||
sunLightIntensity: 1.02,
|
||||
sunLightColor: 0xfff4df,
|
||||
backLightIntensity: 0.3,
|
||||
backLightColor: 0x2b4c78,
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
@@ -237,18 +277,18 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
occluderSegments: 48,
|
||||
|
||||
// Fresnel atmosphere glow — inner rim
|
||||
atmosInnerRadiusFactor: 1.018,
|
||||
atmosInnerRadiusFactor: 1.01,
|
||||
atmosInnerSegments: 64,
|
||||
atmosInnerColor: [0.25, 0.62, 1.0],
|
||||
atmosInnerRimPower: 3.2,
|
||||
atmosInnerIntensity: 0.72,
|
||||
atmosInnerIntensity: 0.18,
|
||||
|
||||
// Fresnel atmosphere glow — outer corona
|
||||
atmosOuterRadiusFactor: 1.07,
|
||||
atmosOuterRadiusFactor: 1.016,
|
||||
atmosOuterSegments: 48,
|
||||
atmosOuterColor: [0.18, 0.45, 0.9],
|
||||
atmosOuterRimPower: 5.0,
|
||||
atmosOuterIntensity: 0.28,
|
||||
atmosOuterIntensity: 0.02,
|
||||
|
||||
// Texture candidates — tried in order, first success wins
|
||||
textureUrls: [
|
||||
@@ -256,4 +296,16 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
],
|
||||
|
||||
dayNight: {
|
||||
enabled: true,
|
||||
sunDirection: { x: 1, y: 0.2, z: 0.4 },
|
||||
nightFloor: 0.32,
|
||||
dayBoost: 0.94,
|
||||
twilightWidth: 0.24,
|
||||
twilightIntensity: 0.14,
|
||||
twilightColor: 0x4ea0ff,
|
||||
nightTintColor: 0x0b1830,
|
||||
nightTintIntensity: 0.05,
|
||||
},
|
||||
};
|
||||
|
||||
41
frontend/public/earth/js/controls.js
vendored
41
frontend/public/earth/js/controls.js
vendored
@@ -18,6 +18,11 @@ import {
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { ensureTVPanelReady } from "./tv.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import {
|
||||
ensureNewsPanelReady,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -30,7 +35,7 @@ let cleanupFns = [];
|
||||
const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
"media-panel",
|
||||
"layer-toggles",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
@@ -89,8 +94,9 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
syncSettingsToggle(panelId, visible);
|
||||
if (panelId === "tv-panel") {
|
||||
if (panelId === "media-panel") {
|
||||
updateTVToggleUI(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
@@ -230,7 +236,7 @@ function setupDraggableHudPanels() {
|
||||
};
|
||||
|
||||
bindListener(handle, "pointerdown", (event) => {
|
||||
if (event.target.closest(".hud-panel-close, .layer-panel-btn, .info-card-close, .tv-panel-select, .tv-panel-action, .tv-panel-player, .tv-panel-edge, .legend-bar-btn")) 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;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
@@ -245,6 +251,7 @@ function setupDraggableHudPanels() {
|
||||
const capturedWidth = panelRect.width;
|
||||
panel.style.position = "absolute";
|
||||
panel.style.width = `${capturedWidth}px`;
|
||||
panel.style.margin = "0";
|
||||
app.appendChild(panel);
|
||||
}
|
||||
|
||||
@@ -617,13 +624,20 @@ function setupLayerPanel() {
|
||||
const emptyState = document.getElementById("layer-panel-empty");
|
||||
if (!panel) return;
|
||||
|
||||
const layerPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".layer-panel-header",
|
||||
body: "#layer-panel-body",
|
||||
collapseBtn,
|
||||
collapsedClass: "layer-panel--collapsed",
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图层列表",
|
||||
collapseLabel: "折叠图层列表",
|
||||
});
|
||||
|
||||
bindListener(collapseBtn, "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isCollapsed = panel.classList.toggle("layer-panel--collapsed");
|
||||
collapseBtn.title = isCollapsed ? "展开" : "折叠";
|
||||
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
|
||||
const icon = collapseBtn.querySelector(".material-symbols-rounded");
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
|
||||
layerPanel.setCollapsed(!layerPanel.isCollapsed());
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
@@ -794,13 +808,17 @@ function setupTerrainControls() {
|
||||
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
|
||||
});
|
||||
|
||||
const tvVisible = !document.getElementById("tv-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(tvVisible);
|
||||
if (tvVisible) {
|
||||
const mediaVisible = !document.getElementById("media-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateTVToggleUI(mediaVisible);
|
||||
if (mediaVisible) {
|
||||
ensureTVPanelReady().catch((error) => {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateNewsToggleUI(mediaVisible);
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻内容失败:", error);
|
||||
});
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
@@ -955,6 +973,7 @@ function resetPanelInlineLayout(panel) {
|
||||
panel.style.transform = "";
|
||||
panel.style.position = "";
|
||||
panel.style.width = "";
|
||||
panel.style.margin = "";
|
||||
delete panel.dataset.dragged;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,73 @@ export let terrain = null;
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
let _earthMaterial = null;
|
||||
let _earthShader = null;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
|
||||
).normalize();
|
||||
|
||||
function applyEarthDayNightShader(material) {
|
||||
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
|
||||
|
||||
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
|
||||
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
|
||||
|
||||
material.onBeforeCompile = (shader) => {
|
||||
_earthShader = shader;
|
||||
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
|
||||
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
|
||||
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
|
||||
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
|
||||
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
|
||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||
shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity };
|
||||
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec3 vWorldNormal;`,
|
||||
).replace(
|
||||
"#include <begin_vertex>",
|
||||
`#include <begin_vertex>
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);`,
|
||||
);
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec3 vWorldNormal;
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uNightFloor;
|
||||
uniform float uDayBoost;
|
||||
uniform float uTwilightWidth;
|
||||
uniform float uTwilightIntensity;
|
||||
uniform vec3 uTwilightColor;
|
||||
uniform vec3 uNightTintColor;
|
||||
uniform float uNightTintIntensity;`,
|
||||
).replace(
|
||||
"#include <output_fragment>",
|
||||
`
|
||||
vec3 worldNormal = normalize(vWorldNormal);
|
||||
vec3 sunDir = normalize(uSunDirectionWorld);
|
||||
float sunFacing = dot(worldNormal, sunDir);
|
||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||
|
||||
outgoingLight *= mix(uNightFloor, uDayBoost, daylight);
|
||||
outgoingLight += uTwilightColor * twilight * uTwilightIntensity;
|
||||
outgoingLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity;
|
||||
|
||||
#include <output_fragment>
|
||||
`,
|
||||
);
|
||||
};
|
||||
|
||||
material.customProgramCacheKey = () => "earth-day-night-v1";
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function createEarth(scene) {
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
@@ -27,6 +94,7 @@ export function createEarth(scene) {
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
});
|
||||
applyEarthDayNightShader(material);
|
||||
_earthMaterial = material;
|
||||
|
||||
earth = new THREE.Mesh(geometry, material);
|
||||
@@ -271,6 +339,14 @@ export function clearEarthTexture() {
|
||||
_earthMaterial.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
if (!direction) return;
|
||||
_earthSunDirection.copy(direction).normalize();
|
||||
if (_earthShader?.uniforms?.uSunDirectionWorld) {
|
||||
_earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEarthTexture() {
|
||||
return new Promise((resolve) => {
|
||||
if (!_earthMaterial) { resolve(); return; }
|
||||
|
||||
233
frontend/public/earth/js/hud-panels.js
Normal file
233
frontend/public/earth/js/hud-panels.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const DEFAULT_COLLAPSED_CLASS = "hud-panel--collapsed";
|
||||
const DEFAULT_HIDDEN_CLASS = "hud-panel-hidden";
|
||||
|
||||
function getHudScale() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const scale = parseFloat(rootStyle.getPropertyValue("--hud-scale"));
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function getEdgeFlipThresholdPx() {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const hudOffset = parseFloat(rootStyle.getPropertyValue("--hud-offset"));
|
||||
if (Number.isFinite(hudOffset) && hudOffset > 0) {
|
||||
return hudOffset;
|
||||
}
|
||||
return 20 * getHudScale();
|
||||
}
|
||||
|
||||
function clampExpandDirection(direction) {
|
||||
return direction === "up" ? "up" : "down";
|
||||
}
|
||||
|
||||
function resolveElement(target, root = document) {
|
||||
if (!target) return null;
|
||||
if (target instanceof HTMLElement) return target;
|
||||
if (typeof target === "string") {
|
||||
return root.querySelector(target);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickExpandDirection({
|
||||
panelRect,
|
||||
preferredDirection,
|
||||
}) {
|
||||
const spaceBelow = Math.max(0, window.innerHeight - panelRect.bottom);
|
||||
const edgeFlipThresholdPx = getEdgeFlipThresholdPx();
|
||||
const preferred = clampExpandDirection(preferredDirection);
|
||||
|
||||
// Pure edge-threshold contract:
|
||||
// - d < threshold => "up" family
|
||||
// - d >= threshold => "down" family
|
||||
// Do not pre-flip early based on expanded height.
|
||||
if (spaceBelow <= edgeFlipThresholdPx) return "up";
|
||||
if (spaceBelow > edgeFlipThresholdPx) return "down";
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function getCollapseButtonState({ collapsed, direction, expandLabel, collapseLabel }) {
|
||||
// The arrow always describes the next action and must stay aligned with the
|
||||
// real expansion direction chosen by the controller. Panels should not add
|
||||
// their own extra CSS rotation on top of this mapping.
|
||||
if (collapsed) {
|
||||
return {
|
||||
title: expandLabel,
|
||||
icon: direction === "up" ? "expand_less" : "expand_more",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: collapseLabel,
|
||||
icon: direction === "up" ? "expand_more" : "expand_less",
|
||||
};
|
||||
}
|
||||
|
||||
export function createHUDPanel({
|
||||
panel,
|
||||
header,
|
||||
body,
|
||||
collapseBtn,
|
||||
bodyCollapsedClass = "",
|
||||
preferredDirection = "down",
|
||||
collapsedClass = DEFAULT_COLLAPSED_CLASS,
|
||||
hiddenClass = DEFAULT_HIDDEN_CLASS,
|
||||
expandLabel = "展开",
|
||||
collapseLabel = "折叠",
|
||||
}) {
|
||||
const panelEl = resolveElement(panel);
|
||||
const headerEl = resolveElement(header, panelEl ?? document);
|
||||
const bodyEl = resolveElement(body, panelEl ?? document);
|
||||
const collapseBtnEl = resolveElement(collapseBtn, panelEl ?? document);
|
||||
|
||||
if (!(panelEl instanceof HTMLElement) || !(headerEl instanceof HTMLElement) || !(bodyEl instanceof HTMLElement)) {
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed() {},
|
||||
setVisible() {},
|
||||
syncLayout() {},
|
||||
destroy() {},
|
||||
isCollapsed() {
|
||||
return false;
|
||||
},
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
getExpandDirection() {
|
||||
return clampExpandDirection(preferredDirection);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let currentDirection = clampExpandDirection(preferredDirection);
|
||||
|
||||
const shouldAnchorBottomDuringToggle = () =>
|
||||
panelEl.dataset.dragged === "true" && typeof panelEl.style.top === "string" && panelEl.style.top !== "";
|
||||
|
||||
const compensateTopForBottomAnchor = (beforeBottom) => {
|
||||
if (!shouldAnchorBottomDuringToggle()) return;
|
||||
const afterBottom = panelEl.getBoundingClientRect().bottom;
|
||||
const delta = afterBottom - beforeBottom;
|
||||
if (delta === 0) return;
|
||||
panelEl.style.top = `${parseFloat(panelEl.style.top) - delta}px`;
|
||||
};
|
||||
|
||||
const syncDirection = () => {
|
||||
const expandedHeight = Math.max(bodyEl.scrollHeight, bodyEl.getBoundingClientRect().height);
|
||||
const nextDirection = pickExpandDirection({
|
||||
panelRect: panelEl.getBoundingClientRect(),
|
||||
preferredDirection,
|
||||
});
|
||||
|
||||
currentDirection = nextDirection;
|
||||
panelEl.classList.toggle("hud-panel--expand-up", nextDirection === "up");
|
||||
panelEl.classList.toggle("hud-panel--expand-down", nextDirection !== "up");
|
||||
panelEl.dataset.expandDirection = nextDirection;
|
||||
};
|
||||
|
||||
const syncButton = () => {
|
||||
if (!(collapseBtnEl instanceof HTMLElement)) return;
|
||||
const iconEl = collapseBtnEl.querySelector(".material-symbols-rounded");
|
||||
const { title, icon } = getCollapseButtonState({
|
||||
collapsed: panelEl.classList.contains(collapsedClass),
|
||||
direction: currentDirection,
|
||||
expandLabel,
|
||||
collapseLabel,
|
||||
});
|
||||
|
||||
collapseBtnEl.title = title;
|
||||
collapseBtnEl.setAttribute("aria-label", title);
|
||||
collapseBtnEl.dataset.expandDirection = currentDirection;
|
||||
if (iconEl) {
|
||||
iconEl.textContent = icon;
|
||||
}
|
||||
};
|
||||
|
||||
const syncLayout = () => {
|
||||
syncDirection();
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setCollapsed = (collapsed) => {
|
||||
syncDirection();
|
||||
const nextCollapsed = Boolean(collapsed);
|
||||
const shouldCompensate = currentDirection === "up" && shouldAnchorBottomDuringToggle();
|
||||
const bottomBefore = shouldCompensate ? panelEl.getBoundingClientRect().bottom : 0;
|
||||
|
||||
if (shouldCompensate) {
|
||||
bodyEl.style.transition = "none";
|
||||
}
|
||||
|
||||
panelEl.classList.toggle(collapsedClass, nextCollapsed);
|
||||
if (bodyCollapsedClass) {
|
||||
bodyEl.classList.toggle(bodyCollapsedClass, nextCollapsed);
|
||||
}
|
||||
|
||||
if (shouldCompensate) {
|
||||
void panelEl.offsetHeight;
|
||||
compensateTopForBottomAnchor(bottomBefore);
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl.style.transition = "";
|
||||
});
|
||||
}
|
||||
|
||||
syncButton();
|
||||
};
|
||||
|
||||
const setVisible = (visible) => {
|
||||
panelEl.classList.toggle(hiddenClass, !visible);
|
||||
if (visible) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass)) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = () => {
|
||||
if (!panelEl.classList.contains(hiddenClass) && panelEl.classList.contains("is-dragging")) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
document.addEventListener("pointerup", handleViewportChange);
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
syncLayout();
|
||||
requestAnimationFrame(syncLayout);
|
||||
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed,
|
||||
setVisible,
|
||||
syncLayout,
|
||||
destroy() {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
document.removeEventListener("pointerup", handleViewportChange);
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
},
|
||||
isCollapsed() {
|
||||
return panelEl.classList.contains(collapsedClass);
|
||||
},
|
||||
isVisible() {
|
||||
return !panelEl.classList.contains(hiddenClass);
|
||||
},
|
||||
getExpandDirection() {
|
||||
return currentDirection;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCollapsibleHudPanel(options) {
|
||||
return createHUDPanel(options);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const LEGEND_MODES = {
|
||||
cables: { title: "海缆" },
|
||||
satellites: { title: "卫星" },
|
||||
@@ -5,6 +7,7 @@ const LEGEND_MODES = {
|
||||
};
|
||||
|
||||
let currentLegendMode = "cables";
|
||||
let legendPanel = null;
|
||||
let legendItemsByMode = {
|
||||
cables: [],
|
||||
satellites: [],
|
||||
@@ -12,34 +15,33 @@ let legendItemsByMode = {
|
||||
};
|
||||
|
||||
export function initLegend() {
|
||||
// Tab click → switch mode
|
||||
const tabsEl = document.getElementById("legend-tabs");
|
||||
if (tabsEl) {
|
||||
tabsEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".legend-tab");
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.legendMode;
|
||||
if (mode) setLegendMode(mode);
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse toggle
|
||||
const collapseBtn = document.getElementById("legend-collapse");
|
||||
const legend = document.getElementById("legend");
|
||||
if (collapseBtn && legend) {
|
||||
legendPanel = createHUDPanel({
|
||||
panel: legend,
|
||||
header: ".legend-bar",
|
||||
body: "#legend-body",
|
||||
collapseBtn,
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开图例",
|
||||
collapseLabel: "折叠图例",
|
||||
});
|
||||
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
legend.classList.toggle("legend--collapsed");
|
||||
legendPanel?.setCollapsed(!(legendPanel?.isCollapsed() ?? false));
|
||||
});
|
||||
}
|
||||
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
|
||||
export function setLegendMode(mode) {
|
||||
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
|
||||
currentLegendMode = nextMode;
|
||||
syncTabs(nextMode);
|
||||
syncCurrentLabel(nextMode);
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
@@ -59,11 +61,10 @@ export function setLegendItems(mode, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabs(mode) {
|
||||
const tabs = document.querySelectorAll("#legend-tabs .legend-tab");
|
||||
tabs.forEach((tab) => {
|
||||
tab.classList.toggle("legend-tab--active", tab.dataset.legendMode === mode);
|
||||
});
|
||||
function syncCurrentLabel(mode) {
|
||||
const labelEl = document.getElementById("legend-current-label");
|
||||
if (!labelEl) return;
|
||||
labelEl.textContent = LEGEND_MODES[mode]?.title || LEGEND_MODES.cables.title;
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
|
||||
@@ -20,12 +20,21 @@ import {
|
||||
createEarth,
|
||||
createClouds,
|
||||
createTerrain,
|
||||
createStars,
|
||||
createGridLines,
|
||||
getEarth,
|
||||
loadEarthTexture,
|
||||
clearEarthTexture,
|
||||
setEarthSunDirection,
|
||||
} from "./earth.js";
|
||||
import {
|
||||
initCelestialLayer,
|
||||
updateCelestialLayer,
|
||||
disposeCelestialLayer,
|
||||
getCelestialDebugState,
|
||||
getSunDirection,
|
||||
setCelestialOrientation,
|
||||
setCelestialFollow,
|
||||
} from "./celestial.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
loadLandingPoints,
|
||||
@@ -127,6 +136,7 @@ import {
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
|
||||
export let scene;
|
||||
export let camera;
|
||||
@@ -164,6 +174,7 @@ let cablesEnabled = true;
|
||||
let satellitesEnabled = true;
|
||||
let cableToggleToken = 0;
|
||||
let satelliteToggleToken = 0;
|
||||
let sceneLights = null;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const interactionRaycaster = new THREE.Raycaster();
|
||||
@@ -173,6 +184,7 @@ const scratchCableCenter = new THREE.Vector3();
|
||||
const scratchCableDirection = new THREE.Vector3();
|
||||
const scratchBGPDirection = new THREE.Vector3();
|
||||
const scratchBGPWorldPosition = new THREE.Vector3();
|
||||
const scratchViewCenterWorld = new THREE.Vector3();
|
||||
|
||||
const cleanupFns = [];
|
||||
const DRAG_SMOOTHING_FACTOR = 0.18;
|
||||
@@ -192,8 +204,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#legend *",
|
||||
"#earth-stats",
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
"#media-panel",
|
||||
"#media-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -688,14 +700,17 @@ function applyCableVisualState() {
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
cable.material.opacity =
|
||||
CABLE_CONFIG.lockedOpacityMin +
|
||||
pulse *
|
||||
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
|
||||
cable.material.color.setRGB(1, 1, 1);
|
||||
Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.lockedOpacityMin +
|
||||
pulse *
|
||||
(CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin),
|
||||
);
|
||||
cable.material.color.setRGB(0.86, 0.96, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
cable.material.opacity = 1;
|
||||
cable.material.color.setRGB(1, 1, 1);
|
||||
cable.material.color.setRGB(0.92, 0.98, 1.0);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
default:
|
||||
@@ -871,6 +886,20 @@ function updateStatsSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentViewCenterCoords() {
|
||||
const earth = getEarth();
|
||||
if (!earth || !camera) return null;
|
||||
|
||||
scratchViewCenterWorld
|
||||
.copy(camera.position)
|
||||
.sub(earth.position)
|
||||
.normalize()
|
||||
.multiplyScalar(CONFIG.earthRadius);
|
||||
|
||||
earth.worldToLocal(scratchViewCenterWorld);
|
||||
return vector3ToLatLon(scratchViewCenterWorld);
|
||||
}
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
console.error("全局错误:", event.error);
|
||||
});
|
||||
@@ -889,13 +918,14 @@ export function init() {
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
getViewportAspect(),
|
||||
0.1,
|
||||
1000,
|
||||
5000,
|
||||
);
|
||||
camera.position.z = CONFIG.defaultCameraZ;
|
||||
setSatelliteCamera(camera);
|
||||
@@ -906,7 +936,7 @@ export function init() {
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
syncRendererViewport();
|
||||
renderer.setClearColor(0x0a0a1a, 1);
|
||||
renderer.setClearColor(0x02040a, 1);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
const container = document.getElementById("container");
|
||||
@@ -915,7 +945,7 @@ export function init() {
|
||||
container.appendChild(renderer.domElement);
|
||||
}
|
||||
|
||||
addLights();
|
||||
sceneLights = addLights();
|
||||
initInfoCard();
|
||||
initLegend();
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
@@ -929,7 +959,12 @@ export function init() {
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
createClouds(scene, earthObj);
|
||||
createTerrain(scene, earthObj, simplex);
|
||||
createStars(scene);
|
||||
initCelestialLayer(scene, {
|
||||
camera,
|
||||
sunLight: sceneLights?.sunLight ?? null,
|
||||
backLight: sceneLights?.backLight ?? null,
|
||||
earth: earthObj,
|
||||
});
|
||||
createGridLines(scene, earthObj);
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
@@ -950,17 +985,25 @@ function registerGlobalApi() {
|
||||
hideInfoCard();
|
||||
clearLockedObject();
|
||||
},
|
||||
celestial: {
|
||||
getState: () => getCelestialDebugState(),
|
||||
setOrientation: (nextEuler) => setCelestialOrientation(nextEuler),
|
||||
setFollow: (nextFollow) => setCelestialFollow(nextFollow),
|
||||
},
|
||||
destroy,
|
||||
init,
|
||||
};
|
||||
}
|
||||
|
||||
function addLights() {
|
||||
scene.add(new THREE.AmbientLight(0x404060));
|
||||
const ambientLight = new THREE.AmbientLight(0x404060);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
directionalLight.position.set(5, 3, 5);
|
||||
scene.add(directionalLight);
|
||||
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
sunLight.position.set(5, 3, 5);
|
||||
sunLight.target.position.set(0, 0, 0);
|
||||
scene.add(sunLight);
|
||||
scene.add(sunLight.target);
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0x446688, 0.3);
|
||||
backLight.position.set(-5, 0, -5);
|
||||
@@ -969,6 +1012,13 @@ function addLights() {
|
||||
const pointLight = new THREE.PointLight(0xffffff, 0.4);
|
||||
pointLight.position.set(10, 10, 10);
|
||||
scene.add(pointLight);
|
||||
|
||||
return {
|
||||
ambientLight,
|
||||
sunLight,
|
||||
backLight,
|
||||
pointLight,
|
||||
};
|
||||
}
|
||||
|
||||
// Yield control to the browser so the renderer can paint a frame before the next step
|
||||
@@ -992,7 +1042,7 @@ async function loadData() {
|
||||
clearCableData(earth);
|
||||
clearSatelliteData();
|
||||
|
||||
setLoadingMessage("正在初始化...", "清除旧数据");
|
||||
setLoadingMessage("正在初始化...");
|
||||
setLoading(true);
|
||||
await yieldFrame();
|
||||
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
|
||||
@@ -1001,7 +1051,7 @@ async function loadData() {
|
||||
|
||||
// Step 1 — Landing points
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载登陆点...", "同步海底光缆登陆站数据");
|
||||
setLoadingMessage("正在加载登陆点...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadLandingPoints(scene, earth);
|
||||
@@ -1014,7 +1064,7 @@ async function loadData() {
|
||||
|
||||
// Step 2 — Cables
|
||||
if (cablesEnabled) {
|
||||
setLoadingMessage("正在加载海缆...", "同步海底光缆网络数据");
|
||||
setLoadingMessage("正在加载海缆...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const cableCount = await loadGeoJSONFromPath(scene, earth);
|
||||
@@ -1033,7 +1083,7 @@ async function loadData() {
|
||||
|
||||
// Step 3 — Satellites
|
||||
if (satellitesEnabled) {
|
||||
setLoadingMessage("正在加载卫星...", "同步在轨卫星轨道数据");
|
||||
setLoadingMessage("正在加载卫星...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
clearSatelliteData();
|
||||
@@ -1053,7 +1103,7 @@ async function loadData() {
|
||||
}
|
||||
|
||||
// Step 4 — BGP
|
||||
setLoadingMessage("正在加载BGP态势...", "同步全球路由观测数据");
|
||||
setLoadingMessage("正在加载BGP态势...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
const bgpResult = await loadBGPAnomalies(scene, earth);
|
||||
@@ -1068,7 +1118,7 @@ async function loadData() {
|
||||
await yieldFrame();
|
||||
|
||||
// Step 5 — Earth texture (loads last so data layers appear on the white sphere first)
|
||||
setLoadingMessage("正在加载地球纹理...", "加载8K卫星地图");
|
||||
setLoadingMessage("正在加载地球纹理...");
|
||||
await yieldFrame(30);
|
||||
try {
|
||||
await loadEarthTexture();
|
||||
@@ -1080,19 +1130,10 @@ async function loadData() {
|
||||
|
||||
// Step 6 — Terrain (if enabled)
|
||||
if (getShowTerrain()) {
|
||||
setLoadingMessage("正在渲染地形...", "生成地表高程数据");
|
||||
setLoadingMessage("正在渲染地形...");
|
||||
await yieldFrame(50);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
showStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已加载", "success");
|
||||
}
|
||||
|
||||
updateStatsSummary();
|
||||
updateCableToggleUi(cablesEnabled);
|
||||
updateSatelliteToggleUi(satellitesEnabled);
|
||||
@@ -1102,6 +1143,15 @@ async function loadData() {
|
||||
refreshLegend();
|
||||
setLoading(false);
|
||||
isDataLoading = false;
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = buildLoadErrorMessage(errors);
|
||||
showError(errorMessage);
|
||||
showStatusMessage(errorMessage, "error");
|
||||
} else {
|
||||
hideError();
|
||||
showStatusMessage("数据已加载", "success");
|
||||
}
|
||||
}
|
||||
|
||||
const POSITION_UPDATE_FORCE_DELTA = 250;
|
||||
@@ -1123,7 +1173,7 @@ export async function setCablesEnabled(enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setLoadingMessage("正在加载线缆数据...", "重建海缆与登陆点对象");
|
||||
setLoadingMessage("正在加载线缆数据...");
|
||||
setLoading(true);
|
||||
hideError();
|
||||
|
||||
@@ -1156,7 +1206,7 @@ export async function setSatellitesEnabled(enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setLoadingMessage("正在加载卫星数据...", "重建卫星点位与轨迹缓存");
|
||||
setLoadingMessage("正在加载卫星数据...");
|
||||
setLoading(true);
|
||||
hideError();
|
||||
|
||||
@@ -1693,6 +1743,9 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateCelestialLayer(new Date(), camera);
|
||||
setEarthSunDirection(getSunDirection());
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
@@ -1733,6 +1786,7 @@ export function destroy() {
|
||||
clearBGPData(getEarth());
|
||||
resetSatelliteState();
|
||||
clearUiState();
|
||||
disposeCelestialLayer();
|
||||
|
||||
if (scene) {
|
||||
disposeSceneObject(scene);
|
||||
@@ -1749,6 +1803,7 @@ export function destroy() {
|
||||
scene = null;
|
||||
camera = null;
|
||||
renderer = null;
|
||||
sceneLights = null;
|
||||
initialized = false;
|
||||
|
||||
delete window.__planetEarth;
|
||||
|
||||
338
frontend/public/earth/js/news.js
Normal file
338
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { getActiveTVTab, openTVPanelTab, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
|
||||
|
||||
// News aggregation now lives inside the shared media panel:
|
||||
// - outer shell: #media-panel
|
||||
// - this module renders into inner pane: #news-panel
|
||||
|
||||
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;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
let payload = null;
|
||||
let lastFocus = null;
|
||||
let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
function getElements() {
|
||||
return {
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
status: document.getElementById("news-board-status"),
|
||||
focusLabel: document.getElementById("news-focus-label"),
|
||||
focusCoords: document.getElementById("news-focus-coords"),
|
||||
sourceCount: document.getElementById("news-source-count"),
|
||||
regionChip: document.getElementById("news-region-chip"),
|
||||
board: document.getElementById("news-board-list"),
|
||||
empty: document.getElementById("news-board-empty"),
|
||||
feedAnchor: document.getElementById("news-feed-anchor"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCoord(value, positiveLabel, negativeLabel) {
|
||||
const abs = Math.abs(value).toFixed(1);
|
||||
return `${abs}°${value >= 0 ? positiveLabel : negativeLabel}`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(raw) {
|
||||
if (!raw) return "刚刚同步";
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚同步";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minutes = Math.max(1, Math.round(diff / 60000));
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export function updateNewsToggleUI(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = active ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyState(message) {
|
||||
const { board, empty, status, openBtn } = getElements();
|
||||
if (board) board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = message;
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "等待聚合新闻源";
|
||||
}
|
||||
if (openBtn) openBtn.disabled = true;
|
||||
}
|
||||
|
||||
function renderPayload(nextPayload) {
|
||||
payload = nextPayload;
|
||||
const {
|
||||
board,
|
||||
empty,
|
||||
status,
|
||||
focusLabel,
|
||||
focusCoords,
|
||||
sourceCount,
|
||||
regionChip,
|
||||
openBtn,
|
||||
feedAnchor,
|
||||
} = getElements();
|
||||
|
||||
if (!board || !status || !focusLabel || !focusCoords || !sourceCount || !regionChip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.isArray(nextPayload?.items) ? nextPayload.items : [];
|
||||
const sources = Array.isArray(nextPayload?.sources) ? nextPayload.sources : [];
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
focusLabel.textContent = focus.label || "全球焦点";
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
|
||||
if (typeof focus.lat === "number" && typeof focus.lon === "number") {
|
||||
focusCoords.textContent = `${formatCoord(focus.lat, "N", "S")} · ${formatCoord(focus.lon, "E", "W")}`;
|
||||
} else {
|
||||
focusCoords.textContent = "跟随当前视角自动聚焦";
|
||||
}
|
||||
|
||||
sourceCount.textContent = `${sources.length} 路聚合源`;
|
||||
if (nextPayload?.stale) {
|
||||
status.textContent = `当前显示最近一次可用新闻缓存,共 ${items.length} 条`;
|
||||
} else {
|
||||
status.textContent = nextPayload?.errors?.length
|
||||
? `已聚合 ${items.length} 条,部分源不可用`
|
||||
: `已聚合 ${items.length} 条态势新闻`;
|
||||
}
|
||||
|
||||
if (feedAnchor) {
|
||||
const matchedSource = sources.find((source) => source.region === focus.region) || sources[0];
|
||||
feedAnchor.href = matchedSource?.homepage_url || "https://news.google.com/";
|
||||
}
|
||||
|
||||
if (openBtn) {
|
||||
openBtn.disabled = !feedAnchor?.href;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
board.innerHTML = "";
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = "当前未拉到可用新闻,请稍后刷新或切换视角区域。";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
board.innerHTML = items
|
||||
.map((item) => {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
if (typeof lon === "number") url.searchParams.set("lon", lon.toFixed(4));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`新闻源请求失败: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function refreshNews(lat, lon, { silent = false } = {}) {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "正在同步全球态势新闻...";
|
||||
}
|
||||
|
||||
refreshPromise = fetchNews(lat, lon)
|
||||
.then((nextPayload) => {
|
||||
renderPayload(nextPayload);
|
||||
lastFetchAt = Date.now();
|
||||
if (Array.isArray(nextPayload?.items) && nextPayload.items.length === 0) {
|
||||
const { status } = getElements();
|
||||
if (status) {
|
||||
status.textContent = "当前区域暂无可用新闻,已完成一次聚合尝试";
|
||||
}
|
||||
}
|
||||
return nextPayload;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("加载 Earth RSS 新闻失败:", error);
|
||||
const message = error?.name === "AbortError"
|
||||
? "新闻聚合请求超时,请稍后重试"
|
||||
: `新闻聚合暂时不可用: ${error?.message || "未知错误"}`;
|
||||
if (!payload) {
|
||||
renderEmptyState(message);
|
||||
} else if (!silent) {
|
||||
showStatusMessage("态势新闻同步失败", "error");
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function shouldRefreshForFocus(lat, lon, region) {
|
||||
const now = Date.now();
|
||||
if (!lastFocus) return true;
|
||||
if (region !== lastFocus.region && now - lastRegionSwitchAt > MIN_REGION_SWITCH_INTERVAL_MS) {
|
||||
lastRegionSwitchAt = now;
|
||||
return true;
|
||||
}
|
||||
if (now - lastFetchAt > DATA_REFRESH_INTERVAL_MS) return true;
|
||||
if (now - (lastFocus.updatedAt || 0) < FOCUS_UPDATE_INTERVAL_MS) return false;
|
||||
const latDrift = Math.abs((lat || 0) - (lastFocus.lat || 0));
|
||||
const lonDrift = Math.abs((lon || 0) - (lastFocus.lon || 0));
|
||||
return latDrift >= 18 || lonDrift >= 25;
|
||||
}
|
||||
|
||||
function inferRegion(lat, lon) {
|
||||
if (typeof lat !== "number" || typeof lon !== "number") return "global";
|
||||
if (lon >= -170 && lon <= -30) return "americas";
|
||||
if (lon > -30 && lon <= 45) return lat >= 30 ? "europe" : "middle-east-africa";
|
||||
if (lon > 45 && lon <= 150) return lat < 10 ? "middle-east-africa" : "asia-pacific";
|
||||
return "asia-pacific";
|
||||
}
|
||||
|
||||
function openCurrentSourceHomepage() {
|
||||
const { feedAnchor } = getElements();
|
||||
if (feedAnchor?.href) {
|
||||
window.open(feedAnchor.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
|
||||
export function updateNewsViewFocus(coords) {
|
||||
if (!initialized) return;
|
||||
if (!coords || typeof coords.lat !== "number" || typeof coords.lon !== "number") return;
|
||||
|
||||
const region = inferRegion(coords.lat, coords.lon);
|
||||
const nextFocus = {
|
||||
lat: coords.lat,
|
||||
lon: coords.lon,
|
||||
region,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const shouldRefresh = shouldRefreshForFocus(coords.lat, coords.lon, region);
|
||||
lastFocus = nextFocus;
|
||||
if (shouldRefresh) {
|
||||
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureNewsPanelReady() {
|
||||
if (!initialized) {
|
||||
initNewsPanel();
|
||||
}
|
||||
if (!payload) {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function initNewsPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { toggleBtn, refreshBtn, openBtn } = getElements();
|
||||
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
const openNewsTab = async () => {
|
||||
openTVPanelTab("news");
|
||||
updateNewsToggleUI(true);
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const visible = isTVPanelVisible();
|
||||
const active = visible && getActiveTVTab() === "news";
|
||||
|
||||
if (!visible) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
await openNewsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setTVPanelVisible(false);
|
||||
updateNewsToggleUI(false);
|
||||
});
|
||||
|
||||
window.addEventListener("earth:tv-tab-change", () => {
|
||||
updateNewsToggleUI(isTVPanelVisible());
|
||||
});
|
||||
window.addEventListener("earth:tv-visibility-change", (event) => {
|
||||
updateNewsToggleUI(Boolean(event.detail?.visible));
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
@@ -24,7 +24,6 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let selectedSatelliteLegendKey = null;
|
||||
|
||||
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
@@ -38,43 +37,82 @@ export let breathingPhase = 0;
|
||||
|
||||
const SATELLITE_LEGEND_RULES = [
|
||||
{
|
||||
key: "starlink",
|
||||
label: "Starlink",
|
||||
color: "#00e6ff",
|
||||
match: (props) => (props?.name || "").includes("STARLINK"),
|
||||
},
|
||||
{
|
||||
key: "geo",
|
||||
label: "GEO / 倾角 20-30",
|
||||
color: "#ffcc00",
|
||||
key: "equatorial",
|
||||
label: "赤道轨道(0-30°)",
|
||||
color: "#ff3333",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 20 && inclination < 30;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 0 && inclination < 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "iridium",
|
||||
label: "Iridium",
|
||||
color: "#ff8000",
|
||||
match: (props) => (props?.name || "").includes("IRIDIUM"),
|
||||
key: "low",
|
||||
label: "低倾角轨道(30-60°)",
|
||||
color: "#ff9933",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 30 && inclination < 60;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "mid-inclination",
|
||||
label: "倾角 50-70",
|
||||
color: "#00ff4d",
|
||||
key: "medium",
|
||||
label: "中倾角轨道(60-90°)",
|
||||
color: "#ffff33",
|
||||
match: (props) => {
|
||||
const inclination = props?.inclination || 53;
|
||||
return inclination > 50 && inclination < 70;
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 60 && inclination < 90;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "high",
|
||||
label: "高倾角轨道(90-120°)",
|
||||
color: "#33ff33",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 90 && inclination < 120;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retrograde",
|
||||
label: "逆行轨道(120-180°)",
|
||||
color: "#3333ff",
|
||||
match: (props) => {
|
||||
const inclination = Number(props?.inclination ?? 0);
|
||||
return inclination >= 120 && inclination <= 180;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
label: "其他卫星",
|
||||
color: "#ffffff",
|
||||
label: "其他",
|
||||
color: "#d7e2f4",
|
||||
match: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
const SATELLITE_RULE_COLOR_CACHE = new Map();
|
||||
|
||||
function getSatelliteLegendRule(props = {}) {
|
||||
return (
|
||||
SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) ||
|
||||
SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
function getSatelliteRuleColor(rule) {
|
||||
if (!rule) {
|
||||
return { r: 1, g: 1, b: 1 };
|
||||
}
|
||||
|
||||
if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) {
|
||||
return SATELLITE_RULE_COLOR_CACHE.get(rule.key);
|
||||
}
|
||||
|
||||
const color = new THREE.Color(rule.color);
|
||||
const rgb = { r: color.r, g: color.g, b: color.b };
|
||||
SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb);
|
||||
return rgb;
|
||||
}
|
||||
|
||||
export function updateBreathingPhase(deltaTime = 16) {
|
||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||
}
|
||||
@@ -98,31 +136,15 @@ export function getSatelliteLegendItems() {
|
||||
.filter((item) => presentKeys.has(item.key))
|
||||
.map(({ key, label, color }) => ({ key, label, color }));
|
||||
|
||||
if (!selectedSatelliteLegendKey) {
|
||||
return items.map(({ label, color }) => ({ label, color }));
|
||||
}
|
||||
|
||||
const selectedIndex = items.findIndex(
|
||||
(item) => item.key === selectedSatelliteLegendKey,
|
||||
);
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
const [selectedItem] = items.splice(selectedIndex, 1);
|
||||
items.unshift(selectedItem);
|
||||
}
|
||||
|
||||
return items.map(({ label, color }) => ({ label, color }));
|
||||
}
|
||||
|
||||
export function setSelectedSatelliteLegend(props) {
|
||||
const rule = SATELLITE_LEGEND_RULES.find((item) =>
|
||||
item.match(props || {}),
|
||||
);
|
||||
selectedSatelliteLegendKey = rule?.key || null;
|
||||
return getSatelliteLegendRule(props || {});
|
||||
}
|
||||
|
||||
export function clearSelectedSatelliteLegend() {
|
||||
selectedSatelliteLegendKey = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function disposeMaterial(material) {
|
||||
@@ -538,36 +560,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false) {
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
|
||||
const inclination = props?.inclination || 53;
|
||||
const name = props?.name || "";
|
||||
const isStarlink = name.includes("STARLINK");
|
||||
const isGeo = inclination > 20 && inclination < 30;
|
||||
const isIridium = name.includes("IRIDIUM");
|
||||
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (isStarlink) {
|
||||
r = 0.0;
|
||||
g = 0.9;
|
||||
b = 1.0;
|
||||
} else if (isGeo) {
|
||||
r = 1.0;
|
||||
g = 0.8;
|
||||
b = 0.0;
|
||||
} else if (isIridium) {
|
||||
r = 1.0;
|
||||
g = 0.5;
|
||||
b = 0.0;
|
||||
} else if (inclination > 50 && inclination < 70) {
|
||||
r = 0.0;
|
||||
g = 1.0;
|
||||
b = 0.3;
|
||||
} else {
|
||||
r = 1.0;
|
||||
g = 1.0;
|
||||
b = 1.0;
|
||||
}
|
||||
const rule = getSatelliteLegendRule(props);
|
||||
const { r, g, b } = getSatelliteRuleColor(rule);
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
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";
|
||||
@@ -21,8 +27,25 @@ let refreshPromise = null;
|
||||
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,
|
||||
};
|
||||
|
||||
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 = {
|
||||
@@ -34,9 +57,9 @@ const HLS_RETRY_CONFIG = {
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("tv-panel"),
|
||||
// Outer media shell node.
|
||||
panel: document.getElementById("media-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
resizeHandle: null,
|
||||
select: document.getElementById("tv-source-select"),
|
||||
title: document.getElementById("tv-source-title"),
|
||||
meta: document.getElementById("tv-source-meta"),
|
||||
@@ -48,45 +71,53 @@ function getElements() {
|
||||
empty: document.getElementById("tv-empty-state"),
|
||||
refreshBtn: document.getElementById("tv-refresh"),
|
||||
openBtn: document.getElementById("tv-open-external"),
|
||||
metaPanel: document.getElementById("tv-panel-meta"),
|
||||
metaWrap: document.getElementById("tv-meta-wrap"),
|
||||
metaToggle: document.getElementById("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"),
|
||||
};
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
const { metaWrap, metaToggle, panel } = getElements();
|
||||
if (!metaWrap) return;
|
||||
mediaPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
const isDragged = panel?.dataset.dragged === "true" && panel.style.top;
|
||||
|
||||
if (isDragged) {
|
||||
// Top-anchored panel: toggle instantly and compensate top so the player
|
||||
// (panel bottom) stays visually fixed.
|
||||
const bottomBefore = panel.getBoundingClientRect().bottom;
|
||||
|
||||
metaWrap.style.transition = "none";
|
||||
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||
metaToggle?.classList.toggle("is-collapsed", collapsed);
|
||||
|
||||
// Force synchronous reflow to get updated panel height
|
||||
void panel.offsetHeight;
|
||||
|
||||
const delta = panel.getBoundingClientRect().bottom - bottomBefore;
|
||||
if (delta !== 0) {
|
||||
panel.style.top = `${parseFloat(panel.style.top) - delta}px`;
|
||||
}
|
||||
|
||||
// Restore CSS transition after this paint
|
||||
requestAnimationFrame(() => {
|
||||
metaWrap.style.transition = "";
|
||||
});
|
||||
} else {
|
||||
metaWrap.classList.toggle("is-collapsed", collapsed);
|
||||
metaToggle?.classList.toggle("is-collapsed", collapsed);
|
||||
function syncPanelActiveTab(tab = activeTab) {
|
||||
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() {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
setMetaCollapsed(false);
|
||||
@@ -102,6 +133,82 @@ 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" ? (mediaPanel?.isCollapsed() ?? false) : 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"),
|
||||
@@ -109,6 +216,27 @@ function getHudScale() {
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function clampPanelToContainer(panel, container) {
|
||||
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||
if (panel.dataset.dragged !== "true") return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
|
||||
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
|
||||
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
|
||||
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
|
||||
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
@@ -116,12 +244,14 @@ function setupResizeHandle() {
|
||||
|
||||
let resizing = false;
|
||||
let activeEdge = "";
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
const resizeStart = {
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
resizing = false;
|
||||
@@ -134,23 +264,23 @@ function setupResizeHandle() {
|
||||
if (!resizing) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hudScale = getHudScale();
|
||||
const minWidth = Math.max(320, Math.round(360 * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(340 * hudScale));
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
const minWidth = Math.max(320, Math.round(TV_PANEL_MIN_WIDTH_PX * hudScale));
|
||||
const minHeight = Math.max(260, Math.round(TV_PANEL_MIN_HEIGHT_PX * hudScale));
|
||||
const dx = event.clientX - resizeStart.pointerX;
|
||||
const dy = event.clientY - resizeStart.pointerY;
|
||||
|
||||
if (activeEdge.includes("r")) {
|
||||
const maxW = containerRect.width - startLeft - 12;
|
||||
panel.style.width = `${Math.min(maxW, Math.max(minWidth, startWidth + dx))}px`;
|
||||
const maxW = containerRect.width - resizeStart.left - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.width = `${Math.min(maxW, Math.max(minWidth, resizeStart.width + dx))}px`;
|
||||
}
|
||||
if (activeEdge.includes("l")) {
|
||||
const newW = Math.max(minWidth, startWidth - dx);
|
||||
const newW = Math.max(minWidth, resizeStart.width - dx);
|
||||
panel.style.width = `${newW}px`;
|
||||
panel.style.left = `${Math.max(0, startLeft + startWidth - newW)}px`;
|
||||
panel.style.left = `${Math.max(0, resizeStart.left + resizeStart.width - newW)}px`;
|
||||
}
|
||||
if (activeEdge.includes("b")) {
|
||||
const maxH = containerRect.height - startTop - 12;
|
||||
panel.style.minHeight = `${Math.min(maxH, Math.max(minHeight, startHeight + dy))}px`;
|
||||
const maxH = containerRect.height - resizeStart.top - PANEL_RESIZE_MARGIN_PX;
|
||||
panel.style.height = `${Math.min(maxH, Math.max(minHeight, resizeStart.height + dy))}px`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,17 +292,21 @@ function setupResizeHandle() {
|
||||
|
||||
activeEdge = edgeEl.dataset.edge ?? "";
|
||||
resizing = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
resizeStart.pointerX = event.clientX;
|
||||
resizeStart.pointerY = event.clientY;
|
||||
|
||||
clearPanelPositioningForResize(panel);
|
||||
panel.dataset.resized = "true";
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const cRect = container.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
startLeft = rect.left - cRect.left;
|
||||
startTop = rect.top - cRect.top;
|
||||
resizeStart.width = rect.width;
|
||||
resizeStart.height = rect.height;
|
||||
resizeStart.left = rect.left - cRect.left;
|
||||
resizeStart.top = rect.top - cRect.top;
|
||||
panel.style.width = `${resizeStart.width}px`;
|
||||
panel.style.height = `${resizeStart.height}px`;
|
||||
panel.style.minHeight = "";
|
||||
|
||||
panel.classList.add("is-resizing");
|
||||
document.body.style.userSelect = "none";
|
||||
@@ -189,15 +323,16 @@ function setupResizeHandle() {
|
||||
function updateToggleButton(visible) {
|
||||
const { toggleBtn } = getElements();
|
||||
if (!toggleBtn) return;
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
const active = visible && activeTab === "live";
|
||||
toggleBtn.classList.toggle("active", active);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = visible ? "关闭新闻直播" : "打开新闻直播";
|
||||
tooltip.textContent = active ? "关闭新闻直播" : "打开新闻直播";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
const input = document.querySelector('[data-settings-panel="tv-panel"]');
|
||||
const input = document.querySelector('[data-settings-panel="media-panel"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
@@ -206,9 +341,201 @@ function syncSettingsToggle(visible) {
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
mediaPanel?.setVisible(visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
|
||||
detail: { visible },
|
||||
}));
|
||||
}
|
||||
|
||||
export function setTVPanelVisible(visible) {
|
||||
setPanelVisible(visible);
|
||||
}
|
||||
|
||||
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 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");
|
||||
|
||||
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") {
|
||||
setPanelVisible(true);
|
||||
if (activeTab === tab) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
export function isTVPanelVisible() {
|
||||
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
|
||||
}
|
||||
|
||||
export function getActiveTVTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
function getEmbeddedUrl(source) {
|
||||
@@ -392,7 +719,7 @@ function attachVideoSource(video, source) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!showEmbeddedFallback(source)) {
|
||||
if (!showEmbeddedFallback(source) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
@@ -425,6 +752,59 @@ function findSourceById(sourceId) {
|
||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||
}
|
||||
|
||||
function markSourceFailed(sourceId) {
|
||||
if (!sourceId) return;
|
||||
failedSourceIds.add(sourceId);
|
||||
renderSourceOptions();
|
||||
if (!probeTimer) {
|
||||
probeTimer = setInterval(probeFailedSources, PROBE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSourceFailed(sourceId) {
|
||||
if (!failedSourceIds.has(sourceId)) return;
|
||||
failedSourceIds.delete(sourceId);
|
||||
renderSourceOptions();
|
||||
if (failedSourceIds.size === 0 && probeTimer) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeFailedSources() {
|
||||
if (failedSourceIds.size === 0) {
|
||||
clearInterval(probeTimer);
|
||||
probeTimer = null;
|
||||
return;
|
||||
}
|
||||
for (const sourceId of [...failedSourceIds]) {
|
||||
const source = findSourceById(sourceId);
|
||||
if (!source) { failedSourceIds.delete(sourceId); continue; }
|
||||
const probeUrl = source.stream_url || source.embed_url;
|
||||
if (!probeUrl) continue;
|
||||
try {
|
||||
const resp = await fetch(probeUrl, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (resp.ok) clearSourceFailed(sourceId);
|
||||
} catch {
|
||||
// 仍然失效,保持标记
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryFallbackSource() {
|
||||
const fallback = tvPayload?.fallback_source;
|
||||
if (!fallback || fallback.id === currentSourceId) return false;
|
||||
markSourceFailed(currentSourceId);
|
||||
currentSourceId = fallback.id;
|
||||
const { select } = getElements();
|
||||
if (select) select.value = currentSourceId;
|
||||
renderSource(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCurrentSource() {
|
||||
return findSourceById(currentSourceId);
|
||||
}
|
||||
@@ -487,10 +867,11 @@ function renderSourceOptions() {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const marker = source.id === tvPayload?.default_source_id ? " · 默认" : "";
|
||||
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 = `${source.name}${marker}`;
|
||||
option.textContent = `${source.name}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -622,22 +1003,54 @@ export function initTVPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
|
||||
const {
|
||||
select,
|
||||
refreshBtn,
|
||||
iframe,
|
||||
video,
|
||||
toggleBtn,
|
||||
panel,
|
||||
metaToggle,
|
||||
liveTabBtn,
|
||||
newsTabBtn,
|
||||
} = getElements();
|
||||
|
||||
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
|
||||
if (panel && metaToggle) {
|
||||
mediaPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#tv-meta-wrap",
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
});
|
||||
}
|
||||
|
||||
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
|
||||
toggleBtn?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextVisible = panel?.classList.contains("hud-panel-hidden") ?? true;
|
||||
setPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
const currentlyVisible = mediaPanel?.isVisible() ?? false;
|
||||
if (!currentlyVisible) {
|
||||
setPanelVisible(true);
|
||||
setActiveTab("live");
|
||||
await ensureTVPanelReady();
|
||||
showStatusMessage("新闻直播窗口已打开", "info");
|
||||
} else {
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab !== "live") {
|
||||
setActiveTab("live");
|
||||
showStatusMessage("已切换到新闻直播", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
showStatusMessage("新闻直播窗口已关闭", "info");
|
||||
});
|
||||
|
||||
select?.addEventListener("change", (event) => {
|
||||
@@ -647,10 +1060,9 @@ export function initTVPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
const { metaToggle } = getElements();
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
|
||||
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
@@ -658,22 +1070,38 @@ export function initTVPanel() {
|
||||
refreshTVPanel();
|
||||
});
|
||||
|
||||
liveTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("live");
|
||||
});
|
||||
newsTabBtn?.addEventListener("click", () => {
|
||||
setActiveTab("news");
|
||||
});
|
||||
|
||||
iframe?.addEventListener("load", () => {
|
||||
if (iframe.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.iframeReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("loadedmetadata", () => {
|
||||
if (video.hidden) return;
|
||||
clearSourceFailed(currentSourceId);
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoReady);
|
||||
});
|
||||
|
||||
video?.addEventListener("error", () => {
|
||||
const currentSource = getCurrentSource();
|
||||
if (!showEmbeddedFallback(currentSource)) {
|
||||
if (!showEmbeddedFallback(currentSource) && !tryFallbackSource()) {
|
||||
setPanelMessage(TV_STATUS_MESSAGE.videoError);
|
||||
}
|
||||
});
|
||||
|
||||
setupResizeHandle();
|
||||
syncPanelActiveTab("live");
|
||||
syncNewsDefaultMaxHeight();
|
||||
updateTabButtonState(liveTabBtn, true);
|
||||
updateTabButtonState(newsTabBtn, false);
|
||||
captureTabState("live");
|
||||
|
||||
window.addEventListener("resize", syncNewsDefaultMaxHeight);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
let statusTimeoutId = null;
|
||||
let statusHideTimeoutId = null;
|
||||
let statusReplayTimeoutId = null;
|
||||
const STATUS_BASE_CLASS = "earth-status-message";
|
||||
const STATUS_DISPLAY_MS = 3000;
|
||||
const STATUS_FADE_MS = 280;
|
||||
let statusQueue = [];
|
||||
let statusBusy = false;
|
||||
let loadingActive = false;
|
||||
let loadingLockedWidth = 0;
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
@@ -19,53 +24,97 @@ function clearStatusTimers() {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
|
||||
if (statusHideTimeoutId) {
|
||||
clearTimeout(statusHideTimeoutId);
|
||||
statusHideTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusReplayTimeoutId) {
|
||||
clearTimeout(statusReplayTimeoutId);
|
||||
statusReplayTimeoutId = null;
|
||||
function clearLoadingWidthLock(statusEl) {
|
||||
loadingLockedWidth = 0;
|
||||
if (statusEl) {
|
||||
statusEl.style.minWidth = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
function updateLoadingWidthLock(statusEl) {
|
||||
if (!statusEl || !loadingActive) return;
|
||||
const nextWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
|
||||
if (nextWidth <= 0) return;
|
||||
loadingLockedWidth = Math.max(loadingLockedWidth, nextWidth);
|
||||
statusEl.style.minWidth = `${loadingLockedWidth}px`;
|
||||
}
|
||||
|
||||
function buildStatusContent(statusEl, message, type) {
|
||||
statusEl.innerHTML = "";
|
||||
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "earth-status-indicator";
|
||||
indicator.setAttribute("aria-hidden", "true");
|
||||
|
||||
const dotCount = type === "loading" ? 3 : 1;
|
||||
for (let i = 0; i < dotCount; i++) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "earth-status-dot";
|
||||
indicator.appendChild(dot);
|
||||
}
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "earth-status-text";
|
||||
text.textContent = message;
|
||||
|
||||
statusEl.appendChild(indicator);
|
||||
statusEl.appendChild(text);
|
||||
}
|
||||
|
||||
function hideStatusElement(statusEl, onHidden) {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
if (!statusEl.classList.contains("visible")) {
|
||||
setElementDisplay(statusEl, false);
|
||||
statusEl.className = STATUS_BASE_CLASS;
|
||||
statusEl.innerHTML = "";
|
||||
}
|
||||
statusHideTimeoutId = null;
|
||||
if (typeof onHidden === "function") {
|
||||
onHidden();
|
||||
}
|
||||
}, STATUS_FADE_MS);
|
||||
}
|
||||
|
||||
function processStatusQueue() {
|
||||
if (loadingActive || statusBusy || statusQueue.length === 0) return;
|
||||
const next = statusQueue.shift();
|
||||
if (!next) return;
|
||||
startTransientStatus(next.message, next.type);
|
||||
}
|
||||
|
||||
function startTransientStatus(message, type = "info") {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl) return;
|
||||
|
||||
clearStatusTimers();
|
||||
statusBusy = true;
|
||||
|
||||
const startShow = () => {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
|
||||
setElementDisplay(statusEl, true);
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
buildStatusContent(statusEl, message, type);
|
||||
statusEl.className = `${STATUS_BASE_CLASS} ${type}`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
|
||||
statusTimeoutId = setTimeout(() => {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
setElementDisplay(statusEl, false);
|
||||
statusEl.textContent = "";
|
||||
statusHideTimeoutId = null;
|
||||
}, 280);
|
||||
statusTimeoutId = null;
|
||||
}, 3000);
|
||||
};
|
||||
statusTimeoutId = setTimeout(() => {
|
||||
hideStatusElement(statusEl, () => {
|
||||
statusBusy = false;
|
||||
processStatusQueue();
|
||||
});
|
||||
statusTimeoutId = null;
|
||||
}, STATUS_DISPLAY_MS);
|
||||
}
|
||||
|
||||
if (statusEl.classList.contains("visible")) {
|
||||
statusEl.classList.remove("visible");
|
||||
statusReplayTimeoutId = setTimeout(() => {
|
||||
startShow();
|
||||
statusReplayTimeoutId = null;
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
|
||||
startShow();
|
||||
// Show status message
|
||||
export function showStatusMessage(message, type = "info") {
|
||||
statusQueue.push({ message, type });
|
||||
processStatusQueue();
|
||||
}
|
||||
|
||||
// Update coordinates display
|
||||
@@ -120,23 +169,49 @@ export function updateEarthStats(stats) {
|
||||
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
|
||||
}
|
||||
|
||||
// Show/hide loading
|
||||
// Show/hide loading via status message
|
||||
export function setLoading(loading) {
|
||||
const loadingEl = getElement("loading");
|
||||
if (!loadingEl) return;
|
||||
setElementDisplay(loadingEl, loading);
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl) return;
|
||||
|
||||
if (loading) {
|
||||
clearStatusTimers();
|
||||
loadingActive = true;
|
||||
statusBusy = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
buildStatusContent(statusEl, "正在加载...", "loading");
|
||||
statusEl.className = `${STATUS_BASE_CLASS} loading`;
|
||||
setElementDisplay(statusEl, true, "inline-flex");
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
requestAnimationFrame(() => {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
} else {
|
||||
if (!statusEl.classList.contains("loading")) {
|
||||
loadingActive = false;
|
||||
clearLoadingWidthLock(statusEl);
|
||||
processStatusQueue();
|
||||
return;
|
||||
}
|
||||
loadingActive = false;
|
||||
hideStatusElement(statusEl, () => {
|
||||
clearLoadingWidthLock(statusEl);
|
||||
statusBusy = false;
|
||||
processStatusQueue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function setLoadingMessage(title, subtitle = "") {
|
||||
const titleEl = getElement("loading-title");
|
||||
const subtitleEl = getElement("loading-subtitle");
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = title;
|
||||
}
|
||||
|
||||
if (subtitleEl) {
|
||||
subtitleEl.textContent = subtitle;
|
||||
export function setLoadingMessage(title) {
|
||||
const statusEl = getElement("status-message");
|
||||
if (!statusEl || !statusEl.classList.contains("loading")) return;
|
||||
const textEl = statusEl.querySelector(".earth-status-text");
|
||||
if (textEl) {
|
||||
textEl.textContent = title;
|
||||
requestAnimationFrame(() => {
|
||||
updateLoadingWidthLock(statusEl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,12 +252,16 @@ export function hideError() {
|
||||
|
||||
export function clearUiState() {
|
||||
clearStatusTimers();
|
||||
statusQueue = [];
|
||||
statusBusy = false;
|
||||
loadingActive = false;
|
||||
|
||||
const statusEl = getElement("status-message");
|
||||
if (statusEl) {
|
||||
statusEl.className = STATUS_BASE_CLASS;
|
||||
setElementDisplay(statusEl, false);
|
||||
statusEl.textContent = "";
|
||||
statusEl.innerHTML = "";
|
||||
clearLoadingWidthLock(statusEl);
|
||||
}
|
||||
|
||||
hideTooltip();
|
||||
|
||||
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
26
frontend/src/components/TableActions/TableActions.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Dropdown } from 'antd'
|
||||
import { MoreOutlined } from '@ant-design/icons'
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean
|
||||
items: MenuProps['items']
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** onCell style for action columns — prevents overflow ellipsis and text wrapping */
|
||||
export const actionCellProps = {
|
||||
style: { whiteSpace: 'nowrap' as const, textOverflow: 'clip' as const },
|
||||
}
|
||||
|
||||
export function TableActions({ collapsed, items, children }: Props) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Dropdown trigger={['click']} menu={{ items }}>
|
||||
<Button type="text" size="small" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
return <div style={{ display: 'inline-flex', gap: 4 }}>{children}</div>
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { useCollapsedActions } from './useCollapsedActions'
|
||||
export { useWebSocket } from './useWebSocket'
|
||||
|
||||
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
39
frontend/src/hooks/useCollapsedActions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 监听容器宽度,宽时展开操作按钮,窄时收入 Dropdown。
|
||||
* @param threshold 折叠阈值(px),默认 700
|
||||
* @returns [collapsed, callbackRef]
|
||||
*/
|
||||
export function useCollapsedActions(threshold = 700) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const ref = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = el
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setCollapsed(entry.contentRect.width < threshold)
|
||||
})
|
||||
observer.observe(el)
|
||||
observerRef.current = observer
|
||||
},
|
||||
[threshold],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
elementRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [collapsed, ref] as const
|
||||
}
|
||||
@@ -390,6 +390,10 @@ body {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-card__icon-button:hover {
|
||||
color: #1677ff !important;
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
@@ -534,6 +538,21 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playground-chat__input-row .playground-chat__input.ant-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input-row .playground-chat__send-button.ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
@@ -543,6 +562,15 @@ body {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap--expanded .playground-chat__input.ant-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.playground-chat__input-wrap:not(.playground-chat__input-wrap--expanded) .playground-chat__input.ant-input {
|
||||
margin-top: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.playground-chat__input.ant-input:focus,
|
||||
.playground-chat__input.ant-input-focused {
|
||||
box-shadow: none;
|
||||
@@ -1288,6 +1316,10 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
@@ -1493,10 +1525,26 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
height: auto !important;
|
||||
/* users table: flex-fill approach so overlay x-track aligns with table bottom */
|
||||
.users-table-region .ant-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.users-table-region .ant-table-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
|
||||
.data-source-table-region .ant-table-wrapper,
|
||||
.data-source-table-region .ant-spin-nested-loading,
|
||||
.data-source-table-region .ant-spin-container {
|
||||
@@ -1911,6 +1959,14 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alerts-tab-panel > .ant-space-item:last-child,
|
||||
.system-alerts-page__stack > .ant-space-item:last-child,
|
||||
.bgp-alerts-page__stack > .ant-space-item:last-child,
|
||||
.situational-alerts-page__stack > .ant-space-item:last-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.system-alerts-page__table-card,
|
||||
.bgp-alerts-page__table-card {
|
||||
flex: 1 1 auto;
|
||||
@@ -1935,8 +1991,22 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs,
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder,
|
||||
.bgp-alerts-page__tabs {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bgp-alerts-page__tabs .ant-tabs-content,
|
||||
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
||||
min-width: 0;
|
||||
@@ -2505,34 +2575,25 @@ body {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar {
|
||||
|
||||
.settings-tv-edit-modal .ant-modal-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-tv-edit-modal__body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
height: min(80vh, 640px);
|
||||
min-height: 0;
|
||||
padding: 16px 0 0 24px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
.settings-tv-edit-modal__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.settings-tv-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-tv-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.data-list-workspace {
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import {
|
||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||
@@ -213,6 +215,8 @@ function DataSources() {
|
||||
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions()
|
||||
const [customActionsCollapsed, customContainerRef] = useCollapsedActions()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -940,10 +944,29 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
width: builtinActionsCollapsed ? 40 : 164,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: BuiltInDataSource) => (
|
||||
<Space size="small">
|
||||
<TableActions
|
||||
collapsed={builtinActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'trigger',
|
||||
label: '触发',
|
||||
icon: <SyncOutlined />,
|
||||
disabled: !record.is_active,
|
||||
onClick: () => handleTrigger(record.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggle(record.id, record.is_active),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -963,7 +986,7 @@ function DataSources() {
|
||||
>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1001,30 +1024,56 @@ function DataSources() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right' as const,
|
||||
width: customActionsCollapsed ? 40 : 228,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: CustomDataSource) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑">
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={record.is_active ? '禁用' : '启用'}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此配置?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
<TableActions
|
||||
collapsed={customActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => openDrawer(record),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: record.is_active ? '禁用' : '启用',
|
||||
icon: record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />,
|
||||
danger: record.is_active,
|
||||
onClick: () => handleToggleCustom(record.id, record.is_active),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: '确定删除此配置?',
|
||||
onOk: () => handleDelete(record.id),
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openDrawer(record)}>编辑</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
danger={record.is_active}
|
||||
style={record.is_active ? undefined : { color: '#52c41a' }}
|
||||
onClick={() => handleToggleCustom(record.id, record.is_active)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Popconfirm title="确定删除此配置?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1034,7 +1083,7 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<div className="data-source-bulk-toolbar__meta">
|
||||
<div className="data-source-bulk-toolbar__title">采集实时进度</div>
|
||||
@@ -1120,7 +1169,7 @@ function DataSources() {
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="page-shell__body data-source-custom-tab">
|
||||
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
|
||||
@@ -239,6 +239,7 @@ function Playground() {
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [composerFocused, setComposerFocused] = useState(false)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const messagesShellRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -681,7 +682,7 @@ function Playground() {
|
||||
title="AI Chatbox"
|
||||
extra={(
|
||||
<Space size={4}>
|
||||
<Tooltip title="服务状态">
|
||||
<Tooltip title="服务状态" className="playground-chat__service-btn">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -849,31 +850,50 @@ function Playground() {
|
||||
</div>
|
||||
|
||||
<div className="playground-chat__composer">
|
||||
<div className="playground-chat__input-wrap">
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-preset-strip__actions">
|
||||
{PLAYGROUND_PRESETS.map((preset) => (
|
||||
<Tag.CheckableTag
|
||||
key={preset.key}
|
||||
checked={preset.key === selectedPreset.key}
|
||||
onChange={() => handleApplyPreset(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="playground-chat__input-row">
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
|
||||
placeholder="在这里输入本次分析请求..."
|
||||
className="playground-chat__input"
|
||||
onFocus={() => setComposerFocused(true)}
|
||||
onBlur={() => setComposerFocused(false)}
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!(composerFocused || !!inputValue) && (
|
||||
<Tooltip title={sendButtonTooltip}>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
className={`playground-chat__send-button${requestPending || streaming ? ' playground-chat__send-button--stop' : ''}`}
|
||||
icon={requestPending || streaming ? <BorderOutlined /> : <ArrowUpOutlined />}
|
||||
onClick={requestPending || streaming ? handleStop : () => void handleSend()}
|
||||
aria-label={sendButtonTooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
placeholder="在这里输入本次分析请求。你可以写观察、问题、目标,或者直接贴一段待分析事实。"
|
||||
className="playground-chat__input"
|
||||
onPressEnter={(event) => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(composerFocused || !!inputValue) && (
|
||||
<div className="playground-chat__actions">
|
||||
<div className="playground-chat__hints">
|
||||
<Tag>{title}</Tag>
|
||||
@@ -890,8 +910,9 @@ function Playground() {
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,11 +9,13 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
@@ -108,11 +113,14 @@ function Settings() {
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
|
||||
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
@@ -206,90 +214,95 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateTvSetting = <K extends keyof TVSettings>(field: K, value: TVSettings[K]) => {
|
||||
setTvSettings((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||
}
|
||||
|
||||
const updateTvSourceField = <K extends keyof TVStreamSource>(
|
||||
sourceId: string,
|
||||
field: K,
|
||||
value: TVStreamSource[K]
|
||||
) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.map((source) => {
|
||||
if (field === 'is_fallback' && value === true) {
|
||||
return { ...source, is_fallback: source.id === sourceId }
|
||||
}
|
||||
if (source.id === sourceId) {
|
||||
return { ...source, [field]: value }
|
||||
}
|
||||
return source
|
||||
})
|
||||
|
||||
const nextDefaultSourceId =
|
||||
field === 'is_enabled' && value === false && prev.default_source_id === sourceId
|
||||
? nextSources.find((source) => source.id !== sourceId && source.is_enabled)?.id || ''
|
||||
: prev.default_source_id
|
||||
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
})
|
||||
const setDefaultSource = (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
const next = { ...tvSettings, default_source_id: sourceId }
|
||||
setTvSettings(next)
|
||||
saveTvSettings(next)
|
||||
}
|
||||
|
||||
const addTvSource = () => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextIndex = prev.sources.length + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sources: [...prev.sources, newSource],
|
||||
}
|
||||
})
|
||||
const nextIndex = (tvSettings?.sources.length || 0) + 1
|
||||
const newSource: TVStreamSource = {
|
||||
id: `manual-tv-${Date.now()}`,
|
||||
name: `新闻直播源 ${nextIndex}`,
|
||||
provider: 'Manual',
|
||||
region: 'Global',
|
||||
language: 'und',
|
||||
source_type: 'iframe',
|
||||
embed_url: '',
|
||||
stream_url: '',
|
||||
homepage_url: '',
|
||||
poster_url: '',
|
||||
youtube_video_id: '',
|
||||
youtube_channel: '',
|
||||
is_enabled: true,
|
||||
is_fallback: false,
|
||||
sort_order: nextIndex * 10,
|
||||
collector_source: null,
|
||||
notes: '',
|
||||
}
|
||||
setEditingSource(newSource)
|
||||
tvEditForm.setFieldsValue(newSource)
|
||||
}
|
||||
|
||||
const removeTvSource = (sourceId: string) => {
|
||||
setTvSettings((prev) => {
|
||||
if (!prev) return prev
|
||||
const nextSources = prev.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
prev.default_source_id === sourceId ? nextSources[0]?.id || '' : prev.default_source_id
|
||||
return {
|
||||
...prev,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
const confirmEditSource = async () => {
|
||||
if (!editingSource || !tvSettings) return
|
||||
const values = tvEditForm.getFieldsValue()
|
||||
const nextSources = tvSettings.sources
|
||||
.map((source) => {
|
||||
if (source.id === editingSource.id) return { ...source, ...values }
|
||||
if (values.is_fallback) return { ...source, is_fallback: false }
|
||||
return source
|
||||
})
|
||||
|
||||
if (!tvSettings.sources.some((source) => source.id === editingSource.id)) {
|
||||
nextSources.push({
|
||||
...editingSource,
|
||||
...values,
|
||||
})
|
||||
if (values.is_fallback) {
|
||||
for (let index = 0; index < nextSources.length - 1; index += 1) {
|
||||
nextSources[index] = { ...nextSources[index], is_fallback: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const nextDefaultSourceId =
|
||||
values.is_enabled === false && tvSettings.default_source_id === editingSource.id
|
||||
? nextSources.find((s) => s.id !== editingSource.id && s.is_enabled)?.id || ''
|
||||
: tvSettings.default_source_id
|
||||
const next = { ...tvSettings, default_source_id: nextDefaultSourceId, sources: nextSources }
|
||||
setTvSettings(next)
|
||||
setEditingSource(null)
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async () => {
|
||||
const removeTvSource = async (sourceId: string) => {
|
||||
if (!tvSettings) return
|
||||
|
||||
const nextSources = tvSettings.sources.filter((source) => source.id !== sourceId)
|
||||
const nextDefaultSourceId =
|
||||
tvSettings.default_source_id === sourceId ? nextSources[0]?.id || '' : tvSettings.default_source_id
|
||||
const next = {
|
||||
...tvSettings,
|
||||
default_source_id: nextDefaultSourceId,
|
||||
sources: nextSources,
|
||||
}
|
||||
|
||||
setTvSettings(next)
|
||||
if (editingSource?.id === sourceId) {
|
||||
setEditingSource(null)
|
||||
}
|
||||
await saveTvSettings(next)
|
||||
}
|
||||
|
||||
const saveTvSettings = async (next?: TVSettings) => {
|
||||
const toSave = next ?? tvSettings
|
||||
if (!toSave) return
|
||||
try {
|
||||
setSavingTvSettings(true)
|
||||
await axios.put('/api/v1/settings/tv', tvSettings)
|
||||
await axios.put('/api/v1/settings/tv', toSave)
|
||||
message.success('电视直播配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
@@ -402,105 +415,39 @@ function Settings() {
|
||||
const tvSourceColumns = [
|
||||
{
|
||||
title: '频道',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 220,
|
||||
render: (_: string, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input value={record.name} onChange={(event) => updateTvSourceField(record.id, 'name', event.target.value)} />
|
||||
<Input
|
||||
value={record.provider}
|
||||
placeholder="提供方"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'provider', event.target.value)}
|
||||
/>
|
||||
width: 180,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{record.name}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{record.provider}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '区域 / 语言',
|
||||
key: 'locale',
|
||||
width: 160,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.region}
|
||||
placeholder="区域"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'region', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.language}
|
||||
placeholder="语言"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'language', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary">{record.region} · {record.language}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'source_type',
|
||||
key: 'source_type',
|
||||
width: 120,
|
||||
render: (value: TVStreamSource['source_type'], record: TVStreamSource) => (
|
||||
<Select
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateTvSourceField(record.id, 'source_type', nextValue)}
|
||||
options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'hls' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'youtube' },
|
||||
{ value: 'external', label: 'external' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '播放地址',
|
||||
key: 'urls',
|
||||
width: 320,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Input
|
||||
value={record.embed_url}
|
||||
placeholder="嵌入地址 / iframe 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'embed_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.stream_url}
|
||||
placeholder="流地址 / HLS 地址"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'stream_url', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_video_id}
|
||||
placeholder="YouTube 视频 ID(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_video_id', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={record.youtube_channel}
|
||||
placeholder="YouTube 频道 Handle / URL(可选)"
|
||||
onChange={(event) => updateTvSourceField(record.id, 'youtube_channel', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '官网',
|
||||
dataIndex: 'homepage_url',
|
||||
key: 'homepage_url',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'homepage_url', event.target.value)} />
|
||||
),
|
||||
width: 90,
|
||||
render: (value: string) => <Tag>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
width: 130,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<Switch checked={record.is_enabled} onChange={(checked) => updateTvSourceField(record.id, 'is_enabled', checked)} />
|
||||
<Switch checked={record.is_fallback} onChange={(checked) => updateTvSourceField(record.id, 'is_fallback', checked)} />
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' as const }}>
|
||||
<Tag color={record.is_enabled ? 'success' : 'default'}>{record.is_enabled ? '启用' : '禁用'}</Tag>
|
||||
{record.id === tvSettings?.default_source_id && <Tag color="gold">默认</Tag>}
|
||||
{record.is_fallback && <Tag color="blue">备用</Tag>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -508,20 +455,82 @@ function Settings() {
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 220,
|
||||
render: (value: string, record: TVStreamSource) => (
|
||||
<Input value={value} onChange={(event) => updateTvSourceField(record.id, 'notes', event.target.value)} />
|
||||
),
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (value: string) => <Text type="secondary">{value || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 90,
|
||||
fixed: 'right' as const,
|
||||
width: tvActionsCollapsed ? 40 : 258,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: TVStreamSource) => (
|
||||
<Button danger onClick={() => removeTvSource(record.id)} disabled={record.id === tvSettings?.default_source_id}>
|
||||
删除
|
||||
</Button>
|
||||
<TableActions
|
||||
collapsed={tvActionsCollapsed}
|
||||
items={[
|
||||
{
|
||||
key: 'default',
|
||||
label: '设为默认',
|
||||
icon: <CheckCircleOutlined />,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => setDefaultSource(record.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: record.id === tvSettings?.default_source_id,
|
||||
onClick: () => {
|
||||
void removeTvSource(record.id)
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => setDefaultSource(record.id)}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSource(record)
|
||||
tvEditForm.setFieldsValue(record)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={record.id === tvSettings?.default_source_id}
|
||||
onClick={() => {
|
||||
void removeTvSource(record.id)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -612,51 +621,111 @@ function Settings() {
|
||||
key: 'tv',
|
||||
label: '电视直播',
|
||||
children: (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card settings-panel-card--table" loading={loading}>
|
||||
<div className="settings-panel-scroll" style={{ display: 'grid', gap: 16 }}>
|
||||
<div className="settings-tv-toolbar">
|
||||
<div className="settings-tv-toolbar__controls">
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">默认直播源</Text>
|
||||
<Select
|
||||
value={tvSettings?.default_source_id}
|
||||
style={{ minWidth: 260 }}
|
||||
options={(tvSettings?.sources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: source.name,
|
||||
}))}
|
||||
onChange={(value) => updateTvSetting('default_source_id', value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-tv-field">
|
||||
<Text type="secondary">自动回退</Text>
|
||||
<Switch
|
||||
checked={tvSettings?.auto_fallback || false}
|
||||
onChange={(checked) => updateTvSetting('auto_fallback', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-tv-toolbar__actions">
|
||||
<Button onClick={addTvSource}>新增直播源</Button>
|
||||
<Button type="primary" loading={savingTvSettings} onClick={saveTvSettings}>
|
||||
保存电视直播配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TableScrollRegion className="data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500, y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
</div>
|
||||
<div className="settings-pane" ref={tvTableRef}>
|
||||
<Card
|
||||
className="settings-panel-card settings-panel-card--table"
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<TableScrollRegion
|
||||
className="data-source-table-region"
|
||||
style={{ flex: '1 1 auto', minHeight: 0 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={tvSourceColumns}
|
||||
dataSource={tvSettings?.sources || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 420 }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
<Tooltip title="新增直播源">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addTvSource}
|
||||
style={{ width: '100%', borderRadius: 0, borderTop: '1px solid rgba(0,0,0,0.06)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
<Modal
|
||||
title={editingSource?.id.startsWith('manual-tv-') ? '新增直播源' : '编辑直播源'}
|
||||
open={editingSource !== null}
|
||||
onOk={confirmEditSource}
|
||||
onCancel={() => {
|
||||
setEditingSource(null)
|
||||
tvEditForm.resetFields()
|
||||
}}
|
||||
okText="保存"
|
||||
okButtonProps={{ loading: savingTvSettings }}
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
centered
|
||||
destroyOnHidden
|
||||
className="settings-tv-edit-modal"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div className="settings-tv-edit-modal__body">
|
||||
<Scrollbar className="settings-tv-edit-modal__scroll">
|
||||
<Form form={tvEditForm} layout="vertical" style={{ paddingBottom: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="name" label="频道名称" rules={[{ required: true, message: '请输入频道名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供方">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="region" label="区域">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_type" label="类型">
|
||||
<Select options={[
|
||||
{ value: 'iframe', label: 'iframe' },
|
||||
{ value: 'hls', label: 'HLS' },
|
||||
{ value: 'video', label: 'video' },
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'external', label: 'external(仅外部打开)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="embed_url" label="嵌入地址 / iframe 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="stream_url" label="流地址 / HLS 地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_video_id" label="YouTube 视频 ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="youtube_channel" label="YouTube 频道 Handle / URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="homepage_url" label="官网地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="is_enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_fallback" label="设为备用源" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Table, Button, Tag, message, Modal, Form, Input, Select } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
@@ -19,9 +21,8 @@ function Users() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
const [actionsCollapsed, containerRef] = useCollapsedActions()
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
@@ -37,24 +38,6 @@ function Users() {
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = tableRegionRef.current?.offsetHeight || 0
|
||||
setTableHeight(Math.max(220, regionHeight - 56))
|
||||
}
|
||||
|
||||
updateTableHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateTableHeight)
|
||||
if (tableRegionRef.current) observer.observe(tableRegionRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [users.length])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingUser(null)
|
||||
form.resetFields()
|
||||
@@ -127,12 +110,21 @@ function Users() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
width: actionsCollapsed ? 56 : 172,
|
||||
onCell: () => actionCellProps,
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
<TableActions
|
||||
collapsed={actionsCollapsed}
|
||||
items={[
|
||||
{ key: 'edit', label: '编辑', icon: <EditOutlined />, onClick: () => handleEdit(record) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, onClick: () => handleDelete(record.id) },
|
||||
]}
|
||||
>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</TableActions>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -144,14 +136,16 @@ function Users() {
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<TableScrollRegion ref={tableRegionRef} className="data-source-table-region users-table-region" style={{ height: '100%' }}>
|
||||
<div className="page-shell__body" ref={containerRef}>
|
||||
<TableScrollRegion className="data-source-table-region users-table-region">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={false}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</TableScrollRegion>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.27.5"
|
||||
version = "0.29.1"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user