feat: refine earth hud panel behaviors and news board
This commit is contained in:
@@ -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,
|
||||
)
|
||||
165
docs/hud-panel-component-plan.md
Normal file
165
docs/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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
@@ -313,10 +384,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 {
|
||||
|
||||
@@ -160,7 +160,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 +170,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 ────────────────────────────────────────────────── */
|
||||
|
||||
211
frontend/public/earth/css/news-panel.css
Normal file
211
frontend/public/earth/css/news-panel.css
Normal file
@@ -0,0 +1,211 @@
|
||||
/* news-panel.css */
|
||||
|
||||
.hud-panel-news {
|
||||
--hud-body-max-height: calc(580px * var(--hud-scale));
|
||||
top: calc(214px * var(--hud-scale));
|
||||
right: var(--hud-offset);
|
||||
width: calc(420px * var(--hud-scale));
|
||||
min-width: calc(320px * var(--hud-scale));
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: calc(12px * var(--hud-scale));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
z-index: 17;
|
||||
}
|
||||
|
||||
.news-panel-header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.news-panel-header-copy {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.news-panel-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.news-panel-title {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
--hud-body-collapse-gap: var(--hud-gap-sm);
|
||||
}
|
||||
|
||||
.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-direction: column;
|
||||
gap: var(--hud-gap-sm);
|
||||
}
|
||||
|
||||
.news-board-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(8px * var(--hud-scale));
|
||||
max-height: calc(420px * var(--hud-scale));
|
||||
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));
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .hud-panel-news:not([data-dragged="true"]) {
|
||||
transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset)));
|
||||
}
|
||||
@@ -14,15 +14,11 @@
|
||||
}
|
||||
|
||||
/* header 内嵌 select + actions */
|
||||
.hud-panel-tv .hud-panel-header {
|
||||
.hud-panel-tv .hud-panel__header {
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
}
|
||||
|
||||
.hud-panel-tv .hud-panel-header .hud-panel-close {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tv-panel-header-title {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
@@ -30,8 +26,9 @@
|
||||
}
|
||||
|
||||
/* select / action 在 drag-handle 内,恢复正常指针 */
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-select,
|
||||
.hud-panel-tv .hud-panel-header .tv-panel-action {
|
||||
.hud-panel-tv .hud-panel__header .tv-panel-select,
|
||||
.hud-panel-tv .hud-panel__header .hud-panel__action,
|
||||
.hud-panel-tv .hud-panel__header .hud-panel-close {
|
||||
cursor: pointer;
|
||||
user-select: auto;
|
||||
}
|
||||
@@ -63,58 +60,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));
|
||||
|
||||
@@ -35,6 +35,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 +62,7 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +172,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 +224,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>
|
||||
@@ -289,23 +295,25 @@
|
||||
</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>
|
||||
<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>
|
||||
<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="刷新直播源">
|
||||
<div class="hud-panel__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="tv-panel-action tv-panel-action--icon" type="button" title="访问官网" aria-label="访问官网">
|
||||
<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="tv-panel-action tv-panel-action--icon tv-panel-meta-toggle" type="button" title="频道信息" aria-label="频道信息">
|
||||
<span class="material-symbols-rounded">expand_more</span>
|
||||
<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>
|
||||
<button class="hud-panel-close hud-panel__action hud-panel__action--close" type="button" data-close-panel="tv-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">
|
||||
@@ -335,6 +343,51 @@
|
||||
<div class="tv-panel-edge" data-edge="bl"></div>
|
||||
</div>
|
||||
|
||||
<div id="news-panel" class="hud-panel hud-panel-news hud-panel-draggable hud-panel--expand-down" data-panel-key="news-panel">
|
||||
<div class="hud-panel__header hud-panel-drag-handle">
|
||||
<div class="news-panel-header-copy hud-panel__title-group">
|
||||
<div class="news-panel-title-row">
|
||||
<span class="hud-panel-title hud-panel__title news-panel-title">全球态势聚合</span>
|
||||
<span id="news-region-chip" class="news-region-chip hud-panel__chip">global</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-panel__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>
|
||||
<button id="news-collapse" class="hud-panel__action hud-panel__action--collapse news-panel-collapse" type="button" title="折叠新闻面板" aria-label="折叠新闻面板">
|
||||
<span class="material-symbols-rounded">expand_less</span>
|
||||
</button>
|
||||
<button class="hud-panel__action hud-panel__action--close hud-panel-close" type="button" data-close-panel="news-panel" aria-label="关闭态势新闻">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="news-panel-body" class="news-panel-body hud-panel__body hud-panel__body--collapsible">
|
||||
<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>
|
||||
</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>
|
||||
@@ -398,6 +451,16 @@
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="earth-settings-item" for="toggle-view-news">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">态势新闻</span>
|
||||
<span class="earth-settings-item-subtitle">控制跟随视角切换的全球态势 RSS 看板显示</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-view-news" type="checkbox" data-settings-panel="news-panel" checked>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
26
frontend/public/earth/js/controls.js
vendored
26
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 {
|
||||
ensureNewsPanelReady,
|
||||
setNewsPanelVisible,
|
||||
updateNewsToggleUI,
|
||||
} from "./news.js";
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -31,6 +36,7 @@ const HUD_PANEL_IDS = [
|
||||
"legend",
|
||||
"earth-stats",
|
||||
"tv-panel",
|
||||
"news-panel",
|
||||
"layer-toggles",
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
@@ -97,6 +103,14 @@ function setHudPanelVisibility(panelId, visible) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (panelId === "news-panel") {
|
||||
updateNewsToggleUI(visible);
|
||||
if (visible) {
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻面板失败:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(panelId, visible) {
|
||||
@@ -230,7 +244,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, .tv-panel-player, .tv-panel-edge, .legend-bar-btn, .news-story-card")) return;
|
||||
isDragging = true;
|
||||
startPointerX = event.clientX;
|
||||
startPointerY = event.clientY;
|
||||
@@ -623,7 +637,7 @@ function setupLayerPanel() {
|
||||
collapseBtn.title = isCollapsed ? "展开" : "折叠";
|
||||
collapseBtn.setAttribute("aria-label", isCollapsed ? "展开图层列表" : "折叠图层列表");
|
||||
const icon = collapseBtn.querySelector(".material-symbols-rounded");
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_less" : "expand_more";
|
||||
if (icon) icon.textContent = isCollapsed ? "expand_more" : "expand_less";
|
||||
});
|
||||
|
||||
if (searchInput) {
|
||||
@@ -801,6 +815,14 @@ function setupTerrainControls() {
|
||||
console.error("初始化电视直播面板失败:", error);
|
||||
});
|
||||
}
|
||||
const newsVisible = !document.getElementById("news-panel")?.classList.contains("hud-panel-hidden");
|
||||
updateNewsToggleUI(newsVisible);
|
||||
setNewsPanelVisible(newsVisible);
|
||||
if (newsVisible) {
|
||||
ensureNewsPanelReady().catch((error) => {
|
||||
console.error("初始化态势新闻面板失败:", error);
|
||||
});
|
||||
}
|
||||
updateLayoutUI(container);
|
||||
}
|
||||
|
||||
|
||||
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";
|
||||
const EXPAND_DIRECTION_BUFFER_PX = 20;
|
||||
|
||||
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({
|
||||
headerRect,
|
||||
expandedHeight,
|
||||
preferredDirection,
|
||||
currentDirection,
|
||||
}) {
|
||||
const spaceAbove = Math.max(0, headerRect.top);
|
||||
const spaceBelow = Math.max(0, window.innerHeight - headerRect.bottom);
|
||||
const preferred = clampExpandDirection(preferredDirection);
|
||||
const fitsAbove = expandedHeight <= spaceAbove;
|
||||
const fitsBelow = expandedHeight <= spaceBelow;
|
||||
const bufferedFitsBelow = expandedHeight + EXPAND_DIRECTION_BUFFER_PX <= spaceBelow;
|
||||
const activeDirection = clampExpandDirection(currentDirection ?? preferred);
|
||||
|
||||
// Hysteresis:
|
||||
// - if we're already in "up", keep it until bottom space drops below h
|
||||
// - if we're already in "down", keep it until bottom space grows beyond h + 20
|
||||
if (activeDirection === "up" && spaceBelow > expandedHeight && fitsAbove) {
|
||||
return "up";
|
||||
}
|
||||
|
||||
if (activeDirection === "down" && spaceBelow < expandedHeight + EXPAND_DIRECTION_BUFFER_PX && fitsBelow) {
|
||||
return "down";
|
||||
}
|
||||
|
||||
// Main contract:
|
||||
// d = spaceBelow, h = expandedHeight
|
||||
// - d > h + 20 => up
|
||||
// - d <= h + 20 => down
|
||||
// Only fall back when the preferred side cannot actually fit.
|
||||
if (bufferedFitsBelow && fitsAbove) return "up";
|
||||
if (!bufferedFitsBelow && fitsBelow) return "down";
|
||||
if (fitsAbove && fitsBelow) return preferred;
|
||||
if (fitsAbove) return "up";
|
||||
if (fitsBelow) return "down";
|
||||
return spaceAbove > spaceBelow ? "up" : "down";
|
||||
}
|
||||
|
||||
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({
|
||||
headerRect: headerEl.getBoundingClientRect(),
|
||||
expandedHeight,
|
||||
preferredDirection,
|
||||
currentDirection,
|
||||
});
|
||||
|
||||
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(collapsedClass) && !panelEl.classList.contains(hiddenClass)) {
|
||||
syncLayout();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
document.addEventListener("pointerup", handleViewportChange);
|
||||
|
||||
syncLayout();
|
||||
|
||||
return {
|
||||
panel: panelEl,
|
||||
header: headerEl,
|
||||
body: bodyEl,
|
||||
collapseBtn: collapseBtnEl,
|
||||
setCollapsed,
|
||||
setVisible,
|
||||
syncLayout,
|
||||
destroy() {
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
document.removeEventListener("pointerup", handleViewportChange);
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -127,6 +127,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;
|
||||
@@ -173,6 +174,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;
|
||||
@@ -194,6 +196,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
||||
"#earth-stats *",
|
||||
"#tv-panel",
|
||||
"#tv-panel *",
|
||||
"#news-panel",
|
||||
"#news-panel *",
|
||||
];
|
||||
|
||||
function bindListener(target, eventName, handler, options) {
|
||||
@@ -871,6 +875,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,6 +907,7 @@ export function init() {
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
@@ -1693,6 +1712,7 @@ function animate() {
|
||||
updateSatellitePositions(deltaTime);
|
||||
updateBreathingPhase(deltaTime);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
350
frontend/public/earth/js/news.js
Normal file
350
frontend/public/earth/js/news.js
Normal file
@@ -0,0 +1,350 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
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;
|
||||
let newsPanel = null;
|
||||
|
||||
function getElements() {
|
||||
return {
|
||||
panel: document.getElementById("news-panel"),
|
||||
toggleBtn: document.getElementById("toggle-news"),
|
||||
refreshBtn: document.getElementById("news-refresh"),
|
||||
openBtn: document.getElementById("news-open-external"),
|
||||
collapseBtn: document.getElementById("news-collapse"),
|
||||
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;
|
||||
toggleBtn.classList.toggle("active", visible);
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = visible ? "关闭态势新闻" : "打开态势新闻";
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
const input = document.querySelector('[data-settings-panel="news-panel"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.checked = visible;
|
||||
}
|
||||
}
|
||||
|
||||
export function setNewsPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
newsPanel?.setVisible(visible);
|
||||
updateNewsToggleUI(visible);
|
||||
syncSettingsToggle(visible);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
function setCollapsed(collapsed) {
|
||||
newsPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
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 { panel, toggleBtn, refreshBtn, openBtn, collapseBtn } = getElements();
|
||||
|
||||
if (panel && collapseBtn) {
|
||||
newsPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#news-panel-body",
|
||||
collapseBtn,
|
||||
preferredDirection: "down",
|
||||
expandLabel: "展开新闻面板",
|
||||
collapseLabel: "折叠新闻面板",
|
||||
});
|
||||
}
|
||||
|
||||
updateNewsToggleUI(true);
|
||||
syncSettingsToggle(true);
|
||||
renderEmptyState("正在准备全球态势新闻聚合源...");
|
||||
|
||||
toggleBtn?.addEventListener("click", async () => {
|
||||
const nextVisible = !(newsPanel?.isVisible() ?? false);
|
||||
setNewsPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
try {
|
||||
await ensureNewsPanelReady();
|
||||
} catch {
|
||||
// surface already handled
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await refreshNews(lastFocus?.lat, lastFocus?.lon);
|
||||
showStatusMessage("态势新闻已刷新", "info");
|
||||
} catch {
|
||||
showStatusMessage("态势新闻刷新失败", "error");
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener("click", openCurrentSourceHomepage);
|
||||
collapseBtn?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
setCollapsed(!newsPanel?.isCollapsed());
|
||||
});
|
||||
|
||||
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
|
||||
const TV_STREAMS_API = "/api/v1/tv/streams";
|
||||
const TV_PROXY_API = "/api/v1/tv/proxy";
|
||||
@@ -21,6 +22,7 @@ let refreshPromise = null;
|
||||
let hlsPlayer = null;
|
||||
let hlsRecoveryAttempts = 0;
|
||||
let metaAutoCollapseTimer = null;
|
||||
let tvPanel = null;
|
||||
const failedSourceIds = new Set();
|
||||
let probeTimer = null;
|
||||
|
||||
@@ -58,36 +60,7 @@ function getElements() {
|
||||
}
|
||||
|
||||
function setMetaCollapsed(collapsed) {
|
||||
const { metaWrap, metaToggle, panel } = getElements();
|
||||
if (!metaWrap) return;
|
||||
|
||||
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);
|
||||
}
|
||||
tvPanel?.setCollapsed(collapsed);
|
||||
}
|
||||
|
||||
function autoExpandMeta() {
|
||||
@@ -209,7 +182,7 @@ function syncSettingsToggle(visible) {
|
||||
function setPanelVisible(visible) {
|
||||
const { panel } = getElements();
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||
tvPanel?.setVisible(visible);
|
||||
updateToggleButton(visible);
|
||||
syncSettingsToggle(visible);
|
||||
}
|
||||
@@ -679,15 +652,28 @@ export function initTVPanel() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel } = getElements();
|
||||
const { select, refreshBtn, iframe, video, toggleBtn, panel, metaToggle } = getElements();
|
||||
|
||||
updateToggleButton(!panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(!panel?.classList.contains("hud-panel-hidden"));
|
||||
if (panel && metaToggle) {
|
||||
tvPanel = createHUDPanel({
|
||||
panel,
|
||||
header: ".hud-panel__header",
|
||||
body: "#tv-meta-wrap",
|
||||
collapseBtn: metaToggle,
|
||||
bodyCollapsedClass: "is-collapsed",
|
||||
preferredDirection: "up",
|
||||
expandLabel: "展开新闻直播信息",
|
||||
collapseLabel: "折叠新闻直播信息",
|
||||
});
|
||||
}
|
||||
|
||||
updateToggleButton(tvPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
|
||||
syncSettingsToggle(tvPanel?.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;
|
||||
const nextVisible = !(tvPanel?.isVisible() ?? false);
|
||||
setPanelVisible(nextVisible);
|
||||
if (nextVisible) {
|
||||
await ensureTVPanelReady();
|
||||
@@ -704,10 +690,9 @@ export function initTVPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
|
||||
const { metaToggle } = getElements();
|
||||
metaToggle?.addEventListener("click", () => {
|
||||
clearTimeout(metaAutoCollapseTimer);
|
||||
const isNowCollapsed = !metaToggle.classList.contains("is-collapsed");
|
||||
const isNowCollapsed = !(tvPanel?.isCollapsed() ?? false);
|
||||
setMetaCollapsed(isNowCollapsed);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user