From 1cf1f32dddbda09563a8324de2dcb377c0c92538 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 17 Apr 2026 17:41:15 +0800 Subject: [PATCH] feat: refine earth hud panel behaviors and news board --- backend/app/api/main.py | 2 + backend/app/api/v1/news.py | 13 + backend/app/services/earth_news.py | 490 ++++++++++++++++++++++ docs/hud-panel-component-plan.md | 165 ++++++++ frontend/public/earth/css/base.css | 1 + frontend/public/earth/css/earth-stats.css | 18 +- frontend/public/earth/css/hud.css | 129 +++++- frontend/public/earth/css/info-panel.css | 11 +- frontend/public/earth/css/layer-panel.css | 35 +- frontend/public/earth/css/legend.css | 85 ++-- frontend/public/earth/css/news-panel.css | 211 ++++++++++ frontend/public/earth/css/tv-panel.css | 63 +-- frontend/public/earth/index.html | 101 ++++- frontend/public/earth/js/controls.js | 26 +- frontend/public/earth/js/hud-panels.js | 233 ++++++++++ frontend/public/earth/js/legend.js | 39 +- frontend/public/earth/js/main.js | 20 + frontend/public/earth/js/news.js | 350 ++++++++++++++++ frontend/public/earth/js/tv.js | 59 +-- uv.lock | 2 +- 20 files changed, 1803 insertions(+), 250 deletions(-) create mode 100644 backend/app/api/v1/news.py create mode 100644 backend/app/services/earth_news.py create mode 100644 docs/hud-panel-component-plan.md create mode 100644 frontend/public/earth/css/news-panel.css create mode 100644 frontend/public/earth/js/hud-panels.js create mode 100644 frontend/public/earth/js/news.js diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 6860a914..54f4f4b6 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -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"]) diff --git a/backend/app/api/v1/news.py b/backend/app/api/v1/news.py new file mode 100644 index 00000000..03946404 --- /dev/null +++ b/backend/app/api/v1/news.py @@ -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) diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py new file mode 100644 index 00000000..22f359a1 --- /dev/null +++ b/backend/app/services/earth_news.py @@ -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, + ) diff --git a/docs/hud-panel-component-plan.md b/docs/hud-panel-component-plan.md new file mode 100644 index 00000000..4abb802f --- /dev/null +++ b/docs/hud-panel-component-plan.md @@ -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. diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index 6f138f47..b6b210d6 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -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); diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index 6affa4c0..e09874a8 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -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 ────────────────────────────────────────── */ diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 868128ed..626425e9 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -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 { diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index e1237918..c7629ec5 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -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 { diff --git a/frontend/public/earth/css/layer-panel.css b/frontend/public/earth/css/layer-panel.css index ffa5a058..f99b2acc 100644 --- a/frontend/public/earth/css/layer-panel.css +++ b/frontend/public/earth/css/layer-panel.css @@ -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 ───────────────────────────────────────────────── */ diff --git a/frontend/public/earth/css/legend.css b/frontend/public/earth/css/legend.css index 3993eb43..8eca724e 100644 --- a/frontend/public/earth/css/legend.css +++ b/frontend/public/earth/css/legend.css @@ -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 ────────────────────────────────────────────────── */ diff --git a/frontend/public/earth/css/news-panel.css b/frontend/public/earth/css/news-panel.css new file mode 100644 index 00000000..ee63a438 --- /dev/null +++ b/frontend/public/earth/css/news-panel.css @@ -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))); +} diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css index 1765d688..8f0e5e32 100644 --- a/frontend/public/earth/css/tv-panel.css +++ b/frontend/public/earth/css/tv-panel.css @@ -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)); diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 06c2baeb..0ef4aa6f 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -35,6 +35,7 @@ + @@ -61,7 +62,7 @@ layers 图层 @@ -171,6 +172,12 @@ 打开新闻直播 + - - +
+ 图例 + 海缆
- -
-
+
@@ -289,23 +295,25 @@
-
- 新闻直播 +
+
+ 新闻直播 +
-
- - - +
-
@@ -335,6 +343,51 @@
+
+
+
+
+ 全球态势聚合 + global +
+
+
+ + + + +
+
+ +
+
跟随地球正面视角自动切换区域新闻
+ +
+
+
当前关注区域
+
全球焦点
+
跟随当前视角自动聚焦
+
+
0 路聚合源
+
+ +
+
正在准备全球态势新闻...
+
+ +
+
+ +
+
正在初始化全球态势数据...
@@ -398,6 +451,16 @@ +
diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 9d3f6d6b..fda5390c 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -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); } diff --git a/frontend/public/earth/js/hud-panels.js b/frontend/public/earth/js/hud-panels.js new file mode 100644 index 00000000..76da1e36 --- /dev/null +++ b/frontend/public/earth/js/hud-panels.js @@ -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); +} diff --git a/frontend/public/earth/js/legend.js b/frontend/public/earth/js/legend.js index 6cd15be3..67567139 100644 --- a/frontend/public/earth/js/legend.js +++ b/frontend/public/earth/js/legend.js @@ -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) { diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 97b48c30..d1b08f5d 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -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 ( diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js new file mode 100644 index 00000000..357250fb --- /dev/null +++ b/frontend/public/earth/js/news.js @@ -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 + ? `
${item.summary}
` + : ""; + return ` + + +
${item.title}
+ ${summary} + +
+ `; + }) + .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(() => {}); +} diff --git a/frontend/public/earth/js/tv.js b/frontend/public/earth/js/tv.js index 17a68e8c..12ac6653 100644 --- a/frontend/public/earth/js/tv.js +++ b/frontend/public/earth/js/tv.js @@ -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); }); diff --git a/uv.lock b/uv.lock index c0c3c537..1268bce0 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.27.6" +version = "0.27.7" source = { virtual = "." } dependencies = [ { name = "aiofiles" },