Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67548d8278 | ||
|
|
4b8feeb87e |
@@ -18,7 +18,6 @@ from app.services.business_logs import emit_business_log, exception_context
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.services.collectors.downloads import DownloadHTTPStatusError, ResumableFileDownloader
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="collector")
|
||||
ACTIVE_GROUP = "active"
|
||||
FALLBACK_GROUPS = (
|
||||
@@ -39,6 +38,7 @@ FALLBACK_GROUPS = (
|
||||
)
|
||||
FETCH_RETRY_ATTEMPTS = 3
|
||||
FETCH_RETRY_BASE_DELAY_SECONDS = 0.8
|
||||
CELESTRAK_CACHE_TTL_SECONDS = 2 * 60 * 60
|
||||
CELESTRAK_NOT_UPDATED_MARKER = "GP data has not updated since your last successful"
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
_downloader = ResumableFileDownloader(
|
||||
cache_namespace="celestrak",
|
||||
default_accept="application/json",
|
||||
preflight_head=False,
|
||||
cache_ttl_seconds=CELESTRAK_CACHE_TTL_SECONDS,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -195,7 +197,9 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
if not is_final_attempt:
|
||||
await asyncio.sleep(FETCH_RETRY_BASE_DELAY_SECONDS * attempt)
|
||||
|
||||
raise RuntimeError(f"CelesTrak active satellite download failed after retries: {last_error}")
|
||||
raise RuntimeError(
|
||||
f"CelesTrak active satellite download failed after retries: {last_error}"
|
||||
)
|
||||
|
||||
async def _fetch_fallback_groups(
|
||||
self,
|
||||
@@ -226,25 +230,25 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
try:
|
||||
for group in FALLBACK_GROUPS:
|
||||
group_url = self._group_url(group)
|
||||
cached_path = self._downloader.get_cached_file(
|
||||
group_url,
|
||||
".json",
|
||||
validate_existing=self._validate_json_file,
|
||||
)
|
||||
if cached_path is not None:
|
||||
body_path = cached_path
|
||||
else:
|
||||
try:
|
||||
body_path = await self._downloader.download_file(
|
||||
client,
|
||||
group_url,
|
||||
extension=".json",
|
||||
accept="application/json",
|
||||
validate_existing=self._validate_json_file,
|
||||
)
|
||||
except DownloadHTTPStatusError as exc:
|
||||
if not self._is_not_updated_response(exc):
|
||||
raise RuntimeError(f"CelesTrak fallback group '{group}' download failed: {exc}") from exc
|
||||
try:
|
||||
body_path = await self._downloader.download_file(
|
||||
client,
|
||||
group_url,
|
||||
extension=".json",
|
||||
accept="application/json",
|
||||
validate_existing=self._validate_json_file,
|
||||
)
|
||||
except DownloadHTTPStatusError as exc:
|
||||
if not self._is_not_updated_response(exc):
|
||||
raise RuntimeError(
|
||||
f"CelesTrak fallback group '{group}' download failed: {exc}"
|
||||
) from exc
|
||||
body_path = self._downloader.get_cached_file(
|
||||
group_url,
|
||||
".json",
|
||||
validate_existing=self._validate_json_file,
|
||||
)
|
||||
if body_path is None:
|
||||
raise RuntimeError(
|
||||
f"CelesTrak fallback group '{group}' has not updated and no local cached copy is available"
|
||||
) from exc
|
||||
@@ -348,13 +352,13 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
commit=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_json_file(path: Path) -> bool:
|
||||
@classmethod
|
||||
def _validate_json_file(cls, path: Path) -> bool:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
cls._load_active_payload(path)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return isinstance(data, list)
|
||||
return True
|
||||
|
||||
async def _log_parse_failure(self, exc: Exception) -> None:
|
||||
await emit_business_log(
|
||||
@@ -375,7 +379,8 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
),
|
||||
)
|
||||
|
||||
def _load_active_payload(self, path: Path) -> List[Dict[str, Any]]:
|
||||
@staticmethod
|
||||
def _load_active_payload(path: Path) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
@@ -391,7 +396,9 @@ class CelesTrakTLECollector(BaseCollector):
|
||||
else:
|
||||
invalid_count += 1
|
||||
if invalid_count:
|
||||
raise RuntimeError(f"CelesTrak active payload contains {invalid_count} invalid record(s)")
|
||||
raise RuntimeError(
|
||||
f"CelesTrak active payload contains {invalid_count} invalid record(s)"
|
||||
)
|
||||
if not records:
|
||||
raise RuntimeError("CelesTrak active payload contains no satellite records")
|
||||
return records
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
ProgressCallback = Callable[[int, int | None], Awaitable[None]]
|
||||
ValidateCallback = Callable[[Path], bool]
|
||||
|
||||
@@ -52,10 +51,14 @@ class ResumableFileDownloader:
|
||||
user_agent: str = "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
default_accept: str = "*/*",
|
||||
cache_root: Path | None = None,
|
||||
preflight_head: bool = True,
|
||||
cache_ttl_seconds: float = 0,
|
||||
) -> None:
|
||||
self._cache_dir = (cache_root or default_download_cache_root()) / cache_namespace
|
||||
self._user_agent = user_agent
|
||||
self._default_accept = default_accept
|
||||
self._preflight_head = preflight_head
|
||||
self._cache_ttl_seconds = cache_ttl_seconds
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(url: str) -> str:
|
||||
@@ -93,8 +96,9 @@ class ResumableFileDownloader:
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except json.JSONDecodeError, OSError:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
@@ -111,20 +115,25 @@ class ResumableFileDownloader:
|
||||
return last_modified == str(meta.get("last_modified") or "").strip()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _response_info(response: httpx.Response) -> dict[str, Any]:
|
||||
# httpx yields decoded bytes; encoded Content-Length cannot validate them.
|
||||
raw_length = response.headers.get("content-length")
|
||||
encoded = response.headers.get("content-encoding", "identity") != "identity"
|
||||
return {
|
||||
"etag": response.headers.get("etag"),
|
||||
"last_modified": response.headers.get("last-modified"),
|
||||
"content_length": int(raw_length) if raw_length and not encoded else None,
|
||||
"accept_ranges": (response.headers.get("accept-ranges") or "").lower(),
|
||||
}
|
||||
|
||||
async def fetch_remote_info(self, client: httpx.AsyncClient, url: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = await client.head(url)
|
||||
if response.status_code >= 400:
|
||||
return {}
|
||||
content_length_raw = response.headers.get("content-length")
|
||||
content_length = int(content_length_raw) if content_length_raw else None
|
||||
return {
|
||||
"etag": response.headers.get("etag"),
|
||||
"last_modified": response.headers.get("last-modified"),
|
||||
"content_length": content_length,
|
||||
"accept_ranges": (response.headers.get("accept-ranges") or "").lower(),
|
||||
}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return self._response_info(response)
|
||||
except httpx.HTTPError, ValueError:
|
||||
return {}
|
||||
|
||||
async def download_file(
|
||||
@@ -139,14 +148,24 @@ class ResumableFileDownloader:
|
||||
) -> Path:
|
||||
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
final_path, part_path, meta_path = self._cache_paths(url, extension)
|
||||
cached_path = self.get_cached_file(url, extension, validate_existing=validate_existing)
|
||||
if cached_path and 0 <= time.time() - cached_path.stat().st_mtime < self._cache_ttl_seconds:
|
||||
size = cached_path.stat().st_size
|
||||
if progress_callback:
|
||||
await progress_callback(size, size)
|
||||
return cached_path
|
||||
meta = self._load_meta(meta_path)
|
||||
remote = await self.fetch_remote_info(client, url)
|
||||
remote = await self.fetch_remote_info(client, url) if self._preflight_head else {}
|
||||
expected_size = remote.get("content_length")
|
||||
|
||||
if final_path.exists():
|
||||
local_size = final_path.stat().st_size
|
||||
size_match = expected_size is None or local_size == expected_size
|
||||
if self._validators_match(meta, remote) and size_match:
|
||||
if (
|
||||
(remote.get("etag") or remote.get("last_modified"))
|
||||
and self._validators_match(meta, remote)
|
||||
and size_match
|
||||
):
|
||||
if validate_existing and not validate_existing(final_path):
|
||||
final_path.unlink(missing_ok=True)
|
||||
else:
|
||||
@@ -181,9 +200,13 @@ class ResumableFileDownloader:
|
||||
headers["If-Range"] = str(remote.get("last_modified"))
|
||||
|
||||
async with client.stream("GET", url, headers=headers) as response:
|
||||
if response.status_code == 304 and final_path.exists():
|
||||
if progress_callback and expected_size and expected_size > 0:
|
||||
await progress_callback(expected_size, expected_size)
|
||||
if response.status_code == 304:
|
||||
if cached_path is None:
|
||||
raise RuntimeError(f"Server returned 304 without a valid local cache for {url}")
|
||||
final_path.touch()
|
||||
size = final_path.stat().st_size
|
||||
if progress_callback:
|
||||
await progress_callback(size, size)
|
||||
return final_path
|
||||
if response.status_code >= 400:
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
@@ -194,12 +217,16 @@ class ResumableFileDownloader:
|
||||
else:
|
||||
mode = "wb"
|
||||
resume_from = 0
|
||||
remote = self._response_info(response)
|
||||
expected_size = remote.get("content_length")
|
||||
|
||||
downloaded = resume_from
|
||||
last_emit_bytes = 0
|
||||
last_emit_time = time.monotonic()
|
||||
min_emit_bytes = (
|
||||
max(expected_size // 150, 512 * 1024) if expected_size and expected_size > 0 else 1024 * 1024
|
||||
max(expected_size // 150, 512 * 1024)
|
||||
if expected_size and expected_size > 0
|
||||
else 1024 * 1024
|
||||
)
|
||||
|
||||
with part_path.open(mode) as f:
|
||||
@@ -230,6 +257,10 @@ class ResumableFileDownloader:
|
||||
f"Resumable download incomplete for {url}: expected={expected_size}, got={final_size}"
|
||||
)
|
||||
|
||||
# Never replace the last good payload with an HTML error or invalid JSON.
|
||||
if validate_existing and not validate_existing(part_path):
|
||||
part_path.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Downloaded file validation failed for {url}")
|
||||
part_path.replace(final_path)
|
||||
self._save_meta(
|
||||
meta_path,
|
||||
@@ -242,9 +273,6 @@ class ResumableFileDownloader:
|
||||
},
|
||||
)
|
||||
|
||||
if validate_existing and not validate_existing(final_path):
|
||||
raise RuntimeError(f"Downloaded file validation failed for {url}")
|
||||
|
||||
if progress_callback and expected_size and expected_size > 0:
|
||||
await progress_callback(expected_size, expected_size)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import codecs
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
@@ -1800,6 +1801,30 @@ def _extract_item_text(element: ET.Element, *names: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _decode_feed_response(response: httpx.Response) -> str:
|
||||
content = response.content
|
||||
for marker, encoding in (
|
||||
(codecs.BOM_UTF32_LE, "utf-32"),
|
||||
(codecs.BOM_UTF32_BE, "utf-32"),
|
||||
(codecs.BOM_UTF16_LE, "utf-16"),
|
||||
(codecs.BOM_UTF16_BE, "utf-16"),
|
||||
(codecs.BOM_UTF8, "utf-8-sig"),
|
||||
):
|
||||
if content.startswith(marker):
|
||||
return content.decode(encoding)
|
||||
|
||||
declaration = re.match(
|
||||
br"\s*<\?xml\s+[^?]*\bencoding\s*=\s*['\"]([^'\"]+)['\"]",
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
encoding = response.charset_encoding or (
|
||||
declaration.group(1).decode("ascii") if declaration else "utf-8"
|
||||
)
|
||||
# Decode strictly before parsing: response.text silently replaces non-UTF-8 bytes.
|
||||
return content.decode(encoding)
|
||||
|
||||
|
||||
def _parse_feed_entries(
|
||||
xml_text: str,
|
||||
source: NewsFeedSource,
|
||||
@@ -2453,7 +2478,9 @@ async def _fetch_single_feed_url(
|
||||
feed=feed,
|
||||
)
|
||||
try:
|
||||
items = _parse_feed_entries(response.text, source, feed=feed, config_payload=config_payload)
|
||||
items = _parse_feed_entries(
|
||||
_decode_feed_response(response), source, feed=feed, config_payload=config_payload
|
||||
)
|
||||
except Exception as exc:
|
||||
error, status = _format_source_fetch_error(
|
||||
status_code=status_code,
|
||||
@@ -2461,7 +2488,7 @@ async def _fetch_single_feed_url(
|
||||
body=response.text,
|
||||
parsed_count=0,
|
||||
)
|
||||
if error is None:
|
||||
if status == "empty":
|
||||
error = str(exc)
|
||||
status = "format_error"
|
||||
return source, [], error, _source_health_result(
|
||||
|
||||
218
backend/tests/test_collector_downloads.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Request-level regressions for quota-limited satellite downloads."""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.collectors.celestrak import CelesTrakTLECollector, CELESTRAK_CACHE_TTL_SECONDS
|
||||
from app.services.collectors.downloads import ResumableFileDownloader
|
||||
|
||||
URL = "https://celestrak.example/gp.php?GROUP=active&FORMAT=json"
|
||||
|
||||
|
||||
def setup_collector(monkeypatch, tmp_path, handler):
|
||||
collector = CelesTrakTLECollector()
|
||||
collector._resolved_url = "https://celestrak.example/gp.php"
|
||||
monkeypatch.setattr(collector._downloader, "_cache_dir", tmp_path)
|
||||
monkeypatch.setattr(collector, "update_phase_progress", AsyncMock())
|
||||
monkeypatch.setattr("app.services.collectors.celestrak.emit_business_log", AsyncMock())
|
||||
monkeypatch.setattr("app.services.collectors.celestrak.asyncio.sleep", AsyncMock())
|
||||
original_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.celestrak.httpx.AsyncClient",
|
||||
lambda **kwargs: original_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||
)
|
||||
return collector
|
||||
|
||||
|
||||
def expire(path):
|
||||
old = time.time() - CELESTRAK_CACHE_TTL_SECONDS - 1
|
||||
os.utime(path, (old, old))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_celestrak_uses_one_get_and_revalidates_after_cache_expiry(monkeypatch, tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
assert request.method == "GET", "HEAD consumes this upstream's download allowance"
|
||||
return httpx.Response(
|
||||
200, json=[{"NORAD_CAT_ID": len(requests)}], headers={"etag": f'"v{len(requests)}"'}
|
||||
)
|
||||
|
||||
collector = setup_collector(monkeypatch, tmp_path, handler)
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 1
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 1
|
||||
assert len(requests) == 1
|
||||
path = collector._downloader.cached_file_path(URL, ".json")
|
||||
expire(path)
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 2
|
||||
assert requests[1].headers["if-none-match"] == '"v1"'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_updated_reuses_valid_cache_without_retry_storm(monkeypatch, tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request.method)
|
||||
return httpx.Response(
|
||||
403, text="GP data has not updated since your last successful download"
|
||||
)
|
||||
|
||||
collector = setup_collector(monkeypatch, tmp_path, handler)
|
||||
path = collector._downloader.cached_file_path(URL, ".json")
|
||||
path.write_text('[{"NORAD_CAT_ID":42}]')
|
||||
expire(path)
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 42
|
||||
assert requests == ["GET"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_download_does_not_replace_last_good_cache(monkeypatch, tmp_path):
|
||||
collector = setup_collector(monkeypatch, tmp_path, lambda request: httpx.Response(200, json=[]))
|
||||
path = collector._downloader.cached_file_path(URL, ".json")
|
||||
path.write_text('[{"NORAD_CAT_ID":42}]')
|
||||
expire(path)
|
||||
with pytest.raises(RuntimeError, match="validation failed"):
|
||||
await collector.fetch()
|
||||
assert json.loads(path.read_text()) == [{"NORAD_CAT_ID": 42}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_corrupt_cache_is_not_reused_or_conditionally_requested(monkeypatch, tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json=[{"NORAD_CAT_ID": 7}])
|
||||
|
||||
collector = setup_collector(monkeypatch, tmp_path, handler)
|
||||
path, _, meta = collector._downloader._cache_paths(URL, ".json")
|
||||
path.write_text("[]")
|
||||
meta.write_text('{"etag":"old"}')
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 7
|
||||
assert "if-none-match" not in requests[0].headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conditional_304_keeps_payload_and_refreshes_cache_window(tmp_path):
|
||||
downloader = ResumableFileDownloader(
|
||||
cache_namespace="test", cache_root=tmp_path, preflight_head=False, cache_ttl_seconds=7200
|
||||
)
|
||||
path, _, meta = downloader._cache_paths(URL, ".json")
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text('[{"NORAD_CAT_ID":42}]')
|
||||
meta.write_text('{"etag":"v1"}')
|
||||
expire(path)
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(304)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
for _ in range(2):
|
||||
await downloader.download_file(
|
||||
client,
|
||||
URL,
|
||||
extension=".json",
|
||||
validate_existing=CelesTrakTLECollector._validate_json_file,
|
||||
)
|
||||
assert len(requests) == 1
|
||||
assert requests[0].headers["if-none-match"] == "v1"
|
||||
assert json.loads(path.read_text())[0]["NORAD_CAT_ID"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compressed_get_length_uses_decoded_payload(tmp_path):
|
||||
raw = json.dumps([{"NORAD_CAT_ID": 42}]).encode()
|
||||
body = gzip.compress(raw)
|
||||
downloader = ResumableFileDownloader(
|
||||
cache_namespace="test", cache_root=tmp_path, preflight_head=False
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
content=body,
|
||||
headers={"content-encoding": "gzip", "content-length": str(len(body))},
|
||||
)
|
||||
)
|
||||
) as client:
|
||||
path = await downloader.download_file(
|
||||
client,
|
||||
URL,
|
||||
extension=".json",
|
||||
validate_existing=CelesTrakTLECollector._validate_json_file,
|
||||
)
|
||||
assert path.read_bytes() == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_collectors_keep_head_and_range_resume(tmp_path):
|
||||
downloader = ResumableFileDownloader(cache_namespace="test", cache_root=tmp_path)
|
||||
final, part, meta = downloader._cache_paths(URL, ".bin")
|
||||
part.parent.mkdir(parents=True)
|
||||
part.write_bytes(b"abc")
|
||||
meta.write_text('{"etag":"v1"}')
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "HEAD":
|
||||
return httpx.Response(
|
||||
200, headers={"etag": "v1", "accept-ranges": "bytes", "content-length": "6"}
|
||||
)
|
||||
assert request.headers["range"] == "bytes=3-"
|
||||
return httpx.Response(206, content=b"def", headers={"content-range": "bytes 3-5/6"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
await downloader.download_file(client, URL, extension=".bin")
|
||||
assert [r.method for r in requests] == ["HEAD", "GET"]
|
||||
assert final.read_bytes() == b"abcdef"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_group_refreshes_expired_cache(monkeypatch, tmp_path):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.params["GROUP"] == "active":
|
||||
return httpx.Response(
|
||||
403, text="GP data has not updated since your last successful download"
|
||||
)
|
||||
return httpx.Response(200, json=[{"NORAD_CAT_ID": 99}])
|
||||
|
||||
collector = setup_collector(monkeypatch, tmp_path, handler)
|
||||
monkeypatch.setattr("app.services.collectors.celestrak.FALLBACK_GROUPS", ("starlink",))
|
||||
path = collector._downloader.cached_file_path(collector._group_url("starlink"), ".json")
|
||||
path.write_text('[{"NORAD_CAT_ID":42}]')
|
||||
expire(path)
|
||||
assert (await collector.fetch())[0]["NORAD_CAT_ID"] == 99
|
||||
assert [r.method for r in requests] == ["GET", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_cache_without_validators_must_refresh(tmp_path):
|
||||
downloader = ResumableFileDownloader(cache_namespace="test", cache_root=tmp_path)
|
||||
path = downloader.cached_file_path(URL, ".json")
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("[]")
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request.method)
|
||||
return httpx.Response(200, content=b"[1]" if request.method == "GET" else b"")
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
await downloader.download_file(client, URL, extension=".json")
|
||||
assert requests == ["HEAD", "GET"]
|
||||
assert path.read_text() == "[1]"
|
||||
86
backend/tests/test_earth_news_encoding.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.earth_news import (
|
||||
NewsFeedEndpoint,
|
||||
NewsFeedSource,
|
||||
ParsedNewsItem,
|
||||
_fetch_single_feed_url,
|
||||
_serialize_item,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_feed(
|
||||
content: bytes, content_type: str = "text/xml"
|
||||
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None, dict[str, Any]]:
|
||||
source = NewsFeedSource(
|
||||
id="ebrun",
|
||||
name="亿邦动力",
|
||||
region="asia-pacific",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
homepage_url="https://example.com/",
|
||||
)
|
||||
feed = NewsFeedEndpoint(id="b2c", name="B2C", url=source.feed_url)
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200, content=content, headers={"content-type": content_type}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
return await _fetch_single_feed_url(client, source, feed)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("encoding", "declaration", "content_type"),
|
||||
[
|
||||
("gbk", 'encoding="GBK"', "text/xml"),
|
||||
("gb2312", "encoding='GB2312'", "application/rss+xml"),
|
||||
("gb18030", 'encoding="GB18030"', "text/xml"),
|
||||
("gbk", "", "text/xml; charset=gbk"),
|
||||
("utf-8", 'encoding="UTF-8"', "application/rss+xml"),
|
||||
("utf-8", "", "text/xml"),
|
||||
("utf-8-sig", 'encoding="UTF-8"', "text/xml"),
|
||||
("utf-16", 'encoding="UTF-16"', "text/xml"),
|
||||
("utf-32", 'encoding="UTF-32"', "text/xml"),
|
||||
],
|
||||
)
|
||||
async def test_feed_encoding_preserves_chinese_in_display(
|
||||
encoding: str, declaration: str, content_type: str
|
||||
):
|
||||
title = "618电商战报:零售额增长266%"
|
||||
summary = "亿邦动力:跨境电商订单增长,物流效率提升。"
|
||||
xml = f'''<?xml version="1.0" {declaration}?>
|
||||
<rss version="2.0"><channel><item>
|
||||
<title><![CDATA[{title}]]></title>
|
||||
<description><![CDATA[<p>{summary}</p>]]></description>
|
||||
<link>https://example.com/story</link>
|
||||
</item></channel></rss>'''
|
||||
|
||||
_, items, error, health = await fetch_feed(xml.encode(encoding), content_type)
|
||||
|
||||
assert error is None
|
||||
assert health["ok"] is True
|
||||
assert len(items) == 1
|
||||
assert items[0].title == title
|
||||
assert items[0].summary == summary
|
||||
payload = _serialize_item(items[0], active_region="asia-pacific", locale="zh-CN")
|
||||
assert payload["display_title"] == title
|
||||
assert payload["display_summary"] == summary
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("encoding", ["UTF-8", "unknown-encoding"])
|
||||
async def test_invalid_feed_encoding_is_reported_instead_of_storing_garbled_text(encoding: str):
|
||||
xml = f'''<?xml version="1.0" encoding="{encoding}"?>
|
||||
<rss><channel><item><title>电商新闻</title>
|
||||
<link>https://example.com/story</link></item></channel></rss>'''
|
||||
|
||||
_, items, error, health = await fetch_feed(xml.encode("gbk"))
|
||||
|
||||
assert items == []
|
||||
assert error
|
||||
assert health["ok"] is False
|
||||
assert health["status"] == "format_error"
|
||||
@@ -8,6 +8,38 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.75.4] — 2026-09-29
|
||||
|
||||
Released: 2026-09-29
|
||||
|
||||
### Highlights
|
||||
- 图例统一承载分类筛选,完善卫星微点与性能、圆形菜单 DPI 适配及星空颜色输出,清理废弃代码与旧方案。
|
||||
|
||||
### Improved / Fixed
|
||||
- 海缆、卫星、算力、船只、BGP 和国界图例直接切换显示;同步聚类、拾取、实时更新、选中卡片和覆盖状态,移除图层中的独立筛选入口。
|
||||
- 卫星使用单批硬边 Points,按参考校准远近点径与类别色,保留平滑短拖尾;400% 起显示真实标签,并加入可持久化 FPS 与滚动平均统计。
|
||||
- 缓存轨道插值半径、合并投影误差小于 0.2px 的冗余拖尾段、仅上传活跃缓冲;覆盖网格复用并在 GPU 投影,保留完整数据与网格精度。
|
||||
- 修复扇形菜单及缩放桥接的 DPI/hover 状态、星空局部 sRGB 输出、加载时菱形纬圈、地表标记侧面遮挡与登陆点海缆对齐;保留船只独立箭头并统一电视方角。
|
||||
- RSS/Atom 严格识别 BOM、HTTP/XML 编码,避免中文新闻乱码;删除旧随机星点、背景点、无效图例接口、未引用绘制分支及已完成的旧计划,更新双语说明。
|
||||
|
||||
---
|
||||
|
||||
## [0.75.3] — 2026-09-27
|
||||
|
||||
Released: 2026-09-27
|
||||
|
||||
### Highlights
|
||||
- 升级地球材质、地图标记与卫星展示,修复拖拽、采集缓存和任务队列,清理废弃渲染路径。
|
||||
|
||||
### Improved / Fixed
|
||||
- 高清地表采用海陆分离 Shader、可读夜侧与单层柔和大气;保留四张底图及真实昼夜,移除旧厚光壳和无用高光配置。
|
||||
- 卫星在真实轨道采样之间平滑移动,类别色在昼夜保持一致,蓝色覆盖与拾取位置同步;预测轨道开放并加入方向流光,消除强制闭合尖角。
|
||||
- 地表设施统一渐变徽标,BGP 保留不规则扁平例外;修复低缩放圆点开关,电视面板与播放器恢复方角。
|
||||
- 鼠标与单指拖出球体仍持续旋转,保留惯性并在极大倍率下限制灵敏度;修复欧拉角换支导致的反向和普通横拖侧歪。
|
||||
- CelesTrak 改为直接 GET、限时有效缓存和校验后原子替换;采集队列按 task ID 保留独立历史,防止失败任务被新任务或迟到消息重新激活。
|
||||
|
||||
---
|
||||
|
||||
## [0.75.2] — 2026-09-25
|
||||
|
||||
Released: 2026-09-25
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
- [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
- [Earth 高精度国界静态瓦片计划](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md)
|
||||
- [Earth Predicted Orbit Plan](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
|
||||
- [Earth WebGL Instancing Satellites Plan](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)
|
||||
- [Earth Real Terrain Plan](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
- [Earth News Source Configuration And Collector Plan](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
# Earth 天球背景与日月位置实施方案
|
||||
|
||||
## 目标
|
||||
|
||||
为 Earth 大屏增加一套真正可用的天文背景层,覆盖三件事:
|
||||
|
||||
1. 用真实天球背景替换当前随机星点
|
||||
2. 在当前时间下显示太阳与月亮的相对位置
|
||||
3. 让太阳方向同时驱动地球受光,形成更可信的昼夜关系
|
||||
|
||||
本方案优先追求:
|
||||
|
||||
- 与当前 Three.js Earth 架构兼容
|
||||
- 风险可控
|
||||
- 先落地一版真实感明显提升的 V1
|
||||
- 为后续更严格的天文参考系升级预留余地
|
||||
|
||||
## 当前现状
|
||||
|
||||
当前 Earth 的基础条件已经具备:
|
||||
|
||||
- 地球、云层、地形、网格都基于 Three.js,主渲染入口在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- 地球实体创建在 [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- 当前所谓“宇宙背景”只是 `createStars()` 生成的随机星点,不是真实星图
|
||||
- Earth 已有倾角常量 `EARTH_CONFIG.tiltRad`,位于 [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- 主循环 `animate()` 已稳定运行,可在其中接入天体更新逻辑
|
||||
|
||||
这意味着:
|
||||
|
||||
- 不需要重写 Earth
|
||||
- 可以在现有 scene/world 层新增一个 celestial layer
|
||||
- 第一阶段不必拆 Earth / satellite / cable 的参考系
|
||||
|
||||
## 总体策略
|
||||
|
||||
采用“两层现实”设计:
|
||||
|
||||
### 1. 世界层(world-space celestial layer)
|
||||
|
||||
用于放置:
|
||||
|
||||
- 天球背景
|
||||
- 太阳
|
||||
- 月亮
|
||||
- 太阳光方向
|
||||
|
||||
这些对象不挂在 `earthObj` 上,而是直接放在 `scene` 中。
|
||||
|
||||
### 2. 地球层(earth-fixed layer)
|
||||
|
||||
继续保持当前结构:
|
||||
|
||||
- 海缆
|
||||
- 登陆点
|
||||
- 卫星点与轨迹
|
||||
- BGP 覆盖
|
||||
- 地球纹理、云层、地形
|
||||
|
||||
这些对象继续挂在 `earthObj` 下,不打断现有交互。
|
||||
|
||||
## 为什么先这样做
|
||||
|
||||
当前用户交互是“拖动地球本体”,而不是“移动相机绕惯性系观测”。
|
||||
如果现在直接做严格惯性参考系改造,会同时影响:
|
||||
|
||||
- `earthObj.rotation`
|
||||
- 卫星轨迹与锁定逻辑
|
||||
- 海缆与登陆点附着关系
|
||||
- resetView / autoRotate / hover / click 等交互链路
|
||||
|
||||
所以第一阶段只做:
|
||||
|
||||
- 真正的天空
|
||||
- 真正的日月方向
|
||||
- 不碰现有 Earth 附着对象的语义
|
||||
|
||||
## 推荐技术选型
|
||||
|
||||
### 天文计算库
|
||||
|
||||
推荐:
|
||||
|
||||
- [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
|
||||
原因:
|
||||
|
||||
- 有 JavaScript 版本
|
||||
- 支持 Sun / Moon 的矢量与坐标变换
|
||||
- 精度、可扩展性都比轻量太阳高度角库更适合本项目
|
||||
- 后续若要加行星、月相、黄道、赤道网,也能继续沿用
|
||||
|
||||
不作为主选的库:
|
||||
|
||||
- [SunCalc](https://github.com/mourner/suncalc)
|
||||
|
||||
原因:
|
||||
|
||||
- 更偏本地观察者视角的太阳/月亮高度角
|
||||
- 用于“地面日出日落”很好
|
||||
- 但不如 Astronomy Engine 适合做真实天球与后续空间参考系扩展
|
||||
|
||||
### Three.js 表现层
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 天球:内翻球壳 + 星图纹理
|
||||
- 太阳:`THREE.Sprite`
|
||||
- 月亮:`THREE.Sprite` 或小型 `THREE.Mesh`
|
||||
- 太阳光:`THREE.DirectionalLight`
|
||||
|
||||
参考:
|
||||
|
||||
- [Three.js SpriteMaterial](https://threejs.org/docs/pages/SpriteMaterial.html)
|
||||
|
||||
## 天球背景资源与星体数据来源
|
||||
|
||||
为避免把“视觉背景”和“可计算天体位置”混为一谈,本方案明确分成两类资源:
|
||||
|
||||
### 1. 背景资源:全天星图贴图
|
||||
|
||||
用于 Phase 1 的“真实天空背景”。
|
||||
|
||||
推荐优先来源:
|
||||
|
||||
- NASA SVS 的 Tycho 全天星图
|
||||
- [The Tycho Catalog Skymap - Version 2.0](https://svs.gsfc.nasa.gov/3572/)
|
||||
- NASA Deep Star Maps 2020
|
||||
- SatelliteMap.space 在 credits 中明确提到其使用了 `NASA Deep Star Maps 2020 - High-resolution star field (1.7 billion stars from Gaia DR2)` 作为星空视觉资源
|
||||
- 这说明行业内成熟实现并不一定直接渲染全部星表点,而很可能先使用一张高质量官方深空星图作为背景层
|
||||
- 如需后续替换,也可评估 ESA / Gaia 的全天 sky map 资源
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议要求:
|
||||
|
||||
- 使用官方来源或官方衍生可复用资源
|
||||
- 等距矩形投影(equirectangular)
|
||||
- 坐标定义尽量明确为赤道坐标展开
|
||||
- 分辨率建议至少 `4k`
|
||||
- 颜色不要过亮,避免压过 Earth HUD 前景
|
||||
- 尽量优先选择官方天文机构已经生产好的深空图,而不是自行拼接低质量星空纹理
|
||||
|
||||
建议本地资源目录:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
### 2. 位置数据:星表与天体计算
|
||||
|
||||
用于 Phase 2+ 的“位置正确的星体”。
|
||||
|
||||
推荐来源分两层:
|
||||
|
||||
- 太阳、月亮位置
|
||||
- 使用 [Astronomy Engine](https://github.com/cosinekitty/astronomy)
|
||||
- 恒星位置
|
||||
- 第一优先:Hipparcos / Tycho
|
||||
- [Hipparcos overview](https://www.cosmos.esa.int/web/Hipparcos)
|
||||
- [Hipparcos catalogues](https://www.cosmos.esa.int/web/hipparcos/catalogues)
|
||||
- 第二优先:Gaia
|
||||
- [Gaia DR3 stories](https://www.cosmos.esa.int/web/gaia/dr3-stories)
|
||||
|
||||
建议策略:
|
||||
|
||||
- V1:背景球壳只用全天星图,不立即生成全量恒星点
|
||||
- V2:只挑选亮星(例如星等 `< 5.5`)生成恒星点层
|
||||
- V3:如果确实需要更丰富的星场,再逐步扩展到更深星等
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- 背景球壳负责“天球真实感”
|
||||
- 亮星点负责“位置正确、可后续标注和高亮”
|
||||
- 不需要一开始就处理数十万甚至数百万颗星
|
||||
|
||||
### 3. 对外部成熟实现的参考结论
|
||||
|
||||
`SatelliteMap.space` 的公开 credits 提供了一个很有价值的参考样板:
|
||||
|
||||
- 图形渲染使用 `TWGL.js`
|
||||
- 天文计算使用 `Skyfield` 与 `Astronomia`
|
||||
- 星空/天球视觉资源使用 `NASA Deep Star Maps 2020`
|
||||
|
||||
这给本项目的启发是:
|
||||
|
||||
- “真实感强的天球背景”完全可以先依赖官方高质量深空图
|
||||
- “位置正确的动态天体”则应依赖单独的天文计算链路
|
||||
- 没有必要在第一版就直接渲染完整星表
|
||||
|
||||
因此本项目推荐继续坚持两层拆分:
|
||||
|
||||
- 背景层:官方深空图 / 全天星图
|
||||
- 计算层:太阳、月亮与后续亮星点
|
||||
|
||||
## 如何保证星体位置正确
|
||||
|
||||
位置正确不是只看“图看起来像”,而是要统一参考系和转换链路。
|
||||
|
||||
### 1. 统一坐标基准
|
||||
|
||||
本方案推荐统一使用:
|
||||
|
||||
- `J2000` 赤道坐标系作为恒星位置基准
|
||||
|
||||
原因:
|
||||
|
||||
- Hipparcos / Tycho 资料和大量天文可视化都容易映射到该基准
|
||||
- 太阳、月亮也可以通过 Astronomy Engine 转到同一坐标系
|
||||
- 这样背景、恒星点、太阳、月亮就能共用一套 sky orientation
|
||||
|
||||
### 2. 背景贴图与点位必须使用同一展开逻辑
|
||||
|
||||
如果背景球壳使用赤道坐标全天图,那么:
|
||||
|
||||
- 亮星点也必须按赤道坐标贴到同一球面方向
|
||||
- 太阳/月亮 sprite 也必须按赤道坐标转换后落到同一 world-space
|
||||
|
||||
否则会出现:
|
||||
|
||||
- 背景银河带是对的
|
||||
- 但太阳/月亮或亮星点飘到不匹配的位置
|
||||
|
||||
### 3. RA / Dec 到 Three.js 坐标的落点方式
|
||||
|
||||
亮星点和日月方向最终都要转成单位球面向量。
|
||||
|
||||
概念步骤:
|
||||
|
||||
1. 读取赤经 `RA`
|
||||
2. 读取赤纬 `Dec`
|
||||
3. 转成弧度
|
||||
4. 映射到单位球面向量
|
||||
5. 再根据 Three.js 当前世界坐标定义做轴向映射
|
||||
|
||||
参考公式:
|
||||
|
||||
```text
|
||||
x = cos(dec) * cos(ra)
|
||||
y = sin(dec)
|
||||
z = cos(dec) * sin(ra)
|
||||
```
|
||||
|
||||
实际接入 Three.js 时,需要做一次项目内坐标轴校准:
|
||||
|
||||
- 验证 `RA = 0h`
|
||||
- 验证 `RA = 6h`
|
||||
- 验证北天极
|
||||
- 验证银河带主方向
|
||||
|
||||
然后确定最终的:
|
||||
|
||||
- `x/y/z` 对应 Three.js 哪个轴
|
||||
- 是否需要 `z` 取反
|
||||
- 是否需要整体再做一个固定 `rotation`
|
||||
|
||||
建议把这层显式封装在:
|
||||
|
||||
```js
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
不要把轴映射散落在不同模块里。
|
||||
|
||||
### 4. 背景球壳与恒星点的关系
|
||||
|
||||
推荐最终组合:
|
||||
|
||||
- 背景层:全天星图球壳
|
||||
- 点位层:亮星点
|
||||
- 动态层:太阳 / 月亮
|
||||
|
||||
这样有三个好处:
|
||||
|
||||
- 背景层提供密集真实的天空纹理
|
||||
- 亮星点提供位置正确、可扩展的标注基础
|
||||
- 太阳/月亮提供与时间相关的真实动态对象
|
||||
|
||||
## 数据与资源建议清单
|
||||
|
||||
### 推荐首批引入资源
|
||||
|
||||
1. 全天星图
|
||||
- 来源:NASA Tycho all-sky map
|
||||
- 用途:背景球壳纹理
|
||||
|
||||
2. 月亮纹理
|
||||
- 用途:Phase 4 月相表现
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/moon_albedo_2k.jpg`
|
||||
|
||||
3. 太阳 glow 贴图
|
||||
- 用途:太阳 sprite halo
|
||||
- 路径建议:
|
||||
- `frontend/public/earth/assets/celestial/sun_glow.png`
|
||||
|
||||
### 推荐首批数据文件
|
||||
|
||||
如果要上亮星层,建议新增一个预处理后的轻量数据文件:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/bright-stars.json`
|
||||
|
||||
建议字段:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 32349,
|
||||
"name": "Sirius",
|
||||
"raDeg": 101.2875,
|
||||
"decDeg": -16.7161,
|
||||
"mag": -1.46,
|
||||
"colorIndex": 0.00
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
建议不要在浏览器里直接吞原始 Gaia 大表,而是先离线裁剪成:
|
||||
|
||||
- 只保留亮星
|
||||
- 只保留渲染必需字段
|
||||
- JSON 或二进制轻量格式
|
||||
|
||||
## 资源与数据实施路线
|
||||
|
||||
### 路线 A:先做可用版本(推荐)
|
||||
|
||||
1. 引入 NASA Tycho 全天图
|
||||
- 或评估替换为更接近 SatelliteMap.space 路线的 `NASA Deep Star Maps 2020`
|
||||
2. 实现背景球壳
|
||||
3. 用 Astronomy Engine 计算太阳/月亮方向
|
||||
4. 暂不做亮星点
|
||||
|
||||
优点:
|
||||
|
||||
- 最快见效
|
||||
- 风险最低
|
||||
- 就能明显提升天球真实感
|
||||
|
||||
### 路线 B:在 A 基础上增强
|
||||
|
||||
1. 离线生成 `bright-stars.json`
|
||||
2. 浏览器端渲染亮星点
|
||||
3. 后续可加:
|
||||
- 星座线
|
||||
- 亮星名称
|
||||
- 特定星体高亮
|
||||
|
||||
优点:
|
||||
|
||||
- 背景真实感和“位置正确的可交互星体”同时兼顾
|
||||
|
||||
## 代码模块建议细化
|
||||
|
||||
### 新增模块
|
||||
|
||||
- `frontend/public/earth/js/celestial.js`
|
||||
- 管理天球背景
|
||||
- 管理太阳/月亮
|
||||
- 管理亮星层(后续)
|
||||
|
||||
- `frontend/public/earth/js/celestial-data.js`
|
||||
- 资源路径
|
||||
- 星图方向配置
|
||||
- 亮星数据加载(后续)
|
||||
|
||||
### 建议函数设计
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date)
|
||||
export function setCelestialVisibility(visible)
|
||||
export function disposeCelestialLayer()
|
||||
|
||||
function loadStarMapTexture()
|
||||
function createSkySphere(texture)
|
||||
function createSunSprite()
|
||||
function createMoonSprite()
|
||||
function getSunEquatorialPosition(date)
|
||||
function getMoonEquatorialPosition(date)
|
||||
function equatorialToWorldVector(raRad, decRad)
|
||||
```
|
||||
|
||||
### 推荐后续预处理脚本
|
||||
|
||||
如要引入亮星层,建议单独做离线脚本:
|
||||
|
||||
- `scripts/build_bright_stars.py`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 Hipparcos / Tycho 源数据读取
|
||||
- 过滤亮星
|
||||
- 生成 `bright-stars.json`
|
||||
|
||||
这样浏览器端只消费轻量结果,不承担大表解析成本。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
## Phase 1:真实天球背景
|
||||
|
||||
### 目标
|
||||
|
||||
用真实全天星图替换当前随机星点背景。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 新增一张全天星图纹理
|
||||
|
||||
建议路径:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/starmap_equatorial_4k.jpg`
|
||||
|
||||
纹理要求:
|
||||
|
||||
- 等距矩形投影
|
||||
- 赤经/赤纬坐标展开
|
||||
- 无地平线、无地景遮挡
|
||||
- 尽量深色、弱干扰,适合大屏 HUD 叠加
|
||||
|
||||
2. 新增天球球壳
|
||||
|
||||
新增模块:
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
建议接口:
|
||||
|
||||
```js
|
||||
export function initCelestialLayer(scene)
|
||||
export function updateCelestialLayer(date, camera, earth)
|
||||
export function disposeCelestialLayer()
|
||||
```
|
||||
|
||||
3. 实现一个大半径内翻球体
|
||||
|
||||
建议参数:
|
||||
|
||||
- 半径:`600 ~ 900`
|
||||
- 材质:`MeshBasicMaterial`
|
||||
- `side: THREE.BackSide`
|
||||
- 不受场景光照影响
|
||||
- 始终围绕场景中心
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 初始加载后背景不再是随机星点
|
||||
- 旋转地球时,背景保持为稳定天球而不是跟地球一起转
|
||||
- 不明显干扰海缆/卫星/BGP 的前景识别
|
||||
|
||||
## Phase 2:太阳与月亮真实位置
|
||||
|
||||
### 目标
|
||||
|
||||
在当前 UTC 时间下,计算太阳与月亮在天球中的方向,并显示出来。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 在 `celestial.js` 内封装天体位置计算
|
||||
|
||||
建议函数:
|
||||
|
||||
```js
|
||||
function getSunDirection(date)
|
||||
function getMoonDirection(date)
|
||||
```
|
||||
|
||||
输出统一为 world-space `THREE.Vector3`
|
||||
|
||||
2. 太阳显示
|
||||
|
||||
- 一个暖色发光 sprite
|
||||
- 比月亮更大、更亮
|
||||
- 可选添加柔和 halo
|
||||
|
||||
3. 月亮显示
|
||||
|
||||
- 一个较小 sprite 或 sphere
|
||||
- 灰白偏冷色
|
||||
- 后续 Phase 3 再做月相
|
||||
|
||||
4. 更新频率
|
||||
|
||||
不要每帧重新做完整天文计算,建议:
|
||||
|
||||
- 每 30 秒或 60 秒重算一次真实位置
|
||||
- 渲染帧内做平滑过渡
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 页面可见太阳与月亮两个对象
|
||||
- 时间变化时位置会更新
|
||||
- 日月不会跟随地球局部旋转而错误附着
|
||||
|
||||
## Phase 3:太阳驱动地球受光
|
||||
|
||||
### 目标
|
||||
|
||||
让地球光照方向与太阳方向一致,不再使用写死的固定主光。
|
||||
|
||||
### 做法
|
||||
|
||||
1. 替换或接管当前主定向光
|
||||
|
||||
当前 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 中 `addLights()` 里用了固定方向的 `DirectionalLight`。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 保留环境补光
|
||||
- 主太阳光方向由 `sunDirection` 决定
|
||||
|
||||
2. 太阳光参数建议
|
||||
|
||||
- `DirectionalLight` 颜色偏暖白
|
||||
- 强度略高于当前主光
|
||||
- 保留一个弱背光作为氛围补偿,避免背面过死黑
|
||||
|
||||
3. 先不做物理级大气散射
|
||||
|
||||
第一版只要求:
|
||||
|
||||
- 亮面与暗面方向真实
|
||||
- 云层和大气仍保持当前风格
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 地球明暗面会随太阳方向改变
|
||||
- 太阳 sprite 和地球亮面方向一致
|
||||
- 不破坏现有海缆、卫星、BGP 的可见性
|
||||
|
||||
## Phase 4:月相与天文细节增强
|
||||
|
||||
### 目标
|
||||
|
||||
在日月真实位置基础上增加更强的“天文可信度”。
|
||||
|
||||
### 可选项
|
||||
|
||||
1. 月相
|
||||
|
||||
- 根据日月夹角计算 illuminated fraction
|
||||
- 用月相纹理或 shader 表达盈亏
|
||||
|
||||
2. 赤道/黄道辅助线
|
||||
|
||||
- 可作为开发调试层,不默认显示
|
||||
|
||||
3. 太阳 terminator 增强
|
||||
|
||||
- 给地球夜面加入更自然的 night tint
|
||||
- 未来可叠加城市夜光纹理
|
||||
|
||||
4. 天文时间入口
|
||||
|
||||
- 设置中加入“当前时刻 / 指定时刻 / 加速时间”模式
|
||||
|
||||
### 验收标准
|
||||
|
||||
- 月亮不再只是一个静态圆点
|
||||
- 后续扩展行星或观测模式时无需推倒重来
|
||||
|
||||
## Phase 5:严格参考系升级(可选,不作为 V1 必做)
|
||||
|
||||
### 目标
|
||||
|
||||
把 Earth 从“用户旋转球体”升级为“真实地球姿态 + 用户观察姿态”的双层模型。
|
||||
|
||||
### 需要处理的问题
|
||||
|
||||
- 地球自转角与 UTC 的一致性
|
||||
- 赤道坐标系、地固坐标系、相机交互层分离
|
||||
- 卫星轨道显示与 Earth 旋转同步关系
|
||||
- resetView 和 autoRotate 的语义重定
|
||||
|
||||
### 风险
|
||||
|
||||
这一步会影响:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [frontend/public/earth/js/cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [frontend/public/earth/js/controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
|
||||
因此不建议与 V1 同时推进。
|
||||
|
||||
## 代码改造清单
|
||||
|
||||
## 1. 新增文件
|
||||
|
||||
- [frontend/public/earth/js/celestial.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/celestial.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 管理天球背景、太阳、月亮
|
||||
- 对外暴露 init/update/dispose
|
||||
|
||||
## 2. 修改 `constants.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
新增:
|
||||
|
||||
```js
|
||||
export const CELESTIAL_CONFIG = {
|
||||
sphereRadius: 800,
|
||||
updateIntervalMs: 60000,
|
||||
sunSpriteScale: 28,
|
||||
moonSpriteScale: 16,
|
||||
sunLightIntensity: 1.25,
|
||||
ambientIntensity: 0.28,
|
||||
backLightIntensity: 0.18,
|
||||
};
|
||||
```
|
||||
|
||||
## 3. 修改 `main.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
1. `init()` 中:
|
||||
- 初始化 celestial layer
|
||||
2. `addLights()` 中:
|
||||
- 把固定太阳光改成可更新的 celestial sun light
|
||||
3. `animate()` 中:
|
||||
- 每帧调 `updateCelestialLayer()`
|
||||
4. `destroy()` 中:
|
||||
- 清理 celestial 资源
|
||||
|
||||
## 4. 修改 `earth.js`
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/public/earth/js/earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
|
||||
主要改动:
|
||||
|
||||
- `createStars()` 逐步退役
|
||||
- 第一阶段可先保留作为 fallback
|
||||
- 当真实星图加载成功后,不再显示随机星点
|
||||
|
||||
## 5. 新增资源
|
||||
|
||||
目录建议:
|
||||
|
||||
- `frontend/public/earth/assets/celestial/`
|
||||
|
||||
建议至少包含:
|
||||
|
||||
- `starmap_equatorial_4k.jpg`
|
||||
- `sun_glow.png`
|
||||
- `moon_albedo_2k.jpg`
|
||||
|
||||
## 数据流设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["main.js:init()"] --> B["initCelestialLayer(scene)"]
|
||||
B --> C["创建天球球壳"]
|
||||
B --> D["创建太阳 sprite + 主定向光"]
|
||||
B --> E["创建月亮 sprite"]
|
||||
|
||||
F["animate()"] --> G["updateCelestialLayer(now, camera, earth)"]
|
||||
G --> H["Astronomy Engine 计算 Sun/Moon 方向"]
|
||||
H --> I["更新 sun sprite / moon sprite 位置"]
|
||||
H --> J["更新太阳 DirectionalLight 方向"]
|
||||
J --> K["地球昼夜方向变化"]
|
||||
```
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
### 1. 星图投影方向容易反
|
||||
|
||||
这会表现为:
|
||||
|
||||
- 星图左右镜像
|
||||
- 赤经方向颠倒
|
||||
- 日月位置和背景对不上
|
||||
|
||||
建议:
|
||||
|
||||
- 先做一个开发调试模式
|
||||
- 显示赤经/赤纬参考点,快速校正纹理朝向
|
||||
|
||||
### 2. 不要让天球跟随 Earth 旋转
|
||||
|
||||
天球背景和日月必须属于 scene/world,而不是 `earthObj`。
|
||||
|
||||
### 3. 不要每帧做重型天文计算
|
||||
|
||||
真实位置更新应节流,否则会浪费 CPU。
|
||||
|
||||
### 4. 月亮先求“方向正确”,再求“月相精致”
|
||||
|
||||
月相属于第二步优化,不应阻塞 V1 上线。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 新建 `celestial.js`
|
||||
2. 用星图球壳替换随机星点
|
||||
3. 接入 Astronomy Engine
|
||||
4. 加太阳/月亮 sprite
|
||||
5. 用太阳方向驱动主光
|
||||
6. 再决定要不要做月相和更严格参考系
|
||||
|
||||
## 最终建议
|
||||
|
||||
对于当前 Planet Earth,最稳妥的方案是:
|
||||
|
||||
- 先做真实天球背景
|
||||
- 再做真实太阳/月亮方向
|
||||
- 再让太阳驱动地球受光
|
||||
- 暂时不做 Earth 参考系重构
|
||||
|
||||
这样可以在不破坏现有 Earth 交互和图层系统的前提下,显著提升空间感、真实感和演示说服力。
|
||||
@@ -1,106 +0,0 @@
|
||||
# Earth Predicted Orbit Plan
|
||||
|
||||
> Source note: this plan absorbs useful ideas from a sisyphus-created draft formerly stored at `.sisyphus/plans/predicted-orbit.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
在 Earth 中锁定卫星时,显示“预测轨道”而不是只有历史尾迹:
|
||||
|
||||
- 从当前时刻开始
|
||||
- 绕地球一圈
|
||||
- 当前点最亮
|
||||
- 向后沿轨道逐步衰减
|
||||
|
||||
## Current State
|
||||
|
||||
当前已经有:
|
||||
|
||||
- 卫星历史轨迹
|
||||
- 锁定卫星
|
||||
- 轨道高亮与相关联动
|
||||
|
||||
但“预测轨道”仍然不是一套稳定、可验证的单独功能计划。
|
||||
|
||||
## Why It Is Valuable
|
||||
|
||||
预测轨道可以明显提升:
|
||||
|
||||
- 锁定卫星后的空间可读性
|
||||
- 轨道类型辨识
|
||||
- 演示解释力
|
||||
|
||||
相比短历史尾迹,预测轨道更符合用户对“这颗卫星接下来会怎么走”的预期。
|
||||
|
||||
## Scope
|
||||
|
||||
### Phase 1
|
||||
|
||||
- 锁定卫星时显示一整圈预测轨道
|
||||
- 解锁时隐藏
|
||||
- 不替代现有普通轨迹系统
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 根据轨道类型调整采样率
|
||||
- GEO / MEO / LEO 不同密度
|
||||
- 进一步减少 fallback 轨迹的比例
|
||||
|
||||
## Implementation Direction
|
||||
|
||||
### 1. Orbit period
|
||||
|
||||
基于 `meanMotion` 估算轨道周期。
|
||||
|
||||
### 2. Predicted samples
|
||||
|
||||
以固定采样步长从 `now -> now + period` 推算轨迹点。
|
||||
|
||||
### 3. Render object lifecycle
|
||||
|
||||
预测轨道应是一个独立渲染对象:
|
||||
|
||||
- show
|
||||
- update
|
||||
- hide
|
||||
- dispose
|
||||
|
||||
### 4. Visual semantics
|
||||
|
||||
预测轨道不应与普通尾迹混淆:
|
||||
|
||||
- 更稳定
|
||||
- 更完整
|
||||
- 透明度沿轨道衰减
|
||||
- 当前点附近更亮
|
||||
|
||||
## Known Risks
|
||||
|
||||
### 1. TLE propagation gaps
|
||||
|
||||
部分卫星可能出现 SGP4 计算不足,需要 fallback。
|
||||
|
||||
### 2. Multiple orbit lines
|
||||
|
||||
必须确保:
|
||||
|
||||
- 锁定切换前先清旧轨道
|
||||
- 页面隐藏/销毁时清理
|
||||
|
||||
### 3. Performance
|
||||
|
||||
GEO 轨道点数高,采样率需要按轨道类型分层。
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. 锁定单颗卫星时只显示一条预测轨道
|
||||
2. 解锁后轨道立即清除
|
||||
3. 不同轨道类型下点数可控
|
||||
4. 页面切换回来不会闪出旧轨道残留
|
||||
|
||||
## Satellite Footprint Follow-Up Items
|
||||
|
||||
从技术文档迁出的 footprint 后续项,作为卫星覆盖能力的计划 backlog:
|
||||
|
||||
1. 为 `iridium-next` 新建独立 footprint adapter。
|
||||
2. 在 UI 上补一个只读提示,让用户知道当前卫星是否支持 footprint。
|
||||
3. 如果未来拿到 GEO beam contour / operator metadata,再为 GEO 开 operator-specific footprint。
|
||||
@@ -472,3 +472,15 @@ Neither collector returns sample data on failure. Empty fetches or transformed r
|
||||
Under the same prefix, `GET/PUT /configuration` and `POST /configuration/reset` require admin/super_admin. Writes accept `{revision, config}` and reset accepts `revision`. Pydantic validates canonical countries, finite values, share totals, duplicate metric/year pairs and report references; each metric/year has one report basis. PostgreSQL category uniqueness and row locking serialize first writes and updates; stale revisions return 409. Configuration and before/after audit records commit in one transaction. Reset removes the override so subsequent reads use the current bundled defaults. `version` is a configuration revision, while `reviewed_at` is the supplied review date. Admin-curated data is not automatically labeled official. Source URLs are references, not scrape instructions.
|
||||
|
||||
`/api/v1/data-products/compute/facilities` reads successful snapshots with `compute_contract=v1`, applying country filters and per-source counts in SQL. Epoch excludes planned, decommissioned, superseded, and suspected duplicate records; TOP500 means list observations. Cross-source deduplication remains pending. Historical cutoffs use collection time, never pretending a recently fetched old source was available earlier. Geographic results are bounded and report truncation; total record counts are independent of this limit. Official site helpers are keyed by exact `source` + `source_id`; their access-map centers are site-level references only and retain their official URL in `source_note`.
|
||||
|
||||
|
||||
### CelesTrak download windows and caching
|
||||
|
||||
Both active and fallback-group downloads skip HEAD preflight and send GET directly: a preflight may consume the upstream download allowance before the body request. Valid complete cache entries are reused for two hours, then refreshed with conditional GET when validators exist, or ordinary GET otherwise. A 304 reuses validated cache; only the explicit “GP data has not updated” 403 permits a valid-cache fallback. Other 403 errors remain failures. Fallback groups also respect cache age rather than reusing old files indefinitely.
|
||||
|
||||
A nonempty GP record list is validated in `.part` before atomically replacing the cache; failed downloads preserve the previous valid payload. Length comes from the GET response, and compressed wire length is not compared with decoded bytes. Other collectors retain HEAD metadata and Range resume support. Restoring a raw cache does not populate the database: trigger a new collection task afterward.
|
||||
|
||||
|
||||
### Earth news encoding
|
||||
|
||||
Before parsing RSS/Atom, `earth_news.py` selects encoding from the BOM, HTTP charset, XML encoding declaration, then UTF-8, and decodes strictly. GBK/GB2312/GB18030 feeds no longer silently replace Chinese bytes with corrupted text. Unknown encodings or invalid bytes produce `format_error` instead of damaged titles. `test_earth_news_encoding.py` covers titles, summaries, display serialization and failure status.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Earth Frontend Context
|
||||
|
||||
## HD surface and atmosphere materials
|
||||
|
||||
The visible HD surface uses a single-draw ShaderMaterial in `earth-surface-material.js`, retaining the R+0.48 mesh, depth policy and surface pick target. The existing controller still switches all four 8K maps, updates the sampler and disposes the previous texture only after success. Normals, view direction and astronomical sunlight share world coordinates. Land is matte; water has grazing-angle cool reflection without a white specular lobe. The existing geographic land/ocean texture owns the optical mask independently of border visibility, with a conservative chroma fallback until loaded and reflection suppression over ice. Original JPEGs are neither repainted nor downsampled.
|
||||
|
||||
Color textures are tagged sRGB with mipmaps. The r128 surface shader decodes sRGB; r160 uses hardware texture decoding. Both combine light in linear space and encode once. A local filmic curve affects only the surface instead of enabling global ACES exposure on the sky and data layers. The terminator spans normal dot products [-0.075, 0.16]; night shading has its own geographic texture ghosting and cool ambient branch. A 0.55 power of the locally tone-mapped texture retains shadow variation, followed by separate muted land and deep-indigo ocean tints and low ambient floors. Day/night mixing remains linear with one final sRGB output. Day exposure and reflections are unchanged; no synthetic city lights are added. One R×1.025 BackSide/Additive shell replaces the three rim shells, with depthWrite=false and depthTest=true; inner scattering is part of the surface pass. Independently switchable clouds use matte day/night shading at renderOrder 1.1. The sky uses the existing `starmap_deep_8k.jpg` without a tint overlay.
|
||||
|
||||
## Configurable compute observations
|
||||
|
||||
Compute cards read annual observations from `/api/v1/data-products/compute/series`; the admin Compute comparison section is the maintenance entry point. `compute-topic.js` fetches with no-store, listens for same-origin `planet-compute-configuration` notifications, and checks every 60 seconds while visible and enabled, plus when the page becomes visible. Observation refresh does not reload facility layers. `destroy()` releases the timer, channel and visibility listener. Country membership, full regional coverage and missing-value semantics remain backend-owned.
|
||||
@@ -8,10 +14,6 @@ Compute cards read annual observations from `/api/v1/data-products/compute/serie
|
||||
|
||||
On 2026-09-21, `assets/8k_earth_daymap.jpg` was replaced with an 8192×4096 Web derivative of the paired Natural Earth I raster. Its loading path and rendering code remain unchanged. The source is a 16200×8100 EPSG:4326 image; the previous JPEG is preserved byte-for-byte under `resources/earth/archive/2026-09-21/`. Source/license records, the catalogue versus embedded-version discrepancy, SHA256 hashes, JPEG parameters, and recalculated world-file metadata live in `resources/earth/natural-earth-1/`. Large originals remain in Git-ignored `downloads/earth/`, outside the frontend release bundle.
|
||||
|
||||
The base-map selector defaults to Original basemap, listed first and saves the source chosen in the display menu. Natural Earth I remains available alongside “Original basemap”, a byte-identical copy of the September 21, 2026 archive; NASA 21K is source archive material only, while runtime delivery stays at one 8K texture. A new source replaces the old GPU texture only after successful loading and releases the old one then; failures retain the old texture and stale responses cannot overwrite the current source. This is a source switch, not an additional rendering layer. The physical texture is JPEG sRGB, does not bake political borders, and detailed boundary alignment is not yet validated. The starfield is restored byte-for-byte to commit `83a10a6c`: `celestial.js` matches that commit, `skyTint` and `skyBrightness` are removed, and `brightStarOpacity` is back to `0.88`; star density and color are no longer calibrated. The HD overlay uses a white `MeshBasicMaterial` with opacity `1` and `toneMapped:false`, with `texture-colors.js` centralizing sRGB read/write. The 0.128 path uses `encoding` plus `LinearTosRGB`; the 0.160.1 path uses `colorSpace` plus the normal colorspace fragment. There is no Phong white-light or colored twilight overlay, blue night tint, `cameraBoost`, or shoulder curve; daylight neutral scaling keeps brightness at or below the source maximum, and dark mode only applies neutral scaling. The original texture asset is unchanged. Natural Earth is intrinsically pale; this does not claim missing detail was restored. Earth still supports the Three 0.128 import-map runtime and frontend package Three 0.160.1 runtime, and WebGL1 derivatives remain enabled for `fwidth`.
|
||||
|
||||
|
||||
Day/night shading uses a narrow smooth transition centered on the solar horizon (normal dot product ±0.12). The day side is capped at the source brightness; night-side linear illumination is 0.24, rising smoothly to at most 0.60 in source shadows to retain dark ocean and forest detail. Shading uses a neutral scalar without added exposure, colored veils, rendering passes, or textures.
|
||||
|
||||
Basemaps and TV channels share `hud-select-menu.js` for the HUD popover, list, selection mark, keyboard navigation, and positioning; `tv-source-menu.js` only adapts channel copy. Basemaps disable search, pagination, and counts. The trigger owner identifies popover clicks as internal to the mobile drawer.
|
||||
This document describes the current real structure of the Earth display frontend. The focus is on helping future changes to the HUD, layers, media panel, real terrain, and BGP visualization avoid repeating past structural and state-sync pitfalls.
|
||||
@@ -288,7 +290,7 @@ The surface hover tooltip preference is persisted by `controls.js` as `shared.su
|
||||
|
||||
The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. `maxRealAltitudeOffset = 25` is a visual cap tuned for the current camera and `earthRadius = 100`: GEO / MEO remain clearly higher than LEO, but the highest orbits stay within about 25% beyond the globe radius so selection targets, red trails, and the globe do not feel disconnected.
|
||||
|
||||
SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude.
|
||||
SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for an open full-period prediction curve, projecting the inertial orbit plane onto the current globe pose. No first vertex or closing chord is appended; arc-length highlights travel toward increasing prediction time, and visual inclination stays aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude.
|
||||
|
||||
Boundary precision is stored separately by `country-boundaries.js` under `planet.earth.boundaries.highPrecisionEnabled`. When high precision is off, Earth keeps using the bundled low-precision `countries-admin0.min.geojson` fallback even if high-precision manifest/PMTiles files exist locally. When high precision is on but the artifact is missing, the Earth toolbar settings call `/api/v1/earth/boundaries/build` and poll progress. After success, `reloadCountryBoundaries()` hot-swaps the boundary layer without refreshing the page. Boundary hover is independent from interactable hover: a country polygon remains highlighted whenever the surface coordinate is inside it, while the tooltip can still prioritize a satellite, vessel, BGP marker, or other interactable.
|
||||
|
||||
@@ -369,8 +371,7 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
|
||||
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
|
||||
|
||||
`globe-navigation.js` maps zoom to `sqrt(R² + (D₀² - R²) / zoom²)` with minimum surface clearance and the matching inverse. The 100% camera pose remains unchanged; 500% stays outside the globe. Mouse and single-touch drags choose a mode on pointer-down from the actual camera frustum. A fully visible globe retains legacy screen-delta sensitivity, 0.18 smoothing and release inertia, including movement outside its silhouette. A viewport-clipped globe uses pixel-aligned ray/sphere grabbing only when the initial pointer hits its surface. Subsequent missed intersections pause surface updates and clear fling speed instead of using a closest-point substitute that can reverse rotation. Grabs starting in space use screen deltas. The mode stays fixed throughout the drag; auto-rotation is suspended during dragging and pinching. Returning from a pinch to one finger reselects the mode and rebases the anchor. Click suppression uses displacement from pointer-down, and cancellation clears inertia.
|
||||
|
||||
`globe-navigation.js` retains the safe zoom-distance mapping. Mouse and single-touch drags uniformly use legacy screen deltas, 0.18 smoothing and release inertia, including movement outside the globe silhouette; ray/sphere intersection no longer gates dragging. Normal zoom retains legacy sensitivity. Once the globe exceeds the viewport, a smooth surface-distance/viewport cap limits excessive rotation at extreme zoom. Horizontal motion uses geographic yaw, vertical input changes tilt, and quaternion math prevents multi-revolution reversal. Pinch transitions and pointer capture remain intact.
|
||||
|
||||
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps projection-based clustering, and `none` disables it. Vessels explicitly disable clustering and avoidance so incremental updates do not rebuild cluster topology. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
|
||||
|
||||
@@ -441,7 +442,6 @@ Compute cards directly reuse the shared `.hud-panel` background, border, shadow,
|
||||
|
||||
Saved placement restoration migrates legacy central positions: only old central coordinate locks without the current position marker return to automatic layout, retaining dimensions. Existing edge positions are preserved and versioned. New drags save `positionVersion=2` and continue to honor deliberate placement. The existing storage key is retained instead of clearing every personalized position.
|
||||
|
||||
|
||||
### First frame and spatial card entrance
|
||||
|
||||
`earth-runtime-plugin.mjs` bundles local Earth modules into content-hashed entries and shared chunks for both development and production. The existing import map still pins third-party versions; relative assets and workers retain the `/earth/js/` base. `earth-preload.js` requests the selected full-size map early. `earth-texture-image.js` decodes an ImageBitmap asynchronously and transfers that same image to Three.js without resampling or changing texture color/sampling settings. Replaced and destroyed textures release their bitmaps. The full-resolution surface precedes independent concurrent data layers; data refresh retains it. Versioned boundary geometry, picking indexes and claim lines use HTTP caching and parallel requests. The live player engine is imported on demand; media and realtime connections start after the surface frame.
|
||||
@@ -451,3 +451,32 @@ The root attributes `data-earth-first-frame-ms` and `data-earth-surface-ready-ms
|
||||
Positioned cards stay hidden while the shared connector draws for 420 ms, then fade in using the HUD 180 ms opacity timing. Targets remain fixed during drawing; subsequent motion does not replay entrance. Reduced-motion preferences skip this animation.
|
||||
|
||||
Object clicks, search focus and gesture confirmation share `PresentationController`. Cable selection updates selection/legend state without independently opening the card. InfoCard reveal callbacks are versioned so staging or hiding invalidates older callbacks. Connector completion gates card reveal; cancelled cruises no longer hide a newer selection. Object details reuse the regional-card edge candidates and collision scoring, with dimensions measured while staged. Selecting a mobile search result collapses the search drawer so the focused object, connector and compact card remain visible.
|
||||
|
||||
### Satellite particles and smooth rendering
|
||||
|
||||
Satellites use one `THREE.Points` batch with `PointsMaterial`: a 32×32 white disc texture of radius 0.42, `alphaTest=0.5`, and `sizeAttenuation=false`. The dark backdrop batch and simulated sphere lighting are removed. `satellite-display.js` calibrates against satellitemap screenshot pixels: point squares are 2.4px at zooms up to 150% and 8px at 500%, with a smoothstep transition. The disc fills 84% of the square, yielding visible cores of about 2px and 6–7px respectively. Blue/orange/green/purple/rose use the reference palette directly without the prototype's extra exposure. Point sizes retain the DPR-cap-2 adjustment without reducing globe resolution. Ordinary breathing defaults off but remains an opt-in setting. Existing hover/selected rings, halos, open predicted orbits and forward-flow animation remain intact.
|
||||
|
||||
Real GP/TLE propagation stays in the Worker at 250ms intervals. `satellite-motion.js` caches sample radii and interpolates radius-preserving positions each frame, shared by points, picking, trail tips, selection rings and coverage. `satellite-trail-geometry.js` merges redundant ribbon segments only when the projected error remains below 0.2 pixels across supported zooms. All satellites and 10 history samples are retained; curved or near-camera trails keep their original segments. Only active buffer ranges are uploaded. Trail width is capped at 60% of the point size and tapers toward the tail, avoiding a thick fragmented rim at distant views. `satellite-footprint-geometry.js` retains the full 260×170 coverage grid, created once. The vertex shader evaluates the same surface projection, while local basis and extent uniforms update each frame instead of rebuilding tens of thousands of vertices. Starlink/Iridium footprints share blue `#3399ff`; sunlight changes opacity only, with real coverage models preserved.
|
||||
|
||||
`SATELLITE_CATEGORIES` owns real inclination categories and the blue/orange/green/purple/rose/gray palette. Selecting a satellite automatically switches to its legend. Legend categories directly control points, trails, labels and picking; hiding a selected category clears selection and coverage. Layers and settings have no separate filter entry. Explicit search selection re-enables its category. `satellite-labels.js` shows actual names and models (NORAD identifiers when unavailable) only at **400% zoom or above**. It caps labels at 36, checks occlusion and overlap in the central 65% of the view, scans candidates every 160ms and follows active labels each frame.
|
||||
|
||||
Settings → Display → Show FPS persists through `shared.showFps`, defaulting off. `frame-stats.js` displays actual frame-interval statistics beside the brand, updating once per second; its native tooltip contains mean interval, P95, main-thread CPU time, draw calls and an approximately 30-second rolling mean FPS (total frames / total duration, not an average of per-second rates). Slow foreground frames remain in the sample; visibility changes reset the sampling window. A single high-FPS sample is not a sustained-performance guarantee. Performance acceptance uses an actual canvas of at least 1920×1080, samples at least 30 seconds and targets a mean of 60 FPS while allowing occasional low frames. Compare distant and 400% views with matching viewport, complete data, layers and playback state, and verify category hiding, search selection and FPS persistence after reload.
|
||||
|
||||
The boundary loading tint, land fill, base and HD layers share the `earth-surface.js` geometry factory. Tint and fill share R+0.32, fixing pre-texture diamond rings without changing boundary/HD controls. Intersection and far-zoom depth margins are covered by `earth-surface.test.js`; see [render layer order](earth-render-layer-order.md).
|
||||
|
||||
Cables and landing points share HD-surface coordinates through `cable-surface.js`. `cable-batches.js` controls related halos with per-instance emphasis cleared on deselection. `interactable.js` uses `surface-marker-occlusion.js` for whole-badge visibility and picking near the limb. BGP change events retain their original icons; collectors use activity-colored broadcast badges. Sky material uses the original brightness (multiplier 1.0).
|
||||
|
||||
`toolbar-hover.js` owns the zoom submenu and radial toolbar as one state machine. Magnifier hover opens zoom; trigger, transparent gap bridge and vertical controls form a continuous keep-open region. Leaving first runs the submenu's 220ms exit, then checks the latest pointer location and pinned state before collapsing the parent. Remaining in the radial wake region or pinning the parent closes only zoom; reentry cancels stale timers. CSS hover/focus-within no longer owns visibility independently. Zoom clicks retain parent pinning, with keyboard and touch entry preserved.
|
||||
|
||||
The radial menu inherits global `--hud-scale` (1920×1080 reference, clamped to 0.7–1) instead of fixing its scale at 1. Buttons, icons, fan radii, bottom spacing and the zoom submenu scale together. Base fan coordinates are scaled in CSS transforms; hover wake bounds use the same coordinate scale to prevent hit-region drift under DPI changes. Scaling changes geometry only, preserving animation timing and pinned/hover state transitions.
|
||||
|
||||
### Sky color output
|
||||
|
||||
The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutput()` to encode only the sky material back to display sRGB. The Three.js r128 renderer retains its existing global linear output so Earth, satellites and other layers remain unchanged. `skyBrightness=1.0` retains source brightness; missing output encoding displays decoded dark values directly and cannot be corrected by raising the multiplier alone. Validate with the same map, camera and orientation, comparing rendered pixels and the 1.0/1.5 response instead of checking asset loading only.
|
||||
|
||||
|
||||
### Direct legend filtering
|
||||
|
||||
The current interactive object continues to determine the legend mode; enabling a layer with its existing switch also selects its legend, without adding a filter entry. Cables filter by route name, compute facilities by type, vessels by type and anchored state, and BGP by severity and collector activity. Coverage and impacted-region entries control their respective effects. The geographic legend separately controls lines, land fill and ocean fill without changing the shared land mask data. The obsolete BGP connections/hubs entry is removed.
|
||||
|
||||
`legend-filters.js` owns independent per-mode session filters. `legend.js` renders shared desktop/mobile toggle buttons with `aria-pressed`, preserving keyboard focus when refreshed. `interactable.js` filters draw batches, cluster candidates and picking while retaining complete source data, counts and object identities. Live vessel type changes, data reload and layer toggles respect filters. Hiding a selected item clears its card and overlays; explicit search selection reveals its category. Vessel track requests are versioned so late responses cannot resurrect a hidden track.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Earth Layer Style Property Index
|
||||
|
||||
`EARTH_MATERIAL_CONFIG.surface.night` centralizes night texture contrast (detailGamma=0.55) and separate land/ocean tint and ambient values. It only contributes to the night branch, keeps day exposure unchanged, and reuses the geographic mask without another texture or draw call.
|
||||
|
||||
## Modern object presentation
|
||||
|
||||
- Surface: `EARTH_MATERIAL_CONFIG.surface` owns exposure=0.9, landSaturation=0.9, oceanAbsorption=0.36, oceanDeepColor=[0.004,0.012,0.032], night retention and Fresnel parameters. `atmosphere` owns the single shell's radius, color and strength. Base and terrain specular highlights are disabled.
|
||||
- Cables blend a cool normal tint with their category color, consistently with the legend. Selected pulses follow cumulative route length with 6 repetitions and speed 4.5 in the existing batch draw. Drawing no longer writes depth; original Line pick objects, cable IDs and state logic remain intact.
|
||||
- Landing points use the 128×128 monochrome `marker-landing-point.svg` micro-core instead of a glossy procedural ball; Sprite picking and billboard size logic remain intact.
|
||||
- Compute markers use canonical SVG inner glyphs over shared gradient plates, pale rims, top highlights and shadows. GPU/AI uses purple stacked layers, supercomputers purple chips, and infrastructure teal server racks. Estimated badges, optional compact dots and picking remain intact.
|
||||
- Satellites share vivid real-inclination category colors with the legend and predicted orbits. Normal markers are prototype hard discs without outlines or sphere shading; breathing defaults off. Coverage stays blue and sunlight only affects opacity.
|
||||
|
||||
|
||||
|
||||
This document records the material, color, opacity, line width, radius offset, and `renderOrder` style properties of all Earth frontend layers. For layer ordering relationships, see [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md).
|
||||
|
||||
## Naming Conventions
|
||||
@@ -20,15 +32,11 @@ This document records the material, color, opacity, line width, radius offset, a
|
||||
| Earth base radius | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| Earth base color | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| Earth base emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x000000` | `MeshPhongMaterial.specular` |
|
||||
| Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `0` | `MeshPhongMaterial.shininess` |
|
||||
| Earth base opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | Standalone HD texture sphere radius; must keep enough depth separation from the land/ocean base and Earth base sphere to avoid far-zoom z-fighting |
|
||||
| HD texture opacity | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | HD texture `MeshPhongMaterial.opacity` |
|
||||
| HD texture renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| HD texture specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | Reduces specular highlight in direct-light areas to avoid blown-out texture |
|
||||
| HD texture shininess | `EARTH_MATERIAL_CONFIG.textureOverlayShininess` | `4` | Reduces specular concentration |
|
||||
| HD texture color multiplier | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
|
||||
|
||||
## Earth Occluder and Day/Night
|
||||
|
||||
@@ -36,7 +44,7 @@ This document records the material, color, opacity, line width, radius offset, a
|
||||
| --- | --- | --- | --- |
|
||||
| Occluder radius factor | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | Depth occluder sphere radius |
|
||||
| Occluder segments | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | Occluder geometry segments |
|
||||
| Occluder renderOrder | inline | `-1` | `occluder.renderOrder` |
|
||||
| Occluder renderOrder | inline | `0.5` | `occluder.renderOrder` |
|
||||
| Day/night sun direction | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | Custom day/night shader |
|
||||
| Night-side minimum brightness | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.24` | Shader uniform |
|
||||
| Day-side boost | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `1.12` | Shader uniform |
|
||||
@@ -50,27 +58,12 @@ This document records the material, color, opacity, line width, radius offset, a
|
||||
|
||||
| Name | Variable | Current Value | Location / Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Inner atmosphere radius factor | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
|
||||
| Inner atmosphere segments | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
|
||||
| Inner atmosphere color | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | Shader RGB |
|
||||
| Inner atmosphere rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | Shader rim falloff |
|
||||
| Inner atmosphere intensity | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | Shader alpha multiplier |
|
||||
| Outer atmosphere radius factor | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.016` | `atmosOuterGeo` |
|
||||
| Outer atmosphere segments | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
|
||||
| Outer atmosphere color | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | Shader RGB |
|
||||
| Outer atmosphere rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `5.0` | Shader rim falloff |
|
||||
| Outer atmosphere intensity | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.02` | Shader alpha multiplier |
|
||||
| Atmosphere blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
|
||||
| Atmosphere renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
|
||||
| No-HD-texture rim glow color | `EARTH_MATERIAL_CONFIG.rimGlowColor` | `[0.35, 0.65, 1.0]` | Fresnel shell RGB when HD texture is hidden or unavailable |
|
||||
| No-HD-texture rim glow power | `EARTH_MATERIAL_CONFIG.rimGlowPower` | `3.8` | Shader rim falloff; higher = narrower edge |
|
||||
| No-HD-texture rim glow intensity | `EARTH_MATERIAL_CONFIG.rimGlowIntensity` | `0.28` | Shader alpha multiplier |
|
||||
| No-HD-texture rim glow segments | `EARTH_MATERIAL_CONFIG.rimGlowSegments` | `64` | `earth-rim-glow` geometry segments |
|
||||
| Cloud layer radius offset | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | Cloud sphere radius |
|
||||
| Cloud layer segments | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | Cloud sphere geometry |
|
||||
| Cloud layer opacity | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
|
||||
| Cloud layer opacity | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshBasicMaterial.opacity` |
|
||||
| Cloud texture | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | Cloud texture map |
|
||||
| Cloud blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
|
||||
| Cloud blending | inline | `THREE.AdditiveBlending` | `MeshBasicMaterial.blending` |
|
||||
|
||||
## Land/Ocean Base and Country Borders
|
||||
|
||||
@@ -94,7 +87,7 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Land/ocean base renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| Land/ocean mask size | `landMaskWidth / landMaskHeight` | `2048 / 1024` | Canvas / DataTexture size |
|
||||
| Country tint color | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | Tint when HD texture is off |
|
||||
| Country tint radius offset | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` radius |
|
||||
| Country tint radius offset | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.32` | `country-tint` radius |
|
||||
| Country tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
|
||||
| Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line |
|
||||
| Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity |
|
||||
@@ -123,8 +116,8 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Terrain opacity | `TERRAIN_CONFIG.opacity` | `0.68` | `MeshPhongMaterial.opacity` |
|
||||
| Terrain color | `TERRAIN_CONFIG.color` | `0x8aa884` | `MeshPhongMaterial.color` |
|
||||
| Terrain emissive | `TERRAIN_CONFIG.emissive` | `0x030704` | Reduces self-emission to preserve terrain shading |
|
||||
| Terrain specular | `TERRAIN_CONFIG.specular` | `0x344438` | Gives terrain local sheen without boosting HD texture brightness |
|
||||
| Terrain shininess | `TERRAIN_CONFIG.shininess` | `16` | Tightens terrain highlight |
|
||||
| Terrain specular | `TERRAIN_CONFIG.specular` | `0x000000` | Matte terrain; no white specular lobe |
|
||||
| Terrain shininess | `TERRAIN_CONFIG.shininess` | `0` | Specular disabled |
|
||||
| Terrain renderOrder | inline | `1.2` | `terrain.renderOrder` |
|
||||
| Terrain polygonOffset | inline | `factor -1`, `units -1` | Reduces z-fighting near sphere surface |
|
||||
|
||||
@@ -148,11 +141,11 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Name | Variable | Current Value | Location / Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Default cable color | `CABLE_COLORS.default` | `0xffff44` | Used when no data color available |
|
||||
| Cable radius offset | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | Cable line radius |
|
||||
| Cable radius offset | `CABLE_CONFIG.line.altitudeOffset` | `0.48` | Cable line radius |
|
||||
| Cable line width | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| Cable opacity | `CABLE_CONFIG.line.opacity` | `1.0` | Cable line opacity |
|
||||
| Cable renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | Cable line level |
|
||||
| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | Same surface height as cable lines, avoiding a floating marker |
|
||||
| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | Same surface height as cable lines, avoiding a floating marker |
|
||||
| Landing point sprite height | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` base height |
|
||||
| Landing point reference FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | Matches the current Earth camera FOV |
|
||||
| Landing point scale minimum | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.16` | Minimum multiplier after roughly 200% zoom, limiting high-zoom screen footprint; `3 * 0.16 = 0.48` |
|
||||
@@ -162,7 +155,7 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
|
||||
| Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | Same level as cable lines; `depthTest: false` keeps the ball whole, while camera-to-center globe occlusion hides back-side points |
|
||||
| Landing point dim brightness | `landingPointVisual.dimBrightness` | `0.62` | Dim state color multiplier |
|
||||
| Related landing point opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | Highlight pulse |
|
||||
| Related landing halo extent | `landingPointVisual.related.haloScale / haloFalloff` | `1.65 / 8` | Expands only the halo canvas; core size and color stay fixed |
|
||||
| Dimmed landing point color | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | Dim state color; avoids dark base showing through as a dark hole |
|
||||
| Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base |
|
||||
|
||||
@@ -174,14 +167,11 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Satellite altitude compression scale | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 altitude compression; larger values soften high-orbit separation |
|
||||
| Satellite real-altitude cap | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | Altitude clamp for the compressed display mapping, covering GEO-range altitude |
|
||||
| Satellite real-altitude display offset range | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | Compressed offset added to `CONFIG.earthRadius`; `25` is about one quarter of the current globe radius `100`, keeping high-orbit separation visible without pushing GEO/MEO trails too far away |
|
||||
| Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | Point shader size |
|
||||
| Satellite backdrop dot scale | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | Backdrop dot size |
|
||||
| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | Breathing animation |
|
||||
| Satellite dot breathing speed | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | Dot opacity animation |
|
||||
| Satellite backdrop renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
|
||||
| Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.2` | Legacy interaction hit-radius basis; not visual size |
|
||||
| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.72 / 1.0` | Selected-point breathing; ordinary breathing defaults off |
|
||||
| Satellite dot renderOrder | inline | `6` | `satellitePoints.renderOrder` |
|
||||
| Satellite trail length | `SATELLITE_CONFIG.trailLength` | `10` | Trail buffer |
|
||||
| Satellite trail line width | `SATELLITE_CONFIG.trailLineWidth` | `3` | Ribbon shader uniform |
|
||||
| Satellite trail line width | `SATELLITE_CONFIG.trailLineWidth` | `3` (cap) | Also capped at 60% of point size with tapered short tails |
|
||||
| Selected ring size | `SATELLITE_CONFIG.ringSize` | `0.07` | Hover / locked ring sprite |
|
||||
| Satellite overlay renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | Locked ring / halo / orbit |
|
||||
| Footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Starlink footprint fill and Iridium coverage ring; must stay above land / texture / terrain surface layers |
|
||||
@@ -216,8 +206,8 @@ AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel
|
||||
| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover overlay size multiplier |
|
||||
| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked overlay size multiplier, with pulse |
|
||||
| Dimmed scale / opacity | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | Dim state |
|
||||
| Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | Marker texture |
|
||||
| GPU cluster color | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | Marker texture |
|
||||
| Supercomputer color | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#a855f7"` | Marker texture |
|
||||
| GPU cluster color | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#a855f7"` | Marker texture |
|
||||
| Linked color | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | Linked state |
|
||||
| Compute center renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | Surface facility below satellites |
|
||||
|
||||
@@ -259,7 +249,48 @@ AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel
|
||||
| Moon halo scale | `CELESTIAL_CONFIG.moonHaloScale` | `62` | Moon halo |
|
||||
| Sun light color / intensity | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | Scene light |
|
||||
| Back light color / intensity | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | Scene light |
|
||||
| Star count | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
|
||||
| Star radius range | `minRadius + radiusJitter` | `800 + 200` | Random distribution |
|
||||
| Star color | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
|
||||
| Star size | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
|
||||
|
||||
|
||||
## Badge taxonomy and palette
|
||||
|
||||
| Type | Primary color | Inner glyph |
|
||||
| --- | --- | --- |
|
||||
| Datacenter/infrastructure | Teal `#14b8a6` → `#0d9488`, rim `#5eead4` | Server rack |
|
||||
| GPU/AI | Purple `#a855f7` → `#7e22ce`, rim `#d8b4fe` | Stacked compute layers |
|
||||
| Supercomputer | Same purple family | Chip |
|
||||
| Cable landing point | Amber `#f59e0b`, pale-gold `#fef3c7` rim | Glowing circular core |
|
||||
| BGP collector | Blue and existing activity heat colors | Rounded badge containing the broadcast-tower glyph |
|
||||
| BGP event | Low/medium/high/critical: blue/amber/orange/red | Irregular flat event symbols, no badge plate |
|
||||
| Vessel | Cargo blue, tanker orange, passenger teal, fishing amber, military indigo, other silver | Heading arrow; anchored state retained |
|
||||
| Custom marker | Place green, note yellow, alert red, default blue | Pin, note sheet, warning triangle |
|
||||
|
||||
BGP change events retain irregular flat symbols as the exception; collectors use rounded badges; other finite surface objects share cached badge textures without a new DOM node or draw object per marker. Vessels retain heading buckets and vertex colors rather than multiplying buckets by vessel type. Satellites use distinct category colors and hard discs without an outline; orbit and footprint calculations stay intact. Optional low-zoom dots remain available, default off; both rendering and pick sizing honor the switch.
|
||||
|
||||
|
||||
### Stable satellite palette
|
||||
|
||||
Category colors follow [satellitemap.space](https://satellitemap.space/) while retaining this project's real inclination classes: 0–30° blue `#3399ff`, 30–60° orange `#ff801a`, 60–90° green `#00e680`, 90–120° purple `#cc33ff`, 120–180° rose `#ff3366`, unknown gray `#94a3b8`. A satellite keeps its hue across day/night; predicted orbits no longer use an unrelated yellow-purple gradient.
|
||||
|
||||
All coverage uses `#3399ff`, opacity 0.34 and day-side opacity multiplier 1.65. Normal points have no dark outline; one `PointsMaterial` batch uses alphaTest=0.5. `satellitePixelSize()` smoothly varies visual size from 2.4 to 8.0.
|
||||
|
||||
### Selected orbit and panel outlines
|
||||
|
||||
Prediction covers the full period without truncating or fading the endpoints and without an appended closing vertex. Highlights travel forward along cumulative arc length in the same Line draw. TV and shared HUD shells use square corners, including the player clip, without changing panel size or dragging. Temporary frame-statistics instrumentation and its QA-only DOM attributes are removed from release code.
|
||||
|
||||
### Landing points, limb badges and subtle sky brightness
|
||||
|
||||
Cables and landing points share R+0.48 and renderOrder=1. `cable-surface.js` generates both on the actual surface triangles, removing ideal-sphere versus surface endpoint parallax. Normal points are yellow discs with a pale ring and no persistent halo. Only related points get a subtle pulsing halo in the existing batch shader; unrelated points dim, and deselection restores the normal yellow state.
|
||||
|
||||
Rounded badges use anchor occlusion through `surface-marker-occlusion.js`: the GPU keeps a visible anchor's whole badge and hides it when Earth occludes the anchor. Screen-space picking shares the same check. Only badge layers replace per-pixel depth clipping; BGP change events keep their flat symbols and existing depth behavior. Collectors retain activity colors inside rounded plates. Normal, hover, locked and cluster badges share the path.
|
||||
|
||||
Only the sky texture material receives `CELESTIAL_CONFIG.skyBrightness = 1.0`; Earth exposure, celestial lighting and business-layer brightness stay unchanged.
|
||||
|
||||
Vessels are independent moving objects: their pre-badge heading arrows, anchored dots and hover/selected glow are restored. They do not use the fixed-facility rounded plates or whole-badge occlusion path.
|
||||
|
||||
Normal and related landing points retain the same orange core and yellow rim. Related state adds a wider soft pulsing halo without changing core color, opacity or size. Deselecting clears the halo; unrelated points retain the dim state.
|
||||
|
||||
|
||||
### Sky color output
|
||||
|
||||
The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutput()` to encode only the sky material back to display sRGB. The Three.js r128 renderer retains its existing global linear output so Earth, satellites and other layers remain unchanged. `skyBrightness=1.0` retains source brightness; missing output encoding displays decoded dark values directly and cannot be corrected by raising the multiplier alone. Validate with the same map, camera and orientation, comparing rendered pixels and the 1.0/1.5 response instead of checking asset loading only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Earth Render Layer Order
|
||||
|
||||
The compute topic reuses the compute-center Interactable icons, depth behavior, and layer order. Country/year changes replace that layer's data and clear prior selection. Its HUD is screen UI excluded from Earth picking in `main.js`. Regional cards are tethered in screen space; rear anchors do not draw connectors through the globe. Facility details reuse the existing card and mobile details drawer. The existing outer atmosphere shell remains, while the starfield is restored to commit `83a10a6c` (`brightStarOpacity:0.88`; no tint/brightness calibration). The HD overlay is a white `MeshBasicMaterial` with opacity 1 and `toneMapped:false`; `texture-colors.js` selects the r128 `encoding`/`LinearTosRGB` path or r160 `colorSpace`/colorspace fragment. No Phong white light, colored twilight, blue night tint, camera boost, or shoulder curve is added. No new post-processing, GPU draw pass, or high-resolution texture was added; the compute topic adds 45 local SVG flag assets.
|
||||
The compute topic reuses the compute-center Interactable icons, depth behavior, and layer order. Country/year changes replace that layer's data and clear prior selection. Its HUD is screen UI excluded from Earth picking in `main.js`. Regional cards are tethered in screen space; rear anchors do not draw connectors through the globe. Facility details reuse the existing card and mobile details drawer. The visible HD surface uses a single-draw ShaderMaterial in `earth-surface-material.js`, retaining the R+0.48 mesh, depth policy and surface pick target. The existing controller still switches all four 8K maps, updates the sampler and disposes the previous texture only after success. Normals, view direction and astronomical sunlight share world coordinates. Land is matte; water has grazing-angle cool reflection without a white specular lobe. The existing geographic land/ocean texture owns the optical mask independently of border visibility, with a conservative chroma fallback until loaded and reflection suppression over ice. Original JPEGs are neither repainted nor downsampled. Color textures are tagged sRGB with mipmaps. The r128 surface shader decodes sRGB; r160 uses hardware texture decoding. Both combine light in linear space and encode once. A local filmic curve affects only the surface instead of enabling global ACES exposure on the sky and data layers. The terminator spans normal dot products [-0.12, 0.28]; night attenuation retains 0.12–0.32 linear illumination after tone mapping to avoid crushing source shadows twice. One R×1.025 BackSide/Additive shell replaces the three rim shells, with depthWrite=false and depthTest=true; inner scattering is part of the surface pass. Independently switchable clouds use matte day/night shading at renderOrder 1.1. The sky uses the existing `starmap_deep_8k.jpg` without a tint overlay.
|
||||
|
||||
This document records the current Earth renderer's layer order and the intent of each layer. When adjusting `renderOrder`, radius offsets, depth strategy, or pointer interaction, update this document accordingly.
|
||||
|
||||
@@ -16,13 +16,13 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| Order | Layer | Source | Render / Radius Strategy | Depth / Interaction Strategy | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | Celestial background mesh | `celestial.js` | Background sphere | Not part of surface picking | Behind all Earth content. |
|
||||
| -1 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. |
|
||||
| 0.5 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. |
|
||||
| 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. |
|
||||
| 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. |
|
||||
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
|
||||
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
|
||||
| 1 | Submarine cables / landing points | `cables.js`, `cable-batches.js` | All cable segments share one `LineSegments`; all landing points use instanced billboards; render order remains `1`; cable altitude offset stays `0.2`, while landing-point anchors use the HD surface offset `0.48` | Original `Line` / `Sprite` objects retain per-item picking and selection state but no longer draw individually; landing-point sphere occlusion remains, with `depthTest: false` on the batch | Style textures and instance attributes preserve color, pulse, size, visibility, and click behavior. |
|
||||
| 1 / 1.1 | Atmosphere / clouds | `earth.js` | One atmosphere shell at R×1.025; clouds at R+3 | Atmosphere uses BackSide, depthWrite=false, depthTest=true; cloud toggle retained | Inner scattering shares the surface pass; no fullscreen post-processing. |
|
||||
| 1 | Submarine cables / landing points | `cables.js`, `cable-batches.js` | All cable segments share one `LineSegments`; all landing points use instanced billboards; render order remains `1`; cables and landing-point anchors share the HD surface offset `0.48` and the same triangle-surface coordinates | Original `Line` / `Sprite` objects retain per-item picking and selection state but no longer draw individually; landing-point sphere occlusion remains, with `depthTest: false` on the batch | Style textures and instance attributes preserve color, pulse, size, visibility, and click behavior. |
|
||||
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
|
||||
| 2.05 | Grid lines | `earth.js` | `CONFIG.earthRadius + 0.14` | Raycast disabled | Low-opacity lines over HD texture. |
|
||||
| 0.97–0.99 | Country borders | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`; claim lines have no extra lift | Raycast disabled | Line geometry still has its own `renderOrder`, but it shares the exact same radius as the HD texture shell to avoid parallax while the globe rotates. |
|
||||
@@ -33,15 +33,14 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| 4.3 | AIS vessel track lines | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`; `CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | Follows vessel selection, not independently picked | Recent track for the selected vessel, below vessel markers. |
|
||||
| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays | `depthTest: true`; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. |
|
||||
| 4.5 | Compute centers | `compute-centers.js`, `interactable.js` | `COMPUTE_CENTER_RENDER_ORDER` | Screen-space Interactable picking, with surface-icon collision handling | Surface facilities, below satellites. |
|
||||
| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..25`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. |
|
||||
| 6 | Satellite dots | `satellites.js` | Same compressed / fallback height as satellite backdrop dots | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
|
||||
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
|
||||
| 6 | Satellite dots | `satellites.js` | SGP4 altitude compressed to R+4..25; fallback R+8 | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
|
||||
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw an open full-period inertial curve with forward-flow highlights; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
|
||||
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
|
||||
|
||||
## Full-set rendering and updates
|
||||
|
||||
- Batching changes GPU submission, not the number of satellites, cable segments, or landing points. It does not filter data by the visible hemisphere. Every adjacent cable vertex pair remains present, and picking still returns the original business object.
|
||||
- Satellite foreground and backdrop remain two `Points` draws. Vertex shaders compute breathing from static per-point parameters and a shared frame-time uniform; existing hover/locked state still controls point masking.
|
||||
- Normal satellites use one hard-disc `Points` batch without a backdrop batch. Optional breathing remains in the vertex shader, defaulting off; category filters and hover/locked masking share the same visibility state.
|
||||
- `satellite-position-worker.js` uses the same Three.js / SGP4 versions as the main thread. `satellite-propagation.js` owns shared orbit, display-altitude, and fallback calculations. Full positions and initial trail samples use transferable arrays; only one calculation is in flight. Reload, clear, and altitude-mode changes terminate the previous Worker.
|
||||
- Worker startup failure or `SATELLITE_CONFIG.workerStartupTimeoutMs` expiry falls back to the shared synchronous calculation. Counts, APIs, the existing update interval, and trail length are unchanged.
|
||||
- Verify full draw ranges, per-item picking, locked overlays, trails, rear-side occlusion, visibility toggles, and resource release after clearing. Frame-time comparisons require identical data, view, and resolution.
|
||||
@@ -88,3 +87,15 @@ Maintenance rules:
|
||||
### Startup loading versus drawing order
|
||||
|
||||
The selected full-resolution surface loads before independent data requests; boundary construction no longer blocks its first frame. This does not change renderOrder, depth behavior, geometry precision or image resolution. Data refresh retains the surface, with texture disposal limited to successful replacement or scene teardown.
|
||||
|
||||
### Loading-state boundary diamond regression
|
||||
|
||||
`v0.58.0` moved land fill to R+0.32 and HD imagery to R+0.48, but left the tint placeholder at R+0.04 with a 64×32 mesh. Its chords intersected the 128×128 base, producing latitude-ring diamonds until loaded imagery covered them. The old geometry penetrated the base by approximately 0.153 at tested samples.
|
||||
|
||||
Base, tint, land fill and HD surface now use `createEarthSurfaceGeometry()` and one UV triangulation. Tint and land share R+0.32; neither writes depth, so their existing renderOrder lets loaded fill replace the tint. HD remains R+0.48. Occluder, terrain, clouds, picking and geographic display policy are unchanged. Do not mask intersecting geometry by changing renderOrder alone, disabling depth tests or hiding the loading globe.
|
||||
|
||||
`earth-surface.test.js` ray-tests the actual loading tint and verifies at least two 24-bit depth steps of separation at 50%, 100% and 500%. Browser checks cover delayed textures, boundary toggles, rotation/zoom, and land/HD completion.
|
||||
|
||||
### Surface marker alignment and limb visibility
|
||||
|
||||
Cables and landing points share R+0.48, the `earthSurfacePoint` triangle projection and renderOrder=1. Related landing halos use an emphasis attribute in the existing batch, adding no Sprite or draw call. Rounded badges use GPU anchor ray/sphere occlusion: draw the entire billboard when its anchor is visible, otherwise clip it entirely. CPU picking uses the same test to reject rear anchors. Only `icon.badge` layers (including compute and collectors; vessels independently use heading arrows) use this path; flat BGP change-event depth testing stays unchanged.
|
||||
|
||||
@@ -75,6 +75,8 @@ This keeps Earth, AI, collection management, and other multi-section pages from
|
||||
|
||||
## Datasource Collection Queue
|
||||
|
||||
`datasource-task-queue.ts` reconciles attempts by task ID. Retrying a source adds a new attempt and preserves failed/completed/cancelled history; source matching is allowed only for active placeholders without an ID. Late events cannot reactivate terminal attempts, and older task polls cannot overwrite the latest datasource task. Task status uses `ListChecks`; refresh retains `RefreshCw`.
|
||||
|
||||
The Admin datasource page routes single-source trigger, table-selected trigger, and trigger-all into a browser-download-list style collection queue:
|
||||
|
||||
- Queue state is managed by [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx). It is a current-session visibility layer and does not fake task history in `localStorage`.
|
||||
|
||||
@@ -289,6 +289,10 @@ The bottom-left legend follows the focused or enabled layer. Covered: cables, sa
|
||||
|
||||
AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, moored/slow, other. Triangles indicate moving vessels; dots indicate moored or slow targets.
|
||||
|
||||
Click legend entries to hide or restore their route, category or visual effect; hidden entries appear dimmed. Each layer retains its filter state for the current page. Selecting another object or enabling a layer switches to its legend without a separate filter button. Hiding a selected object dismisses its details and coverage; explicitly selecting a hidden object through search reveals its category. Filtering does not change total statistics. Vessel-type and anchored/slow filters compose; geographic fill controls are visible when the base map is shown.
|
||||
|
||||
Satellite names and NORAD identifiers appear in a limited set at 400% zoom and above, with point size growing smoothly with zoom. Settings → Display → Show FPS displays actual frame rate beside the brand; hover for an approximately 30-second average. The toggle persists. The radial menu and zoom submenu follow interface DPI scaling; moving from the magnifier across the gap to the plus/minus controls keeps the submenu open.
|
||||
|
||||
### Search
|
||||
|
||||
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
|
||||
@@ -425,3 +429,13 @@ The default national metric is global compute-device scale, covering basic, inte
|
||||
This prototype shows up to six content cards. The main card centers on the selected compute metric: a large share percentage or absolute value with its unit, observation year, historical context, and source entry. Missing comparable data is shown as “No comparable data yet”; a trend is omitted when there are no observations rather than rendered as an empty chart. Facility records and coverage metadata remain complete but move to the expanded auxiliary area; sample counts are evidence context, never a compute value or global coverage rate. Region previews may show member counts, and long lists open only when needed. Desktop compact cards are about 268px wide with content-driven height; expanded cards support user resizing up to 480px. Mobile keeps the same single metric meaning in the one-card accordion rather than promoting facility counts. Local card position and size are saved, with a reset-to-auto action. The content layout leaves room for later image and video integration, but those media data are not connected yet. When outside space is insufficient, cards can travel through the globe's foreground instead of teleporting or shrinking to labels.
|
||||
|
||||
When the map cards switch is off, the overview summary still loads. Turning it on shares the map loading event; a newer request cancels the previous one. Drawer event handling follows `composedPath()` so replacing inner HTML cannot accidentally collapse the active row. The shared HUD collapsible panel animation is reused and respects reduced-motion preferences. Facility names remain in a scroll region capped at 240px; rows are left-aligned, wrap long names, and retain at least 42px of touch height.
|
||||
|
||||
## Surface and object appearance
|
||||
|
||||
The real surface retains all four map choices. Day/night shading follows the sun, with uniform illumination when disabled. Selected cables show traveling pulses and landing points use amber micro-markers. Facilities show chip symbols up close while the compact-dot preference remains available. Satellites retain self-glow/ground-coverage modes, trails and their existing orbital motion. BGP severity, vessel heading, related highlights, detail cards and mobile drawers retain their established interactions.
|
||||
|
||||
### Satellite orbits and collection attempts
|
||||
|
||||
Selecting a satellite shows an open predicted arc with highlights moving in the travel direction. Satellites, trails and footprints remain synchronized. Coverage stays blue across day/night; particle and orbit category colors match the legend. Dragging continues outside the globe silhouette, with reduced sensitivity at extreme zoom. The TV panel has square corners like other cards.
|
||||
|
||||
Retriggering a datasource creates a new task while older failures remain in the failed group. Task Status uses a checklist icon, distinct from Refresh. CelesTrak can reuse valid recent download cache; each new collection attempt still gets its own task and updates collected data.
|
||||
|
||||
@@ -311,6 +311,7 @@ For each newly confirmed cause, update both language tables with a stable code,
|
||||
<!-- planet-error-catalog:start -->
|
||||
| Error code | Matching fragments (semicolon-separated) | Cause / established failure | Remedy |
|
||||
| --- | --- | --- | --- |
|
||||
| P_CELESTRAK_NOT_UPDATED | GP data has not updated since your last successful | CelesTrak rejected a repeated download within an update window. HEAD 200 followed by GET 403 can mean preflight consumed the allowance, not an unreachable egress route. | Use the collector without HEAD preflight and preserve validated raw cache. Without valid cache, wait for the next GP update or configure Space-Track instead of retrying repeatedly. |
|
||||
| P_WINDOWS_LAUNCHER_TARGET_MISSING | P_WINDOWS_LAUNCHER_TARGET_MISSING | The bound WSL project is unavailable or lacks launcher scripts, commonly after moving a checkout, losing access to a distribution or copying another machine's desktop launcher. | Run the repository planet.cmd from the current checkout to regenerate this machine's desktop launcher and verify the WSL distribution; do not hardcode another machine's path. |
|
||||
| P_PROXY_EXTERNAL | PLANET_PROXY_EXTERNAL | Docker proxy settings belong to another configuration source or have been edited manually. | Inspect daemon.json, systemd proxy settings and the Planet ownership record before changing ownership; existing settings are preserved. |
|
||||
| P_AUTH_BOOTSTRAP_MISSING | DEFAULT_LOGIN_USER_MISSING | The active backend database is confirmed to lack default accounts, often in an isolated preview that skipped initialization. | Verify database ownership and run the existing account initializer without resetting existing passwords or clearing data. |
|
||||
|
||||
@@ -57,6 +57,8 @@ Once in, verify:
|
||||
- The settings panel can switch rotate / cruise / motion modes; motion settings can select the input source and allowed gestures; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
- The base map defaults to Original basemap, listed first; the display menu can switch and save the map source. Natural Earth I remains available, NASA 21K is source archive material only, and runtime uses one 8K texture at a time.
|
||||
|
||||
After selecting an object or enabling a layer, click its legend entries to filter directly. Display settings can enable FPS; satellite names appear at zoom levels of 400% or above.
|
||||
|
||||
## 5. Recover a Lost Password
|
||||
|
||||
Open `/forgot-password`, enter your email, receive a code, then enter the code plus a new password. The same confirmation is shown for unknown emails (to avoid enumeration).
|
||||
@@ -77,3 +79,7 @@ Open `/forgot-password`, enter your email, receive a code, then enter the code p
|
||||
To maintain card values, an administrator opens **Earth content → Compute comparison**, adds report sources, enters comparable annual country observations, then chooses **Save and publish**. Omit missing countries; facility counts never replace compute shares. Bundled defaults can be restored and Earth cards automatically check published configuration.
|
||||
|
||||
Open the analysis icon beside Compute Centers and enable Show region cards. Closing configuration keeps the cards active; the switch controls their visibility. Resize or collapse desktop cards and inspect observed history, sources, and facility details. On mobile, use the Compute drawer tab. National indicators and facility samples are separate. “—” means missing evidence, not zero capacity.
|
||||
|
||||
### Inspect satellites and retry collection
|
||||
|
||||
Enable satellites in Earth and select a particle to inspect its open predicted orbit, travel-direction highlights and supported footprint. Dragging continues beyond the globe silhouette. A failed datasource can be triggered again: the new task is separate, and older failures remain in the current queue’s failed group.
|
||||
|
||||
@@ -503,3 +503,15 @@ Epoch 改用官方公开 CSV,保留 FP16 理论性能、原始日期、状态
|
||||
同前缀的 `GET/PUT /configuration` 和 `POST /configuration/reset` 仅允许 admin/super_admin。写入体为 `{revision, config}`,重置只需要 `revision`。Pydantic 校验国家目录、有限数值、比例总和、重复指标年份和报告引用;每个指标年份只允许一个报告口径。PostgreSQL 唯一分类与行锁串行化首次写入和更新,旧版本返回 409。审计记录和配置在同一事务提交;重置移除覆盖,下次读取随版本默认值。`version` 为配置修订标识,`reviewed_at` 为录入的核验日期,不能将管理员配置一概称为官方报告。来源链接只作引用,不触发抓取。
|
||||
|
||||
`/api/v1/data-products/compute/facilities` 仅读取 `compute_contract=v1` 的成功快照,数据库执行国家过滤和按来源计数。Epoch 默认排除规划、退役、已被替代和疑似重复记录;TOP500 表示榜单观测,跨源仍未合并。历史选择按采集时间截止,不将最近抓到的旧资料冒充过去已知的快照。地理结果有返回上限与截断标记,记录统计不受该上限影响。官方 site helper 按精确 `source` + `source_id` 匹配;access map 中心只表示 site 级参考点,并在 `source_note` 保留官方 URL。
|
||||
|
||||
|
||||
### CelesTrak 下载窗口与缓存
|
||||
|
||||
CelesTrak active 和 fallback group 均禁用 HEAD 预检,直接 GET,避免预检被上游记为下载后使正文请求收到 403。完整有效缓存两小时内复用;到期后进行条件 GET(有 ETag/Last-Modified 时)或普通 GET。304 复用已校验缓存;仅明确的“GP data has not updated”403 可以回退到有效缓存,其他 403 不伪装成功。fallback group 同样检查缓存时效,不永久使用旧文件。
|
||||
|
||||
下载在 `.part` 上校验非空 GP 记录列表后原子替换缓存,失败保留上一份有效文件。内容长度以 GET 响应为准;压缩响应不拿压缩长度校验解压后的字节数。其他采集器继续支持 HEAD 元数据和 Range 断点续传。原始缓存不等于已入库数据,恢复缓存后仍须触发新采集任务。
|
||||
|
||||
|
||||
### Earth 新闻编码
|
||||
|
||||
`earth_news.py` 在解析 RSS/Atom 前按 BOM、HTTP charset、XML encoding 声明和默认 UTF-8 的顺序确定编码,并严格解码。GBK/GB2312/GB18030 源不会再被隐式替换为乱码;未知编码或非法字节标记为 `format_error`,不写入损坏标题。`test_earth_news_encoding.py` 覆盖中文标题、摘要、展示序列化与失败状态。
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# 智能星球前端结构
|
||||
|
||||
## 高清地表与大气材质
|
||||
|
||||
高清地表由 `earth-surface-material.js` 的单次绘制 `ShaderMaterial` 负责,保持原有 R+0.48 网格、深度策略和地表拾取对象。四张 8K 底图仍由原控制器切换,成功后更新采样 uniform 并释放旧纹理,失败保留原图。法线、视线和真实太阳方向统一到世界坐标;土地哑光,海洋只有掠射角冷色反射,没有白色镜面高光。海洋区分优先复用现有海陆地理纹理,未加载时使用保守色差估计,冰雪抑制反射;关闭国界线不移除光学遮罩。原始 JPEG 不重绘、不降采样。
|
||||
|
||||
颜色纹理标记 sRGB 并启用 mipmaps。r128 在地表 Shader 中解码 sRGB,r160 由纹理硬件格式解码;二者在线性空间合成光照后只编码一次。局部 filmic 曲线只作用于地表,不打开全局 ACES 曝光,避免改变星空和业务图层。晨昏范围为法线点积 [-0.075, 0.16],夜侧单独计算地理纹理透光与冷色环境漫射:对局部色调映射后的纹理用 0.55 次幂保留暗部层次,再分别应用陆地冷灰绿、海洋深靛蓝的纹理系数和极弱环境底色;最后在线性空间按晨昏系数混合昼夜并统一输出 sRGB。日面曝光和反射不变,不添加虚构城市灯光。大气收敛为 R×1.025 的一个 BackSide/Additive 壳,depthWrite=false、depthTest=true,内沿散射并入地表;云图独立开关,使用哑光昼夜材质并在 1.1 顺序绘制。星图使用已有 `starmap_deep_8k.jpg`,不加染色遮罩。
|
||||
|
||||
## 地表贴图资源记录
|
||||
|
||||
2026-09-21,`assets/8k_earth_daymap.jpg` 替换为 Natural Earth I 配套栅格的 8192×4096 Web 派生图,加载路径和渲染代码未变。源图为 EPSG:4326、16200×8100;旧 JPG 在仓库 `resources/earth/archive/2026-09-21/` 逐字节保留。来源、许可、源包/目录版本差异、SHA256、JPEG 参数及按导出尺寸重算的定位文件见 `resources/earth/natural-earth-1/`。原始大文件位于 Git 忽略的 `downloads/earth/`,不进入前端发布包。
|
||||
|
||||
底图选择器默认使用“原始底图”,并将该选项放在首位,显示菜单可切换并在本地保存选择。已有 Natural Earth I 保留可用,并增加“原始底图”选项,使用 2026-09-21 归档原图的逐字节副本;NASA 21K 只作为源归档,运行时统一使用一张 8K 贴图。新贴图成功加载后才替换并释放旧 GPU 纹理,失败时保留旧纹理,过期响应不会覆盖当前结果;这是源切换,不新增渲染层。物理贴图为 JPEG sRGB,未烘焙政治边界,也未完成逐段边界对齐验收。星空逐字节恢复到上次提交前的 commit `83a10a6c`:`celestial.js` 与该提交一致,删除 `skyTint`、`skyBrightness`,`brightStarOpacity` 恢复为 `.88`,不再校准星空密度和颜色。高清 overlay 使用白色 `MeshBasicMaterial`、opacity 1、`toneMapped:false`,由 `texture-colors.js` 统一 sRGB 读写;r128 使用 `encoding`/`LinearTosRGB`,r160 使用 `colorSpace`/colorspace fragment。不叠加 Phong 白光、彩色暮光、蓝色夜染、`cameraBoost` 或 shoulder 曲线;daylight 中性亮度不超过原图最大值,dark 仅做 neutral 缩放,原图资源不变。Natural Earth 原图本身偏浅,不声称恢复不存在的细节。为 WebGL1 的 `fwidth` 开启 derivatives。
|
||||
|
||||
|
||||
昼夜明暗使用以太阳地平线为中心的窄平滑过渡(法线点积 ±0.12)。日面保持原图亮度上限;夜面线性光照系数为 0.24,原图暗部最高保留 0.60,按源亮度连续过渡,以保持暗色海洋和森林细节。只使用中性标量,不增加曝光、彩色罩层、额外渲染通道或纹理。
|
||||
|
||||
地表底图与新闻频道复用 `hud-select-menu.js` 的 HUD 浮层、列表、选中标记、键盘导航和定位;`tv-source-menu.js` 仅保留频道文案适配。底图关闭搜索、分页和计数,菜单通过触发按钮归属参与移动抽屉的内部点击判断。
|
||||
本文件描述当前智能星球前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
|
||||
@@ -439,6 +441,8 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
|
||||
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护智能星球缩放状态,所有入口最终都必须调用 `setZoomLevel()` 写相机距离。不要在其他模块直接写 `camera.position.z`,否则缩放百分比、拖拽灵敏度和 Interactable 聚合阈值会再次分叉。
|
||||
|
||||
鼠标与单指统一使用原有屏幕位移驱动、0.18 平滑和松手惯性。按下后拖出球体轮廓仍连续旋转,不再调用射线/球面命中作为拖动前提。常规缩放保留原灵敏度;球体超出视口后平滑加入按相机距地表距离和视口尺寸计算的旋转上限,修正极大倍率的放大效应。左右绕地理轴、上下改倾角,四元数保持多圈不反向;双指缩放切换与 pointer capture 保留。
|
||||
|
||||
滚轮输入分两类处理:传统鼠标滚轮保留 10% 档位和短动画,并用 `wheelZoomTarget` 作为连续滚动的逻辑基准;触控板 / 高精度滚轮按 pixel delta 连续缩放,直接调用 `setZoomLevel()`,不走 10% 档位动画。触控板路径还会过滤反向后的短窗口旧方向小 residual delta,避免惯性尾巴把用户刚反向的缩放又拉回旧方向。
|
||||
|
||||
该提示每 90ms 最多更新一次,显示 760ms 后淡出。它是视角反馈,不是数据加载进度,也不应该写进图层 loading 状态。
|
||||
@@ -495,7 +499,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
|
||||
卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时,SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。`maxRealAltitudeOffset = 25` 是当前相机和 `earthRadius = 100` 下的视觉上限:它让 GEO / MEO 比 LEO 明显更高,但把最高轨道控制在地球半径外约 25%,避免选择点、红色轨迹和主体地球之间出现过大的空场。
|
||||
|
||||
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
|
||||
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一个完整周期的惯性轨道投到当前地球姿态上显示为开放弧线;不追加首点或用直线强制封口,轨道面倾角与详情卡一致。流光按累计弧长和未来采样方向移动。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
|
||||
|
||||
国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,智能星球工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。
|
||||
|
||||
@@ -571,7 +575,6 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
|
||||
|
||||
保存位置恢复时会迁移旧版居中拖放坐标:只解除位于屏幕核心区域、且没有新版位置标记的旧坐标锁,保留宽高;旧版边缘位置保持不动并更新标记。新拖放保存 `positionVersion=2`,以后仍尊重用户的新位置。迁移沿用原存储键,避免因全量清空布局而丢失所有个性化位置。
|
||||
|
||||
|
||||
### 首帧与空间卡片入场
|
||||
|
||||
Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的入口与共享块,开发服务和正式构建使用同一路径;第三方依赖仍由原 import map 固定版本,`/earth/js/` 的相对资源及 Worker 路径不变。`earth-preload.js` 在应用初始化前请求选中的原尺寸底图,`earth-texture-image.js` 使用 ImageBitmap 异步解码,并把同一个解码结果交给 Three.js;不重采样、不改变色彩空间和采样设置,替换/销毁纹理时释放位图。高清地表先于数据图层就绪,刷新数据保留已有贴图。独立数据图层并行请求,国界版本化资源使用 HTTP 缓存,基础几何、拾取索引与声明线并行读取。直播引擎按需导入,媒体及实时连接在地表首帧后启动。
|
||||
@@ -581,3 +584,32 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
|
||||
空间卡片定位后保持隐藏,先复用 `CalloutConnector` 的 420 ms 绘线动画,再按共享 HUD 的 180 ms 淡入时长显示卡片。绘线期间固定目标位置,后续跟随运动不重播入场;系统减少动态效果时直接呈现。
|
||||
|
||||
物件点击、搜索定位及手势确认统一通过 `PresentationController`。海缆选择处理器只更新选择与图例,不再绕过控制器提前显示卡片;共享 InfoCard 的显示回调带版本标记,新的隐藏/预布局请求会使旧回调失效。所有连线卡片以绘线完成事件作为显示门槛,取消的旧巡航不能关闭新的选择卡片。物件详情复用算力卡的边缘候选与避让评分,按隐藏状态下测得的真实尺寸定位置;手机搜索结果被选中时收起搜索抽屉,让地图定位、引导线与精简卡片可见。
|
||||
|
||||
### 卫星粒子与平滑显示
|
||||
|
||||
卫星由单批 `THREE.Points` 与 `PointsMaterial` 绘制:32×32 白色圆盘贴图、半径 0.42、`alphaTest=0.5`、`sizeAttenuation=false`,不再绘制深色背景点或伪球面明暗。`satellite-display.js` 按 satellitemap 的截图像素校准:150% 及以下的点方框为 2.4px、500% 为 8px,实心圆盘占 84%,对应约 2px 与 6–7px 的可见点径,中间 smoothstep 过渡。蓝/橙/绿/紫/玫红直接使用参考色板,不额外叠加原型曝光。点尺寸保留 DPR 上限 2 的折算,地球渲染分辨率不变。普通呼吸默认关闭,设置仍可单独开启;悬停/选中环、光晕、开放预测轨道及流光保持现有交互。
|
||||
|
||||
真实 GP/TLE 仍在 Worker 每 250ms 传播。`satellite-motion.js` 缓存采样半径并逐帧半径保持插值,粒子、拾取、短尾迹末端、选中环和覆盖共享同一位置。`satellite-trail-geometry.js` 仅在所有支持缩放下的投影误差不超过 0.2 像素时合并冗余尾迹段;保留全部卫星和 10 个历史采样,曲率或近相机条件不满足时保留原分段。仅上传活跃缓冲区,尾迹宽度不超过点尺寸的 60%,尾端收细,避免远景形成粗碎线圈。`satellite-footprint-geometry.js` 保留 260×170 的完整覆盖网格,仅创建一次;同一地表投影公式在顶点着色器计算,每帧更新局部基向量和范围参数,避免移动时重建数万个顶点。Starlink/Iridium 覆盖统一蓝色 `#3399ff`,日照只调整透明度,真实覆盖模型不变。
|
||||
|
||||
`SATELLITE_CATEGORIES` 统一真实倾角分类及蓝/橙/绿/紫/玫红/灰色。选中卫星时自动切换到卫星图例,直接点击图例分类控制点、拖尾、标签和拾取,隐藏已选分类会清除选择与覆盖;图层和设置中不另设筛选入口。搜索明确选择隐藏类别的卫星时重新显示该类。`satellite-labels.js` 仅在缩放达到 **400%** 时显示真实名称与型号(缺省为 NORAD 编号),最多 36 个,中央 65% 范围内做遮挡和重叠检查;候选扫描每 160ms 一次,已选标签逐帧跟随。
|
||||
|
||||
“设置 → 显示 → 显示 FPS”由 `shared.showFps` 持久化,默认关闭。`frame-stats.js` 在品牌右侧显示实际帧间隔统计,每秒更新;原生提示包含平均帧间隔、P95、主线程 CPU 时间、Draw calls 和约 30 秒滚动平均 FPS(总帧数 / 总时长,不平均每秒 FPS)。前台慢帧必须计入,隐藏页切换时重置窗口;不能把个别高帧样本当成稳定性能保证。性能验收至少使用 1920×1080 实际画布,连续采样至少 30 秒,以平均 60 FPS 为目标,允许偶发低帧。验证应使用同一视口、缩放、完整数据、图层和播放状态,对比远景及 400% 近景,并检查分类隐藏、搜索选中和 FPS 刷新后的持久化。
|
||||
|
||||
国界加载占位球、海陆填色、主球和高清层使用 `earth-surface.js` 的统一网格工厂;占位球与海陆填色共用 R+0.32,修复纹理加载前的菱形纬圈。国界和高清开关行为保持不变;几何穿插与远距深度余量由 `earth-surface.test.js` 覆盖,详见[渲染图层顺序](earth-render-layer-order.md)。
|
||||
|
||||
海缆与登陆点使用 `cable-surface.js` 共用高清地表坐标;关联光晕由 `cable-batches.js` 的逐实例 emphasis 控制,解除选择后清零。圆角徽标在 `interactable.js` 中接入 `surface-marker-occlusion.js`,以锚点决定整块显隐及拾取,避免近地平线被地球切开。BGP 变化事件保留原图标,观测站采用带活动色的广播塔圆角徽标;星空材质使用原始亮度(系数 1.0)。
|
||||
|
||||
缩放子菜单与环形工具菜单由 `toolbar-hover.js` 的统一状态机控制。放大镜 hover 打开子菜单;触发器、透明桥接间隙和纵向按钮区构成连续保活区域。离开该区域先执行子菜单 220ms 收起,再根据最新指针位置及固定展开状态决定是否收起外层。鼠标仍在环形唤醒区域或外层已 toggle 固定时,仅收起子菜单;迅速返回会取消旧计时器。CSS 不再用 hover/focus-within 独立控制显隐,点击缩放按钮不会撤销外层固定状态;键盘与触摸入口仍保留。
|
||||
|
||||
扇形菜单继承全局 `--hud-scale`(1920×1080 基准,范围 0.7–1),不再固定为 1。按钮、图标、扇形半径、底部留白与缩放子菜单一起缩放;轨道基准坐标仅在 CSS 变换时乘比例,hover 唤醒范围使用相同坐标比例,避免 DPI 变化后的视觉位置与命中区域错位。缩放仅改变几何尺寸,不改变展开动画时序及 pinned/hover 状态机。
|
||||
|
||||
### 星空颜色输出
|
||||
|
||||
星空贴图按 sRGB 解码;`celestial.js` 通过共享 `preserveTextureOutput()` 仅为星空材质补齐屏幕 sRGB 输出。Three.js r128 的全局 renderer 仍保持既有线性输出,避免改变地球、卫星及其他图层。`skyBrightness=1.0` 表示原图亮度;缺少输出编码时,解码后的暗部会再次以线性数值显示,不能靠提高倍率修正。验证应固定星图、相机与视角,对比渲染像素和 1.0/1.5 两档响应,不能只检查加载成功。
|
||||
|
||||
|
||||
### 图例直接筛选
|
||||
|
||||
当前交互物件继续决定图例类型;通过现有开关启用图层也会切换到对应图例,不增加筛选入口。海缆按线路名称、算力按类型、船只按船型及停泊状态、BGP 按事件级别和观测站活跃度筛选;覆盖范围和影响区域各自控制对应效果。国界图例分别控制线条、陆地填色与海洋填色,海陆 mask 数据本身不变。已不存在的 BGP“事件连线 / 枢纽”行被移除。
|
||||
|
||||
`legend-filters.js` 保存各类独立的页面内筛选状态;`legend.js` 统一绘制带 `aria-pressed` 的图例按钮,桌面和移动端共享同一状态,刷新图例保留键盘焦点。`interactable.js` 在绘制批次、聚类候选及拾取前应用筛选,保留完整原始数据、统计总量和对象身份。实时船只类型变化、数据重载及图层开关不会绕过筛选;隐藏选中项清除卡片和覆盖,显式搜索选中隐藏项则重新显示相应类别。船只轨迹请求使用版本检查,避免隐藏后迟到响应恢复旧轨迹。
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 智能星球图层样式属性索引
|
||||
|
||||
`EARTH_MATERIAL_CONFIG.surface.night` 集中夜侧纹理对比(detailGamma=0.55)及海陆各自的 tint/ambient 参数。它只参与夜侧混合,不调整日面曝光;沿用现有地理遮罩,无新增贴图或 Draw Call。
|
||||
|
||||
## 现代物件视觉
|
||||
|
||||
- 地表:`EARTH_MATERIAL_CONFIG.surface` 集中 exposure=0.9、landSaturation=0.9、oceanAbsorption=0.36、oceanDeepColor=[0.004,0.012,0.032]、夜面保留与 Fresnel 参数;`atmosphere` 集中单壳半径、颜色和强度。低清基座及地形关闭镜面高光。
|
||||
- 海缆:常态冷色与原分类色混合,图例与线路保持一致;选中后参考流光沿真实线路累计长度移动,6 个周期、速度 4.5。复用同一批量 draw,绘制材质不写深度;原 Line 对象、cableId、选中逻辑不变。
|
||||
- 登陆点:`marker-landing-point.svg` 为 128×128 琥珀微核单色源,取代带高光的程序球;Sprite 拾取和 billboard 批量绘制保持原尺寸逻辑。
|
||||
- 算力:三个规范 SVG 只负责内置符号;`marker-badges.js` 与 `interactable.js` 共同绘制渐变方块底板、浅色边圈、上沿高光与投影。GPU/AI 使用电紫堆叠层,超算使用电紫芯片,数据中心/基础设施使用青绿机柜。估算标志和原拾取/联动保留。
|
||||
- 卫星:真实倾角分类与图例、预测轨道共享鲜明类别色;普通态使用原型硬边圆盘,无衬边或球面明暗,默认不呼吸。覆盖统一蓝色,日照只改变透明度。
|
||||
|
||||
|
||||
|
||||
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
|
||||
`renderOrder` 等样式属性。层级关系请配合
|
||||
[智能星球渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)
|
||||
@@ -23,15 +35,11 @@
|
||||
| 智能星球基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| 智能星球基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| 智能星球基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| 智能星球基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| 智能星球基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| 智能星球基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x000000` | `MeshPhongMaterial.specular` |
|
||||
| 智能星球基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `0` | `MeshPhongMaterial.shininess` |
|
||||
| 智能星球基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | 独立高清材质球半径;必须与海陆基座和地球基座保持足够深度间距,避免远距 z-fighting |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| 高清材质 specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | 降低直射区域镜面高光,避免贴图死白 |
|
||||
| 高清材质 shininess | `EARTH_MATERIAL_CONFIG.textureOverlayShininess` | `4` | 降低高光集中度 |
|
||||
| 高清材质颜色乘色 | inline | `0xffffff` | `_earthTextureOverlayMaterial.color` |
|
||||
|
||||
## Earth 遮挡与昼夜
|
||||
|
||||
@@ -39,7 +47,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 遮挡球半径系数 | `EARTH_MATERIAL_CONFIG.occluderRadiusFactor` | `0.999` | 深度遮挡球半径 |
|
||||
| 遮挡球分段 | `EARTH_MATERIAL_CONFIG.occluderSegments` | `48` | 遮挡球几何分段 |
|
||||
| 遮挡球 renderOrder | inline | `-1` | `occluder.renderOrder` |
|
||||
| 遮挡球 renderOrder | inline | `0.5` | `occluder.renderOrder` |
|
||||
| 昼夜太阳方向 | `EARTH_MATERIAL_CONFIG.dayNight.sunDirection` | `{ x: 1, y: 0.2, z: 0.4 }` | 自定义 day/night shader |
|
||||
| 夜侧最低亮度 | `EARTH_MATERIAL_CONFIG.dayNight.nightFloor` | `0.24` | shader uniform |
|
||||
| 日侧增强 | `EARTH_MATERIAL_CONFIG.dayNight.dayBoost` | `1.12` | shader uniform |
|
||||
@@ -53,30 +61,13 @@
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 内层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosInnerRadiusFactor` | `1.01` | `atmosInnerGeo` |
|
||||
| 内层大气分段 | `EARTH_MATERIAL_CONFIG.atmosInnerSegments` | `64` | `atmosInnerGeo` |
|
||||
| 内层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosInnerColor` | `[0.25, 0.62, 1.0]` | shader RGB |
|
||||
| 内层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosInnerRimPower` | `3.2` | shader rim |
|
||||
| 内层大气强度 | `EARTH_MATERIAL_CONFIG.atmosInnerIntensity` | `0.18` | shader alpha multiplier |
|
||||
| 外层大气半径系数 | `EARTH_MATERIAL_CONFIG.atmosOuterRadiusFactor` | `1.0025` | `atmosOuterGeo` |
|
||||
| 外层大气分段 | `EARTH_MATERIAL_CONFIG.atmosOuterSegments` | `48` | `atmosOuterGeo` |
|
||||
| 外层大气颜色 | `EARTH_MATERIAL_CONFIG.atmosOuterColor` | `[0.18, 0.45, 0.9]` | shader RGB |
|
||||
| 外层大气 rim power | `EARTH_MATERIAL_CONFIG.atmosOuterRimPower` | `9.0` | shader rim |
|
||||
| 外层大气强度 | `EARTH_MATERIAL_CONFIG.atmosOuterIntensity` | `0.0025` | shader alpha multiplier |
|
||||
| 大气辉光 blending | inline | `THREE.AdditiveBlending` | `ShaderMaterial.blending` |
|
||||
| 大气辉光 renderOrder | inline | `1` | `atmosInner/Outer.renderOrder` |
|
||||
| 无高清材质边缘光颜色 | `EARTH_MATERIAL_CONFIG.rimGlowColor` | `[0.42, 0.72, 1.0]` | 高清材质隐藏或不可用时的 Fresnel shell RGB |
|
||||
| 无高清材质边缘光半径系数 | `EARTH_MATERIAL_CONFIG.rimGlowRadiusFactor` | `1.0035` | `earth-rim-glow` 外扩球壳半径 |
|
||||
| 无高清材质边缘光 rim power | `EARTH_MATERIAL_CONFIG.rimGlowPower` | `3.4` | shader rim 衰减;值越大边缘越窄 |
|
||||
| 无高清材质边缘光强度 | `EARTH_MATERIAL_CONFIG.rimGlowIntensity` | `0.24` | shader alpha multiplier |
|
||||
| 无高清材质边缘光分段 | `EARTH_MATERIAL_CONFIG.rimGlowSegments` | `96` | `earth-rim-glow` 几何分段 |
|
||||
| 无高清材质边缘光 renderOrder | `EARTH_MATERIAL_CONFIG.rimGlowRenderOrder` | `1.08` | `_earthRimGlow.renderOrder` |
|
||||
| 无高清材质边缘光 depthTest | inline | `false` | 避免被海陆基座或地表填充遮住 |
|
||||
| 云图半径偏移 | `CLOUD_LAYER_CONFIG.radiusOffset` | `3` | 云层球半径 |
|
||||
| 云图分段 | `CLOUD_LAYER_CONFIG.widthSegments / heightSegments` | `64 / 64` | 云层球几何分段 |
|
||||
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshPhongMaterial.opacity` |
|
||||
| 云图透明度 | `CLOUD_LAYER_CONFIG.opacity` | `0.15` | `MeshBasicMaterial.opacity` |
|
||||
| 云图贴图 | `CLOUD_LAYER_CONFIG.textureUrl` | `"./assets/earth_clouds_1024.png"` | 云层贴图 |
|
||||
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshPhongMaterial.blending` |
|
||||
| 云图 blending | inline | `THREE.AdditiveBlending` | `MeshBasicMaterial.blending` |
|
||||
|
||||
## 海陆基座与国界
|
||||
|
||||
@@ -100,7 +91,7 @@
|
||||
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
|
||||
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
|
||||
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.04` | `country-tint` 半径 |
|
||||
| 国界 tint 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset` | `0.32` | `country-tint` 半径 |
|
||||
| 国界 tint renderOrder | `COUNTRY_BOUNDARY_CONFIG.tintRenderOrder` | `0.2` | `country-tint.renderOrder` |
|
||||
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
|
||||
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
|
||||
@@ -129,8 +120,8 @@
|
||||
| 地形透明度 | `TERRAIN_CONFIG.opacity` | `0.68` | `MeshPhongMaterial.opacity` |
|
||||
| 地形颜色 | `TERRAIN_CONFIG.color` | `0x8aa884` | `MeshPhongMaterial.color` |
|
||||
| 地形 emissive | `TERRAIN_CONFIG.emissive` | `0x030704` | 降低自发光,恢复地形明暗层次 |
|
||||
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x344438` | 给地形局部光泽,不抬高高清贴图直射亮度 |
|
||||
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `16` | 收紧地形高光,增强起伏辨识 |
|
||||
| 地形 specular | `TERRAIN_CONFIG.specular` | `0x000000` | 给地形局部光泽,不抬高高清贴图直射亮度 |
|
||||
| 地形 shininess | `TERRAIN_CONFIG.shininess` | `0` | 关闭镜面高光 |
|
||||
| 地形 renderOrder | inline | `1.2` | `terrain.renderOrder` |
|
||||
| 地形 polygonOffset | inline | `factor -1`, `units -1` | 降低贴近球面时的闪烁 |
|
||||
|
||||
@@ -154,11 +145,11 @@
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
|
||||
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.2` | 海缆线半径 |
|
||||
| 海缆半径偏移 | `CABLE_CONFIG.line.altitudeOffset` | `0.48` | 海缆线半径 |
|
||||
| 海缆线宽 | `CABLE_CONFIG.line.lineWidth` | `1` | `LineBasicMaterial.linewidth` |
|
||||
| 海缆透明度 | `CABLE_CONFIG.line.opacity` | `1.0` | 海缆线 opacity |
|
||||
| 海缆 renderOrder | `CABLE_CONFIG.line.renderOrder` | `1` | 海缆线层级 |
|
||||
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 与海缆线同层贴地,避免凌空 |
|
||||
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.48` | 与海缆线同层贴地,避免凌空 |
|
||||
| 登陆点 sprite 高度 | local `LANDING_POINT_SPRITE_HEIGHT` | `3` | `THREE.Sprite` 基准高度 |
|
||||
| 登陆点缩放参考 FOV | local `LANDING_POINT_SIZE_REFERENCE_FOV` | `75` | 与当前 Earth 相机 FOV 一致 |
|
||||
| 登陆点缩放下限 | local `LANDING_POINT_SIZE_SCALE_MIN` | `0.16` | 地球放到 200% 之后的最小倍率,限制高倍 zoom 下的屏幕占比;`3 * 0.16 = 0.48` |
|
||||
@@ -168,7 +159,7 @@
|
||||
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
|
||||
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | 与海缆线同层;`depthTest: false` 保持球体完整,背面通过相机到球心的球体遮挡判断隐藏 |
|
||||
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
|
||||
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
|
||||
| 关联登陆点光晕范围 | `landingPointVisual.related.haloScale / haloFalloff` | `1.65 / 8` | 仅扩大光晕画布,圆点大小和颜色不变 |
|
||||
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
|
||||
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
|
||||
|
||||
@@ -180,17 +171,12 @@
|
||||
| 卫星真实高度压缩尺度 | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 高度压缩映射,数值越大高轨差异越温和 |
|
||||
| 卫星真实高度上限 | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | 压缩映射的高度截断,覆盖 GEO 附近高度 |
|
||||
| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | 压缩后映射到 `CONFIG.earthRadius + offset`;`25` 约为当前地球半径 `100` 的四分之一,用于保留高轨分层同时避免 GEO/MEO 轨迹离主体过远 |
|
||||
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size |
|
||||
| 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 |
|
||||
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | 呼吸动画 |
|
||||
| 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 |
|
||||
| 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor |
|
||||
| 卫星背景点透明度 | inline | `0.42` | backdrop point opacity |
|
||||
| 卫星点透明度 | inline | `0.9` | point material opacity |
|
||||
| 卫星背景点 renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` |
|
||||
| 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.2` | 旧交互命中半径计算基准;不控制视觉尺寸 |
|
||||
| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.72 / 1.0` | 选中点呼吸动画;普通点默认不呼吸 |
|
||||
| 卫星点透明度 | inline | `1.0` | 硬边圆盘普通态 |
|
||||
| 卫星点 renderOrder | inline | `6` | `satellitePoints.renderOrder` |
|
||||
| 卫星轨迹长度 | `SATELLITE_CONFIG.trailLength` | `10` | trail buffer |
|
||||
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3` | ribbon shader uniform |
|
||||
| 卫星轨迹线宽 | `SATELLITE_CONFIG.trailLineWidth` | `3`(上限) | 实际宽度同时限制为点径的 60%,保留渐细短拖尾 |
|
||||
| 选中 ring 大小 | `SATELLITE_CONFIG.ringSize` | `0.07` | hover / locked ring sprite |
|
||||
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
|
||||
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
|
||||
@@ -235,8 +221,8 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
|
||||
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover overlay 尺寸倍率 |
|
||||
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked overlay 尺寸倍率,并叠加 pulse |
|
||||
| dimmed 缩放 / 透明度 | `dimmedScale / dimmedOpacity` | `0.82 / 0.34` | dim 状态 |
|
||||
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#38bdf8"` | marker texture |
|
||||
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#2dd4bf"` | marker texture |
|
||||
| 超算颜色 | `COMPUTE_CENTER_CONFIG.colors.supercomputer` | `"#a855f7"` | marker texture |
|
||||
| GPU 集群颜色 | `COMPUTE_CENTER_CONFIG.colors.gpu_cluster` | `"#a855f7"` | marker texture |
|
||||
| 关联颜色 | `COMPUTE_CENTER_CONFIG.colors.linked` | `"#f8fafc"` | 关联态 |
|
||||
| 算力中心 renderOrder | local `COMPUTE_CENTER_RENDER_ORDER` | `4.5` | 地表设施低于卫星点 |
|
||||
|
||||
@@ -282,7 +268,48 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
|
||||
| 月亮 halo 缩放 | `CELESTIAL_CONFIG.moonHaloScale` | `62` | 月亮 halo |
|
||||
| 太阳光颜色 / 强度 | `sunLightColor / sunLightIntensity` | `0xfff4df / 1.02` | scene light |
|
||||
| 背光颜色 / 强度 | `backLightColor / backLightIntensity` | `0x2b4c78 / 0.3` | scene light |
|
||||
| 星空点数量 | `STARFIELD_CONFIG.count` | `8000` | `createStars()` |
|
||||
| 星空半径范围 | `minRadius + radiusJitter` | `800 + 200` | 随机分布 |
|
||||
| 星空点颜色 | `STARFIELD_CONFIG.color` | `0xffffff` | `PointsMaterial.color` |
|
||||
| 星空点大小 | `STARFIELD_CONFIG.size` | `0.5` | `PointsMaterial.size` |
|
||||
|
||||
|
||||
## 方块徽标分类与配色
|
||||
|
||||
| 类型 | 主色 | 内置图标 |
|
||||
| --- | --- | --- |
|
||||
| 数据中心/基础设施 | 青绿 `#14b8a6` → `#0d9488`,边框 `#5eead4` | 服务器机柜 |
|
||||
| GPU/AI | 电紫 `#a855f7` → `#7e22ce`,边框 `#d8b4fe` | 堆叠算力层 |
|
||||
| 超级计算机 | 同一电紫 | 芯片 |
|
||||
| 海缆登陆点 | 琥珀 `#f59e0b`,浅金 `#fef3c7` 边圈 | 发光圆核 |
|
||||
| BGP 观测站 | 蓝色及原活动热度色 | 圆角矩形底板内置广播塔 |
|
||||
| BGP 事件 | 低/中/高/严重:蓝/琥珀/橙/红 | 不规则扁平符号(三角、感叹号、波动、爆发、泄漏),不使用方块 |
|
||||
| 船舶 | 货轮蓝、油轮橙、客船青绿、渔船琥珀、军舰靛紫、其他银灰 | 独立航向箭头,无徽标底板;停泊为圆点,悬停/选中发光 |
|
||||
| 自定义标记 | 地点绿、备注黄、告警红、默认蓝 | 地点针、备注页、告警三角 |
|
||||
|
||||
BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用圆角徽标;其他有限地表物件使用缓存的徽标纹理,不为每个图标新增 DOM 或独立绘制对象。船舶继续按航向分桶,以顶点色区分船型,不将船型与航向相乘扩张批次数。卫星使用参考的鲜明类别色与无衬边硬圆盘;轨道和覆盖计算不变。“低缩放圆点”开关仍可开启,新默认关闭;渲染与拾取尺寸共同尊重开关,不再关闭后仍强制用圆点。
|
||||
|
||||
|
||||
### 卫星统一色板
|
||||
|
||||
参考 [satellitemap.space](https://satellitemap.space/) 的类别色;本地 3d.html 中的模拟壳层与数量不作为项目分类依据。当前项目仍按真实轨道倾角分类:0–30° 蓝 `#3399ff`、30–60° 橙 `#ff801a`、60–90° 绿 `#00e680`、90–120° 紫 `#cc33ff`、120–180° 玫红 `#ff3366`,未知灰 `#94a3b8`。同一颗卫星跨昼夜保持同一颜色,预测轨道不再使用无关的黄紫渐变。
|
||||
|
||||
覆盖统一 `#3399ff`,opacity=0.34,日侧透明度增益=1.65。普通点不带深色衬边,单批 `PointsMaterial` 使用 alphaTest=0.5;视觉尺寸由 `satellitePixelSize()` 在 2.4–8.0 之间平滑变化。
|
||||
|
||||
### 选中轨道与面板轮廓
|
||||
|
||||
预测轨道绘制完整周期的开放曲线,不追加首点,不截短采样范围或隐藏端部;按累计弧长沿未来采样方向推进亮色流光,复用同一 Line 绘制。电视与通用 HUD 外壳恢复方角,播放器裁剪区域同样为方角;不改变窗口尺寸与拖拽。发布代码移除临时逐帧性能统计,不保留只用于验收的 DOM 诊断接口。
|
||||
|
||||
### 登陆点、侧面徽标与星空微调
|
||||
|
||||
海缆与登陆点共用 R+0.48、renderOrder=1,均通过 `cable-surface.js` 的地表三角网格坐标生成,避免理想球与实际地表之间的端点视差。普通点为黄色实心圆与浅色圈,不带常驻光晕;只有 `related` 点在原有合批 Shader 中产生轻微脉动光晕,其他点变暗,取消高亮恢复普通黄色点。
|
||||
|
||||
圆角徽标改为锚点遮挡:`surface-marker-occlusion.js` 在 GPU 判断锚点是否被球体挡住,正面徽标整块显示、背面整块隐藏;屏幕拾取复用相同判断。仅徽标层关闭逐像素深度裁切;BGP 变化事件保持原扁平符号与深度行为,观测站保留活动热度色并使用圆角底板。悬停、选中和聚合徽标使用同一遮挡路径。
|
||||
|
||||
星空纹理材质独立使用 `CELESTIAL_CONFIG.skyBrightness = 1.0`;不改变地球曝光、天体灯光或业务图层亮度。
|
||||
|
||||
船只为独立移动对象,恢复徽标改造前的航向箭头、停泊圆点和悬停/选中光晕,不接入固定地表物件的圆角底板及整块徽标遮挡路径。
|
||||
|
||||
登陆点普通态和关联态均保持橙色内芯、黄色外圈;关联态只扩大外侧柔光区域并脉动,不改变核心颜色、透明度或尺寸。取消关联后光晕归零;非关联点继续使用暗态。
|
||||
|
||||
|
||||
### 星空颜色输出
|
||||
|
||||
星空贴图按 sRGB 解码;`celestial.js` 通过共享 `preserveTextureOutput()` 仅为星空材质补齐屏幕 sRGB 输出。Three.js r128 的全局 renderer 仍保持既有线性输出,避免改变地球、卫星及其他图层。`skyBrightness=1.0` 表示原图亮度;缺少输出编码时,解码后的暗部会再次以线性数值显示,不能靠提高倍率修正。验证应固定星图、相机与视角,对比渲染像素和 1.0/1.5 两档响应,不能只检查加载成功。
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
|
||||
## 地表图层栈
|
||||
|
||||
算力专题沿用原算力中心 Interactable 图标、深度与层次顺序;国家/年份切换会替换该层数据并清除旧选中状态。专题 HUD 属于屏幕 UI,在 `main.js` 的指针排除列表中,不参与地球 picking;区域数据卡在屏幕空间由地理锚点牵引,背面不画穿球连线,设施详情复用原详情卡和移动端详情抽屉。外层大气壳保持原有实现;星空逐字节恢复到 commit `83a10a6c`,`brightStarOpacity` 为 `.88`,删除 `skyTint`/`skyBrightness`,不再校准密度和颜色。高清 overlay 使用白色 `MeshBasicMaterial`、opacity 1、`toneMapped:false`;`texture-colors.js` 统一选择 r128 的 `encoding`/`LinearTosRGB` 或 r160 的 `colorSpace`/colorspace fragment。不叠加 Phong 白光、彩色暮光、蓝色夜染、camera boost 或 shoulder 曲线。没有新增后处理、GPU draw pass 或高分辨率纹理;算力专题新增 45 个本地 SVG 旗帜资源。
|
||||
算力专题沿用原算力中心 Interactable 图标、深度与层次顺序;国家/年份切换会替换该层数据并清除旧选中状态。专题 HUD 属于屏幕 UI,在 `main.js` 的指针排除列表中,不参与地球 picking;区域数据卡在屏幕空间由地理锚点牵引,背面不画穿球连线,设施详情复用原详情卡和移动端详情抽屉。高清地表由 `earth-surface-material.js` 的单次绘制 `ShaderMaterial` 负责,保持原有 R+0.48 网格、深度策略和地表拾取对象。四张 8K 底图仍由原控制器切换,成功后更新采样 uniform 并释放旧纹理,失败保留原图。法线、视线和真实太阳方向统一到世界坐标;土地哑光,海洋只有掠射角冷色反射,没有白色镜面高光。海洋区分优先复用现有海陆地理纹理,未加载时使用保守色差估计,冰雪抑制反射;关闭国界线不移除光学遮罩。原始 JPEG 不重绘、不降采样。 颜色纹理标记 sRGB 并启用 mipmaps。r128 在地表 Shader 中解码 sRGB,r160 由纹理硬件格式解码;二者在线性空间合成光照后只编码一次。局部 filmic 曲线只作用于地表,不打开全局 ACES 曝光,避免改变星空和业务图层。晨昏范围为法线点积 [-0.12, 0.28],夜面在线性色调映射后保留 0.12–0.32 光照,避免暗部被曲线再次压黑。大气收敛为 R×1.025 的一个 BackSide/Additive 壳,depthWrite=false、depthTest=true,内沿散射并入地表;云图独立开关,使用哑光昼夜材质并在 1.1 顺序绘制。星图使用已有 `starmap_deep_8k.jpg`,不加染色遮罩。
|
||||
|
||||
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有智能星球内容之后。 |
|
||||
| -1 | 智能星球遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0.5 | 智能星球遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | 智能星球基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset = 0.32`,与海陆填色共用网格及半径 | depthTest=true、depthWrite=false,禁用 raycast | 加载占位及高清材质关闭时使用;填色就绪后按 renderOrder 覆盖。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js`, `cable-batches.js` | 海缆全量线段合并为一个 `LineSegments`;登陆点全量使用实例化 billboard;`renderOrder = 1`、海缆半径偏移仍为 `0.2`;登陆点锚点改为高清地表的 `0.48` | 原始 `Line` / `Sprite` 仅作为逐项拾取和选择状态对象,材质不再单独绘制;登陆点仍按球体遮挡判断背面可见性,批量材质 `depthTest: false` | 样式通过线缆样式纹理和登陆点实例属性同步;保留颜色、脉冲、尺寸、显隐与点击语义。 |
|
||||
| 1 / 1.1 | 大气辉光 / 云图 | `earth.js` | 单层大气 R×1.025;云图 R+3 | 大气 BackSide、depthWrite=false、depthTest=true;保留云图开关 | 内沿光照并入高清地表,不新增全屏后处理。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js`, `cable-batches.js` | 海缆全量线段合并为一个 `LineSegments`;登陆点全量使用实例化 billboard;`renderOrder = 1`、海缆与登陆点统一使用高清地表 `0.48` 偏移和同源三角表面坐标 | 原始 `Line` / `Sprite` 仅作为逐项拾取和选择状态对象,材质不再单独绘制;登陆点仍按球体遮挡判断背面可见性,批量材质 `depthTest: false` | 样式通过线缆样式纹理和登陆点实例属性同步;保留颜色、脉冲、尺寸、显隐与点击语义。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 0.97–0.99 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`;claim 线不再额外抬高 | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制,但半径与高清材质壳完全一致,避免转动地球时与高清贴图出现视差。 |
|
||||
@@ -34,15 +34,14 @@
|
||||
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
|
||||
| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 |
|
||||
| 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 |
|
||||
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..25`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 |
|
||||
| 6 | 卫星点 | `satellites.js` | 与卫星背景点使用同一压缩高度 / fallback 高度 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 6 | 卫星点 | `satellites.js` | SGP4 高度压缩为 R+4..25;回退高度 R+8 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制 完整周期开放惯性轨道与方向流光;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
|
||||
|
||||
## 全量绘制与更新约束
|
||||
|
||||
- 合批只改变 GPU 提交方式,不减少卫星、线段或登陆点数量,也不按相机半球裁剪数据。海缆每对相邻顶点都保留,拾取仍返回原始业务对象。
|
||||
- 卫星普通点与背景点保持两个 `Points` 绘制;呼吸动画由顶点着色器使用静态逐点参数和每帧统一时间计算。hover / locked 的隐藏标记仍由原有选择状态控制。
|
||||
- 卫星普通态为单批硬边圆盘 `Points`,无背景点批次;可选呼吸仍由顶点着色器计算,默认关闭。分类过滤与 hover / locked 的点隐藏保持统一。
|
||||
- `satellite-position-worker.js` 使用与主线程相同的 Three.js / SGP4 版本,通过 `satellite-propagation.js` 共享轨道、显示高度和 fallback 计算;全量位置及初始轨迹以可转移数组交给主线程。主线程维持一个在途计算,不堆积过期帧;重载、清空和高度模式变更会终止旧 Worker。
|
||||
- Worker 启动失败或超过 `SATELLITE_CONFIG.workerStartupTimeoutMs` 时退回共享的同步计算,避免图层无限等待。计数、数据接口、原有更新周期和轨迹长度不变。
|
||||
- 验证时同时检查全量 draw range、逐项拾取、锁定覆盖层、轨迹、背面遮挡、开关及清空后的资源释放;比较帧耗时必须使用相同数据、相同视角和分辨率。
|
||||
@@ -90,3 +89,15 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
|
||||
### 启动加载与绘制顺序
|
||||
|
||||
运行时优先完成选中的完整高清地表,再启动相互独立的数据图层请求;国界构建不再阻塞地表首帧。该启动优化不改变各图层的 renderOrder、深度策略、几何精度或贴图分辨率。数据刷新不清除当前地表,纹理仅在替换成功或场景销毁时释放。
|
||||
|
||||
### 国界加载态菱形格回归防护
|
||||
|
||||
`v0.58.0` 曾将海陆填色抬至 R+0.32、高清贴图抬至 R+0.48,但遗留的占位 tint 仍为 R+0.04、64×32 网格。它与 128×128 主球的弦面交叉,会在海陆/高清纹理未就绪时形成纬圈菱形格;加载后只被上层盖住,并非问题消失。实测旧几何在采样处切入主球约 0.153。
|
||||
|
||||
基座、占位 tint、海陆填色和高清地表统一由 `createEarthSurfaceGeometry()` 使用相同 UV 三角网格。tint 和 land 共用 R+0.32;两者不写深度,靠原 renderOrder 完成填色替换,高清贴图仍为 R+0.48。遮挡球、地形、云图、国界拾取及政治显示策略不变。不能仅靠改 renderOrder、关闭深度测试或隐藏加载球掩盖几何穿插。
|
||||
|
||||
`earth-surface.test.js` 用实际创建的加载态 tint 做径向相交检查,并在 50% / 100% / 500% 下验证至少两个 24-bit 深度单位的前后余量。浏览器验收需覆盖延迟贴图、国界开关、远近距旋转和填色/高清就绪后的状态。
|
||||
|
||||
### 地表标记对齐与侧面显隐
|
||||
|
||||
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。该路径只作用于 `icon.badge` 层(含算力、观测站;船舶独立使用航向箭头),不改变 BGP 变化事件的原深度测试。
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
|
||||
## 数据源采集队列
|
||||
|
||||
队列由 `datasource-task-queue.ts` 按独立 task ID 合并状态。同一数据源再次采集会新增一条任务,旧失败/完成/取消记录保留;仅尚未取得 task ID 的活动占位项允许按数据源绑定。迟到推送不能把终态改回进行中,旧任务轮询也不能覆盖数据源当前的新任务。任务状态按钮使用 `ListChecks`,刷新按钮仍使用 `RefreshCw`。
|
||||
|
||||
Admin 的数据源页把单源触发、表格勾选触发和触发全部统一接入浏览器下载列表式采集队列:
|
||||
|
||||
- 队列状态由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 管理,只保存当前会话中的可见任务,不用 `localStorage` 伪造历史。
|
||||
|
||||
@@ -288,6 +288,10 @@ Base URL 输入框尾端的插头图标会触发轻量连接测试:检查代
|
||||
|
||||
AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军舰、停泊/低速、其他船只。三角形对应航行船只标记,圆点对应停泊或低速状态。
|
||||
|
||||
直接点击图例项可隐藏或恢复对应线路、类别或效果,隐藏项会变暗。各图层独立保留本页筛选状态;切换到另一个物件或启用图层后,对应图例自动出现,无需额外筛选按钮。隐藏已选物件会收起其详情与覆盖;搜索明确选择隐藏物件时,会重新显示其类别。筛选只影响地图显示,不改变统计总数。船型与“停泊/低速”可以同时筛选;国界中的填色控制在基座地图可见时生效。
|
||||
|
||||
卫星在 400% 及以上缩放显示有限数量的真实名称与 NORAD 编号;点径随缩放平滑变化。设置 → 显示 → 显示 FPS 可在品牌右侧查看实时帧率,悬停可查看约 30 秒平均成绩,开关会记住。扇形菜单与缩放子菜单会随界面 DPI 比例适配;鼠标从放大镜经过间隙移到加减按钮时,子菜单保持打开。
|
||||
|
||||
### 搜索
|
||||
|
||||
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
|
||||
@@ -424,3 +428,13 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
当前原型最多同时显示六张内容卡。主卡围绕当前选择的算力指标:大号百分比或带单位的绝对量、观测年份、历史上下文和来源入口。缺少可比数据时明确显示“暂无可比数据”;没有观测值时不绘制空趋势图。设施记录和覆盖信息仍完整保留,但移到展开后的辅助区;样本数只是证据上下文,不能当作算力值或全球覆盖率。区域预览可以显示成员数,长列表仅在需要时展开。桌面卡片约宽 268 像素,高度随内容自适应;展开卡片支持用户调整至最高 480 像素。手机手风琴单卡也保持同一占比/绝对量语义,不把设施条数提升为主数字。位置和尺寸保存于本地,并可恢复自动布局。拖动或展开后先留出阅读时间;地区随地球明显转到另一侧或上下方后,卡片才会在能减少连线交叉、绕行的情况下成组平滑换位。鼠标正在卡片上操作时不移动,手动位置不会被立即抢回,也不会永久阻止必要调整。图片、视频等内容可以沿用内容区布局,当前尚未接入这些媒体数据。空间不足时卡片可以经过球体前景移动,不再靠瞬移避让或缩成小标签。
|
||||
|
||||
地图卡片开关关闭时仍会加载概览摘要,打开后共享地图加载事件;新请求会取消旧请求。抽屉事件使用 `composedPath()`,即使通过 innerHTML 换节点也不会误收起当前卡片。复用共享 HUD 折叠面板动画,并尊重 reduced-motion。设施名单使用最高 240px 的内部滚动区域,名称左对齐并正常换行,每行至少保留 42px 触控高度。
|
||||
|
||||
## 地表与物件视觉
|
||||
|
||||
真实地表保留原有四种底图选择;日夜模式按太阳方向控制明暗,关闭后均匀照亮。海缆选中时沿线显示流动脉冲,登陆点使用琥珀微标;设施在近距离显示芯片图标,低缩放圆点设置继续有效。卫星仍可切换自身发光/真实地表覆盖,显示轨迹并沿既有轨道运动。BGP 告警、船舶方向、关联高亮、详情卡与移动端抽屉继续使用原有操作。
|
||||
|
||||
### 卫星轨道与采集任务
|
||||
|
||||
选中卫星后,预测轨道以开放弧线显示,线上流光表示运行方向;卫星、轨迹与覆盖投影继续同步移动。覆盖区昼夜保持蓝色,粒子和轨道按图例保持固定类别色。拖动从地球移到轮廓外仍可继续旋转,大倍率下灵敏度会减小。电视窗口使用与其他卡片一致的方角。
|
||||
|
||||
再次触发同一数据源会创建新任务,旧失败项留在失败分组。“任务状态”使用清单图标,与刷新操作区分。CelesTrak 在短时间内可复用有效下载缓存;重新采集仍会生成独立任务并更新采集数据。
|
||||
|
||||
@@ -311,6 +311,7 @@ OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600
|
||||
<!-- planet-error-catalog:start -->
|
||||
| 错误编号 | 匹配片段(分号分隔) | 原因/已确认的故障现象 | 处理建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| P_CELESTRAK_NOT_UPDATED | GP data has not updated since your last successful | CelesTrak 拒绝同一更新窗口的重复下载;若日志是 HEAD 200 后 GET 403,预检可能已消耗下载额度,这不表示出口不通。 | 使用不发 HEAD 的采集器版本,保留并校验原始下载缓存;无有效缓存时等待下一次 GP 更新或配置 Space-Track,避免连续重试。 |
|
||||
| P_WINDOWS_LAUNCHER_TARGET_MISSING | P_WINDOWS_LAUNCHER_TARGET_MISSING | 桌面启动器绑定的 WSL 项目目录不可访问或缺少启动脚本,常见于项目移动、发行版不可用或复制旧机器的副本。 | 从当前项目目录运行仓库 planet.cmd 重新生成本机桌面副本,检查 WSL 发行版;不修改成其他电脑的固定路径。 |
|
||||
| P_PROXY_EXTERNAL | PLANET_PROXY_EXTERNAL | 当前 Docker 代理由其他配置管理,或已被人工修改,不能安全自动覆盖。 | 检查 daemon.json、systemd 代理及 Planet 管理记录,确认归属后再调整;脚本保留现有配置。 |
|
||||
| P_AUTH_BOOTSTRAP_MISSING | DEFAULT_LOGIN_USER_MISSING | 已确认当前后端数据库缺少默认账户,常见于跳过初始化的隔离预览。 | 核对数据库归属后运行现有默认账户初始化,不重置已有账户或清空数据库。 |
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;动捕设置可以选择输入源和允许识别的动作;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
- 底图默认使用“原始底图”,并将该选项放在首位;显示菜单可以切换并保存底图源。Natural Earth I 仍保留可用,NASA 21K 只作源归档,运行时同一时间只使用一张 8K 贴图。
|
||||
|
||||
点击物件或开启图层后,可直接点击对应图例项筛选;无需在图层中寻找额外入口。设置 → 显示可开启 FPS,卫星名称在缩放达到 400% 后显示。
|
||||
|
||||
## 5. 找回密码
|
||||
|
||||
忘记密码时打开 `/forgot-password`,输入邮箱并接收验证码,再输入验证码 + 新密码即可。系统对未注册的邮箱也会返回同样的提示(防止账号枚举)。
|
||||
@@ -77,3 +79,7 @@
|
||||
维护卡片数值:管理员打开“智能星球内容 → 算力对比”,先维护报告来源,再填写年度国家观测并“保存并发布”。只录入同年同口径数据,缺失国家不填,设施数量不会替代算力份额。可恢复内置默认值,地球卡片自动检查已发布配置。
|
||||
|
||||
在算力中心旁打开“算力态势分析”,开启“显示区域卡片”,选择地区并旋转地球。关闭配置窗会保留卡片,显示开关才控制启停。桌面卡片可调整大小、收起并查看真实历史图;点击来源或设施进入详情。手机使用底部“算力”页。国家指标与设施样本分开,“—”表示缺数据,不代表算力为零。
|
||||
|
||||
### 查看卫星与重试采集
|
||||
|
||||
在 Earth 启用卫星并选中一个粒子,可查看开放预测轨道、方向流光及受支持的覆盖投影。拖动移出球体仍会继续旋转。数据源采集失败后可重新触发;新任务独立显示,旧失败记录保留在当前任务队列的失败分组。
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.75.2`
|
||||
- `dev` 当前开发分支历史推导到:`0.75.4`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.75.4` | improvement | `dev` | `v0.75.4` | 图例统一筛选、卫星 Points 点径与性能优化、FPS、菜单 DPI/hover、星空 sRGB、地表对齐及新闻编码修复,清理废弃代码文档 |
|
||||
| `0.75.3` | improvement | `dev` | `v0.75.3` | 地球海陆材质与可读夜侧、连续拖拽、现代徽标、卫星平滑开放轨道及流光,修复 CelesTrak 缓存与任务历史并清理废弃实现 |
|
||||
| `0.75.2` | bugfix | `dev` | `v0.75.2` | Windows 启动器优先 Terminal 与 PowerShell 7,修复管理员入口分流,补齐兼容回退与提权测试 |
|
||||
| `0.75.1` | improvement | `dev` | `v0.75.1` | Windows 启动器自动生成本机桌面入口,后台维护算力比较数据,恢复小球惯性与大球跟手分流并更新 agent 策略 |
|
||||
| `0.75.0` | feature | `dev` | `v0.75.0` | 四图切换默认原始底图,新增算力区域态势卡片,统一空间连线与重排,优化启动和缩放拖动并补齐地理规则交付 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.75.2",
|
||||
"version": "0.75.4",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#2dd4bf" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223Z"/>
|
||||
<path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
<path d="M2 9.161V10c0 1.007.875 1.755 1.904 2.223C4.978 12.711 6.427 13 8 13s3.022-.289 4.096-.777C13.125 11.755 14 11.007 14 10v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
<path d="M2 12.161V13c0 1.007.875 1.755 1.904 2.223C4.978 15.711 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972Z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" color="white"><g fill="currentColor"><path d="M64 17L110 40L64 63L18 40Z"/><path d="M18 57L64 80L110 57V72L64 95L18 72Z"/><path d="M18 88L64 111L110 88V101L64 124L18 101Z"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 271 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f8fafc" viewBox="0 0 16 16">
|
||||
<path d="M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h5.5v3A1.5 1.5 0 0 0 6 11.5H.5a.5.5 0 0 0 0 1H6A1.5 1.5 0 0 0 7.5 14h1a1.5 1.5 0 0 0 1.5-1.5h5.5a.5.5 0 0 0 0-1H10A1.5 1.5 0 0 0 8.5 10V7H14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm.5 3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" color="white"><g fill="none" stroke="currentColor" stroke-width="7" stroke-linejoin="round"><rect x="27" y="14" width="74" height="100" rx="9"/><path d="M27 47H101M27 80H101M61 31H87M61 64H87M61 97H87"/></g><g fill="currentColor"><circle cx="43" cy="31" r="4"/><circle cx="43" cy="64" r="4"/><circle cx="43" cy="97" r="4"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 421 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" class="bi bi-buildings-fill" viewBox="0 0 16 16">
|
||||
<path d="M15 .5a.5.5 0 0 0-.724-.447l-8 4A.5.5 0 0 0 6 4.5v3.14L.342 9.526A.5.5 0 0 0 0 10v5.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V14h1v1.5a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5V.5ZM2 11h1v1H2v-1Zm2 0h1v1H4v-1Zm-1 2v1H2v-1h1Zm1 0h1v1H4v-1Zm9-10v1h-1V3h1ZM8 5h1v1H8V5Zm1 2v1H8V7h1ZM8 9h1v1H8V9Zm2 0h1v1h-1V9Zm-1 2v1H8v-1h1Zm1 0h1v1h-1v-1Zm3-2v1h-1V9h1Zm-1 2h1v1h-1v-1Zm-2-4h1v1h-1V7Zm3 0v1h-1V7h1Zm-2-2v1h-1V5h1Zm1 0h1v1h-1V5Z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" color="white"><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><rect x="27" y="27" width="74" height="74" rx="9"/><rect x="46" y="46" width="36" height="36" rx="3"/><path d="M45 9V27M64 9V27M83 9V27M45 101V119M64 101V119M83 101V119M9 45H27M9 64H27M9 83H27M101 45H119M101 64H119M101 83H119"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 562 B After Width: | Height: | Size: 418 B |
@@ -1,10 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 50 600 680">
|
||||
<!-- Location pin with circular cutout. Coordinates in 1000-unit space (canvas scale: size/1000). -->
|
||||
<!-- States: color via fill attribute — default white for 3D canvas, orange (#ffaa00) for normal, white for hover/locked -->
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
fill="currentColor"
|
||||
d="M400 704 C386 704 375 697 367 684 L173 378 C117 290 144 173 229 111 C278 75 337 57 400 57 C463 57 522 75 571 111 C656 173 683 290 627 378 L433 684 C425 697 414 704 400 704 Z
|
||||
M400 320 m-86 0 a86 86 0 1 0 172 0 a86 86 0 1 0 -172 0"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" color="white">
|
||||
<circle cx="64" cy="64" r="34" fill="currentColor"/>
|
||||
<circle cx="64" cy="64" r="34" fill="none" stroke="currentColor" stroke-width="7"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 250 B |
@@ -25,10 +25,10 @@
|
||||
--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);
|
||||
--hud-surface-top: rgba(10, 23, 37, 0.92);
|
||||
--hud-surface-bottom: rgba(5, 13, 24, 0.88);
|
||||
--hud-surface-overlay: rgba(157, 204, 255, 0.07);
|
||||
--hud-border: rgba(201, 225, 247, 0.14);
|
||||
--hud-border: rgba(124, 190, 218, 0.18);
|
||||
--hud-border-hover: rgba(226, 238, 250, 0.24);
|
||||
--hud-border-active: rgba(235, 244, 255, 0.32);
|
||||
--hud-shadow: 0 18px 46px rgba(1, 7, 16, 0.34);
|
||||
@@ -38,7 +38,7 @@
|
||||
--hud-title: #dbe8f5;
|
||||
--hud-text: #eef4fb;
|
||||
--hud-text-muted: #8ea3ba;
|
||||
--hud-text-soft: #6f849b;
|
||||
--hud-text-soft: #a0b5c7;
|
||||
--hud-accent: #91baff;
|
||||
--hud-accent-strong: #d7e8ff;
|
||||
--glass-fill-top: rgba(255, 255, 255, 0.08);
|
||||
@@ -111,14 +111,3 @@ body.earth-page.is-globe-dragging * {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatDock {
|
||||
0%,
|
||||
100% {
|
||||
--float-offset: 0px;
|
||||
}
|
||||
|
||||
50% {
|
||||
--float-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border-radius: 0;
|
||||
background:
|
||||
radial-gradient(circle at var(--panel-glow-x) var(--panel-glow-y), rgba(255, 255, 255, calc(0.08 + var(--panel-glow-opacity))), transparent 30%),
|
||||
radial-gradient(circle at var(--panel-glow-x) var(--panel-glow-y), rgba(91, 190, 230, calc(0.025 + var(--panel-glow-opacity) * 0.25)), transparent 45%),
|
||||
radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 26%),
|
||||
linear-gradient(180deg, rgba(145, 211, 240, 0.025), transparent 26%),
|
||||
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom));
|
||||
border: 1px solid var(--hud-border);
|
||||
box-shadow:
|
||||
@@ -21,8 +22,8 @@
|
||||
var(--hud-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 0 20px rgba(123, 176, 236, calc(0.04 + var(--panel-glow-opacity) * 0.32));
|
||||
backdrop-filter: blur(18px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(125%);
|
||||
backdrop-filter: blur(12px) saturate(115%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(115%);
|
||||
transition:
|
||||
background 0.22s ease,
|
||||
border-color 0.22s ease,
|
||||
@@ -36,7 +37,7 @@
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.02) 58%, transparent 100%);
|
||||
opacity: 0.52;
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
perspective(240px)
|
||||
@@ -83,7 +84,7 @@
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::before {
|
||||
opacity: 0.6;
|
||||
opacity: 0.23;
|
||||
}
|
||||
|
||||
.hud-panel:hover:not(.is-dragging)::after {
|
||||
@@ -2566,8 +2567,8 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
.earth-tooltip {
|
||||
position: absolute;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(21, 37, 61, 0.96), rgba(8, 18, 31, 0.95));
|
||||
border: 1px solid rgba(214, 230, 247, 0.14);
|
||||
linear-gradient(180deg, rgba(10, 27, 43, 0.97), rgba(5, 15, 28, 0.96));
|
||||
border: 1px solid rgba(90, 196, 228, 0.32);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.78rem;
|
||||
@@ -2577,6 +2578,16 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
display: none;
|
||||
user-select: none;
|
||||
animation: earth-tooltip-reveal 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes earth-tooltip-reveal {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.earth-tooltip { animation: none; }
|
||||
}
|
||||
|
||||
.earth-search-modal {
|
||||
@@ -4099,3 +4110,39 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
/* .hud-panel-stats layout-expanded rule lives in earth-stats.css */
|
||||
/* .hud-panel-layers layout-expanded rule lives in layer-panel.css */
|
||||
/* .hud-panel-media layout-expanded rule lives in tv-panel.css */
|
||||
|
||||
.earth-fps {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
left: calc(var(--hud-offset, 14px) + 282px * var(--hud-scale, 1));
|
||||
z-index: 250;
|
||||
padding: 5px 10px;
|
||||
color: #6ee7b7;
|
||||
background: rgba(7, 17, 30, 0.88);
|
||||
border: 1px solid rgba(110, 231, 183, 0.2);
|
||||
font: 12px ui-monospace, monospace;
|
||||
}
|
||||
.earth-fps[hidden] { display: none; }
|
||||
|
||||
.legend-filter {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legend-filter[aria-pressed="false"] { opacity: 0.35; }
|
||||
.legend-filter:focus-visible { outline: 1px solid #7dd3fc; outline-offset: -1px; }
|
||||
|
||||
.satellite-labels { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 6; contain: layout style paint; }
|
||||
.satellite-labels[hidden], .satellite-map-label[hidden] { display: none; }
|
||||
.satellite-map-label {
|
||||
position: absolute; top: 0; left: 0; width: max-content; max-width: 130px;
|
||||
text-align: center; color: #f1f5f9; font: 600 11px/13px system-ui, sans-serif;
|
||||
will-change: transform;
|
||||
text-shadow: 0 1px 2px #020617, 1px 0 2px #020617, -1px 0 2px #020617;
|
||||
}
|
||||
.satellite-map-label span, .satellite-map-label small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.satellite-map-label small { font-size:10px; font-weight:400; color:#cbd5e1; }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.earth-toolbar-group {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
bottom: calc(18px * var(--hud-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
@@ -24,24 +24,14 @@
|
||||
}
|
||||
|
||||
.earth-toolbar {
|
||||
--toolbar-scale: var(--initial-toolbar-scale, 1);
|
||||
--toolbar-orb-size: var(--initial-toolbar-orb-size, calc(46px * var(--toolbar-scale)));
|
||||
--toolbar-hub-size: var(--initial-toolbar-hub-size, calc(58px * var(--toolbar-scale)));
|
||||
--toolbar-arc-width: var(--initial-toolbar-arc-width, calc(420px * var(--toolbar-scale)));
|
||||
--toolbar-arc-height: var(--initial-toolbar-arc-height, calc(160px * var(--toolbar-scale)));
|
||||
--toolbar-inner-arc-width: var(--initial-toolbar-inner-arc-width, calc(260px * var(--toolbar-scale)));
|
||||
--toolbar-inner-arc-height: var(--initial-toolbar-inner-arc-height, calc(56px * var(--toolbar-scale)));
|
||||
--toolbar-light-rgb: 91, 186, 255;
|
||||
--toolbar-light-intensity: 0.8;
|
||||
--toolbar-light-size-ratio: 0.85;
|
||||
--toolbar-glass-opacity: 0.07;
|
||||
--toolbar-glass-blur: calc(16px * var(--toolbar-scale));
|
||||
--toolbar-light-blur: calc(24px * var(--toolbar-scale));
|
||||
--toolbar-outer-glow-blur: calc(16px * var(--toolbar-scale));
|
||||
--toolbar-active-light-scale: 1.15;
|
||||
--toolbar-scale: var(--hud-scale);
|
||||
--toolbar-orb-size: calc(40px * var(--toolbar-scale));
|
||||
--toolbar-hub-size: calc(54px * var(--toolbar-scale));
|
||||
--toolbar-radius-x: calc(156px * var(--toolbar-scale));
|
||||
--toolbar-radius-y: calc(88px * var(--toolbar-scale));
|
||||
position: relative;
|
||||
width: min(620px, calc(100vw - 40px));
|
||||
height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale)));
|
||||
width: min(calc(620px * var(--toolbar-scale)), calc(100vw - 40px * var(--toolbar-scale)));
|
||||
height: calc(var(--toolbar-radius-y) + var(--toolbar-hub-size) + 24px * var(--toolbar-scale));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -63,34 +53,35 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(14px * var(--toolbar-scale));
|
||||
width: var(--toolbar-arc-width);
|
||||
height: var(--toolbar-arc-height);
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--toolbar-hub-size) / 2);
|
||||
width: calc(var(--toolbar-radius-x) * 2 + 24px * var(--toolbar-scale));
|
||||
height: calc(var(--toolbar-radius-y) * 2 + 24px * var(--toolbar-scale));
|
||||
transform: translate(-50%, 50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(145, 186, 255, 0.08);
|
||||
border-bottom-color: transparent;
|
||||
background:
|
||||
radial-gradient(circle at 50% 100%, rgba(145, 186, 255, 0.05), transparent 58%);
|
||||
opacity: 0.9;
|
||||
mask: linear-gradient(180deg, rgba(0, 0, 0, 0.82), transparent 86%);
|
||||
border: 1px dashed rgba(56, 189, 248, 0.25);
|
||||
mask-image: linear-gradient(to top, transparent 40%, black 75%);
|
||||
-webkit-mask-image: linear-gradient(to top, transparent 40%, black 75%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(30px * var(--toolbar-scale));
|
||||
bottom: calc((var(--toolbar-hub-size) - var(--toolbar-orb-size)) / 2);
|
||||
transform: translate(-50%, -50%);
|
||||
--toolbar-button-size: var(--toolbar-orb-size);
|
||||
}
|
||||
|
||||
.earth-toolbar-hub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(8px * var(--toolbar-scale));
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
--toolbar-button-size: var(--toolbar-hub-size);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb,
|
||||
@@ -102,16 +93,18 @@
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform 0.36s cubic-bezier(0.34, 1.15, 0.64, 1),
|
||||
opacity 0.24s ease;
|
||||
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
opacity 0.25s ease;
|
||||
transition-delay: var(--orb-close-delay, 0ms);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-orb {
|
||||
transform: translate(calc(-50% + var(--orb-x)), calc(-50% + var(--orb-y)));
|
||||
transform: translate(calc(-50% + var(--orb-x) * var(--toolbar-scale)), calc(var(--orb-y) * var(--toolbar-scale)));
|
||||
transition-delay: var(--orb-open-delay, 0ms);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-collapsed .earth-toolbar-orb {
|
||||
transform: translate(-50%, -50%) scale(0.42);
|
||||
transform: translate(-50%, 0) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -120,71 +113,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb::before,
|
||||
.earth-toolbar-hub::before,
|
||||
.earth-toolbar-orb::after,
|
||||
.earth-toolbar-hub::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
transition:
|
||||
opacity 0.55s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.55s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
filter 0.55s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb::before,
|
||||
.earth-toolbar-hub::before {
|
||||
width: calc(var(--toolbar-button-size) * var(--toolbar-light-size-ratio));
|
||||
height: calc(var(--toolbar-button-size) * var(--toolbar-light-size-ratio));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(var(--toolbar-light-rgb), var(--toolbar-light-intensity)) 0%,
|
||||
rgba(var(--toolbar-light-rgb), 0.28) 28%,
|
||||
rgba(var(--toolbar-light-rgb), 0) 72%
|
||||
);
|
||||
filter: blur(var(--toolbar-light-blur));
|
||||
opacity: 0.12;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb::after,
|
||||
.earth-toolbar-hub::after {
|
||||
width: calc(var(--toolbar-button-size) + (24px * var(--toolbar-scale)));
|
||||
height: calc(var(--toolbar-button-size) + (24px * var(--toolbar-scale)));
|
||||
background:
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(var(--toolbar-light-rgb), calc(var(--toolbar-light-intensity) * 0.42)) 0%,
|
||||
rgba(var(--toolbar-light-rgb), 0) 72%
|
||||
);
|
||||
filter: blur(var(--toolbar-outer-glow-blur));
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb:hover::before,
|
||||
.earth-toolbar-orb:focus-within::before,
|
||||
.earth-toolbar-orb:has(> .liquid-glass-surface.active)::before,
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub::before {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(var(--toolbar-active-light-scale));
|
||||
filter: blur(calc(var(--toolbar-light-blur) * 0.85));
|
||||
}
|
||||
|
||||
.earth-toolbar-orb:hover::after,
|
||||
.earth-toolbar-orb:focus-within::after,
|
||||
.earth-toolbar-orb:has(> .liquid-glass-surface.active)::after,
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub::after {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
}
|
||||
|
||||
.earth-toolbar-orb:has(#layer-action) {
|
||||
display: none;
|
||||
}
|
||||
@@ -201,15 +129,6 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation: floatDock 4.6s ease-in-out infinite;
|
||||
animation-delay: var(--orb-delay, 0s);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-dock-engaged .earth-toolbar-orb > .liquid-glass-surface {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn,
|
||||
.earth-toolbar-hub-btn {
|
||||
position: relative;
|
||||
@@ -236,8 +155,8 @@
|
||||
min-width: var(--toolbar-orb-size);
|
||||
min-height: var(--toolbar-orb-size);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn {
|
||||
@@ -246,8 +165,8 @@
|
||||
min-width: var(--toolbar-hub-size);
|
||||
min-height: var(--toolbar-hub-size);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
overflow: visible;
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
@@ -264,8 +183,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(243, 244, 246, 0.9);
|
||||
text-shadow: 0 1.5px 3px rgba(0, 0, 0, 0.5);
|
||||
color: inherit;
|
||||
text-shadow: none;
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 0.3s ease,
|
||||
@@ -304,234 +223,63 @@
|
||||
}
|
||||
|
||||
.liquid-glass-surface {
|
||||
--toolbar-panel-base: linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom));
|
||||
--elastic-x: 0px;
|
||||
--elastic-y: 0px;
|
||||
--tilt-x: 0deg;
|
||||
--tilt-y: 0deg;
|
||||
--btn-scale: 1;
|
||||
--press-offset: 0px;
|
||||
--float-offset: 0px;
|
||||
--mouse-x: 0.5;
|
||||
--mouse-y: 0.5;
|
||||
--toolbar-atmosphere-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.25);
|
||||
--toolbar-atmosphere-hover: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.55);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
outline: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.16) 0%,
|
||||
rgba(255, 255, 255, 0.02) 40%,
|
||||
rgba(15, 23, 42, 0.35) 75%,
|
||||
rgba(3, 7, 18, 0.85) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 8px 24px -4px rgba(0, 0, 0, 0.65),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.15),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.45),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.03),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
|
||||
transform:
|
||||
translate3d(
|
||||
var(--elastic-x),
|
||||
calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)),
|
||||
0
|
||||
)
|
||||
scale(var(--btn-scale));
|
||||
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(10, 20, 40, 0.72);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
color: #94a3b8;
|
||||
transform: scale(var(--btn-scale));
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 0.18s ease;
|
||||
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 32%;
|
||||
border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.38) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
filter: blur(0.4px);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * 5px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * 3px)
|
||||
);
|
||||
transform-origin: top center;
|
||||
transition: transform 0.25s ease-out;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -10%;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 35%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at bottom,
|
||||
var(--toolbar-atmosphere-color) 0%,
|
||||
rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.02) 70%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
filter: blur(1px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * -3px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * -2px)
|
||||
);
|
||||
transition:
|
||||
transform 0.25s ease-out,
|
||||
opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.18) 0%,
|
||||
rgba(255, 255, 255, 0.025) 42%,
|
||||
rgba(15, 23, 42, 0.32) 74%,
|
||||
rgba(3, 7, 18, 0.82) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 9px 26px -4px rgba(0, 0, 0, 0.68),
|
||||
inset 0 0 1.8px 1.3px rgba(255, 255, 255, 0.17),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.5),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.05;
|
||||
--press-offset: -3px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.04) 40%,
|
||||
rgba(15, 23, 42, 0.25) 75%,
|
||||
rgba(3, 7, 18, 0.8) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 14px 28px -6px rgba(0, 0, 0, 0.8),
|
||||
0 0 15px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.8px 1.2px rgba(255, 255, 255, 0.22),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.65),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover .icon,
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface:hover > .material-symbols-rounded,
|
||||
.earth-zoom-btn.liquid-glass-surface:hover > span {
|
||||
color: #ffffff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: -1px;
|
||||
transition: transform 0.1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow:
|
||||
0 5px 12px -3px rgba(0, 0, 0, 0.9),
|
||||
0 0 8px -2px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.18),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 4px rgba(255, 255, 255, 0.01);
|
||||
.liquid-glass-surface:hover,
|
||||
.liquid-glass-surface:focus-visible {
|
||||
--btn-scale: 1.15;
|
||||
color: #38bdf8;
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 13px 28px -6px rgba(0, 0, 0, 0.78),
|
||||
0 0 16px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.9px 1.25px rgba(255, 255, 255, 0.24),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.66),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.11);
|
||||
color: #38bdf8;
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active:hover {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.28) 0%,
|
||||
rgba(255, 255, 255, 0.055) 40%,
|
||||
rgba(15, 23, 42, 0.22) 75%,
|
||||
rgba(3, 7, 18, 0.76) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 15px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 18px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.27),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.7),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.12);
|
||||
.liquid-glass-surface:focus-visible {
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
),
|
||||
var(--toolbar-panel-base);
|
||||
box-shadow:
|
||||
0 14px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 17px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.26),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.68),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.12);
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background: radial-gradient(circle at 35% 25%, rgba(56, 189, 248, 0.3), rgba(8, 18, 38, 0.85) 75%);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(56, 189, 248, 0.45);
|
||||
box-shadow: 0 0 25px rgba(56, 189, 248, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||
color: #e0f2fe;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface:hover,
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface:focus-visible {
|
||||
--btn-scale: 1.08;
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 35px rgba(56, 189, 248, 0.45), inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn > .material-symbols-rounded {
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub-btn > .material-symbols-rounded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.earth-rotate-toggle .icon-play,
|
||||
@@ -546,8 +294,6 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.earth-zoom-group:hover > .earth-zoom-toolbar,
|
||||
.earth-zoom-group:focus-within > .earth-zoom-toolbar,
|
||||
.earth-zoom-group.open > .earth-zoom-toolbar {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@@ -555,13 +301,6 @@
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.earth-zoom-group.force-closed > .earth-zoom-toolbar {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(8px * var(--toolbar-scale)));
|
||||
}
|
||||
|
||||
.earth-toolbar-popover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -569,8 +308,13 @@
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(56px * var(--toolbar-scale));
|
||||
height: calc(16px * var(--toolbar-scale));
|
||||
height: calc(22px * var(--toolbar-scale));
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover.open::before {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.earth-toolbar-popover > .earth-stack-toolbar {
|
||||
@@ -588,14 +332,15 @@
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.22s ease,
|
||||
transform 0.22s ease,
|
||||
visibility 0.22s ease;
|
||||
opacity var(--zoom-menu-duration, 220ms) ease,
|
||||
transform var(--zoom-menu-duration, 220ms) ease,
|
||||
visibility var(--zoom-menu-duration, 220ms) ease;
|
||||
z-index: 220;
|
||||
}
|
||||
|
||||
.earth-zoom-toolbar .earth-zoom-btn,
|
||||
.earth-zoom-toolbar .earth-zoom-value {
|
||||
cursor: pointer;
|
||||
width: calc(42px * var(--toolbar-scale));
|
||||
min-width: calc(42px * var(--toolbar-scale));
|
||||
border-radius: 50%;
|
||||
@@ -621,45 +366,16 @@
|
||||
}
|
||||
|
||||
.earth-app.layout-expanded .earth-toolbar-group {
|
||||
bottom: 18px;
|
||||
bottom: calc(18px * var(--hud-scale));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(56px * var(--toolbar-scale));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 31, 52, 0.96), rgba(8, 18, 32, 0.95));
|
||||
color: var(--hud-text);
|
||||
padding: calc(6px * var(--toolbar-scale)) calc(12px * var(--toolbar-scale));
|
||||
border-radius: calc(6px * var(--toolbar-scale));
|
||||
font-size: calc(12px * var(--toolbar-scale));
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--hud-border);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: var(--hud-shadow-soft);
|
||||
}
|
||||
|
||||
.earth-toolbar-btn:hover .earth-toolbar-tooltip,
|
||||
.earth-toolbar-popover:hover > .earth-toolbar-btn .earth-toolbar-tooltip,
|
||||
.earth-toolbar-popover:focus-within > .earth-toolbar-btn .earth-toolbar-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: calc(58px * var(--toolbar-scale));
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .earth-toolbar-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: calc(6px * var(--toolbar-scale)) solid transparent;
|
||||
border-top-color: rgba(18, 31, 52, 0.96);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.earth-toolbar-orb,
|
||||
.earth-toolbar-cluster::before,
|
||||
.liquid-glass-surface,
|
||||
.earth-toolbar-hub-btn > .material-symbols-rounded {
|
||||
transition-duration: 0.01ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(220px * var(--hud-scale));
|
||||
border-radius: calc(16px * var(--hud-scale));
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201, 225, 247, 0.1);
|
||||
background:
|
||||
|
||||
@@ -49,86 +49,6 @@
|
||||
document.documentElement.style.setProperty("--hud-scale", clampedScale.toFixed(3));
|
||||
})();
|
||||
|
||||
(function applyInitialToolbarLayout() {
|
||||
var width = window.innerWidth;
|
||||
var height = window.innerHeight;
|
||||
var toolbarBaseWidth = 620;
|
||||
var toolbarMinScale = 0.68;
|
||||
var orbSizeBase = 46;
|
||||
var hubSizeBase = 58;
|
||||
var orbGapBase = 12;
|
||||
var archSpanBase = 232;
|
||||
var archRiseBase = 40;
|
||||
var sidePaddingBase = 12;
|
||||
var bottomClearanceBase = 34;
|
||||
var extraHeightBase = 34;
|
||||
var visibleOrbCount = 8;
|
||||
var toolbarWidth = Math.min(toolbarBaseWidth, Math.max(0, width - 40));
|
||||
var viewportScale = Math.min(width / 1920, height / 1080);
|
||||
var toolbarScale = Math.max(
|
||||
toolbarMinScale,
|
||||
Math.min(1, Math.min(toolbarWidth / toolbarBaseWidth, viewportScale)),
|
||||
);
|
||||
var orbSize = orbSizeBase * toolbarScale;
|
||||
var desiredGap = orbGapBase * toolbarScale;
|
||||
var span = archSpanBase * toolbarScale;
|
||||
var rise = archRiseBase * toolbarScale;
|
||||
var minSpanForSpacing =
|
||||
visibleOrbCount > 1
|
||||
? (visibleOrbCount - 1) * (orbSize + desiredGap)
|
||||
: orbSize;
|
||||
var maxSpanByWidth =
|
||||
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
|
||||
|
||||
if (minSpanForSpacing > maxSpanByWidth) {
|
||||
toolbarScale = Math.max(
|
||||
toolbarMinScale,
|
||||
Math.min(toolbarScale, maxSpanByWidth / minSpanForSpacing),
|
||||
);
|
||||
orbSize = orbSizeBase * toolbarScale;
|
||||
desiredGap = orbGapBase * toolbarScale;
|
||||
span = archSpanBase * toolbarScale;
|
||||
rise = archRiseBase * toolbarScale;
|
||||
minSpanForSpacing =
|
||||
visibleOrbCount > 1
|
||||
? (visibleOrbCount - 1) * (orbSize + desiredGap)
|
||||
: orbSize;
|
||||
maxSpanByWidth =
|
||||
toolbarWidth - orbSize - sidePaddingBase * 2 * toolbarScale;
|
||||
}
|
||||
|
||||
span = Math.max(minSpanForSpacing, Math.min(maxSpanByWidth, span));
|
||||
rise = Math.min(rise, span * 0.32);
|
||||
|
||||
var hubSize = hubSizeBase * toolbarScale;
|
||||
var maxVerticalReach = rise + orbSize * 0.5;
|
||||
var toolbarHeight =
|
||||
maxVerticalReach +
|
||||
hubSize +
|
||||
bottomClearanceBase * toolbarScale +
|
||||
extraHeightBase * toolbarScale;
|
||||
var rootStyle = document.documentElement.style;
|
||||
var roundedOrbSize = Math.round(orbSize);
|
||||
var roundedHubSize = Math.round(hubSize);
|
||||
|
||||
rootStyle.setProperty("--initial-toolbar-scale", toolbarScale.toFixed(3));
|
||||
rootStyle.setProperty("--initial-toolbar-orb-size", roundedOrbSize + "px");
|
||||
rootStyle.setProperty("--initial-toolbar-hub-size", roundedHubSize + "px");
|
||||
rootStyle.setProperty("--initial-toolbar-height", Math.ceil(toolbarHeight) + "px");
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-arc-width",
|
||||
Math.ceil(span + orbSize + sidePaddingBase * 2 * toolbarScale) + "px",
|
||||
);
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-arc-height",
|
||||
Math.ceil(rise + orbSize * 0.95) + "px",
|
||||
);
|
||||
rootStyle.setProperty("--initial-toolbar-inner-arc-width", Math.ceil(span * 0.72) + "px");
|
||||
rootStyle.setProperty(
|
||||
"--initial-toolbar-inner-arc-height",
|
||||
Math.ceil(hubSize * 0.8 + desiredGap * 0.5) + "px",
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/hud.css">
|
||||
@@ -152,6 +72,7 @@
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<output id="earth-fps" class="earth-fps" aria-label="渲染帧率" hidden>— FPS</output>
|
||||
<div id="container" class="earth-app">
|
||||
<div id="left-column" class="earth-left-column">
|
||||
<div id="brand-panel" class="hud-panel hud-panel-brand">
|
||||
@@ -339,23 +260,21 @@
|
||||
<div id="right-toolbar-group" class="earth-toolbar-group">
|
||||
<div id="control-toolbar" class="earth-toolbar">
|
||||
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0" style="--orb-delay: 0s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="0">
|
||||
<button id="layer-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="图层">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">layers</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">图层</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="1" style="--orb-delay: 0.12s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="1">
|
||||
<button id="search-action" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="搜索">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">search</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">搜索</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="2" style="--orb-delay: 0.24s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="2">
|
||||
<button id="rotate-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-rotate-toggle" title="自动旋转">
|
||||
<span class="icon rotate-icon icon-pause" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">pause</span>
|
||||
@@ -363,23 +282,20 @@
|
||||
<span class="icon rotate-icon icon-play" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">play_arrow</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="4">
|
||||
<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>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重新加载数据</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5" style="--orb-delay: 0.72s;">
|
||||
<div class="earth-toolbar-orb earth-toolbar-popover earth-zoom-group" id="zoom-control-group" data-orb-index="5">
|
||||
<button id="zoom-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="缩放控制">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">zoom_in</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">缩放控制</span>
|
||||
</button>
|
||||
<div id="zoom-toolbar" class="earth-stack-toolbar earth-zoom-toolbar">
|
||||
<button id="zoom-in" class="liquid-glass-surface earth-zoom-btn" title="放大" aria-label="放大"><span aria-hidden="true">+</span></button>
|
||||
@@ -387,23 +303,21 @@
|
||||
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true">−</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="6" style="--orb-delay: 0.9s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="6">
|
||||
<button id="settings-trigger" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="设置">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">settings</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="7" style="--orb-delay: 1.08s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="7">
|
||||
<button id="reset-view" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重置视角">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">my_location</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">重置视角</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-orb" data-orb-index="8" style="--orb-delay: 1.26s;">
|
||||
<div class="earth-toolbar-orb" data-orb-index="8">
|
||||
<button id="layout-toggle" class="floating-btn liquid-glass-surface earth-toolbar-btn earth-layout-toggle" title="最大化布局">
|
||||
<span class="icon layout-icon layout-expand" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">open_in_full</span>
|
||||
@@ -411,7 +325,6 @@
|
||||
<span class="icon layout-icon layout-collapse" aria-hidden="true">
|
||||
<span class="material-symbols-rounded">close_fullscreen</span>
|
||||
</span>
|
||||
<span class="tooltip earth-toolbar-tooltip">最大化布局</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-toolbar-hub">
|
||||
@@ -954,6 +867,16 @@
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-mobile-settings-title">显示</div>
|
||||
<label class="earth-mobile-settings-card" for="mobile-toggle-fps">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">显示 FPS</span>
|
||||
<span class="earth-mobile-settings-subtitle">显示实际渲染帧率,悬停查看帧耗时</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input id="mobile-toggle-fps" type="checkbox" data-fps-toggle>
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="earth-mobile-settings-subsection-title">地球与地表</div>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
@@ -1361,6 +1284,7 @@
|
||||
<section class="earth-settings-section" data-settings-tab-panel="runtime">
|
||||
<div class="earth-settings-section-title">运行</div>
|
||||
<div class="earth-settings-list">
|
||||
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">旋转模式</span>
|
||||
@@ -1490,6 +1414,16 @@
|
||||
<section class="earth-settings-section" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-settings-section-title">显示</div>
|
||||
<div class="earth-settings-list">
|
||||
<label class="earth-settings-item" for="toggle-fps">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">显示 FPS</span>
|
||||
<span class="earth-settings-item-subtitle">显示实际渲染帧率,悬停查看帧耗时</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-fps" type="checkbox" data-fps-toggle>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="earth-settings-subsection-title">地球与地表</div>
|
||||
<label class="earth-settings-item" for="toggle-daynight">
|
||||
<div class="earth-settings-copy">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectorLegendKey, isLegendItemEnabled } from "./legend-filters.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||
@@ -20,13 +21,11 @@ let totalAnomalyCount = 0;
|
||||
let totalIncidentCount = 0;
|
||||
let textureCache = null;
|
||||
let eventRingTextureCache = null;
|
||||
const eventTextureCache = new Map();
|
||||
let activeEventOverlay = null;
|
||||
let activeCollectorOverlayContext = null;
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
|
||||
numeric: "auto",
|
||||
});
|
||||
const collectorWorldPosition = new THREE.Vector3();
|
||||
const colorScratchA = new THREE.Color();
|
||||
const colorScratchB = new THREE.Color();
|
||||
const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
|
||||
@@ -37,7 +36,7 @@ const BGP_EVENT_SYMBOL_SIZE = 60;
|
||||
const BGP_COLLECTOR_RENDER_ORDER = 4.4;
|
||||
const BGP_COLLECTOR_ALTITUDE_OFFSET = BGP_CONFIG.collectorAltitudeOffset;
|
||||
const BGP_COLLECTOR_POINT_SIZE = 36;
|
||||
const BGP_COLLECTOR_ICON_FIT_SIZE = 60;
|
||||
const BGP_COLLECTOR_ICON_FIT_SIZE = 88;
|
||||
const BGP_COLLECTOR_ICON_SOURCE = new URL("../assets/icons/bgp-broadcast-pin.svg", import.meta.url).href;
|
||||
const BGP_COLLECTOR_HOVER_SCALE = 1.08;
|
||||
const BGP_COLLECTOR_LOCKED_SCALE = 1.12;
|
||||
@@ -177,45 +176,6 @@ function drawDotSymbol(context) {
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function getEventTexture(anomalyType) {
|
||||
const kind = getEventSymbolKind(anomalyType);
|
||||
if (eventTextureCache.has(kind)) return eventTextureCache.get(kind);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
const fallback = new THREE.Texture(canvas);
|
||||
eventTextureCache.set(kind, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 128, 128);
|
||||
context.fillStyle = "rgba(255,255,255,0.96)";
|
||||
context.strokeStyle = "rgba(255,255,255,0.96)";
|
||||
context.shadowBlur = 0;
|
||||
context.lineJoin = "round";
|
||||
|
||||
if (kind === "triangle") {
|
||||
drawTriangleSymbol(context);
|
||||
} else if (kind === "exclamation") {
|
||||
drawExclamationSymbol(context);
|
||||
} else if (kind === "wave") {
|
||||
drawWaveSymbol(context);
|
||||
} else if (kind === "burst") {
|
||||
drawBurstSymbol(context);
|
||||
} else if (kind === "leak") {
|
||||
drawLeakSymbol(context);
|
||||
} else {
|
||||
drawDotSymbol(context);
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
eventTextureCache.set(kind, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function normalizeSeverity(severity) {
|
||||
const value = String(severity || "").trim().toLowerCase();
|
||||
|
||||
@@ -283,6 +243,7 @@ function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false })
|
||||
|
||||
const bgpEventIconLayer = createInteractableLayer({
|
||||
id: "bgp-events",
|
||||
isItemVisible: item => isLegendItemEnabled("bgp", `event:${normalizeSeverity(item.severity)}`),
|
||||
objectType: "bgp",
|
||||
renderOrder: BGP_EVENT_RENDER_ORDER,
|
||||
altitudeOffset: BGP_CONFIG.altitudeOffset,
|
||||
@@ -340,6 +301,7 @@ const bgpEventIconLayer = createInteractableLayer({
|
||||
|
||||
const bgpCollectorIconLayer = createInteractableLayer({
|
||||
id: "bgp-collectors",
|
||||
isItemVisible: item => isLegendItemEnabled("bgp", collectorLegendKey(getCollectorActivityProfile(item).tier)),
|
||||
objectType: "bgp_collector",
|
||||
renderOrder: BGP_COLLECTOR_RENDER_ORDER,
|
||||
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
|
||||
@@ -370,6 +332,10 @@ const bgpCollectorIconLayer = createInteractableLayer({
|
||||
amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE,
|
||||
},
|
||||
icon: {
|
||||
badge: ({ marker, item }) => ({
|
||||
color: colorNumberHex(getCollectorActivityProfile(marker?.userData || item || {}).color),
|
||||
}),
|
||||
colorable: false,
|
||||
coordinates: "canvas",
|
||||
fitSize: BGP_COLLECTOR_ICON_FIT_SIZE,
|
||||
glowBlur: 16,
|
||||
@@ -992,36 +958,6 @@ function createCoverageBoundaryLine(points, color, opacity = 0.3) {
|
||||
return new THREE.Line(geometry, material);
|
||||
}
|
||||
|
||||
function createCoverageSector(points, color, opacity = 0.12) {
|
||||
const center = points[0];
|
||||
const positions = [];
|
||||
|
||||
for (let index = 1; index < points.length - 1; index += 1) {
|
||||
const current = points[index];
|
||||
const next = points[index + 1];
|
||||
positions.push(
|
||||
center.x, center.y, center.z,
|
||||
current.x, current.y, current.z,
|
||||
next.x, next.y, next.z,
|
||||
);
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
return new THREE.Mesh(geometry, material);
|
||||
}
|
||||
|
||||
function createCoverageSectorMesh(
|
||||
anchorLatitude,
|
||||
anchorLongitude,
|
||||
@@ -1207,7 +1143,7 @@ function createEventRingSprite(marker) {
|
||||
);
|
||||
ring.position.copy(marker.position);
|
||||
ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05;
|
||||
ring.visible = showBGP;
|
||||
ring.visible = showBGP && !marker.userData.legend_filtered;
|
||||
return ring;
|
||||
}
|
||||
|
||||
@@ -1444,6 +1380,7 @@ export async function loadBGPAnomalies(scene, earth) {
|
||||
export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cruiseMarker = null) {
|
||||
const now = performance.now();
|
||||
updateCollectorOverlayScan(lockedObjectType, lockedObject);
|
||||
syncBGPLegendEffects();
|
||||
const hasLockedLayer = Boolean(
|
||||
lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType),
|
||||
);
|
||||
@@ -1621,7 +1558,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
|
||||
ring.scale.setScalar(desiredWorldScale);
|
||||
ring.material.color.setHex(markerColor);
|
||||
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
|
||||
ring.visible = showBGP;
|
||||
ring.visible = showBGP && !marker.userData.legend_filtered;
|
||||
ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05;
|
||||
};
|
||||
|
||||
@@ -1692,7 +1629,7 @@ export function toggleBGP(show) {
|
||||
});
|
||||
});
|
||||
anomalyMarkers.forEach((marker) => {
|
||||
marker.userData.ringA && (marker.userData.ringA.visible = showBGP);
|
||||
marker.userData.ringA && (marker.userData.ringA.visible = showBGP && !marker.userData.legend_filtered);
|
||||
marker.userData.ringB && (marker.userData.ringB.visible = false);
|
||||
});
|
||||
}
|
||||
@@ -1978,15 +1915,34 @@ function updateCollectorOverlayScan(lockedObjectType, lockedObject) {
|
||||
|
||||
export function getBGPLegendItems() {
|
||||
return [
|
||||
{ color: "#6db7ff", label: "静态观测站" },
|
||||
{ color: "#fbbf24", label: "中活跃观测站" },
|
||||
{ color: "#ff5f57", label: "高活跃观测站" },
|
||||
{ color: "#6db7ff", label: "观测范围示意" },
|
||||
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
|
||||
{ color: "#2dd4bf", label: "影响区域" },
|
||||
{ color: "#ff4d4f", label: "严重事件" },
|
||||
{ color: "#ff9f43", label: "高危事件" },
|
||||
{ color: "#ffd166", label: "中危事件" },
|
||||
{ color: "#4dabf7", label: "低危事件" },
|
||||
{ color: "#6db7ff", label: "静态观测站", key: "collector:idle" },
|
||||
{ color: "#fbbf24", label: "中活跃观测站", key: "collector:medium" },
|
||||
{ color: "#ff5f57", label: "高活跃观测站", key: "collector:high" },
|
||||
{ color: "#6db7ff", label: "观测范围示意", key: "coverage" },
|
||||
{ color: "#2dd4bf", label: "影响区域", key: "regions" },
|
||||
{ color: "#ff4d4f", label: "严重事件", key: "event:critical" },
|
||||
{ color: "#ff9f43", label: "高危事件", key: "event:high" },
|
||||
{ color: "#ffd166", label: "中危事件", key: "event:medium" },
|
||||
{ color: "#4dabf7", label: "低危事件", key: "event:low" },
|
||||
];
|
||||
}
|
||||
|
||||
function syncBGPLegendEffects() {
|
||||
bgpEventOverlayGroup.visible = isLegendItemEnabled("bgp", "regions");
|
||||
bgpCollectorRadarGroup.visible = isLegendItemEnabled("bgp", "coverage");
|
||||
collectorMarkers.forEach(marker => {
|
||||
for (const key of ["heatHalo", "pulseHalo", "statusCore", "coverageHalo"]) {
|
||||
const effect = marker.userData[key];
|
||||
if (effect) effect.visible = showBGP && !marker.userData.legend_filtered
|
||||
&& (key !== "coverageHalo" || isLegendItemEnabled("bgp", "coverage"));
|
||||
}
|
||||
});
|
||||
}
|
||||
export function applyBGPLegendFilter() {
|
||||
bgpEventIconLayer.refreshFilter();
|
||||
bgpCollectorIconLayer.refreshFilter();
|
||||
syncBGPLegendEffects();
|
||||
anomalyMarkers.forEach(marker => {
|
||||
if (marker.userData.ringA) marker.userData.ringA.visible = showBGP && !marker.userData.legend_filtered;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import { CABLE_CONFIG } from "./constants.js";
|
||||
|
||||
const CABLE_STYLE_TEXTURE_MAX_WIDTH = 1024;
|
||||
|
||||
@@ -24,6 +25,7 @@ export function createCableLineBatch(lines) {
|
||||
count + Math.max(0, line.geometry.attributes.position.count - 1) * 2, 0);
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const styleIndices = new Float32Array(vertexCount);
|
||||
const progress = new Float32Array(vertexCount);
|
||||
const width = Math.min(lines.length, CABLE_STYLE_TEXTURE_MAX_WIDTH);
|
||||
const height = Math.ceil(lines.length / width);
|
||||
const styles = new Float32Array(width * height * 4);
|
||||
@@ -32,12 +34,19 @@ export function createCableLineBatch(lines) {
|
||||
let cursor = 0;
|
||||
lines.forEach((line, index) => {
|
||||
const source = line.geometry.attributes.position;
|
||||
const distances = new Float32Array(source.count);
|
||||
for (let i = 1; i < source.count; i++) {
|
||||
distances[i] = distances[i - 1] + Math.hypot(source.getX(i) - source.getX(i - 1),
|
||||
source.getY(i) - source.getY(i - 1), source.getZ(i) - source.getZ(i - 1));
|
||||
}
|
||||
const totalDistance = distances[source.count - 1] || 1;
|
||||
for (let segment = 0; segment < source.count - 1; segment += 1) {
|
||||
for (const endpoint of [segment, segment + 1]) {
|
||||
positions[cursor * 3] = source.getX(endpoint);
|
||||
positions[cursor * 3 + 1] = source.getY(endpoint);
|
||||
positions[cursor * 3 + 2] = source.getZ(endpoint);
|
||||
styleIndices[cursor] = index;
|
||||
progress[cursor] = distances[endpoint] / totalDistance;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
@@ -48,30 +57,44 @@ export function createCableLineBatch(lines) {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("styleIndex", new THREE.BufferAttribute(styleIndices, 1));
|
||||
geometry.setAttribute("progress", new THREE.BufferAttribute(progress, 1));
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
styles: { value: styleTexture },
|
||||
styleSize: { value: new THREE.Vector2(width, height) },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: true,
|
||||
depthWrite: false,
|
||||
vertexShader: `
|
||||
uniform sampler2D styles;
|
||||
uniform vec2 styleSize;
|
||||
attribute float styleIndex;
|
||||
attribute float progress;
|
||||
varying vec4 vStyle;
|
||||
varying float vProgress;
|
||||
void main() {
|
||||
vec2 uv = (vec2(mod(styleIndex, styleSize.x), floor(styleIndex / styleSize.x)) + 0.5) / styleSize;
|
||||
vStyle = texture2D(styles, uv);
|
||||
vProgress = progress;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec4 vStyle;
|
||||
varying float vProgress;
|
||||
uniform float uTime;
|
||||
void main() {
|
||||
if (vStyle.a <= 0.0) discard;
|
||||
gl_FragColor = vStyle;
|
||||
float alpha = abs(vStyle.a);
|
||||
if (alpha <= 0.0) discard;
|
||||
vec3 color = vStyle.rgb;
|
||||
if (vStyle.a < 0.0) {
|
||||
float pulse = smoothstep(0.6, 1.0, fract(vProgress * ${CABLE_CONFIG.visual.pulseRepeats.toFixed(1)} - uTime * ${CABLE_CONFIG.visual.pulseSpeed.toFixed(1)}));
|
||||
color = mix(vec3(0.3, 0.8, 1.0), vec3(0.7, 1.0, 1.0), pulse);
|
||||
alpha = max(alpha, 0.8 + pulse * 0.2);
|
||||
}
|
||||
gl_FragColor = vec4(color, alpha);
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -80,11 +103,12 @@ export function createCableLineBatch(lines) {
|
||||
object.name = "cable-line-batch";
|
||||
object.renderOrder = lines[0].renderOrder;
|
||||
object.onBeforeRender = () => {
|
||||
material.uniforms.uTime.value = performance.now() * 0.001;
|
||||
let changed = false;
|
||||
lines.forEach((line, index) => {
|
||||
const { color, opacity } = line.material;
|
||||
const offset = index * 4;
|
||||
const alpha = line.visible ? opacity : 0;
|
||||
const alpha = line.visible ? opacity * (line.material.userData.cableSelected ? -1 : 1) : 0;
|
||||
if (styles[offset] !== Math.fround(color.r) || styles[offset + 1] !== Math.fround(color.g)
|
||||
|| styles[offset + 2] !== Math.fround(color.b) || styles[offset + 3] !== Math.fround(alpha)) {
|
||||
styles[offset] = color.r;
|
||||
@@ -111,6 +135,7 @@ export function createLandingPointBatch(markers, texture) {
|
||||
.setUsage(THREE.DynamicDrawUsage);
|
||||
const styles = new THREE.InstancedBufferAttribute(new Float32Array(markers.length * 4), 4)
|
||||
.setUsage(THREE.DynamicDrawUsage);
|
||||
const emphasis = new THREE.InstancedBufferAttribute(new Float32Array(markers.length), 1).setUsage(THREE.DynamicDrawUsage);
|
||||
markers.forEach((marker, index) => {
|
||||
marker.position.toArray(centers, index * 3);
|
||||
marker.material.visible = false;
|
||||
@@ -118,6 +143,7 @@ export function createLandingPointBatch(markers, texture) {
|
||||
geometry.setAttribute("center", new THREE.InstancedBufferAttribute(centers, 3));
|
||||
geometry.setAttribute("size", sizes);
|
||||
geometry.setAttribute("style", styles);
|
||||
geometry.setAttribute("emphasis", emphasis);
|
||||
geometry.instanceCount = markers.length;
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: { map: { value: texture } },
|
||||
@@ -128,23 +154,45 @@ export function createLandingPointBatch(markers, texture) {
|
||||
attribute vec3 center;
|
||||
attribute vec2 size;
|
||||
attribute vec4 style;
|
||||
attribute float emphasis;
|
||||
varying float vEmphasis;
|
||||
varying float vHaloScale;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vEmphasis = emphasis;
|
||||
vHaloScale = emphasis > 0.0 ? ${CABLE_CONFIG.landingPointVisual.related.haloScale.toFixed(3)} : 1.0;
|
||||
vStyle = style;
|
||||
vec2 scale = vec2(length(modelMatrix[0].xyz), length(modelMatrix[1].xyz));
|
||||
vec4 viewPosition = modelViewMatrix * vec4(center, 1.0);
|
||||
viewPosition.xy += position.xy * size * scale;
|
||||
viewPosition.xy += position.xy * size * scale * vHaloScale;
|
||||
gl_Position = style.a > 0.0 ? projectionMatrix * viewPosition : vec4(2.0, 2.0, 2.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D map;
|
||||
varying float vEmphasis;
|
||||
varying float vHaloScale;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
vec4 color = texture2D(map, vUv) * vStyle;
|
||||
// Expand only the halo canvas; remap UVs so the orange core and
|
||||
// yellow ring retain exactly the same size and color on selection.
|
||||
vec2 coreUv = (vUv - 0.5) * vHaloScale + 0.5;
|
||||
vec4 color = texture2D(map, coreUv) * vStyle;
|
||||
float radius = distance(coreUv, vec2(0.5));
|
||||
float border = smoothstep(0.242, 0.254, radius) * (1.0 - smoothstep(0.283, 0.296, radius));
|
||||
float brightness = max(vStyle.r, max(vStyle.g, vStyle.b));
|
||||
color.rgb = mix(color.rgb, vec3(1.0, 0.86, 0.3) * brightness, border);
|
||||
// The normal yellow core stays unchanged; only related points glow.
|
||||
float halo = exp(-radius * radius * ${CABLE_CONFIG.landingPointVisual.related.haloFalloff.toFixed(1)})
|
||||
* smoothstep(0.29, 0.32, radius)
|
||||
* (1.0 - smoothstep(0.7, 0.82, radius)) * vEmphasis;
|
||||
float haloAlpha = halo * vStyle.a;
|
||||
float alpha = color.a + haloAlpha * (1.0 - color.a);
|
||||
color.rgb = (color.rgb * color.a + vec3(1.0, 0.63, 0.08) * haloAlpha * (1.0 - color.a)) / max(alpha, 0.001);
|
||||
color.a = alpha;
|
||||
if (color.a < 0.01) discard;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
@@ -156,9 +204,16 @@ export function createLandingPointBatch(markers, texture) {
|
||||
object.onBeforeRender = () => {
|
||||
let sizeChanged = false;
|
||||
let styleChanged = false;
|
||||
let emphasisChanged = false;
|
||||
const pulse = 0.8 + 0.2 * Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed);
|
||||
markers.forEach((marker, index) => {
|
||||
const { color, opacity } = marker.material;
|
||||
const alpha = marker.visible ? opacity : 0;
|
||||
const glow = marker.visible && marker.userData.landing_visual_state === "related" ? pulse : 0;
|
||||
if (emphasis.getX(index) !== Math.fround(glow)) {
|
||||
emphasis.setX(index, glow);
|
||||
emphasisChanged = true;
|
||||
}
|
||||
if (sizes.getX(index) !== Math.fround(marker.scale.x) || sizes.getY(index) !== Math.fround(marker.scale.y)) {
|
||||
sizes.setXY(index, marker.scale.x, marker.scale.y);
|
||||
sizeChanged = true;
|
||||
@@ -171,6 +226,7 @@ export function createLandingPointBatch(markers, texture) {
|
||||
});
|
||||
if (sizeChanged) sizes.needsUpdate = true;
|
||||
if (styleChanged) styles.needsUpdate = true;
|
||||
if (emphasisChanged) emphasis.needsUpdate = true;
|
||||
};
|
||||
return ownBatch(object);
|
||||
}
|
||||
|
||||
71
frontend/public/earth/js/cable-surface.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CABLE_CONFIG, CONFIG } from "./constants.js";
|
||||
import { earthSurfacePoint } from "./earth-surface.js";
|
||||
|
||||
export function cableSurfacePoint(latitude, longitude) {
|
||||
return earthSurfacePoint(latitude, longitude, CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset);
|
||||
}
|
||||
|
||||
export function calculateGreatCirclePoints(
|
||||
lat1,
|
||||
lon1,
|
||||
lat2,
|
||||
lon2,
|
||||
radius,
|
||||
segments = 50,
|
||||
) {
|
||||
const points = [];
|
||||
const phi1 = (lat1 * Math.PI) / 180;
|
||||
const lambda1 = (lon1 * Math.PI) / 180;
|
||||
const phi2 = (lat2 * Math.PI) / 180;
|
||||
const lambda2 = (lon2 * Math.PI) / 180;
|
||||
|
||||
const dLambda = Math.min(
|
||||
Math.abs(lambda2 - lambda1),
|
||||
2 * Math.PI - Math.abs(lambda2 - lambda1),
|
||||
);
|
||||
const cosDelta =
|
||||
Math.sin(phi1) * Math.sin(phi2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
|
||||
|
||||
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
|
||||
|
||||
if (delta < CABLE_CONFIG.line.nearPointThreshold) {
|
||||
const p1 = earthSurfacePoint(lat1, lon1, radius);
|
||||
const p2 = earthSurfacePoint(lat2, lon2, radius);
|
||||
return [p1, p2];
|
||||
}
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const sinDelta = Math.sin(delta);
|
||||
const A = Math.sin((1 - t) * delta) / sinDelta;
|
||||
const B = Math.sin(t * delta) / sinDelta;
|
||||
|
||||
const x1 = Math.cos(phi1) * Math.cos(lambda1);
|
||||
const y1 = Math.cos(phi1) * Math.sin(lambda1);
|
||||
const z1 = Math.sin(phi1);
|
||||
|
||||
const x2 = Math.cos(phi2) * Math.cos(lambda2);
|
||||
const y2 = Math.cos(phi2) * Math.sin(lambda2);
|
||||
const z2 = Math.sin(phi2);
|
||||
|
||||
let x = A * x1 + B * x2;
|
||||
let y = A * y1 + B * y2;
|
||||
let z = A * z1 + B * z2;
|
||||
|
||||
const norm = Math.sqrt(x * x + y * y + z * z);
|
||||
x = (x / norm) * radius;
|
||||
y = (y / norm) * radius;
|
||||
z = (z / norm) * radius;
|
||||
|
||||
const lat = (Math.asin(z / radius) * 180) / Math.PI;
|
||||
let lon = (Math.atan2(y, x) * 180) / Math.PI;
|
||||
|
||||
if (lon > 180) lon -= 360;
|
||||
if (lon < -180) lon += 360;
|
||||
|
||||
points.push(earthSurfacePoint(lat, lon, radius));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
70
frontend/public/earth/js/cable-surface.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { CABLE_CONFIG, CONFIG } from "./constants.js";
|
||||
import { cableSurfacePoint, calculateGreatCirclePoints } from "./cable-surface.js";
|
||||
import { createLandingPointBatch } from "./cable-batches.js";
|
||||
|
||||
test("landing anchors and cable endpoints share the exact surface in every view", () => {
|
||||
expect(CABLE_CONFIG.line.altitudeOffset).toBe(CABLE_CONFIG.landingPoint.altitudeOffset);
|
||||
expect(CABLE_CONFIG.line.renderOrder).toBe(CABLE_CONFIG.landingPoint.renderOrder);
|
||||
for (const [lat1, lon1, lat2, lon2] of [[43, 145, 35, 140], [35, 179.9, 36, -179.9], [51, 2, 51.0001, 2.0001]]) {
|
||||
const points = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, CONFIG.earthRadius + CABLE_CONFIG.line.altitudeOffset);
|
||||
for (const [point, lat, lon] of [[points[0], lat1, lon1], [points.at(-1), lat2, lon2]]) {
|
||||
const anchor = cableSurfacePoint(lat, lon);
|
||||
expect(point.distanceTo(anchor)).toBeLessThan(1e-6);
|
||||
const transform = new THREE.Matrix4().makeRotationY(1.4);
|
||||
expect(point.clone().applyMatrix4(transform).distanceTo(anchor.clone().applyMatrix4(transform))).toBeLessThan(1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("only related landing points get a glow and clearing selection removes it", () => {
|
||||
const texture = new THREE.Texture();
|
||||
const markers = ["normal", "related", "dimmed"].map(state => {
|
||||
const marker = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xf59e0b }));
|
||||
marker.userData.landing_visual_state = state;
|
||||
return marker;
|
||||
});
|
||||
const batch = createLandingPointBatch(markers, texture);
|
||||
batch.object.onBeforeRender();
|
||||
const emphasis = batch.object.geometry.attributes.emphasis;
|
||||
expect(emphasis.getX(0)).toBe(0);
|
||||
expect(emphasis.getX(1)).toBeGreaterThan(0.5);
|
||||
expect(emphasis.getX(2)).toBe(0);
|
||||
markers[1].userData.landing_visual_state = "normal";
|
||||
batch.object.onBeforeRender();
|
||||
expect(Array.from(emphasis.array)).toEqual([0, 0, 0]);
|
||||
markers[1].userData.landing_visual_state = "related";
|
||||
markers[1].visible = false;
|
||||
batch.object.onBeforeRender();
|
||||
expect(emphasis.getX(1)).toBe(0);
|
||||
batch.dispose();texture.dispose();markers.forEach(marker=>marker.material.dispose());
|
||||
});
|
||||
|
||||
test("selecting a cable adds halo without whitening or resizing its landing cores", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = { addEventListener() {}, removeEventListener() {} };
|
||||
const { landingPoints, applyLandingPointVisualState, resetLandingPointVisualState } = await import("./cables.js");
|
||||
const original = landingPoints.slice();
|
||||
const marker = new THREE.Sprite(new THREE.SpriteMaterial());
|
||||
marker.userData = { cableNames: ["selected-cable"] };
|
||||
landingPoints.push(marker);
|
||||
try {
|
||||
resetLandingPointVisualState();
|
||||
const color = marker.material.color.getHex();
|
||||
const opacity = marker.material.opacity;
|
||||
const size = marker.scale.clone();
|
||||
applyLandingPointVisualState("selected-cable");
|
||||
expect(marker.userData.landing_visual_state).toBe("related");
|
||||
expect(marker.material.color.getHex()).toBe(color);
|
||||
expect(marker.material.opacity).toBe(opacity);
|
||||
expect(marker.scale.equals(size)).toBe(true);
|
||||
resetLandingPointVisualState();
|
||||
expect(marker.userData.landing_visual_state).toBe("normal");
|
||||
expect(marker.material.color.getHex()).toBe(color);
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
landingPoints.splice(0, landingPoints.length, ...original);
|
||||
marker.material.dispose();
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { isLegendItemEnabled } from "./legend-filters.js";
|
||||
// cables.js - Cable loading and rendering module
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createCableLineBatch, createLandingPointBatch } from "./cable-batches.js";
|
||||
import { earthSurfacePoint } from "./earth-surface.js";
|
||||
import { cableSurfacePoint, calculateGreatCirclePoints } from "./cable-surface.js";
|
||||
import { configureColorTexture } from "./texture-colors.js";
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
CABLE_STATE,
|
||||
CABLE_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
|
||||
import { getSurfaceMarkerCameraScale } from "./utils.js";
|
||||
import { setEarthStatValue, updateEarthStats, showStatusMessage } from "./ui.js";
|
||||
import { setLegendItems, setLegendMode } from "./legend.js";
|
||||
import { earthMessage } from "./i18n.js";
|
||||
@@ -36,15 +38,8 @@ const LANDING_POINT_SPRITE_ASPECT = 1;
|
||||
const LANDING_POINT_SIZE_REFERENCE_FOV = 75;
|
||||
const LANDING_POINT_SIZE_SCALE_MIN = 0.16;
|
||||
const LANDING_POINT_SIZE_SCALE_MAX = 3;
|
||||
const LANDING_POINT_ATLAS_CELL_SIZE = 128;
|
||||
let landingPointTexture = null;
|
||||
|
||||
function getLandingPointPulse() {
|
||||
return (
|
||||
Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1
|
||||
) * 0.5;
|
||||
}
|
||||
|
||||
function getLandingPointDimColor() {
|
||||
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
|
||||
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
|
||||
@@ -56,57 +51,14 @@ function getLandingPointDimColor() {
|
||||
return `#${color.getHexString()}`;
|
||||
}
|
||||
|
||||
function createLandingPointBallTexture() {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = LANDING_POINT_ATLAS_CELL_SIZE;
|
||||
canvas.height = LANDING_POINT_ATLAS_CELL_SIZE;
|
||||
const context = canvas.getContext("2d");
|
||||
const center = LANDING_POINT_ATLAS_CELL_SIZE / 2;
|
||||
const radius = 46;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const shadow = context.createRadialGradient(
|
||||
center - 16,
|
||||
center - 18,
|
||||
8,
|
||||
center,
|
||||
center,
|
||||
radius,
|
||||
);
|
||||
shadow.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
shadow.addColorStop(0.48, "rgba(238, 238, 238, 0.98)");
|
||||
shadow.addColorStop(0.82, "rgba(178, 178, 178, 0.94)");
|
||||
shadow.addColorStop(1, "rgba(92, 92, 92, 0.88)");
|
||||
|
||||
context.beginPath();
|
||||
context.arc(center, center, radius, 0, Math.PI * 2);
|
||||
context.fillStyle = shadow;
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.ellipse(center - 14, center - 18, 14, 9, -0.45, 0, Math.PI * 2);
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.38)";
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.arc(center, center, radius - 1, 0, Math.PI * 2);
|
||||
context.strokeStyle = "rgba(255, 255, 255, 0.24)";
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
async function getLandingPointTexture() {
|
||||
if (landingPointTexture) return landingPointTexture;
|
||||
landingPointTexture = createLandingPointBallTexture();
|
||||
const source = new URL("../assets/icons/marker-landing-point.svg", import.meta.url).href;
|
||||
landingPointTexture = await new THREE.TextureLoader().loadAsync(source);
|
||||
configureColorTexture(landingPointTexture);
|
||||
landingPointTexture.generateMipmaps = false;
|
||||
landingPointTexture.minFilter = THREE.LinearFilter;
|
||||
landingPointTexture.magFilter = THREE.LinearFilter;
|
||||
return landingPointTexture;
|
||||
}
|
||||
|
||||
@@ -120,8 +72,8 @@ function getLandingPointDistanceScale(camera) {
|
||||
});
|
||||
}
|
||||
|
||||
function setLandingPointScale(point, camera = null) {
|
||||
const height = LANDING_POINT_SPRITE_HEIGHT * getLandingPointDistanceScale(camera);
|
||||
function setLandingPointScale(point, camera = null, distanceScale = getLandingPointDistanceScale(camera)) {
|
||||
const height = LANDING_POINT_SPRITE_HEIGHT * distanceScale;
|
||||
point.scale.set(height * LANDING_POINT_SPRITE_ASPECT, height, 1);
|
||||
}
|
||||
|
||||
@@ -192,6 +144,8 @@ function getCableColor(properties) {
|
||||
|
||||
function createCableLine(points, color, properties) {
|
||||
if (points.length < 2) return null;
|
||||
color = new THREE.Color(CABLE_CONFIG.visual.normalTint)
|
||||
.lerp(new THREE.Color(color), CABLE_CONFIG.visual.sourceColorWeight).getHex();
|
||||
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
lineGeometry.computeBoundingSphere();
|
||||
@@ -231,6 +185,8 @@ function createCableLine(points, color, properties) {
|
||||
lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(),
|
||||
};
|
||||
cableLine.renderOrder = CABLE_CONFIG.line.renderOrder;
|
||||
cableLine.visible = cablesVisible && isLegendItemEnabled("cables", cableLine.userData.name);
|
||||
cableLine.userData.legend_filtered = !isLegendItemEnabled("cables", cableLine.userData.name);
|
||||
|
||||
if (!cableIdMap.has(cableId)) {
|
||||
cableIdMap.set(cableId, []);
|
||||
@@ -240,71 +196,6 @@ function createCableLine(points, color, properties) {
|
||||
return cableLine;
|
||||
}
|
||||
|
||||
function calculateGreatCirclePoints(
|
||||
lat1,
|
||||
lon1,
|
||||
lat2,
|
||||
lon2,
|
||||
radius,
|
||||
segments = 50,
|
||||
) {
|
||||
const points = [];
|
||||
const phi1 = (lat1 * Math.PI) / 180;
|
||||
const lambda1 = (lon1 * Math.PI) / 180;
|
||||
const phi2 = (lat2 * Math.PI) / 180;
|
||||
const lambda2 = (lon2 * Math.PI) / 180;
|
||||
|
||||
const dLambda = Math.min(
|
||||
Math.abs(lambda2 - lambda1),
|
||||
2 * Math.PI - Math.abs(lambda2 - lambda1),
|
||||
);
|
||||
const cosDelta =
|
||||
Math.sin(phi1) * Math.sin(phi2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
|
||||
|
||||
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
|
||||
|
||||
if (delta < CABLE_CONFIG.line.nearPointThreshold) {
|
||||
const p1 = latLonToVector3(lat1, lon1, radius);
|
||||
const p2 = latLonToVector3(lat2, lon2, radius);
|
||||
return [p1, p2];
|
||||
}
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const sinDelta = Math.sin(delta);
|
||||
const A = Math.sin((1 - t) * delta) / sinDelta;
|
||||
const B = Math.sin(t * delta) / sinDelta;
|
||||
|
||||
const x1 = Math.cos(phi1) * Math.cos(lambda1);
|
||||
const y1 = Math.cos(phi1) * Math.sin(lambda1);
|
||||
const z1 = Math.sin(phi1);
|
||||
|
||||
const x2 = Math.cos(phi2) * Math.cos(lambda2);
|
||||
const y2 = Math.cos(phi2) * Math.sin(lambda2);
|
||||
const z2 = Math.sin(phi2);
|
||||
|
||||
let x = A * x1 + B * x2;
|
||||
let y = A * y1 + B * y2;
|
||||
let z = A * z1 + B * z2;
|
||||
|
||||
const norm = Math.sqrt(x * x + y * y + z * z);
|
||||
x = (x / norm) * radius;
|
||||
y = (y / norm) * radius;
|
||||
z = (z / norm) * radius;
|
||||
|
||||
const lat = (Math.asin(z / radius) * 180) / Math.PI;
|
||||
let lon = (Math.atan2(y, x) * 180) / Math.PI;
|
||||
|
||||
if (lon > 180) lon -= 360;
|
||||
if (lon < -180) lon += 360;
|
||||
|
||||
points.push(latLonToVector3(lat, lon, radius));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function clearCableLines(earthObj = null) {
|
||||
cableLineBatch?.dispose();
|
||||
cableLineBatch = null;
|
||||
@@ -476,11 +367,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = earthSurfacePoint(
|
||||
lat,
|
||||
lon,
|
||||
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset,
|
||||
);
|
||||
const position = cableSurfacePoint(lat, lon);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
@@ -527,6 +414,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
}
|
||||
const validCount = landingPoints.length;
|
||||
|
||||
applyCableLegendFilter();
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
|
||||
if (!silent) {
|
||||
@@ -636,9 +524,10 @@ export function getAllLandingPoints() {
|
||||
}
|
||||
|
||||
function isFacingCamera(lp, camera) {
|
||||
lp.getWorldPosition(_lpWorldPos);
|
||||
_lpWorldPos.copy(lp.position);
|
||||
if (lp.parent) {
|
||||
lp.parent.getWorldPosition(_lpEarthWorldPos);
|
||||
_lpWorldPos.applyMatrix4(lp.parent.matrixWorld);
|
||||
_lpEarthWorldPos.setFromMatrixPosition(lp.parent.matrixWorld);
|
||||
} else {
|
||||
_lpEarthWorldPos.set(0, 0, 0);
|
||||
}
|
||||
@@ -670,35 +559,34 @@ function isFacingCamera(lp, camera) {
|
||||
}
|
||||
|
||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
|
||||
// Parent transforms and view-dependent sizes are shared by the whole batch.
|
||||
landingPoints[0]?.parent?.updateWorldMatrix(true, false);
|
||||
const distanceScale = getLandingPointDistanceScale(camera);
|
||||
const dimColor = getLandingPointDimColor();
|
||||
const relatedNames = Array.isArray(lockedCableName)
|
||||
? lockedCableName.filter(Boolean)
|
||||
: lockedCableName
|
||||
? [lockedCableName]
|
||||
: [];
|
||||
landingPoints.forEach((lp) => {
|
||||
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isVisible = cablesVisible && isLandingPointLegendVisible(lp) && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isRelated =
|
||||
!dimAll &&
|
||||
Array.isArray(lp.userData.cableNames) &&
|
||||
lp.userData.cableNames.some((name) => relatedNames.includes(name));
|
||||
|
||||
lp.visible = isVisible;
|
||||
setLandingPointScale(lp, camera);
|
||||
setLandingPointScale(lp, camera, distanceScale);
|
||||
|
||||
if (isRelated) {
|
||||
const pulse = getLandingPointPulse();
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: 0xffd27a,
|
||||
opacity: Math.max(
|
||||
0.92,
|
||||
CABLE_CONFIG.landingPointVisual.related.opacityBase +
|
||||
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
|
||||
),
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
});
|
||||
lp.userData.landing_visual_state = "related";
|
||||
} else {
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: getLandingPointDimColor(),
|
||||
color: dimColor,
|
||||
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
|
||||
});
|
||||
lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden";
|
||||
@@ -707,10 +595,12 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false, ca
|
||||
}
|
||||
|
||||
export function resetLandingPointVisualState(camera = null) {
|
||||
landingPoints[0]?.parent?.updateWorldMatrix(true, false);
|
||||
const distanceScale = getLandingPointDistanceScale(camera);
|
||||
landingPoints.forEach((lp) => {
|
||||
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
|
||||
const isVisible = cablesVisible && isLandingPointLegendVisible(lp) && (camera ? isFacingCamera(lp, camera) : true);
|
||||
lp.visible = isVisible;
|
||||
setLandingPointScale(lp, camera);
|
||||
setLandingPointScale(lp, camera, distanceScale);
|
||||
setLandingPointMaterialState(lp, {
|
||||
color: CABLE_CONFIG.landingPoint.color,
|
||||
opacity: CABLE_CONFIG.landingPoint.opacity,
|
||||
@@ -724,13 +614,23 @@ export function toggleCables(show) {
|
||||
if (cableLineBatch) cableLineBatch.object.visible = show;
|
||||
if (landingPointBatch) landingPointBatch.object.visible = show;
|
||||
cableLines.forEach((cable) => {
|
||||
cable.visible = cablesVisible;
|
||||
cable.visible = cablesVisible && isLegendItemEnabled("cables", cable.userData?.name || "未知线缆");
|
||||
});
|
||||
landingPoints.forEach((lp) => {
|
||||
lp.visible = cablesVisible;
|
||||
lp.visible = cablesVisible && isLandingPointLegendVisible(lp);
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowCables() {
|
||||
return cablesVisible;
|
||||
}
|
||||
|
||||
function isLandingPointLegendVisible(marker) {
|
||||
const names = marker.userData?.cableNames;
|
||||
return !names?.length || names.some(name => isLegendItemEnabled("cables", name));
|
||||
}
|
||||
export function applyCableLegendFilter() {
|
||||
toggleCables(cablesVisible);
|
||||
cableLines.forEach(line => { line.userData.legend_filtered = !line.visible; });
|
||||
landingPoints.forEach(marker => { marker.userData.legend_filtered = !isLandingPointLegendVisible(marker); });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SCENE_LIGHT_CONFIG,
|
||||
} from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { configureColorTexture, preserveTextureOutput } from "./texture-colors.js";
|
||||
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
|
||||
@@ -142,15 +143,6 @@ function applyCelestialOrientation(direction) {
|
||||
.normalize();
|
||||
}
|
||||
|
||||
function configureTextureEncoding(texture) {
|
||||
if (!texture) return;
|
||||
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
} else if ("encoding" in texture && THREE.sRGBEncoding) {
|
||||
texture.encoding = THREE.sRGBEncoding;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscTexture(stops, size = 256) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
@@ -170,7 +162,7 @@ function createDiscTexture(stops, size = 256) {
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
configureTextureEncoding(texture);
|
||||
configureColorTexture(texture);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
@@ -199,15 +191,23 @@ function createSkySphere() {
|
||||
64,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
color: new THREE.Color().setScalar(CELESTIAL_CONFIG.skyBrightness),
|
||||
side: THREE.BackSide,
|
||||
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
|
||||
opacity: CELESTIAL_CONFIG.skyOpacity,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
toneMapped: false,
|
||||
fog: false,
|
||||
});
|
||||
|
||||
// r128 decodes the sRGB map but this scene keeps linear renderer output.
|
||||
// Encode only the sky once, preserving the other layers' color pipelines.
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.fragmentShader = preserveTextureOutput(shader.fragmentShader);
|
||||
};
|
||||
material.customProgramCacheKey = () => "celestial-sky-srgb-output-v1";
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = "celestial-sky-sphere";
|
||||
mesh.renderOrder = -1000;
|
||||
@@ -216,7 +216,7 @@ function createSkySphere() {
|
||||
textureLoader.load(
|
||||
CELESTIAL_CONFIG.starMapUrl,
|
||||
(texture) => {
|
||||
configureTextureEncoding(texture);
|
||||
configureColorTexture(texture);
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isLegendItemEnabled } from "./legend-filters.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { computeStableComputeCenterPositions } from "./compute-center-positioning.js";
|
||||
@@ -10,7 +11,7 @@ import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||
const COMPUTE_CENTER_POINT_SIZE = 36;
|
||||
const COMPUTE_CENTER_ICON_FIT_SIZE = 60;
|
||||
const COMPUTE_CENTER_ICON_FIT_SIZE = 100;
|
||||
const COMPUTE_CENTER_ATLAS_CELL_SIZE = 128;
|
||||
const COMPUTE_CENTER_ICON_SOURCES = {
|
||||
supercomputer: new URL("../assets/icons/compute-supercomputer.svg", import.meta.url).href,
|
||||
@@ -165,6 +166,7 @@ function normalizeSiteType(siteType) {
|
||||
|
||||
const computeCenterIconLayer = createInteractableLayer({
|
||||
id: "computeCenters",
|
||||
isItemVisible: item => isLegendItemEnabled("computeCenters", item.site_type === "supercomputer" ? "supercomputer" : "gpu_cluster"),
|
||||
objectType: "compute_center",
|
||||
renderOrder: COMPUTE_CENTER_RENDER_ORDER,
|
||||
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
|
||||
@@ -191,6 +193,8 @@ const computeCenterIconLayer = createInteractableLayer({
|
||||
amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude,
|
||||
},
|
||||
icon: {
|
||||
colorable: false,
|
||||
badge: ({ marker, item }) => ({ color: COMPUTE_CENTER_CONFIG.colors[marker?.userData?.site_type || item?.site_type] || "#14b8a6" }),
|
||||
coordinates: "canvas",
|
||||
fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
|
||||
glowBlur: 16,
|
||||
@@ -290,11 +294,11 @@ export function formatComputeCenterLocationConfidence(markerData) {
|
||||
export function getComputeCenterLegendItems() {
|
||||
return [
|
||||
{
|
||||
label: "超算中心",
|
||||
key: "supercomputer", label: "超算中心",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
|
||||
},
|
||||
{
|
||||
label: "GPU 集群",
|
||||
key: "gpu_cluster", label: "GPU 集群",
|
||||
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
|
||||
},
|
||||
];
|
||||
@@ -686,3 +690,5 @@ export function updateComputeCenterVisualState(lockedObjectType, lockedObject, c
|
||||
updateComputeCenterLocationPreview();
|
||||
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||
}
|
||||
|
||||
export function applyComputeCenterLegendFilter() { computeCenterIconLayer.refreshFilter(); }
|
||||
|
||||
@@ -65,6 +65,7 @@ export const SURFACE_HOVER_INFO_MODES = {
|
||||
export const DEFAULT_SURFACE_HOVER_INFO_MODE =
|
||||
SURFACE_HOVER_INFO_MODES.FULL;
|
||||
|
||||
const EARTH_SURFACE_FILL_ALTITUDE_OFFSET = 0.32;
|
||||
export const EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48;
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
@@ -90,17 +91,6 @@ export const HUD_CONFIG = {
|
||||
brandLanguage: "zh",
|
||||
};
|
||||
|
||||
export const TOOLBAR_GLASS_CONFIG = {
|
||||
lightRgb: [91, 186, 255],
|
||||
glassBlurPx: 16,
|
||||
lightBlurPx: 24,
|
||||
lightIntensity: 0.8,
|
||||
lightSizeRatio: 0.85,
|
||||
glassOpacity: 0.07,
|
||||
activeLightScale: 1.15,
|
||||
outerGlowBlurPx: 16,
|
||||
};
|
||||
|
||||
// Earth coordinate constants
|
||||
export const EARTH_CONFIG = {
|
||||
tilt: 23.5, // earth tilt angle (degrees)
|
||||
@@ -120,7 +110,8 @@ export const CELESTIAL_CONFIG = {
|
||||
updateIntervalMs: 10_000,
|
||||
skyRadius: 2600,
|
||||
skyOpacity: 1,
|
||||
starMapUrl: "./assets/celestial/starmap_equatorial_4k.jpg",
|
||||
skyBrightness: 1.0, // Original sky texture brightness
|
||||
starMapUrl: "./assets/celestial/starmap_deep_8k.jpg",
|
||||
brightStarsUrl: "./assets/celestial/bright-stars.json",
|
||||
orientationEulerRad: {
|
||||
x: 0.46,
|
||||
@@ -208,8 +199,8 @@ export const TERRAIN_CONFIG = {
|
||||
opacity: 0.68,
|
||||
color: 0x8aa884,
|
||||
emissive: 0x030704,
|
||||
specular: 0x344438,
|
||||
shininess: 16,
|
||||
specular: 0x000000,
|
||||
shininess: 0,
|
||||
urlTemplate:
|
||||
"/api/v1/visualization/terrain/terrarium/{z}/{x}/{y}.png",
|
||||
batchUrl: "/api/v1/visualization/terrain/terrarium/batch",
|
||||
@@ -254,12 +245,14 @@ export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0,
|
||||
tintAltitudeOffset: 0.04,
|
||||
// Tint and land fill share a depth-safe shell. Neither writes depth;
|
||||
// renderOrder lets the loaded land fill replace the loading tint.
|
||||
tintAltitudeOffset: EARTH_SURFACE_FILL_ALTITUDE_OFFSET,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.32,
|
||||
landAltitudeOffset: EARTH_SURFACE_FILL_ALTITUDE_OFFSET,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
@@ -291,12 +284,12 @@ export const VESSEL_CONFIG = {
|
||||
dimmedOpacity: 0.26,
|
||||
},
|
||||
colors: {
|
||||
cargo: "#4A90D9",
|
||||
tanker: "#E85D04",
|
||||
passenger: "#06D6A0",
|
||||
fishing: "#FFD166",
|
||||
military: "#73797E",
|
||||
other: "#9B9B9B",
|
||||
cargo: "#38bdf8",
|
||||
tanker: "#f97316",
|
||||
passenger: "#14b8a6",
|
||||
fishing: "#f59e0b",
|
||||
military: "#818cf8",
|
||||
other: "#94a3b8",
|
||||
},
|
||||
sizeStabilization: {
|
||||
min: 0.1,
|
||||
@@ -329,8 +322,9 @@ export const COMPUTE_CENTER_CONFIG = {
|
||||
pulseAmplitude: 0.03,
|
||||
},
|
||||
colors: {
|
||||
supercomputer: "#38bdf8",
|
||||
gpu_cluster: "#2dd4bf",
|
||||
supercomputer: "#a855f7",
|
||||
gpu_cluster: "#a855f7",
|
||||
infrastructure: "#14b8a6",
|
||||
linked: "#f8fafc",
|
||||
},
|
||||
sizeStabilization: {
|
||||
@@ -349,6 +343,13 @@ export const CABLE_COLORS = {
|
||||
};
|
||||
|
||||
export const CABLE_CONFIG = {
|
||||
visual: {
|
||||
normalTint: 0x328ebc,
|
||||
sourceColorWeight: 0.38,
|
||||
normalOpacity: 0.7,
|
||||
pulseRepeats: 6,
|
||||
pulseSpeed: 4.5,
|
||||
},
|
||||
lockedOpacityMin: 0.2,
|
||||
lockedOpacityMax: 1.0,
|
||||
otherOpacity: 0.5,
|
||||
@@ -356,7 +357,7 @@ export const CABLE_CONFIG = {
|
||||
pulseSpeed: 0.008,
|
||||
pulseCoefficient: 0.4,
|
||||
line: {
|
||||
altitudeOffset: 0.2,
|
||||
altitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
greatCircleSegments: 50,
|
||||
nearPointThreshold: 0.01,
|
||||
lineWidth: 1,
|
||||
@@ -365,7 +366,7 @@ export const CABLE_CONFIG = {
|
||||
},
|
||||
landingPoint: {
|
||||
altitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
color: 0xffaa00,
|
||||
color: 0xf59e0b,
|
||||
opacity: 1.0,
|
||||
renderOrder: 1,
|
||||
},
|
||||
@@ -373,8 +374,8 @@ export const CABLE_CONFIG = {
|
||||
pulseSpeed: 0.003,
|
||||
dimBrightness: 0.62,
|
||||
related: {
|
||||
opacityBase: 0.8,
|
||||
opacityPulse: 0.2,
|
||||
haloScale: 1.65,
|
||||
haloFalloff: 8,
|
||||
},
|
||||
dimmed: {
|
||||
colorRGB: { r: 180, g: 116, b: 28 },
|
||||
@@ -403,18 +404,21 @@ export const SATELLITE_CONFIG = {
|
||||
maxRealAltitudeOffset: 25,
|
||||
frontFacingDotThreshold: 0.015,
|
||||
overlayRenderOrder: 12,
|
||||
dotBaseSize: 2.8,
|
||||
dotBackdropScale: 1.28,
|
||||
dotZoomScalePower: 1,
|
||||
dotBaseSize: 2.2,
|
||||
labelMinZoom: 4,
|
||||
labelMaxCount: 36,
|
||||
labelScanIntervalMs: 160,
|
||||
footprintColor: 0x3399ff,
|
||||
footprintOpacity: 0.34,
|
||||
footprintDayVisibilityBoost: 1.65,
|
||||
ringSize: 0.07,
|
||||
apiPath: '/api/v1/visualization/geo/satellites',
|
||||
breathingSpeed: 0.036,
|
||||
breathingScaleAmplitude: 0.22,
|
||||
breathingOpacityMin: 0.34,
|
||||
breathingOpacityMax: 0.9,
|
||||
dotBreathingSpeed: 0.12,
|
||||
dotBreathingScaleAmplitude: 0.28,
|
||||
dotOpacityMin: 0.42,
|
||||
dotOpacityMin: 0.72,
|
||||
dotOpacityMax: 1.0
|
||||
};
|
||||
|
||||
@@ -448,10 +452,10 @@ export const BGP_CONFIG = {
|
||||
lockedMax: 1.0
|
||||
},
|
||||
severityColors: {
|
||||
critical: 0xff4d4f,
|
||||
high: 0xff9f43,
|
||||
medium: 0xffd166,
|
||||
low: 0x4dabf7
|
||||
critical: 0xf43f5e,
|
||||
high: 0xf97316,
|
||||
medium: 0xf59e0b,
|
||||
low: 0x38bdf8
|
||||
},
|
||||
severityScales: {
|
||||
critical: 1.18,
|
||||
@@ -508,11 +512,6 @@ export const BGP_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PREDICTED_ORBIT_CONFIG = {
|
||||
sampleInterval: 10,
|
||||
opacity: 0.8
|
||||
};
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
radiusOffset: 0.14,
|
||||
color: 0xc0e0ff,
|
||||
@@ -532,54 +531,46 @@ export const CLOUD_LAYER_CONFIG = {
|
||||
textureUrl: "./assets/earth_clouds_1024.png",
|
||||
};
|
||||
|
||||
export const STARFIELD_CONFIG = {
|
||||
count: 8000,
|
||||
minRadius: 800,
|
||||
radiusJitter: 200,
|
||||
color: 0xffffff,
|
||||
size: 0.5,
|
||||
};
|
||||
|
||||
export const EARTH_MATERIAL_CONFIG = {
|
||||
// Base sphere sits below the country fill and high-res texture overlays.
|
||||
// Keep it dark so a delayed overlay never flashes or reads as a white layer.
|
||||
color: 0x010609,
|
||||
specular: 0x1a2d45,
|
||||
shininess: 12,
|
||||
specular: 0x000000,
|
||||
shininess: 0,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
textureWidthSegments: 128,
|
||||
textureHeightSegments: 128,
|
||||
textureOverlayOpacity: 1,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
|
||||
// Depth-mask occluder keeps far-side objects hidden behind the earth
|
||||
occluderRadiusFactor: 0.999,
|
||||
occluderSegments: 48,
|
||||
|
||||
// Fresnel atmosphere glow — inner rim
|
||||
atmosInnerRadiusFactor: 1.01,
|
||||
atmosInnerSegments: 64,
|
||||
atmosInnerColor: [0.38, 0.60, 0.84],
|
||||
atmosInnerRimPower: 3.2,
|
||||
atmosInnerIntensity: 0.10,
|
||||
|
||||
// Fresnel atmosphere glow — outer corona
|
||||
atmosOuterRadiusFactor: 1.025,
|
||||
atmosOuterSegments: 48,
|
||||
atmosOuterColor: [0.32, 0.52, 0.74],
|
||||
atmosOuterRimPower: 6.0,
|
||||
atmosOuterIntensity: 0.08,
|
||||
atmosOuterEdgeFeather: 0.14,
|
||||
|
||||
// Subtle Fresnel edge cue shown when the high-res texture is hidden or unavailable.
|
||||
rimGlowColor: [0.42, 0.72, 1.0],
|
||||
rimGlowRadiusFactor: 1.0035,
|
||||
rimGlowPower: 3.4,
|
||||
rimGlowIntensity: 0.24,
|
||||
rimGlowSegments: 96,
|
||||
rimGlowRenderOrder: 1.08,
|
||||
surface: {
|
||||
exposure: 0.9,
|
||||
landSaturation: 0.9,
|
||||
oceanAbsorption: 0.36,
|
||||
oceanDeepColor: [0.004, 0.012, 0.032],
|
||||
// Independent night-side texture contrast and cool ambient, in linear RGB.
|
||||
night: {
|
||||
detailGamma: 0.55,
|
||||
landTint: [0.07, 0.075, 0.08],
|
||||
landAmbient: [0.003, 0.005, 0.008],
|
||||
oceanTint: [0.008, 0.016, 0.024],
|
||||
oceanAmbient: [0.0015, 0.004, 0.012],
|
||||
},
|
||||
terminator: [-0.075, 0.16],
|
||||
oceanReflection: [0.025, 0.12, 0.32],
|
||||
atmosphereColor: [0.08, 0.28, 0.65],
|
||||
innerRimStrength: 0.28,
|
||||
},
|
||||
atmosphere: {
|
||||
radiusFactor: 1.025,
|
||||
color: [0.18, 0.48, 0.88],
|
||||
strength: 0.65,
|
||||
},
|
||||
|
||||
dayNight: {
|
||||
enabled: true,
|
||||
|
||||
447
frontend/public/earth/js/controls.js
vendored
@@ -1,3 +1,6 @@
|
||||
import { selectLayerLegend } from "./legend.js";
|
||||
import { getFpsEnabled, setFpsEnabled } from "./frame-stats.js";
|
||||
import { bindToolbarHover } from "./toolbar-hover.js";
|
||||
import { distanceForGlobeZoom, globeZoomForDistance } from "./globe-navigation.js";
|
||||
import { EARTH_SETTINGS_STORAGE_KEY } from "./earth-settings-storage.js";
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
@@ -18,7 +21,6 @@ import {
|
||||
ROTATION_MODE,
|
||||
SATELLITE_DISPLAY_STYLES,
|
||||
SURFACE_HOVER_INFO_MODES,
|
||||
TOOLBAR_GLASS_CONFIG,
|
||||
} from "./constants.js";
|
||||
import {
|
||||
setEarthStatValue,
|
||||
@@ -160,16 +162,12 @@ const HUD_PANEL_IDS = [
|
||||
];
|
||||
const DRAGGABLE_PANEL_SELECTOR = ".hud-panel-draggable";
|
||||
const PANEL_LAYOUT_ANIMATION_MS = 420;
|
||||
const TOOLBAR_BASE_WIDTH_PX = 620;
|
||||
const TOOLBAR_MIN_SCALE = 0.68;
|
||||
const TOOLBAR_ORB_SIZE_PX = 46;
|
||||
const TOOLBAR_HUB_SIZE_PX = 58;
|
||||
const TOOLBAR_ORB_GAP_PX = 12;
|
||||
const TOOLBAR_ARCH_SPAN_PX = 232;
|
||||
const TOOLBAR_ARCH_RISE_PX = 40;
|
||||
const TOOLBAR_SIDE_PADDING_PX = 12;
|
||||
const TOOLBAR_BOTTOM_CLEARANCE_PX = 34;
|
||||
const TOOLBAR_EXTRA_HEIGHT_PX = 34;
|
||||
const TOOLBAR_FAN_RADIUS_X_PX = 156;
|
||||
const TOOLBAR_FAN_RADIUS_Y_PX = 88;
|
||||
const TOOLBAR_FAN_START_DEG = -160;
|
||||
const TOOLBAR_FAN_SWEEP_DEG = 140;
|
||||
const TOOLBAR_OPEN_STAGGER_MS = 28;
|
||||
const TOOLBAR_CLOSE_STAGGER_MS = 20;
|
||||
const HUD_EDGE_GAP_PX = 20;
|
||||
const SETTINGS_MODAL_OPEN_ANIMATION_MS = 420;
|
||||
const SETTINGS_MODAL_CLOSE_ANIMATION_MS = 320;
|
||||
@@ -177,7 +175,7 @@ const SETTINGS_SHEET_MIN_SCALE = 0.06;
|
||||
const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||
const EARTH_SETTINGS_VERSION = 18;
|
||||
const EARTH_SETTINGS_VERSION = 19;
|
||||
const GRID_LINES_DEFAULT_VERSION = 3;
|
||||
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
||||
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
||||
@@ -778,6 +776,7 @@ async function toggleLayerFromShortcut(layerId) {
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
if (definition.getVisible()) selectLayerLegend(definition.id);
|
||||
showStatusMessage(
|
||||
earthMessage("status.layerVisibility", {
|
||||
layer: definition.label,
|
||||
@@ -1140,6 +1139,7 @@ function renderMobileLayerCards() {
|
||||
const definition = layerId ? getLayerDefinition(layerId) : null;
|
||||
if (!definition) return;
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
if (definition.getVisible()) selectLayerLegend(definition.id);
|
||||
syncMobileLayerCards();
|
||||
});
|
||||
});
|
||||
@@ -1490,6 +1490,7 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
cruiseRegionOrder: getCruiseRegionOrder(),
|
||||
satelliteDisplayStyle: getSatelliteDisplayStyle(),
|
||||
trailsEnabled: getShowTrails(),
|
||||
showFps: getFpsEnabled(),
|
||||
layerVisibility: Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
|
||||
),
|
||||
@@ -1556,6 +1557,7 @@ function cloneEarthSettings(settings) {
|
||||
satelliteDisplayStyle:
|
||||
settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE,
|
||||
trailsEnabled: settings.shared.trailsEnabled !== false,
|
||||
showFps: settings.shared.showFps === true,
|
||||
terrainOpacity: settings.shared.terrainOpacity,
|
||||
earthMapSource: normalizeEarthMapSource(settings.shared.earthMapSource),
|
||||
dayNightEnabled: settings.shared.dayNightEnabled,
|
||||
@@ -1574,7 +1576,7 @@ function cloneEarthSettings(settings) {
|
||||
satelliteRealAltitudeEnabled:
|
||||
settings.shared.satelliteRealAltitudeEnabled !== false,
|
||||
interactableCompactDotsEnabled:
|
||||
settings.shared.interactableCompactDotsEnabled !== false,
|
||||
settings.shared.interactableCompactDotsEnabled === true,
|
||||
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
|
||||
settings.shared.surfaceHoverInfoMode,
|
||||
),
|
||||
@@ -1766,6 +1768,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
cruiseRegionOrder: nextCruiseRegionOrder,
|
||||
satelliteDisplayStyle: nextSatelliteDisplayStyle,
|
||||
trailsEnabled: nextTrailsEnabled,
|
||||
showFps: sharedSettings?.showFps === true,
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
@@ -2542,6 +2545,7 @@ async function applyEarthMapSourceSelection(
|
||||
async function applyEarthSettings(settings, { applyLayers = true } = {}) {
|
||||
if (!settings) return;
|
||||
earthSettingsState = cloneEarthSettings(settings);
|
||||
setFpsEnabled(settings.shared.showFps === true);
|
||||
|
||||
applyCurrentViewportPanelVisibility({ persist: false });
|
||||
await applyEarthMapSourceSelection(settings.shared.earthMapSource, {
|
||||
@@ -3625,20 +3629,8 @@ function animateSettingsSheet(sheet, trigger, opening) {
|
||||
};
|
||||
}
|
||||
|
||||
function getFloatingGroups() {
|
||||
return [
|
||||
document.getElementById("zoom-control-group"),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function isFloatingMenuVisible() {
|
||||
return getFloatingGroups().some((group) => {
|
||||
return (
|
||||
group.classList.contains("open") ||
|
||||
group.matches(":hover") ||
|
||||
group.matches(":focus-within")
|
||||
);
|
||||
});
|
||||
return toolbarHubController?.isZoomOpen?.() || false;
|
||||
}
|
||||
|
||||
function isSettingsModalOpen() {
|
||||
@@ -3648,14 +3640,7 @@ function isSettingsModalOpen() {
|
||||
}
|
||||
|
||||
function closeFloatingMenus() {
|
||||
getFloatingGroups().forEach((group) => {
|
||||
group.classList.remove("open");
|
||||
group.classList.add("force-closed");
|
||||
});
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
toolbarHubController?.closeZoom?.();
|
||||
}
|
||||
|
||||
function openSettingsModal() {
|
||||
@@ -4303,15 +4288,6 @@ function resetAllShortcutBindings() {
|
||||
showStatusMessage(earthMessage("status.shortcutsReset"), "info");
|
||||
}
|
||||
|
||||
function moveCruiseRegionInOrder(region, targetRegion) {
|
||||
if (!region || !targetRegion || region === targetRegion) return;
|
||||
const currentOrder = getCruiseRegionOrder().filter((item) => item !== region);
|
||||
const targetIndex = currentOrder.indexOf(targetRegion);
|
||||
if (targetIndex < 0) return;
|
||||
currentOrder.splice(targetIndex, 0, region);
|
||||
setCruiseRegionOrder(currentOrder);
|
||||
}
|
||||
|
||||
function getCruiseRegionOrderRows(list) {
|
||||
if (!(list instanceof HTMLElement)) return [];
|
||||
return Array.from(list.querySelectorAll("[data-cruise-region-order-item]")).filter(
|
||||
@@ -4699,6 +4675,13 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-fps-toggle]").forEach(toggle => {
|
||||
bindListener(toggle, "change", () => {
|
||||
setFpsEnabled(toggle.checked);
|
||||
persistEarthSettings();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => {
|
||||
if (!(toggle instanceof HTMLInputElement)) return;
|
||||
bindListener(toggle, "change", () => {
|
||||
@@ -4984,44 +4967,12 @@ function setupDraggableHudPanels() {
|
||||
});
|
||||
}
|
||||
|
||||
function clearForcedFloatingClose() {
|
||||
getFloatingGroups().forEach((group) => {
|
||||
group.classList.remove("force-closed");
|
||||
});
|
||||
}
|
||||
|
||||
function clearForcedFloatingCloseIfPointerOutside() {
|
||||
getFloatingGroups().forEach((group) => {
|
||||
if (!group.matches(":hover")) {
|
||||
group.classList.remove("force-closed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setFloatingMenuOpen(group, shouldOpen) {
|
||||
closeFloatingMenus();
|
||||
clearForcedFloatingClose();
|
||||
group?.classList.toggle("open", shouldOpen);
|
||||
}
|
||||
|
||||
function clearSelectionIfHiding(shouldHide) {
|
||||
if (shouldHide) {
|
||||
clearLockedObject();
|
||||
}
|
||||
}
|
||||
|
||||
function bindFloatingMenu(trigger, group) {
|
||||
bindListener(trigger, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
const shouldOpen = !group?.classList.contains("open");
|
||||
setFloatingMenuOpen(group, shouldOpen);
|
||||
});
|
||||
|
||||
bindListener(group, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function updateTVToggleUI(visible) {
|
||||
const btn = document.getElementById("toggle-tv");
|
||||
if (!btn) return;
|
||||
@@ -5524,6 +5475,7 @@ function bindLayerButton(row, definition) {
|
||||
return;
|
||||
}
|
||||
await definition.setVisible(!definition.getVisible());
|
||||
if (definition.getVisible()) selectLayerLegend(definition.id);
|
||||
});
|
||||
button.dataset.layerBound = "true";
|
||||
}
|
||||
@@ -5616,8 +5568,6 @@ function setupTerrainControls() {
|
||||
const terrainBtn = getLayerButton("terrain");
|
||||
const layoutBtn = document.getElementById("layout-toggle");
|
||||
const reloadBtn = document.getElementById("reload-data");
|
||||
const zoomGroup = document.getElementById("zoom-control-group");
|
||||
const zoomTrigger = document.getElementById("zoom-trigger");
|
||||
setupSettingsControls();
|
||||
setupHudPanelControls();
|
||||
setupDraggableHudPanels();
|
||||
@@ -5660,12 +5610,7 @@ function setupTerrainControls() {
|
||||
await reloadData();
|
||||
});
|
||||
|
||||
bindFloatingMenu(zoomTrigger, zoomGroup);
|
||||
|
||||
bindListener(document, "click", (event) => {
|
||||
const openGroups = [zoomGroup].filter((group) =>
|
||||
group?.classList.contains("open"),
|
||||
);
|
||||
if (
|
||||
isMobileLayout() &&
|
||||
mobileDrawerOpen &&
|
||||
@@ -5680,18 +5625,6 @@ function setupTerrainControls() {
|
||||
setMobileDrawerState({ open: false });
|
||||
}
|
||||
|
||||
if (openGroups.length === 0) return;
|
||||
|
||||
const clickedInsideOpenGroup = openGroups.some((group) =>
|
||||
group.contains(event.target),
|
||||
);
|
||||
if (!clickedInsideOpenGroup) {
|
||||
closeFloatingMenus();
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(document, "mousemove", () => {
|
||||
clearForcedFloatingCloseIfPointerOutside();
|
||||
});
|
||||
|
||||
bindListener(layoutBtn, "click", () => {
|
||||
@@ -5861,298 +5794,62 @@ function setupLiquidGlassInteractions() {
|
||||
});
|
||||
}
|
||||
|
||||
// Equal angles crowd the ends of an ellipse; distribute by arc length instead.
|
||||
function getToolbarFanPositions(count) {
|
||||
const samples = [];
|
||||
let distance = 0;
|
||||
for (let degree = 0; degree <= TOOLBAR_FAN_SWEEP_DEG; degree += 1) {
|
||||
const angle = THREE.MathUtils.degToRad(TOOLBAR_FAN_START_DEG + degree);
|
||||
const x = Math.cos(angle) * TOOLBAR_FAN_RADIUS_X_PX;
|
||||
const y = Math.sin(angle) * TOOLBAR_FAN_RADIUS_Y_PX;
|
||||
const previous = samples.at(-1);
|
||||
if (previous) distance += Math.hypot(x - previous.x, y - previous.y);
|
||||
samples.push({ x, y, distance });
|
||||
}
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const target = distance * (count === 1 ? 0.5 : index / (count - 1));
|
||||
const endIndex = samples.findIndex((sample) => sample.distance >= target);
|
||||
const end = samples[Math.max(0, endIndex)];
|
||||
const start = samples[Math.max(0, endIndex - 1)];
|
||||
const fraction = (target - start.distance) / (end.distance - start.distance || 1);
|
||||
return {
|
||||
x: THREE.MathUtils.lerp(start.x, end.x, fraction),
|
||||
y: THREE.MathUtils.lerp(start.y, end.y, fraction),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setupToolbarHubCluster() {
|
||||
const cluster = document.getElementById("toolbar-cluster");
|
||||
const hub = document.getElementById("toolbar-hub");
|
||||
const toolbar = document.getElementById("control-toolbar");
|
||||
if (
|
||||
!(cluster instanceof HTMLElement) ||
|
||||
!(hub instanceof HTMLButtonElement) ||
|
||||
!(toolbar instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let collapseTimer = null;
|
||||
let expandedToolbarBounds = null;
|
||||
let refreshBoundsFrameId = 0;
|
||||
let hubPinnedOpen = false;
|
||||
const zoomGroup = document.getElementById("zoom-control-group");
|
||||
const zoomTrigger = document.getElementById("zoom-trigger");
|
||||
const zoomMenu = document.getElementById("zoom-toolbar");
|
||||
if (!cluster || !hub || !toolbar || !zoomGroup || !zoomTrigger || !zoomMenu) return;
|
||||
|
||||
const layoutToolbarOrbs = () => {
|
||||
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb")).filter(
|
||||
(orb) => getComputedStyle(orb).display !== "none",
|
||||
);
|
||||
if (orbs.length === 0) return;
|
||||
|
||||
const toolbarWidth = toolbar.clientWidth || TOOLBAR_BASE_WIDTH_PX;
|
||||
const orbCount = orbs.length;
|
||||
const viewportScale = Math.min(
|
||||
window.innerWidth / 1920,
|
||||
window.innerHeight / 1080,
|
||||
);
|
||||
|
||||
let toolbarScale = THREE.MathUtils.clamp(
|
||||
Math.min(toolbarWidth / TOOLBAR_BASE_WIDTH_PX, viewportScale),
|
||||
TOOLBAR_MIN_SCALE,
|
||||
1,
|
||||
);
|
||||
|
||||
let orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
|
||||
let desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
|
||||
let span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
|
||||
let rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
|
||||
const minSpanForSpacing =
|
||||
orbCount > 1 ? (orbCount - 1) * (orbSize + desiredGap) : orbSize;
|
||||
const maxSpanByWidth =
|
||||
toolbarWidth - orbSize - TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale;
|
||||
|
||||
if (minSpanForSpacing > maxSpanByWidth) {
|
||||
toolbarScale = THREE.MathUtils.clamp(
|
||||
maxSpanByWidth / minSpanForSpacing,
|
||||
TOOLBAR_MIN_SCALE,
|
||||
toolbarScale,
|
||||
);
|
||||
orbSize = TOOLBAR_ORB_SIZE_PX * toolbarScale;
|
||||
desiredGap = TOOLBAR_ORB_GAP_PX * toolbarScale;
|
||||
span = TOOLBAR_ARCH_SPAN_PX * toolbarScale;
|
||||
rise = TOOLBAR_ARCH_RISE_PX * toolbarScale;
|
||||
}
|
||||
|
||||
span = THREE.MathUtils.clamp(
|
||||
span,
|
||||
minSpanForSpacing,
|
||||
maxSpanByWidth,
|
||||
);
|
||||
rise = Math.min(rise, span * 0.32);
|
||||
|
||||
const hubSize = TOOLBAR_HUB_SIZE_PX * toolbarScale;
|
||||
const maxVerticalReach = rise + orbSize * 0.5;
|
||||
const toolbarHeight = maxVerticalReach + hubSize + TOOLBAR_BOTTOM_CLEARANCE_PX * toolbarScale + TOOLBAR_EXTRA_HEIGHT_PX * toolbarScale;
|
||||
|
||||
toolbar.style.setProperty("--toolbar-scale", toolbarScale.toFixed(3));
|
||||
toolbar.style.setProperty("--toolbar-orb-size", `${Math.round(orbSize)}px`);
|
||||
toolbar.style.setProperty("--toolbar-hub-size", `${Math.round(hubSize)}px`);
|
||||
toolbar.style.setProperty("--toolbar-light-rgb", TOOLBAR_GLASS_CONFIG.lightRgb.join(", "));
|
||||
toolbar.style.setProperty("--toolbar-light-intensity", `${TOOLBAR_GLASS_CONFIG.lightIntensity}`);
|
||||
toolbar.style.setProperty("--toolbar-light-size-ratio", `${TOOLBAR_GLASS_CONFIG.lightSizeRatio}`);
|
||||
toolbar.style.setProperty("--toolbar-glass-opacity", `${TOOLBAR_GLASS_CONFIG.glassOpacity}`);
|
||||
toolbar.style.setProperty("--toolbar-active-light-scale", `${TOOLBAR_GLASS_CONFIG.activeLightScale}`);
|
||||
toolbar.style.setProperty("--toolbar-glass-blur", `${TOOLBAR_GLASS_CONFIG.glassBlurPx * toolbarScale}px`);
|
||||
toolbar.style.setProperty("--toolbar-light-blur", `${TOOLBAR_GLASS_CONFIG.lightBlurPx * toolbarScale}px`);
|
||||
toolbar.style.setProperty("--toolbar-outer-glow-blur", `${TOOLBAR_GLASS_CONFIG.outerGlowBlurPx * toolbarScale}px`);
|
||||
toolbar.style.height = `${Math.ceil(toolbarHeight)}px`;
|
||||
toolbar.style.setProperty("--toolbar-arc-width", `${Math.ceil(span + orbSize + (TOOLBAR_SIDE_PADDING_PX * 2 * toolbarScale))}px`);
|
||||
toolbar.style.setProperty("--toolbar-arc-height", `${Math.ceil(rise + orbSize * 0.95)}px`);
|
||||
toolbar.style.setProperty("--toolbar-inner-arc-width", `${Math.ceil(span * 0.72)}px`);
|
||||
toolbar.style.setProperty("--toolbar-inner-arc-height", `${Math.ceil((hubSize * 0.8) + (desiredGap * 0.5))}px`);
|
||||
|
||||
const orbs = Array.from(cluster.querySelectorAll(".earth-toolbar-orb"))
|
||||
.filter(orb => getComputedStyle(orb).display !== "none");
|
||||
const positions = getToolbarFanPositions(orbs.length);
|
||||
orbs.forEach((orb, index) => {
|
||||
const t = orbCount === 1 ? 0.5 : index / (orbCount - 1);
|
||||
const x = (t - 0.5) * span;
|
||||
const normalized = (x / (span / 2 || 1));
|
||||
const y = -(1 - normalized * normalized) * rise;
|
||||
const { x, y } = positions[index];
|
||||
orb.style.setProperty("--orb-x", `${x.toFixed(2)}px`);
|
||||
orb.style.setProperty("--orb-y", `${y.toFixed(2)}px`);
|
||||
orb.style.setProperty("--orb-open-delay", `${index * TOOLBAR_OPEN_STAGGER_MS}ms`);
|
||||
orb.style.setProperty("--orb-close-delay", `${(orbs.length - 1 - index) * TOOLBAR_CLOSE_STAGGER_MS}ms`);
|
||||
});
|
||||
|
||||
if (cluster.classList.contains("is-expanded")) {
|
||||
scheduleExpandedToolbarBoundsRefresh();
|
||||
}
|
||||
toolbar.style.setProperty("--toolbar-radius-x", `calc(${TOOLBAR_FAN_RADIUS_X_PX}px * var(--toolbar-scale))`);
|
||||
toolbar.style.setProperty("--toolbar-radius-y", `calc(${TOOLBAR_FAN_RADIUS_Y_PX}px * var(--toolbar-scale))`);
|
||||
};
|
||||
|
||||
const setExpanded = (expanded) => {
|
||||
cluster.classList.toggle("is-expanded", expanded);
|
||||
cluster.classList.toggle("is-collapsed", !expanded);
|
||||
if (!expanded) {
|
||||
expandedToolbarBounds = null;
|
||||
if (refreshBoundsFrameId) {
|
||||
cancelAnimationFrame(refreshBoundsFrameId);
|
||||
refreshBoundsFrameId = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleExpandedToolbarBoundsRefresh();
|
||||
};
|
||||
|
||||
const closePinnedToolbar = () => {
|
||||
hubPinnedOpen = false;
|
||||
cancelCollapse();
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
const scheduleCollapse = () => {
|
||||
if (hubPinnedOpen) return;
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
collapseTimer = window.setTimeout(() => {
|
||||
setExpanded(false);
|
||||
collapseTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const cancelCollapse = () => {
|
||||
if (collapseTimer) {
|
||||
clearTimeout(collapseTimer);
|
||||
collapseTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
cleanupFns.push(() => {
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
if (refreshBoundsFrameId) {
|
||||
cancelAnimationFrame(refreshBoundsFrameId);
|
||||
refreshBoundsFrameId = 0;
|
||||
}
|
||||
if (toolbarHubController?.cluster === cluster) {
|
||||
toolbarHubController = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Start collapsed — hub acts as the hover target to reveal the arc
|
||||
layoutToolbarOrbs();
|
||||
setExpanded(false);
|
||||
|
||||
bindListener(window, "resize", layoutToolbarOrbs);
|
||||
|
||||
bindListener(hub, "mouseenter", () => {
|
||||
cancelCollapse();
|
||||
setExpanded(true);
|
||||
const controller = bindToolbarHover({ cluster, hub, zoomGroup, zoomTrigger, zoomMenu, listen: bindListener });
|
||||
toolbarHubController = { ...controller, cluster };
|
||||
cleanupFns.push(() => {
|
||||
controller.dispose();
|
||||
if (toolbarHubController?.cluster === cluster) toolbarHubController = null;
|
||||
});
|
||||
|
||||
bindListener(hub, "focus", () => {
|
||||
cancelCollapse();
|
||||
setExpanded(true);
|
||||
});
|
||||
|
||||
bindListener(hub, "click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancelCollapse();
|
||||
if (hubPinnedOpen) {
|
||||
closePinnedToolbar();
|
||||
} else {
|
||||
hubPinnedOpen = true;
|
||||
setExpanded(true);
|
||||
}
|
||||
});
|
||||
|
||||
const HOVER_PADDING_PX = 12;
|
||||
const collectExpandedToolbarBounds = () => {
|
||||
const rects = [];
|
||||
const pushRect = (element) => {
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
rects.push(rect);
|
||||
};
|
||||
|
||||
pushRect(hub);
|
||||
cluster.querySelectorAll(".earth-toolbar-orb > *").forEach((node) => {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
pushRect(node);
|
||||
const popover = node.parentElement?.querySelector(".earth-stack-toolbar");
|
||||
if (
|
||||
popover instanceof HTMLElement &&
|
||||
(node.parentElement?.matches(":hover") ||
|
||||
node.parentElement?.matches(":focus-within") ||
|
||||
node.parentElement?.classList.contains("open"))
|
||||
) {
|
||||
pushRect(popover);
|
||||
}
|
||||
});
|
||||
|
||||
if (rects.length === 0) return null;
|
||||
|
||||
const bounds = rects.reduce(
|
||||
(acc, rect) => ({
|
||||
left: Math.min(acc.left, rect.left),
|
||||
top: Math.min(acc.top, rect.top),
|
||||
right: Math.max(acc.right, rect.right),
|
||||
bottom: Math.max(acc.bottom, rect.bottom),
|
||||
}),
|
||||
{
|
||||
left: Number.POSITIVE_INFINITY,
|
||||
top: Number.POSITIVE_INFINITY,
|
||||
right: Number.NEGATIVE_INFINITY,
|
||||
bottom: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
left: bounds.left - HOVER_PADDING_PX,
|
||||
top: bounds.top - HOVER_PADDING_PX,
|
||||
right: bounds.right + HOVER_PADDING_PX,
|
||||
bottom: bounds.bottom + HOVER_PADDING_PX,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleExpandedToolbarBoundsRefresh = () => {
|
||||
if (refreshBoundsFrameId) return;
|
||||
refreshBoundsFrameId = window.requestAnimationFrame(() => {
|
||||
expandedToolbarBounds = collectExpandedToolbarBounds();
|
||||
refreshBoundsFrameId = 0;
|
||||
});
|
||||
};
|
||||
|
||||
bindListener(document, "mousemove", (event) => {
|
||||
if (hubPinnedOpen) return;
|
||||
if (!cluster.classList.contains("is-expanded")) return;
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest("#toolbar-cluster, .earth-stack-toolbar")
|
||||
) {
|
||||
scheduleExpandedToolbarBoundsRefresh();
|
||||
}
|
||||
const activeBounds = expandedToolbarBounds;
|
||||
if (!activeBounds) {
|
||||
scheduleCollapse();
|
||||
return;
|
||||
}
|
||||
const isInside =
|
||||
event.clientX >= activeBounds.left &&
|
||||
event.clientX <= activeBounds.right &&
|
||||
event.clientY >= activeBounds.top &&
|
||||
event.clientY <= activeBounds.bottom;
|
||||
if (isInside) {
|
||||
cancelCollapse();
|
||||
} else {
|
||||
scheduleCollapse();
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(cluster, "mouseenter", () => {
|
||||
if (cluster.classList.contains("is-expanded")) {
|
||||
scheduleExpandedToolbarBoundsRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(cluster, "focusin", () => {
|
||||
if (cluster.classList.contains("is-expanded")) {
|
||||
scheduleExpandedToolbarBoundsRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
cluster.querySelectorAll(".earth-toolbar-orb > button").forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement) || button === hub) return;
|
||||
bindListener(button, "click", () => {
|
||||
if (hubPinnedOpen) {
|
||||
hubPinnedOpen = false;
|
||||
setExpanded(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bindListener(document, "pointerdown", (event) => {
|
||||
if (!hubPinnedOpen) return;
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest("#toolbar-cluster")) return;
|
||||
closePinnedToolbar();
|
||||
});
|
||||
|
||||
toolbarHubController = {
|
||||
cluster,
|
||||
isOpen: () => hubPinnedOpen || cluster.classList.contains("is-expanded"),
|
||||
close: closePinnedToolbar,
|
||||
};
|
||||
}
|
||||
|
||||
export function teardownControls() {
|
||||
@@ -6188,16 +5885,12 @@ function updateRotateUI() {
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", autoRotate);
|
||||
btn.classList.toggle("is-stopped", !autoRotate);
|
||||
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
|
||||
const activeLabel =
|
||||
rotationMode === ROTATION_MODE.CRUISE
|
||||
? "巡航"
|
||||
: rotationMode === ROTATION_MODE.MOTION
|
||||
? "动捕"
|
||||
: "自动旋转";
|
||||
if (tooltip) {
|
||||
tooltip.textContent = translateText(autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`);
|
||||
}
|
||||
btn.title = translateText(`${getRotationModeLabel()} · ${activeLabel}`);
|
||||
}
|
||||
|
||||
@@ -6363,10 +6056,8 @@ function updateLayoutUI(container) {
|
||||
const btn = document.getElementById("layout-toggle");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", layoutExpanded);
|
||||
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
|
||||
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
|
||||
btn.title = nextLabel;
|
||||
if (tooltip) tooltip.textContent = nextLabel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { isLegendItemEnabled } from "./legend-filters.js";
|
||||
import * as THREE from "three";
|
||||
import { PMTiles } from "pmtiles";
|
||||
import { VectorTile } from "@mapbox/vector-tile";
|
||||
import Pbf from "pbf";
|
||||
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
||||
import { screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
||||
import { appendSurfaceSegments } from "./earth-surface.js";
|
||||
import { appendSurfaceSegments, createEarthSurfaceGeometry } from "./earth-surface.js";
|
||||
import { setEarthLandMask } from "./earth.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
@@ -230,7 +232,7 @@ function buildLandTexture(features) {
|
||||
|
||||
function makeLandMesh(tex) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 128, 64);
|
||||
const geo = createEarthSurfaceGeometry(r);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
map: tex,
|
||||
@@ -239,6 +241,20 @@ function makeLandMesh(tex) {
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const legendUniforms = {
|
||||
uLegendLand: { value: isLegendItemEnabled("countryBoundaries", "land") ? 1 : 0 },
|
||||
uLegendOcean: { value: isLegendItemEnabled("countryBoundaries", "ocean") ? 1 : 0 },
|
||||
uLegendOceanColor: { value: new THREE.Color(OCEAN_HEX) },
|
||||
};
|
||||
mat.userData.legendUniforms = legendUniforms;
|
||||
mat.onBeforeCompile = shader => {
|
||||
Object.assign(shader.uniforms, legendUniforms);
|
||||
shader.fragmentShader = "uniform float uLegendLand; uniform float uLegendOcean; uniform vec3 uLegendOceanColor;\n" + shader.fragmentShader.replace(
|
||||
"#include <map_fragment>", `#include <map_fragment>
|
||||
bool legendLand = distance(diffuseColor.rgb, uLegendOceanColor) > 0.001;
|
||||
if ((legendLand ? uLegendLand : uLegendOcean) < 0.5) discard;`);
|
||||
};
|
||||
mat.customProgramCacheKey = () => "country-fill-legend-v1";
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-land-ocean";
|
||||
mesh.renderOrder = COUNTRY_BOUNDARY_CONFIG.landRenderOrder;
|
||||
@@ -249,7 +265,7 @@ function makeLandMesh(tex) {
|
||||
|
||||
function makeTintMesh() {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset;
|
||||
const geo = new THREE.SphereGeometry(r, 64, 32);
|
||||
const geo = createEarthSurfaceGeometry(r);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.tintColor, depthWrite: false });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.name = "country-tint";
|
||||
@@ -359,7 +375,7 @@ function makeBoundaryTileObject(features, { claim = false } = {}) {
|
||||
lines.renderOrder = claim
|
||||
? COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02
|
||||
: COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.01;
|
||||
lines.visible = _visible;
|
||||
lines.visible = boundaryLegendVisible();
|
||||
lines.raycast = () => {};
|
||||
if (claim && typeof lines.computeLineDistances === "function") {
|
||||
lines.computeLineDistances();
|
||||
@@ -391,7 +407,7 @@ function trimTileCache() {
|
||||
}
|
||||
|
||||
function setTileObjectVisible(object, visible) {
|
||||
if (object) object.visible = _visible && visible;
|
||||
if (object) object.visible = boundaryLegendVisible() && visible;
|
||||
}
|
||||
|
||||
function setActiveTileKeys(nextKeys) {
|
||||
@@ -401,16 +417,6 @@ function setActiveTileKeys(nextKeys) {
|
||||
});
|
||||
}
|
||||
|
||||
function tileUrlForKey(key) {
|
||||
const [kind, z, x, y] = key.split("/");
|
||||
const prefix = COUNTRY_BOUNDARY_CONFIG.tileBasePath.replace(/\/?$/, "/");
|
||||
const versionSuffix = _tileAssetVersion ? `?v=${encodeURIComponent(_tileAssetVersion)}` : "";
|
||||
if (kind === "claim") {
|
||||
return `${prefix}china-claims/${z}/${x}/${y}.geojson${versionSuffix}`;
|
||||
}
|
||||
return `${prefix}${z}/${x}/${y}.geojson${versionSuffix}`;
|
||||
}
|
||||
|
||||
function versionedBoundaryAssetUrl(path) {
|
||||
const url = new URL(path, COUNTRY_BOUNDARY_CONFIG.tileBasePath);
|
||||
if (_tileAssetVersion) url.searchParams.set("v", _tileAssetVersion);
|
||||
@@ -487,16 +493,6 @@ async function loadPmtilesMvtFeatures(key) {
|
||||
return features;
|
||||
}
|
||||
|
||||
async function loadDebugGeojsonFeatures(key) {
|
||||
const resp = await fetch(tileUrlForKey(key));
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) return [];
|
||||
throw new Error(`boundary tile ${key} HTTP ${resp.status}`);
|
||||
}
|
||||
const payload = await resp.json();
|
||||
return (payload.features || []).filter(f => f.geometry);
|
||||
}
|
||||
|
||||
async function loadBoundaryTile(key) {
|
||||
if (_tileCache.has(key)) {
|
||||
touchTileKey(key);
|
||||
@@ -723,13 +719,13 @@ function updateBoundaryLineDimState() {
|
||||
if (_coastlineLines?.material) {
|
||||
_coastlineLines.material.opacity = COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||||
_coastlineLines.material.needsUpdate = true;
|
||||
_coastlineLines.visible = _visible && !_hoveredFeature;
|
||||
_coastlineLines.visible = boundaryLegendVisible() && !_hoveredFeature;
|
||||
}
|
||||
setBoundaryLinesDimmed(Boolean(_hoveredFeature) || _activeTileKeys.size > 0);
|
||||
}
|
||||
|
||||
function setHoverLinesVisible(visible) {
|
||||
const nextVisible = _visible && Boolean(visible);
|
||||
const nextVisible = boundaryLegendVisible() && Boolean(visible);
|
||||
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
|
||||
if (_hoverLines) _hoverLines.visible = nextVisible;
|
||||
}
|
||||
@@ -1028,6 +1024,7 @@ export async function loadCountryBoundaries() {
|
||||
const coastlineFeatures = baseFeatures.filter(isStandaloneCoastlineFeature);
|
||||
|
||||
_landTexture = buildLandTexture(_features);
|
||||
setEarthLandMask(_landTexture, OCEAN_HEX, COUNTRY_BOUNDARY_CONFIG.landColor);
|
||||
_landMesh = makeLandMesh(_landTexture);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
@@ -1097,12 +1094,12 @@ export function toggleCountryBoundaries(
|
||||
if (_landMesh) {
|
||||
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
if (_boundaryLines) _boundaryLines.visible = _visible;
|
||||
if (_coastlineLines) _coastlineLines.visible = _visible && !_hoveredFeature;
|
||||
if (_boundaryLines) _boundaryLines.visible = boundaryLegendVisible();
|
||||
if (_coastlineLines) _coastlineLines.visible = boundaryLegendVisible() && !_hoveredFeature;
|
||||
_tileCache.forEach((entry, key) => {
|
||||
setTileObjectVisible(entry.object, _visible && _activeTileKeys.has(key));
|
||||
});
|
||||
if (_claimGroup) _claimGroup.visible = _visible && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
|
||||
if (_claimGroup) _claimGroup.visible = boundaryLegendVisible() && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
|
||||
setHoverLinesVisible(_hoveredFeature);
|
||||
|
||||
if (!_visible) {
|
||||
@@ -1175,7 +1172,7 @@ export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
||||
* Returns a country-info object when hovering over land, or null over ocean.
|
||||
*/
|
||||
export function updateCountryBoundaryHover(coords) {
|
||||
if (!_loaded || !_visible) return null;
|
||||
if (!_loaded || !boundaryLegendVisible()) return null;
|
||||
const { lat, lon } = coords;
|
||||
|
||||
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
||||
@@ -1245,6 +1242,7 @@ export function clearCountryBoundaryData() {
|
||||
disposeObj(_claimGroup);
|
||||
disposeObj(_landMesh);
|
||||
disposeObj(_tintMesh);
|
||||
setEarthLandMask(null);
|
||||
_landTexture?.dispose?.();
|
||||
_tileCache.forEach(entry => disposeTileObject(entry.object));
|
||||
_tileCache.clear();
|
||||
@@ -1283,8 +1281,25 @@ export function clearCountryBoundaryData() {
|
||||
|
||||
export function getCountryBoundaryLegendItems() {
|
||||
return [
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色" },
|
||||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.lineColor), label: "国界线", key: "lines" },
|
||||
{ color: hexToStyle(COUNTRY_BOUNDARY_CONFIG.landColor), label: "陆地填色", key: "land" },
|
||||
{ color: hexToStyle(OCEAN_HEX), label: "海洋填色", key: "ocean" },
|
||||
];
|
||||
}
|
||||
|
||||
function boundaryLegendVisible() {
|
||||
return _visible && isLegendItemEnabled("countryBoundaries", "lines");
|
||||
}
|
||||
export function applyCountryBoundaryLegendFilter() {
|
||||
if (!boundaryLegendVisible()) clearCountryBoundaryHover();
|
||||
if (_boundaryLines) _boundaryLines.visible = boundaryLegendVisible();
|
||||
if (_coastlineLines) _coastlineLines.visible = boundaryLegendVisible() && !_hoveredFeature;
|
||||
if (_claimGroup) _claimGroup.visible = boundaryLegendVisible() && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
|
||||
_tileCache.forEach((entry, key) => setTileObjectVisible(entry.object, _activeTileKeys.has(key)));
|
||||
setHoverLinesVisible(_hoveredFeature);
|
||||
const uniforms = _landMesh?.material?.userData?.legendUniforms;
|
||||
if (uniforms) {
|
||||
uniforms.uLegendLand.value = isLegendItemEnabled("countryBoundaries", "land") ? 1 : 0;
|
||||
uniforms.uLegendOcean.value = isLegendItemEnabled("countryBoundaries", "ocean") ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,22 +37,30 @@ function normalizeInteractableItem(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function drawInteractableIcon(context, { glow = false, color = "#7dd3fc" }) {
|
||||
if (glow) {
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = 16;
|
||||
}
|
||||
function drawInteractableIcon(context, { marker, color = "#7dd3fc" }) {
|
||||
const kind = marker?.userData?.kind || "default";
|
||||
context.strokeStyle = color;
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = "rgba(8, 15, 27, 0.92)";
|
||||
context.lineWidth = 7;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
context.arc(0, 0, 27, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (kind === "note") {
|
||||
context.roundRect(-27, -34, 54, 68, 6);
|
||||
context.moveTo(-14, -16); context.lineTo(14, -16);
|
||||
context.moveTo(-14, 0); context.lineTo(14, 0);
|
||||
context.moveTo(-14, 16); context.lineTo(6, 16);
|
||||
} else if (kind === "alert") {
|
||||
context.moveTo(0, -34); context.lineTo(34, 28); context.lineTo(-34, 28); context.closePath();
|
||||
context.moveTo(0, -13); context.lineTo(0, 6);
|
||||
context.moveTo(0, 17); context.lineTo(0, 18);
|
||||
} else {
|
||||
context.moveTo(0, 36);
|
||||
context.bezierCurveTo(-49, -6, -29, -35, 0, -35);
|
||||
context.bezierCurveTo(29, -35, 49, -6, 0, 36);
|
||||
context.moveTo(11, -10); context.arc(0, -10, 11, 0, Math.PI * 2);
|
||||
}
|
||||
context.stroke();
|
||||
context.fillStyle = "rgba(255,255,255,0.95)";
|
||||
context.beginPath();
|
||||
context.arc(0, 0, 9, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
const earthInteractableLayer = createInteractableLayer({
|
||||
@@ -81,6 +89,7 @@ const earthInteractableLayer = createInteractableLayer({
|
||||
},
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
icon: {
|
||||
badge: true,
|
||||
draw: drawInteractableIcon,
|
||||
},
|
||||
getPosition: (item) => ({
|
||||
@@ -88,6 +97,7 @@ const earthInteractableLayer = createInteractableLayer({
|
||||
longitude: item.longitude,
|
||||
}),
|
||||
getKind: (item) => item.kind || "default",
|
||||
getBucketKey: (marker) => marker.userData.kind || "default",
|
||||
getUserData: (item) => ({
|
||||
...item,
|
||||
type: "earth_interactable",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { applyEarthDayNightShader } from "./earth.js";
|
||||
import { CELESTIAL_CONFIG, EARTH_MATERIAL_CONFIG } from "./constants.js";
|
||||
import { CELESTIAL_CONFIG } from "./constants.js";
|
||||
import { configureColorTexture, preserveTextureOutput } from "./texture-colors.js";
|
||||
|
||||
function basicShader(legacy = false) {
|
||||
@@ -27,10 +27,9 @@ for (const legacy of [false, true]) {
|
||||
});
|
||||
|
||||
}
|
||||
test("color maps are sRGB and the surface does not blend the dark base into the image", () => {
|
||||
test("color maps are sRGB without a legacy sky tint", () => {
|
||||
const texture = new THREE.Texture();
|
||||
configureColorTexture(texture);
|
||||
expect(texture.colorSpace).toBe(THREE.SRGBColorSpace);
|
||||
expect(EARTH_MATERIAL_CONFIG.textureOverlayOpacity).toBe(1);
|
||||
expect(CELESTIAL_CONFIG.skyTint).toBeUndefined();
|
||||
});
|
||||
|
||||
168
frontend/public/earth/js/earth-surface-material.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// ShaderMaterial does not apply output conversion automatically. r128 samples
|
||||
// encoded color textures; modern Three uploads them using the sRGB GPU format.
|
||||
const LEGACY_TEXTURE_ENCODING = !THREE.SRGBColorSpace;
|
||||
const COLOR_PIPELINE = `
|
||||
vec3 surfaceLinear(vec3 color) {
|
||||
return mix(color / 12.92, pow((color + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), color));
|
||||
}
|
||||
vec3 surfaceSRGB(vec3 color) {
|
||||
color = max(color, vec3(0.0));
|
||||
return mix(color * 12.92, 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), color));
|
||||
}
|
||||
vec3 surfaceToneMap(vec3 color) {
|
||||
// Bounded filmic shoulder, local to Earth so sky and data colors stay stable.
|
||||
return clamp((color * (2.51 * color + 0.03)) / (color * (2.43 * color + 0.59) + 0.14), 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const SURFACE_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
varying vec3 vWorldNormal;
|
||||
varying vec3 vWorldPosition;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
// normalMatrix handles parent transforms; move its view-space result back
|
||||
// to world space without assuming the camera is at the origin.
|
||||
vWorldNormal = normalize(vec3(normalize(normalMatrix * normal) * mat3(viewMatrix)));
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SURFACE_FRAGMENT_SHADER = `
|
||||
uniform sampler2D uDayMap;
|
||||
uniform sampler2D uLandMask;
|
||||
uniform float uLandMaskAvailable;
|
||||
uniform vec3 uMaskOcean;
|
||||
uniform vec3 uMaskLand;
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uDayNightEnabled;
|
||||
uniform float uExposure;
|
||||
uniform float uOceanAbsorption;
|
||||
uniform vec3 uOceanDeepColor;
|
||||
uniform float uLandSaturation;
|
||||
uniform float uNightDetailGamma;
|
||||
uniform vec3 uNightLandTint;
|
||||
uniform vec3 uNightLandAmbient;
|
||||
uniform vec3 uNightOceanTint;
|
||||
uniform vec3 uNightOceanAmbient;
|
||||
uniform vec2 uTerminator;
|
||||
uniform vec3 uOceanReflection;
|
||||
uniform vec3 uAtmosphereColor;
|
||||
uniform float uInnerRimStrength;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vWorldNormal;
|
||||
varying vec3 vWorldPosition;
|
||||
${COLOR_PIPELINE}
|
||||
void main() {
|
||||
vec3 source = texture2D(uDayMap, vUv).rgb;
|
||||
#ifdef LEGACY_TEXTURE_ENCODING
|
||||
source = surfaceLinear(source);
|
||||
#endif
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
|
||||
float sunFacing = dot(normal, normalize(uSunDirectionWorld));
|
||||
float daylight = mix(1.0, smoothstep(uTerminator.x, uTerminator.y, sunFacing), uDayNightEnabled);
|
||||
float luminance = dot(source, vec3(0.2126, 0.7152, 0.0722));
|
||||
// Until geography is ready use a conservative blue-chroma estimate. Once
|
||||
// available the shared geographic mask is independent of the chosen map.
|
||||
float ocean = smoothstep(0.015, 0.12, source.b - max(source.r, source.g));
|
||||
if (uLandMaskAvailable > 0.5) {
|
||||
vec3 mask = texture2D(uLandMask, vUv).rgb;
|
||||
vec3 range = uMaskLand - uMaskOcean;
|
||||
ocean = 1.0 - clamp(dot(mask - uMaskOcean, range) / max(dot(range, range), 0.00001), 0.0, 1.0);
|
||||
}
|
||||
// Ice retains its source appearance, even over ocean polygons.
|
||||
ocean *= 1.0 - smoothstep(0.45, 0.8, min(source.r, min(source.g, source.b)));
|
||||
vec3 land = mix(vec3(luminance), source, uLandSaturation);
|
||||
vec3 base = mix(land, source * uOceanAbsorption + uOceanDeepColor, ocean);
|
||||
float diffuse = mix(1.0, 0.78 + 0.22 * max(sunFacing, 0.0), uDayNightEnabled);
|
||||
vec3 color = base * diffuse;
|
||||
float fresnel = pow(1.0 - clamp(dot(normal, viewDirection), 0.0, 1.0), 3.4);
|
||||
// No white specular lobe: only grazing-angle sky reflection on water.
|
||||
color += uOceanReflection * fresnel * ocean * daylight;
|
||||
float rimLight = mix(1.0, smoothstep(-0.18, 0.45, sunFacing), uDayNightEnabled);
|
||||
color += uAtmosphereColor * pow(fresnel, 1.6) * uInnerRimStrength * rimLight;
|
||||
// A separate night branch preserves geographic contrast without raising
|
||||
// day exposure or adding a uniform gray veil. All values remain linear.
|
||||
vec3 nightDetail = pow(surfaceToneMap(mix(land, source * uOceanAbsorption, ocean) * uExposure), vec3(uNightDetailGamma));
|
||||
vec3 nightLand = nightDetail * uNightLandTint + uNightLandAmbient;
|
||||
vec3 nightOcean = nightDetail * uNightOceanTint + uNightOceanAmbient;
|
||||
vec3 nightColor = mix(nightLand, nightOcean, ocean);
|
||||
color = mix(nightColor, surfaceToneMap(color * uExposure), daylight);
|
||||
gl_FragColor = vec4(surfaceSRGB(color), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createEarthSurfaceMaterial(config, sunDirection, enabled) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uDayMap: { value: null },
|
||||
uLandMask: { value: null },
|
||||
uLandMaskAvailable: { value: 0 },
|
||||
uMaskOcean: { value: new THREE.Vector3() },
|
||||
uMaskLand: { value: new THREE.Vector3(1, 1, 1) },
|
||||
uSunDirectionWorld: { value: sunDirection.clone() },
|
||||
uDayNightEnabled: { value: enabled ? 1 : 0 },
|
||||
uExposure: { value: config.exposure },
|
||||
uOceanAbsorption: { value: config.oceanAbsorption },
|
||||
uOceanDeepColor: { value: new THREE.Vector3(...config.oceanDeepColor) },
|
||||
uLandSaturation: { value: config.landSaturation },
|
||||
uNightDetailGamma: { value: config.night.detailGamma },
|
||||
uNightLandTint: { value: new THREE.Vector3(...config.night.landTint) },
|
||||
uNightLandAmbient: { value: new THREE.Vector3(...config.night.landAmbient) },
|
||||
uNightOceanTint: { value: new THREE.Vector3(...config.night.oceanTint) },
|
||||
uNightOceanAmbient: { value: new THREE.Vector3(...config.night.oceanAmbient) },
|
||||
uTerminator: { value: new THREE.Vector2(...config.terminator) },
|
||||
uOceanReflection: { value: new THREE.Vector3(...config.oceanReflection) },
|
||||
uAtmosphereColor: { value: new THREE.Vector3(...config.atmosphereColor) },
|
||||
uInnerRimStrength: { value: config.innerRimStrength },
|
||||
},
|
||||
defines: LEGACY_TEXTURE_ENCODING ? { LEGACY_TEXTURE_ENCODING: 1 } : {},
|
||||
vertexShader: SURFACE_VERTEX_SHADER,
|
||||
fragmentShader: SURFACE_FRAGMENT_SHADER,
|
||||
side: THREE.FrontSide,
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEarthAtmosphereMaterial(config, sunDirection, enabled) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uSunDirectionWorld: { value: sunDirection.clone() },
|
||||
uDayNightEnabled: { value: enabled ? 1 : 0 },
|
||||
uColor: { value: new THREE.Vector3(...config.color) },
|
||||
uStrength: { value: config.strength },
|
||||
},
|
||||
vertexShader: SURFACE_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uDayNightEnabled;
|
||||
uniform vec3 uColor;
|
||||
uniform float uStrength;
|
||||
varying vec3 vWorldNormal;
|
||||
varying vec3 vWorldPosition;
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
|
||||
float facing = clamp(abs(dot(normal, viewDirection)), 0.0, 1.0);
|
||||
float rim = pow(1.0 - facing, 3.0) * smoothstep(0.0, 0.22, facing);
|
||||
float daylight = smoothstep(-0.22, 0.45, dot(normal, normalize(uSunDirectionWorld)));
|
||||
float illumination = mix(1.0, mix(0.12, 1.0, daylight), uDayNightEnabled);
|
||||
gl_FragColor = vec4(uColor, rim * illumination * uStrength);
|
||||
}
|
||||
`,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
toneMapped: false,
|
||||
});
|
||||
}
|
||||
77
frontend/public/earth/js/earth-surface-material.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
createEarth, getEarthSurfacePickTarget, setDayNightEnabled,
|
||||
setEarthLandMask, setEarthSunDirection, setEarthTextureVisible,
|
||||
} from "./earth.js";
|
||||
import { CONFIG, EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET } from "./constants.js";
|
||||
import { createCableLineBatch } from "./cable-batches.js";
|
||||
|
||||
test("the real texture shell retains its geometry and pick target, with one atmosphere draw", () => {
|
||||
const earth = createEarth(new THREE.Scene());
|
||||
const surface = earth.getObjectByName("earth-high-res-texture-overlay");
|
||||
const atmosphere = earth.getObjectByName("earth-atmosphere-scattering");
|
||||
expect(surface.geometry.parameters.radius).toBe(CONFIG.earthRadius + EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET);
|
||||
expect(surface.material.isShaderMaterial).toBe(true);
|
||||
expect(surface.material.depthWrite).toBe(false);
|
||||
expect(surface.raycast).toBe(THREE.Mesh.prototype.raycast);
|
||||
expect(earth.children.filter(child => child.material?.isShaderMaterial).length).toBe(2);
|
||||
expect(atmosphere.material.side).toBe(THREE.BackSide);
|
||||
expect(atmosphere.material.depthWrite).toBe(false);
|
||||
expect(atmosphere.material.depthTest).toBe(true);
|
||||
expect(getEarthSurfacePickTarget()).toBe(earth);
|
||||
setEarthTextureVisible(false);
|
||||
expect(getEarthSurfacePickTarget()).toBe(earth);
|
||||
setEarthTextureVisible(true);
|
||||
});
|
||||
|
||||
test("world-space sunlight, day/night and geographic masking update the visible shader owners", () => {
|
||||
const earth = createEarth(new THREE.Scene());
|
||||
const surface = earth.getObjectByName("earth-high-res-texture-overlay");
|
||||
const atmosphere = earth.getObjectByName("earth-atmosphere-scattering");
|
||||
const direction = new THREE.Vector3(-2, 1, 3);
|
||||
setEarthSunDirection(direction);
|
||||
for (const mesh of [surface, atmosphere]) {
|
||||
expect(mesh.material.uniforms.uSunDirectionWorld.value.distanceTo(direction.clone().normalize())).toBeLessThan(1e-8);
|
||||
}
|
||||
setDayNightEnabled(false);
|
||||
expect(surface.material.uniforms.uDayNightEnabled.value).toBe(0);
|
||||
expect(atmosphere.material.uniforms.uDayNightEnabled.value).toBe(0);
|
||||
setDayNightEnabled(true);
|
||||
const mask = new THREE.Texture();
|
||||
setEarthLandMask(mask, 0x010609, 0x080f1b);
|
||||
expect(surface.material.uniforms.uLandMask.value).toBe(mask);
|
||||
expect(surface.material.uniforms.uMaskLand.value.z).toBeCloseTo(27 / 255);
|
||||
setEarthLandMask(null);
|
||||
expect(surface.material.uniforms.uLandMask.value).toBeNull();
|
||||
expect(surface.material.uniforms.uLandMaskAvailable.value).toBe(0);
|
||||
});
|
||||
|
||||
test("cable pulses use arc length while keeping original pick objects and independent states", () => {
|
||||
const lines = [0, 1].map(index => {
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, index, 0), new THREE.Vector3(1, index, 0), new THREE.Vector3(4, index, 0),
|
||||
]);
|
||||
const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0x328ebc, opacity: 0.7 }));
|
||||
line.userData.cableId = `route-${index}`;
|
||||
return line;
|
||||
});
|
||||
const originalPositions = lines[0].geometry.attributes.position.array.slice();
|
||||
const originalRaycast = lines[0].raycast;
|
||||
const batch = createCableLineBatch(lines);
|
||||
const progress = batch.object.geometry.attributes.progress;
|
||||
expect([...progress.array.slice(0, 4)]).toEqual([0, 0.25, 0.25, 1]);
|
||||
lines[0].material.userData.cableSelected = true;
|
||||
batch.object.onBeforeRender();
|
||||
const styles = batch.object.material.uniforms.styles.value.image.data;
|
||||
expect(styles[3]).toBeLessThan(0);
|
||||
expect(styles[7]).toBeGreaterThan(0);
|
||||
lines[0].visible = false;
|
||||
batch.object.onBeforeRender();
|
||||
expect(styles[3]).toBe(0);
|
||||
expect(lines[0].raycast).toBe(originalRaycast);
|
||||
expect(lines[0].userData.cableId).toBe("route-0");
|
||||
expect(lines[0].geometry.attributes.position.array).toEqual(originalPositions);
|
||||
batch.dispose();
|
||||
expect(lines[0].geometry.attributes.position.count).toBe(3);
|
||||
});
|
||||
@@ -2,6 +2,16 @@ import * as THREE from "three";
|
||||
import { CONFIG, EARTH_MATERIAL_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
// Near-concentric layers must share triangle edges: a coarse sphere can cut
|
||||
// through a finer inner sphere even when its nominal radius is larger.
|
||||
export function createEarthSurfaceGeometry(radius = CONFIG.earthRadius) {
|
||||
return new THREE.SphereGeometry(
|
||||
radius,
|
||||
EARTH_MATERIAL_CONFIG.textureWidthSegments,
|
||||
EARTH_MATERIAL_CONFIG.textureHeightSegments,
|
||||
);
|
||||
}
|
||||
|
||||
let cachedGrid = null;
|
||||
function surfaceGrid(radius, width, height) {
|
||||
const key = `${radius}:${width}:${height}`;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { CABLE_CONFIG, CONFIG, COUNTRY_BOUNDARY_CONFIG, EARTH_MATERIAL_CONFIG } from "./constants.js";
|
||||
import { buildHoverGlowLines, buildHoverLines } from "./country-boundaries.js";
|
||||
import { createCountryBoundaryLayer, setSurfaceTintEnabled, toggleCountryBoundaries, buildHoverGlowLines, buildHoverLines } from "./country-boundaries.js";
|
||||
import { createEarth } from "./earth.js";
|
||||
import { distanceForGlobeZoom } from "./globe-navigation.js";
|
||||
import { appendSurfaceSegments, earthSurfacePoint } from "./earth-surface.js";
|
||||
|
||||
test("geographic anchors sit on actual Earth UV triangles, including near the limb", () => {
|
||||
@@ -49,3 +51,60 @@ test("all boundary emphasis stays below landing points and uses surface occlusio
|
||||
lines.geometry.dispose(); lines.material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("country loading tint cannot intersect the base before any map data arrives", () => {
|
||||
const root = createEarth(new THREE.Scene());
|
||||
createCountryBoundaryLayer(root);
|
||||
setSurfaceTintEnabled(true);
|
||||
toggleCountryBoundaries(true, { showTint: true, showLandFill: false });
|
||||
const tint = root.getObjectByName("country-tint");
|
||||
expect(tint.visible).toBe(true);
|
||||
const baseSurface = new THREE.Mesh(root.geometry, new THREE.MeshBasicMaterial());
|
||||
const tintSurface = new THREE.Mesh(tint.geometry, new THREE.MeshBasicMaterial());
|
||||
baseSurface.updateMatrixWorld();
|
||||
tintSurface.updateMatrixWorld();
|
||||
let minimumGap = Infinity;
|
||||
for (const lat of [0.7, 3, 28, 50, 70]) for (const lon of [1.8, 32.7, 94.5]) {
|
||||
const direction = earthSurfacePoint(lat, lon).normalize();
|
||||
const ray = new THREE.Raycaster(direction.clone().multiplyScalar(120), direction.clone().negate());
|
||||
const baseHit = ray.intersectObject(baseSurface)[0];
|
||||
const tintHit = ray.intersectObject(tintSurface)[0];
|
||||
expect(Boolean(baseHit && tintHit)).toBe(true);
|
||||
minimumGap = Math.min(minimumGap, baseHit.distance - tintHit.distance);
|
||||
}
|
||||
expect(minimumGap).toBeGreaterThan(0.31);
|
||||
expect(tint.geometry.parameters.widthSegments).toBe(root.geometry.parameters.widthSegments);
|
||||
expect(tint.geometry.parameters.heightSegments).toBe(root.geometry.parameters.heightSegments);
|
||||
// At 50% zoom an aligned but too-close shell can still quantize to the
|
||||
// same depth. Require two 24-bit depth steps at the real camera near/far.
|
||||
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 5000);
|
||||
baseSurface.rotation.set(0.41, 0.7, 0);
|
||||
tintSurface.rotation.copy(baseSurface.rotation);
|
||||
baseSurface.updateMatrixWorld();
|
||||
tintSurface.updateMatrixWorld();
|
||||
for (const zoom of [0.5, 1, 5]) {
|
||||
camera.position.z = distanceForGlobeZoom(zoom, CONFIG.earthRadius, CONFIG.defaultCameraZ);
|
||||
camera.updateMatrixWorld();
|
||||
const discRadius = CONFIG.earthRadius / Math.sqrt(camera.position.z ** 2 - CONFIG.earthRadius ** 2)
|
||||
/ Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
|
||||
for (const fraction of [0, 0.4, 0.8]) {
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.setFromCamera(new THREE.Vector2(discRadius * fraction, 0), camera);
|
||||
const baseHit = ray.intersectObject(baseSurface)[0];
|
||||
const tintHit = ray.intersectObject(tintSurface)[0];
|
||||
const baseDepth = baseHit.point.clone().project(camera).z;
|
||||
const tintDepth = tintHit.point.clone().project(camera).z;
|
||||
expect((baseDepth - tintDepth) / 2).toBeGreaterThan(2 / (2 ** 24 - 1));
|
||||
}
|
||||
}
|
||||
expect(COUNTRY_BOUNDARY_CONFIG.tintAltitudeOffset).toBe(COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset);
|
||||
expect(tint.material.depthTest).toBe(true);
|
||||
expect(tint.material.depthWrite).toBe(false);
|
||||
toggleCountryBoundaries(false);
|
||||
expect(tint.visible).toBe(false);
|
||||
toggleCountryBoundaries(true, { showTint: true });
|
||||
expect(tint.visible).toBe(true);
|
||||
baseSurface.material.dispose();
|
||||
tintSurface.material.dispose();
|
||||
root.traverse(object => { object.geometry?.dispose(); object.material?.dispose(); });
|
||||
});
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
EARTH_CONFIG,
|
||||
EARTH_MATERIAL_CONFIG,
|
||||
GRID_CONFIG,
|
||||
STARFIELD_CONFIG,
|
||||
TERRAIN_CONFIG,
|
||||
} from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
import { createEarthSurfaceGeometry } from './earth-surface.js';
|
||||
import { configureColorTexture, preserveTextureOutput, shaderOutputMarker } from './texture-colors.js';
|
||||
import { createEarthMapController } from './earth-map-sources.js';
|
||||
import { earthTextureImages } from './earth-texture-image.js';
|
||||
import { createEarthAtmosphereMaterial, createEarthSurfaceMaterial } from './earth-surface-material.js';
|
||||
|
||||
export let earth = null;
|
||||
export let clouds = null;
|
||||
@@ -28,7 +29,6 @@ let _earthTextureOverlayMaterial = null;
|
||||
let _earthShaders = [];
|
||||
let _dayNightEnabled = true;
|
||||
let _textureVisible = true;
|
||||
let _earthRimGlow = null;
|
||||
let _cloudTexture = null;
|
||||
let _cloudTextureLoadPromise = null;
|
||||
const _earthSunDirection = new THREE.Vector3(
|
||||
@@ -50,6 +50,7 @@ const earthMap = createEarthMapController({
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.anisotropy = 16;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
@@ -57,11 +58,9 @@ const earthMap = createEarthMapController({
|
||||
},
|
||||
applyTexture: (texture) => {
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = texture;
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
_earthTextureOverlayMaterial.uniforms.uDayMap.value = texture;
|
||||
}
|
||||
if (_earthTextureOverlay) _earthTextureOverlay.visible = _textureVisible && Boolean(texture);
|
||||
if (_earthRimGlow) _earthRimGlow.visible = !(_textureVisible && texture);
|
||||
},
|
||||
onChange: (detail) => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -118,7 +117,7 @@ export function applyEarthDayNightShader(material, options = {}) {
|
||||
|
||||
export function createEarth(scene) {
|
||||
_earthShaders = [];
|
||||
const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128);
|
||||
const geometry = createEarthSurfaceGeometry(CONFIG.earthRadius);
|
||||
|
||||
const C = EARTH_MATERIAL_CONFIG;
|
||||
|
||||
@@ -141,21 +140,11 @@ export function createEarth(scene) {
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureOverlayGeometry = new THREE.SphereGeometry(
|
||||
const textureOverlayGeometry = createEarthSurfaceGeometry(
|
||||
CONFIG.earthRadius + C.textureOverlayAltitudeOffset,
|
||||
C.textureWidthSegments,
|
||||
C.textureHeightSegments,
|
||||
);
|
||||
_earthTextureOverlayMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
opacity: C.textureOverlayOpacity,
|
||||
side: THREE.FrontSide,
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
});
|
||||
applyEarthDayNightShader(_earthTextureOverlayMaterial);
|
||||
_earthTextureOverlayMaterial = createEarthSurfaceMaterial(C.surface, _earthSunDirection, _dayNightEnabled);
|
||||
_earthShaders.push(_earthTextureOverlayMaterial);
|
||||
_earthTextureOverlay = new THREE.Mesh(
|
||||
textureOverlayGeometry,
|
||||
_earthTextureOverlayMaterial,
|
||||
@@ -182,115 +171,14 @@ export function createEarth(scene) {
|
||||
occluder.renderOrder = 0.5;
|
||||
earth.add(occluder);
|
||||
|
||||
// Keep the original atmosphere shells on the legacy camera-facing shader so
|
||||
// they stay as a soft edge cue instead of becoming a visible transparent hull
|
||||
// at close zoom levels.
|
||||
const ATMOS_VERTEX_SHADER = `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const RIM_VERTEX_SHADER = `
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDirection;
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
vViewDirection = normalize(-mvPosition.xyz);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fresnel atmosphere — inner rim
|
||||
const [ir, ig, ib] = C.atmosInnerColor;
|
||||
const atmosInnerGeo = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.atmosInnerRadiusFactor,
|
||||
C.atmosInnerSegments,
|
||||
C.atmosInnerSegments,
|
||||
const atmosphere = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(CONFIG.earthRadius * C.atmosphere.radiusFactor, 64, 48),
|
||||
createEarthAtmosphereMaterial(C.atmosphere, _earthSunDirection, _dayNightEnabled),
|
||||
);
|
||||
const atmosInnerMat = new THREE.ShaderMaterial({
|
||||
vertexShader: ATMOS_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
|
||||
float intensity = pow(rim, ${C.atmosInnerRimPower.toFixed(1)});
|
||||
gl_FragColor = vec4(${ir.toFixed(2)}, ${ig.toFixed(2)}, ${ib.toFixed(2)}, intensity * ${C.atmosInnerIntensity.toFixed(2)});
|
||||
}
|
||||
`,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const atmosInner = new THREE.Mesh(atmosInnerGeo, atmosInnerMat);
|
||||
atmosInner.renderOrder = 1;
|
||||
earth.add(atmosInner);
|
||||
|
||||
// Fresnel atmosphere — outer corona
|
||||
const [outerR, outerG, outerB] = C.atmosOuterColor;
|
||||
const atmosOuterGeo = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.atmosOuterRadiusFactor,
|
||||
C.atmosOuterSegments,
|
||||
C.atmosOuterSegments,
|
||||
);
|
||||
const atmosOuterMat = new THREE.ShaderMaterial({
|
||||
vertexShader: ATMOS_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0)));
|
||||
// Fade at the outer silhouette instead of drawing a hard luminous ring.
|
||||
float facing = abs(dot(normalize(vNormal), vec3(0.0, 0.0, 1.0)));
|
||||
float edgeFade = smoothstep(0.0, ${C.atmosOuterEdgeFeather.toFixed(2)}, facing);
|
||||
float intensity = pow(rim, ${C.atmosOuterRimPower.toFixed(1)}) * edgeFade;
|
||||
gl_FragColor = vec4(${outerR.toFixed(2)}, ${outerG.toFixed(2)}, ${outerB.toFixed(2)}, intensity * ${C.atmosOuterIntensity.toFixed(2)});
|
||||
}
|
||||
`,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const atmosOuter = new THREE.Mesh(atmosOuterGeo, atmosOuterMat);
|
||||
atmosOuter.renderOrder = 1;
|
||||
earth.add(atmosOuter);
|
||||
|
||||
// Fresnel rim cue: an outer shell keeps the edge tied to the globe while bypassing
|
||||
// the darker fill layers that can otherwise hide a same-radius glow. Unlike the
|
||||
// legacy atmosphere shells, this one uses the real view direction so its highlight
|
||||
// stays attached to the visible globe edge while zooming.
|
||||
const [rr, rg, rb] = C.rimGlowColor;
|
||||
const rimGlowGeo = new THREE.SphereGeometry(
|
||||
CONFIG.earthRadius * C.rimGlowRadiusFactor,
|
||||
C.rimGlowSegments,
|
||||
C.rimGlowSegments,
|
||||
);
|
||||
const rimGlowMat = new THREE.ShaderMaterial({
|
||||
vertexShader: RIM_VERTEX_SHADER,
|
||||
fragmentShader: `
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vViewDirection;
|
||||
void main() {
|
||||
float viewFacing = max(dot(normalize(vNormal), normalize(vViewDirection)), 0.0);
|
||||
float rim = 1.0 - viewFacing;
|
||||
float alpha = pow(rim, ${C.rimGlowPower.toFixed(1)}) * ${C.rimGlowIntensity.toFixed(2)};
|
||||
gl_FragColor = vec4(${rr.toFixed(2)}, ${rg.toFixed(2)}, ${rb.toFixed(2)}, alpha);
|
||||
}
|
||||
`,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.FrontSide,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
_earthRimGlow = new THREE.Mesh(rimGlowGeo, rimGlowMat);
|
||||
_earthRimGlow.name = "earth-rim-glow";
|
||||
_earthRimGlow.renderOrder = C.rimGlowRenderOrder;
|
||||
earth.add(_earthRimGlow);
|
||||
atmosphere.name = "earth-atmosphere-scattering";
|
||||
atmosphere.renderOrder = 1;
|
||||
_earthShaders.push(atmosphere.material);
|
||||
earth.add(atmosphere);
|
||||
|
||||
// Texture is loaded separately via loadEarthTexture() for staged loading
|
||||
return earth;
|
||||
@@ -302,7 +190,7 @@ export function createClouds(scene, earthObj) {
|
||||
CLOUD_LAYER_CONFIG.widthSegments,
|
||||
CLOUD_LAYER_CONFIG.heightSegments,
|
||||
);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: CLOUD_LAYER_CONFIG.opacity,
|
||||
depthTest: true,
|
||||
@@ -310,9 +198,11 @@ export function createClouds(scene, earthObj) {
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
applyEarthDayNightShader(material);
|
||||
|
||||
clouds = new THREE.Mesh(geometry, material);
|
||||
clouds.name = "earth-atmosphere-clouds";
|
||||
clouds.renderOrder = 1.1;
|
||||
clouds.visible = false;
|
||||
earthObj.add(clouds);
|
||||
|
||||
@@ -343,6 +233,7 @@ export function loadCloudTexture() {
|
||||
textureLoader.load(
|
||||
CLOUD_LAYER_CONFIG.textureUrl,
|
||||
(texture) => {
|
||||
configureColorTexture(texture);
|
||||
_cloudTexture = texture;
|
||||
clouds.material.map = texture;
|
||||
clouds.material.needsUpdate = true;
|
||||
@@ -401,36 +292,6 @@ export function toggleTerrain(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createStars(scene) {
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = STARFIELD_CONFIG.count;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
|
||||
for (let i = 0; i < starCount * 3; i += 3) {
|
||||
const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
|
||||
starPositions[i] = r * Math.sin(phi) * Math.cos(theta);
|
||||
starPositions[i + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
starPositions[i + 2] = r * Math.cos(phi);
|
||||
}
|
||||
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
|
||||
const starMaterial = new THREE.PointsMaterial({
|
||||
color: STARFIELD_CONFIG.color,
|
||||
size: STARFIELD_CONFIG.size,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
const stars = new THREE.Points(starGeometry, starMaterial);
|
||||
scene.add(stars);
|
||||
|
||||
return stars;
|
||||
}
|
||||
|
||||
let latitudeLines = [];
|
||||
let longitudeLines = [];
|
||||
|
||||
@@ -513,6 +374,16 @@ export function clearEarthTexture() {
|
||||
earthMap.clear();
|
||||
}
|
||||
|
||||
export function setEarthLandMask(texture, oceanHex = 0, landHex = 0xffffff) {
|
||||
const uniforms = _earthTextureOverlayMaterial?.uniforms;
|
||||
if (!uniforms) return;
|
||||
uniforms.uLandMask.value = texture;
|
||||
uniforms.uLandMaskAvailable.value = texture ? 1 : 0;
|
||||
for (const [uniform, color] of [[uniforms.uMaskOcean, oceanHex], [uniforms.uMaskLand, landHex]]) {
|
||||
uniform.value.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255);
|
||||
}
|
||||
}
|
||||
|
||||
export function setEarthSunDirection(direction) {
|
||||
if (!direction) return;
|
||||
_earthSunDirection.copy(direction).normalize();
|
||||
@@ -556,11 +427,7 @@ export function setEarthTextureVisible(visible) {
|
||||
_earthTextureOverlay.visible = textureShowing;
|
||||
}
|
||||
if (_earthTextureOverlayMaterial) {
|
||||
_earthTextureOverlayMaterial.map = earthMap.getTexture();
|
||||
_earthTextureOverlayMaterial.needsUpdate = true;
|
||||
}
|
||||
if (_earthRimGlow) {
|
||||
_earthRimGlow.visible = !textureShowing;
|
||||
_earthTextureOverlayMaterial.uniforms.uDayMap.value = earthMap.getTexture();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
65
frontend/public/earth/js/frame-stats.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getEarthLocale } from "./i18n.js";
|
||||
let enabled = false;
|
||||
let output = null;
|
||||
const samples = [];
|
||||
const frameWindows = [];
|
||||
const AVERAGE_WINDOW_MS = 30000;
|
||||
let previousStart = 0, reportedAt = 0;
|
||||
function positionFpsOutput() {
|
||||
if (!output || !enabled) return;
|
||||
const brand = document.querySelector("#brand-root .earth-brand") || document.getElementById("brand-panel");
|
||||
const rect = brand?.getBoundingClientRect();
|
||||
const shown = rect && rect.width > 0 && rect.right > 0;
|
||||
output.style.left = `${shown ? Math.min(rect.right + 14, window.innerWidth - 90) : 14}px`;
|
||||
output.style.top = `${shown ? Math.max(12, rect.top + 2) : 14}px`;
|
||||
}
|
||||
export function summarizeFrames(frames) {
|
||||
if (!frames.length) return null;
|
||||
const intervals = frames.map(frame => frame.interval).sort((a,b)=>a-b);
|
||||
const mean = intervals.reduce((a,b)=>a+b,0) / intervals.length;
|
||||
return { fps:1000/mean, mean,
|
||||
cpu:frames.reduce((sum,frame)=>sum+frame.cpu,0)/frames.length,
|
||||
p95:intervals[Math.min(intervals.length-1,Math.floor(intervals.length*.95))] };
|
||||
}
|
||||
if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
|
||||
samples.length = 0; frameWindows.length = 0; previousStart = 0; reportedAt = 0;
|
||||
if (output && enabled) output.textContent = "— FPS";
|
||||
});
|
||||
export function summarizeFrameWindows(windows) {
|
||||
const duration = windows.reduce((sum, window) => sum + window.duration, 0);
|
||||
const count = windows.reduce((sum, window) => sum + window.count, 0);
|
||||
return { fps: duration > 0 ? count * 1000 / duration : 0, seconds: duration / 1000 };
|
||||
}
|
||||
export function getFpsEnabled() { return enabled; }
|
||||
export function setFpsEnabled(value) {
|
||||
enabled = Boolean(value);
|
||||
output = document.getElementById("earth-fps");
|
||||
if (output) { output.hidden = !enabled; output.textContent = "— FPS"; }
|
||||
samples.length = 0; frameWindows.length = 0; previousStart = 0; reportedAt = 0;
|
||||
positionFpsOutput();
|
||||
document.querySelectorAll("[data-fps-toggle]").forEach(input => { input.checked = enabled; });
|
||||
}
|
||||
export function beginFrameStats() {
|
||||
return enabled ? performance.now() : 0;
|
||||
}
|
||||
export function finishFrameStats(start, renderer) {
|
||||
if (!enabled || !output) return;
|
||||
const now = performance.now();
|
||||
if (document.hidden) {
|
||||
samples.length = 0; frameWindows.length = 0; previousStart = 0; reportedAt = start;
|
||||
}
|
||||
if (previousStart) samples.push({ interval: start - previousStart, cpu: now - start });
|
||||
previousStart = start;
|
||||
if (!reportedAt) reportedAt = start;
|
||||
if (start - reportedAt < 1000 || samples.length < 2) return;
|
||||
const { fps, mean, cpu, p95 } = summarizeFrames(samples);
|
||||
frameWindows.push({ at: start, duration: mean * samples.length, count: samples.length });
|
||||
while (frameWindows.length > 1 && frameWindows[0].at < start - AVERAGE_WINDOW_MS) frameWindows.shift();
|
||||
const average = summarizeFrameWindows(frameWindows);
|
||||
output.dataset.averageFps = average.fps.toFixed(2);
|
||||
output.dataset.sampleSeconds = average.seconds.toFixed(1);
|
||||
output.textContent = `${Math.round(fps)} FPS`;
|
||||
positionFpsOutput();
|
||||
output.title = `${getEarthLocale() === "en-US" ? "Frame interval" : "帧间隔"} ${mean.toFixed(2)} ms · P95 ${p95.toFixed(2)} ms · CPU ${cpu.toFixed(2)} ms · Draw calls ${renderer.info.render.calls} · ${average.seconds.toFixed(1)}s AVG ${average.fps.toFixed(1)} FPS`;
|
||||
samples.length = 0; reportedAt = start;
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const DRAG_INERTIA = Object.freeze({
|
||||
frameMs: 16,
|
||||
minimumSampleMs: 8,
|
||||
maximumFrameMs: 40,
|
||||
releasePauseMs: 80,
|
||||
minimumSpeed: 0.000005,
|
||||
maximumSpeed: 0.003,
|
||||
damping: 0.92,
|
||||
});
|
||||
|
||||
// Magnify the apparent sphere radius, not the camera's distance to its center.
|
||||
// At zoom=1 the existing camera pose is unchanged; larger zoom never enters it.
|
||||
export function distanceForGlobeZoom(zoom, radius, defaultDistance, minimumAltitude = 10) {
|
||||
@@ -23,91 +13,55 @@ export function globeZoomForDistance(distance, radius, defaultDistance) {
|
||||
Math.max(0.0001, distance ** 2 - radius ** 2));
|
||||
}
|
||||
|
||||
// Use the actual camera frustum so portrait screens switch by their width,
|
||||
// while landscape screens switch by their height. Decide only on pointer-down.
|
||||
export function isGlobeClippedByViewport(earth, camera, radius) {
|
||||
camera.updateMatrixWorld();
|
||||
const center = earth.getWorldPosition(new THREE.Vector3());
|
||||
const scale = earth.getWorldScale(new THREE.Vector3());
|
||||
const worldRadius = radius * Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z));
|
||||
const viewProjection = new THREE.Matrix4().multiplyMatrices(
|
||||
camera.projectionMatrix, camera.matrixWorldInverse,
|
||||
// Keep the legacy pixel sensitivity until the globe fills the viewport. At
|
||||
// extreme zoom cap it by the surface distance, with no pointer/sphere gating.
|
||||
export function globeDragRotationFactor(zoom, camera, viewport, config) {
|
||||
const legacy = config.dragRotationFactorBase * THREE.MathUtils.clamp(
|
||||
Math.pow(Math.max(zoom, 0.01), -config.dragRotationZoomExponent),
|
||||
config.dragRotationScaleMin, config.dragRotationScaleMax,
|
||||
);
|
||||
const frustum = new THREE.Frustum().setFromProjectionMatrix(viewProjection);
|
||||
return frustum.planes.slice(0, 4).some(plane => plane.distanceToPoint(center) < worldRadius);
|
||||
const distance = camera.position.length();
|
||||
const radius = config.earthRadius;
|
||||
const tangent = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
||||
const diameter = radius / Math.sqrt(Math.max(1, distance * distance - radius * radius)) * viewport.height / tangent;
|
||||
const coverage = diameter / Math.max(1, Math.min(viewport.width, viewport.height));
|
||||
const blend = THREE.MathUtils.smoothstep(coverage, 1, 1.5);
|
||||
const surface = 2 * Math.max(config.minimumCameraAltitude, distance - radius) * tangent / (Math.max(1, viewport.height) * radius);
|
||||
return legacy + (Math.min(legacy, surface) - legacy) * blend;
|
||||
}
|
||||
|
||||
export function createSurfaceDrag(radius) {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const center = new THREE.Vector3();
|
||||
const hit = new THREE.Vector3();
|
||||
const sphere = new THREE.Sphere(center, radius);
|
||||
const anchor = new THREE.Vector3();
|
||||
const current = new THREE.Vector3();
|
||||
// All pointer drags are pixel-driven, including outside the silhouette. Rotate
|
||||
// around the geographic axis horizontally and camera right vertically. Keep
|
||||
// quaternion math so Euler rebasing cannot reverse drag or add unwanted bank.
|
||||
export function createScreenSpaceDrag() {
|
||||
const pending = new THREE.Vector2();
|
||||
const cameraRotation = new THREE.Quaternion();
|
||||
const parentInverse = new THREE.Quaternion();
|
||||
const initial = new THREE.Quaternion();
|
||||
const change = new THREE.Quaternion();
|
||||
const previous = new THREE.Quaternion();
|
||||
const axis = new THREE.Vector3();
|
||||
let active = false, speed = 0, lastMoveAt = 0;
|
||||
|
||||
function pointerNormal(earth, camera, point, viewport, output) {
|
||||
camera.updateMatrixWorld();
|
||||
earth.getWorldPosition(center);
|
||||
raycaster.setFromCamera(pointer.set(
|
||||
(point.x - viewport.left) / viewport.width * 2 - 1,
|
||||
1 - (point.y - viewport.top) / viewport.height * 2,
|
||||
), camera);
|
||||
if (!raycaster.ray.intersectSphere(sphere, hit)) {
|
||||
// No real surface point exists here. A closest-point substitute folds
|
||||
// back toward the camera as the pointer moves away, reversing rotation.
|
||||
return false;
|
||||
}
|
||||
output.copy(hit).sub(center).normalize().applyQuaternion(parentInverse);
|
||||
return true;
|
||||
}
|
||||
|
||||
const screenUp = new THREE.Vector3();
|
||||
const north = new THREE.Vector3();
|
||||
const localNorth = new THREE.Vector3(0, 1, 0);
|
||||
const change = new THREE.Quaternion();
|
||||
return {
|
||||
begin(earth, camera, point, viewport, now) {
|
||||
speed = 0; active = true; lastMoveAt = now;
|
||||
move(yaw, pitch) { pending.x += yaw; pending.y += pitch; },
|
||||
stop() { pending.set(0, 0); },
|
||||
advance(earth, camera, smoothing) {
|
||||
const length = pending.length();
|
||||
if (!length) return;
|
||||
// Bound a delayed frame without throwing away accumulated pointer motion.
|
||||
const fraction = Math.min(smoothing, (Math.PI / 4) / length);
|
||||
camera.getWorldQuaternion(cameraRotation);
|
||||
parentInverse.identity();
|
||||
if (earth.parent) earth.parent.getWorldQuaternion(parentInverse).invert();
|
||||
active = pointerNormal(earth, camera, point, viewport, anchor);
|
||||
initial.copy(earth.quaternion);
|
||||
return active;
|
||||
},
|
||||
move(earth, camera, point, viewport, now) {
|
||||
if (!active) return;
|
||||
if (!pointerNormal(earth, camera, point, viewport, current)) {
|
||||
speed = 0;
|
||||
lastMoveAt = now;
|
||||
return;
|
||||
}
|
||||
previous.copy(earth.quaternion);
|
||||
change.setFromUnitVectors(anchor, current);
|
||||
earth.quaternion.copy(initial).premultiply(change);
|
||||
change.copy(earth.quaternion).multiply(previous.invert()).normalize();
|
||||
if (change.w < 0) change.set(-change.x, -change.y, -change.z, -change.w);
|
||||
axis.set(change.x, change.y, change.z);
|
||||
const angle = 2 * Math.atan2(axis.length(), Math.max(0, change.w));
|
||||
axis.normalize();
|
||||
speed = Math.min(DRAG_INERTIA.maximumSpeed, angle / Math.max(DRAG_INERTIA.minimumSampleMs, now - lastMoveAt));
|
||||
lastMoveAt = now;
|
||||
},
|
||||
end(now, cancelled = false) {
|
||||
active = false;
|
||||
if (cancelled || now - lastMoveAt > DRAG_INERTIA.releasePauseMs) speed = 0;
|
||||
},
|
||||
stop() { active = false; speed = 0; },
|
||||
hasInertia() { return !active && speed > DRAG_INERTIA.minimumSpeed; },
|
||||
advance(earth, deltaMs) {
|
||||
if (active || speed <= DRAG_INERTIA.minimumSpeed) return false;
|
||||
const elapsed = Math.min(deltaMs, DRAG_INERTIA.maximumFrameMs);
|
||||
change.setFromAxisAngle(axis, speed * elapsed);
|
||||
screenUp.set(0, 1, 0).applyQuaternion(cameraRotation).applyQuaternion(parentInverse);
|
||||
north.copy(localNorth).applyQuaternion(earth.quaternion);
|
||||
const direction = north.dot(screenUp) < 0 ? -1 : 1;
|
||||
change.setFromAxisAngle(localNorth, pending.x * fraction * direction);
|
||||
earth.quaternion.multiply(change);
|
||||
axis.set(1, 0, 0).applyQuaternion(cameraRotation).applyQuaternion(parentInverse);
|
||||
change.setFromAxisAngle(axis, pending.y * fraction);
|
||||
earth.quaternion.premultiply(change).normalize();
|
||||
speed *= Math.pow(DRAG_INERTIA.damping, elapsed / DRAG_INERTIA.frameMs);
|
||||
return true;
|
||||
pending.multiplyScalar(1 - fraction);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET } from "./constants.js";
|
||||
import { createSurfaceDrag, distanceForGlobeZoom, globeZoomForDistance, isGlobeClippedByViewport } from "./globe-navigation.js";
|
||||
import { createScreenSpaceDrag, globeDragRotationFactor, distanceForGlobeZoom, globeZoomForDistance } from "./globe-navigation.js";
|
||||
|
||||
const radius = CONFIG.earthRadius + EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET;
|
||||
const distance = zoom => distanceForGlobeZoom(zoom, CONFIG.earthRadius,
|
||||
@@ -33,84 +33,96 @@ function scene(zoom, width, height) {
|
||||
const p = local.clone().applyMatrix4(earth.matrixWorld).project(camera);
|
||||
return { x: viewport.left + (p.x + 1) * width / 2, y: viewport.top + (1 - p.y) * height / 2 };
|
||||
};
|
||||
const grabbed = new THREE.Vector3(0, 0, radius).applyQuaternion(earth.quaternion.clone().invert());
|
||||
return { camera, earth, viewport, project, grabbed };
|
||||
return { camera, earth, viewport, project };
|
||||
}
|
||||
|
||||
for (const [zoom, width, height, clipped] of [
|
||||
[0.5, 1440, 900, false], [1, 1440, 900, false], [2, 1440, 900, false],
|
||||
[3, 1440, 900, true], [5, 1440, 900, true],
|
||||
[0.5, 390, 844, false], [0.9, 390, 844, false], [1, 390, 844, true], [2, 390, 844, true],
|
||||
]) {
|
||||
test(`${zoom * 100}% at ${width}x${height}: choose surface drag only when the globe is clipped`, () => {
|
||||
const f = scene(zoom, width, height);
|
||||
expect(isGlobeClippedByViewport(f.earth, f.camera, radius)).toBe(clipped);
|
||||
});
|
||||
}
|
||||
|
||||
test("a grab outside the globe is rejected so screen-space dragging can handle it", () => {
|
||||
const f = scene(1, 1440, 900);
|
||||
const drag = createSurfaceDrag(radius);
|
||||
expect(drag.begin(f.earth, f.camera, { x: 20, y: 30 }, f.viewport, 0)).toBe(false);
|
||||
const before = f.earth.quaternion.clone();
|
||||
drag.move(f.earth, f.camera, { x: 150, y: 30 }, f.viewport, 16);
|
||||
expect(f.earth.quaternion.angleTo(before)).toBeCloseTo(0, 7);
|
||||
});
|
||||
|
||||
test("moving a large-globe grab beyond its limb never reverses or generates inertia", () => {
|
||||
const f = scene(3, 1440, 900);
|
||||
const drag = createSurfaceDrag(radius), start = f.project(f.grabbed);
|
||||
expect(drag.begin(f.earth, f.camera, start, f.viewport, 0)).toBe(true);
|
||||
let previous = f.earth.quaternion.clone();
|
||||
for (let step = 1; step <= 100; step++) {
|
||||
drag.move(f.earth, f.camera, { x: start.x + step * 10, y: start.y }, f.viewport, step * 16);
|
||||
const delta = f.earth.quaternion.clone().multiply(previous.clone().invert());
|
||||
expect(delta.y).toBeGreaterThanOrEqual(-1e-10);
|
||||
previous.copy(f.earth.quaternion);
|
||||
}
|
||||
drag.end(1601);
|
||||
expect(drag.hasInertia()).toBe(false);
|
||||
});
|
||||
|
||||
for (const zoom of [0.5, 1, 2, 3, 5]) for (const [width, height] of [[1440, 900], [390, 844]]) {
|
||||
test(`${zoom * 100}% at ${width}px: the grabbed surface follows the pointer pixel for pixel`, () => {
|
||||
const f = scene(zoom, width, height);
|
||||
const drag = createSurfaceDrag(radius);
|
||||
const start = f.project(f.grabbed);
|
||||
drag.begin(f.earth, f.camera, start, f.viewport, 0);
|
||||
for (let step = 1; step <= 20; step++) {
|
||||
const target = { x: start.x + step, y: start.y + step * 0.6 };
|
||||
drag.move(f.earth, f.camera, target, f.viewport, step * 16);
|
||||
const actual = f.project(f.grabbed);
|
||||
expect(actual.x).toBeCloseTo(target.x, 6);
|
||||
expect(actual.y).toBeCloseTo(target.y, 6);
|
||||
expect(drag.advance(f.earth, 16)).toBe(false);
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) {
|
||||
test(`screen drag keeps pointer direction after multiple revolutions and quaternion rebasing at ${width}px`, () => {
|
||||
const f = scene(0.5, width, height);
|
||||
const drag = createScreenSpaceDrag();
|
||||
for (const turns of [-12, -3, 0, 5, 20]) for (const pitch of [0.3, 2.6, -2.7]) {
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
f.earth.rotation.set(pitch, turns * Math.PI * 2 + 1.8, 0.7);
|
||||
// Camera focusing writes quaternions; this
|
||||
// canonicalizes XYZ angles, reproducing the legacy reversed Y axis.
|
||||
f.earth.quaternion.copy(f.earth.quaternion.clone());
|
||||
const point = new THREE.Vector3(0, 0, radius).applyQuaternion(f.earth.quaternion.clone().invert());
|
||||
const before = f.project(point);
|
||||
drag.stop();
|
||||
drag.move(dx * 0.02, dy * 0.02);
|
||||
drag.advance(f.earth, f.camera, 0.25);
|
||||
const after = f.project(point);
|
||||
if (dx) expect((after.x - before.x) * dx).toBeGreaterThan(0);
|
||||
if (dy) expect((after.y - before.y) * dy).toBeGreaterThan(0);
|
||||
expect(f.earth.quaternion.length()).toBeCloseTo(1, 10);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a paused release or cancellation cannot fling the globe", () => {
|
||||
const f = scene(5, 1440, 900);
|
||||
const drag = createSurfaceDrag(radius), start = f.project(f.grabbed);
|
||||
drag.begin(f.earth, f.camera, start, f.viewport, 0);
|
||||
drag.move(f.earth, f.camera, { x: start.x + 50, y: start.y }, f.viewport, 16);
|
||||
drag.end(200);
|
||||
expect(drag.hasInertia()).toBe(false);
|
||||
drag.begin(f.earth, f.camera, start, f.viewport, 220);
|
||||
drag.move(f.earth, f.camera, { x: start.x + 30, y: start.y }, f.viewport, 236);
|
||||
drag.end(237, true);
|
||||
expect(drag.hasInertia()).toBe(false);
|
||||
test("screen drag follows a rotated camera with a transformed parent and retains damped motion", () => {
|
||||
const f = scene(0.5, 1440, 900), drag = createScreenSpaceDrag();
|
||||
const parent = new THREE.Object3D();
|
||||
parent.rotation.set(0.8, 1.2, -0.7); parent.add(f.earth);
|
||||
f.camera.position.set(250, 100, 400); f.camera.lookAt(0, 0, 0); f.camera.updateMatrixWorld();
|
||||
parent.updateMatrixWorld(true);
|
||||
const point = f.camera.position.clone().normalize().multiplyScalar(radius);
|
||||
f.earth.worldToLocal(point);
|
||||
let previous = f.project(point);
|
||||
drag.move(0.05, 0.02);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
drag.advance(f.earth, f.camera, 0.25);
|
||||
const current = f.project(point);
|
||||
expect(current.x).toBeGreaterThan(previous.x);
|
||||
expect(current.y).toBeGreaterThan(previous.y);
|
||||
previous = current;
|
||||
}
|
||||
drag.stop();
|
||||
const rotation = f.earth.quaternion.clone();
|
||||
drag.advance(f.earth, f.camera, 0.25);
|
||||
expect(f.earth.quaternion.angleTo(rotation)).toBeCloseTo(0, 7);
|
||||
});
|
||||
|
||||
test("pinch zoom can rebase the remaining finger without a jump", () => {
|
||||
const f = scene(1, 390, 844), drag = createSurfaceDrag(radius);
|
||||
const start = f.project(f.grabbed);
|
||||
drag.begin(f.earth, f.camera, start, f.viewport, 0);
|
||||
drag.stop();
|
||||
f.camera.position.z = distance(5);
|
||||
f.camera.updateMatrixWorld();
|
||||
const before = f.earth.quaternion.clone();
|
||||
drag.begin(f.earth, f.camera, start, f.viewport, 100);
|
||||
drag.move(f.earth, f.camera, start, f.viewport, 116);
|
||||
expect(f.earth.quaternion.angleTo(before)).toBeCloseTo(0, 7);
|
||||
|
||||
test("repeated horizontal small-globe drags and inertia preserve the existing geographic axis", () => {
|
||||
const f = scene(1, 1440, 900), drag = createScreenSpaceDrag();
|
||||
for (const pitch of [0.4, 2.6, -2.7]) {
|
||||
f.earth.rotation.set(pitch, 18 * Math.PI + 1.8, 0.35);
|
||||
const north = new THREE.Vector3(0, 1, 0).applyQuaternion(f.earth.quaternion);
|
||||
for (let step = 0; step < 600; step++) {
|
||||
drag.move(step % 80 < 40 ? 0.035 : -0.035, 0);
|
||||
drag.advance(f.earth, f.camera, 0.18);
|
||||
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(f.earth.quaternion).distanceTo(north)).toBeLessThan(1e-10);
|
||||
}
|
||||
drag.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("vertical small-globe input deliberately changes tilt", () => {
|
||||
const f = scene(1, 1440, 900), drag = createScreenSpaceDrag();
|
||||
const north = new THREE.Vector3(0, 1, 0).applyQuaternion(f.earth.quaternion);
|
||||
drag.move(0, 0.3); drag.advance(f.earth, f.camera, 1);
|
||||
expect(new THREE.Vector3(0, 1, 0).applyQuaternion(f.earth.quaternion).distanceTo(north)).toBeGreaterThan(0.1);
|
||||
});
|
||||
|
||||
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) for (const zoom of [0.5, 1, 3, 5]) {
|
||||
test(`${zoom * 100}% at ${width}px: dragging outside the globe keeps rotating in the pointer direction`, () => {
|
||||
const f = scene(zoom, width, height), drag = createScreenSpaceDrag();
|
||||
const factor = globeDragRotationFactor(zoom, f.camera, f.viewport, CONFIG);
|
||||
let previous = f.earth.quaternion.clone();
|
||||
for (let x = width / 2; x < width * 1.2; x += 10) {
|
||||
drag.move(10 * factor, 0); drag.advance(f.earth, f.camera, 0.18);
|
||||
const north = new THREE.Vector3(0, 1, 0).applyQuaternion(previous);
|
||||
const delta = f.earth.quaternion.clone().multiply(previous.clone().invert());
|
||||
expect(new THREE.Vector3(delta.x, delta.y, delta.z).dot(north)).toBeGreaterThan(0);
|
||||
previous.copy(f.earth.quaternion);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("normal zoom keeps legacy sensitivity while extreme zoom limits magnification", () => {
|
||||
const normal = scene(1, 1440, 900), large = scene(5, 1440, 900);
|
||||
expect(globeDragRotationFactor(1, normal.camera, normal.viewport, CONFIG)).toBe(CONFIG.dragRotationFactorBase);
|
||||
expect(globeDragRotationFactor(5, large.camera, large.viewport, CONFIG)).toBeLessThan(CONFIG.dragRotationFactorBase * CONFIG.dragRotationScaleMin);
|
||||
});
|
||||
|
||||
@@ -347,6 +347,8 @@ const EARTH_MESSAGE_TEMPLATES = {
|
||||
};
|
||||
|
||||
const EXACT_TRANSLATIONS = {
|
||||
"显示 FPS": "Show FPS",
|
||||
"显示实际渲染帧率,悬停查看帧耗时": "Show actual rendering FPS; hover for frame timing",
|
||||
"智能星球计划 - 现实层宇宙全息感知": "Intelligent Planet - Reality Layer Situational Awareness",
|
||||
"智能星球计划": "Intelligent Planet Program",
|
||||
"现实层宇宙全息感知系统": "Reality Layer Situational Awareness System",
|
||||
@@ -739,7 +741,6 @@ const EXACT_TRANSLATIONS = {
|
||||
"中活跃观测站": "Medium-Activity Collector",
|
||||
"高活跃观测站": "High-Activity Collector",
|
||||
"观测范围示意": "Observation Coverage",
|
||||
"事件连线 / 枢纽": "Event Links / Hubs",
|
||||
"严重事件": "Critical Events",
|
||||
"高危事件": "High-Risk Events",
|
||||
"中危事件": "Medium-Risk Events",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isSurfaceAnchorVisible, SURFACE_ANCHOR_OCCLUSION_GLSL } from "./surface-marker-occlusion.js";
|
||||
import { drawMarkerBadge } from "./marker-badges.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG } from "./constants.js";
|
||||
@@ -16,7 +18,6 @@ const CLUSTER_OVERLAP_FACTOR = 0.9;
|
||||
const CLUSTER_MIN_COUNT = 2;
|
||||
const CLUSTER_MAX_MARKERS_PER_DOT = 14;
|
||||
const CLUSTER_MAX_POINT_SIZE = 32;
|
||||
const CLUSTER_MAX_SCREEN_DIAMETER_PX = 96;
|
||||
const CLUSTER_SEED_DISTANCE_FACTOR = 1.8;
|
||||
const CLUSTER_TRANSITION_MS = 220;
|
||||
const CLUSTER_BAND_HYSTERESIS = 0.08;
|
||||
@@ -50,7 +51,7 @@ const SPHERICAL_CLUSTER_BANDS = Object.freeze([
|
||||
Object.freeze({ key: "near", maxZoom: 3.5, distance: 4 }),
|
||||
Object.freeze({ key: "detail", maxZoom: Number.POSITIVE_INFINITY, distance: 0 }),
|
||||
]);
|
||||
let compactDotsEnabled = true;
|
||||
let compactDotsEnabled = false;
|
||||
let screenAvoidanceRevision = 0;
|
||||
|
||||
// Named overlap profiles. Layers that should be recognized as sharing a place
|
||||
@@ -530,32 +531,11 @@ function findScreenClusterGroups(entries) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function getScreenDistance(entryA, entryB) {
|
||||
return Math.hypot(entryA.x - entryB.x, entryA.y - entryB.y);
|
||||
}
|
||||
|
||||
function getAngularDistance(entryA, entryB) {
|
||||
const dot = entryA.direction.dot(entryB.direction);
|
||||
return Math.acos(Math.min(1, Math.max(-1, dot)));
|
||||
}
|
||||
|
||||
function getScreenBounds(entries) {
|
||||
return entries.reduce(
|
||||
(bounds, entry) => ({
|
||||
left: Math.min(bounds.left, entry.x - entry.radiusPx),
|
||||
right: Math.max(bounds.right, entry.x + entry.radiusPx),
|
||||
top: Math.min(bounds.top, entry.y - entry.radiusPx),
|
||||
bottom: Math.max(bounds.bottom, entry.y + entry.radiusPx),
|
||||
}),
|
||||
{
|
||||
left: Number.POSITIVE_INFINITY,
|
||||
right: Number.NEGATIVE_INFINITY,
|
||||
top: Number.POSITIVE_INFINITY,
|
||||
bottom: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function splitLargeScreenClusterGroup(groupEntries) {
|
||||
const maxMarkersPerDot = groupEntries.reduce(
|
||||
(maxCount, entry) => Math.min(maxCount, entry.maxMarkersPerDot || CLUSTER_MAX_MARKERS_PER_DOT),
|
||||
@@ -796,6 +776,7 @@ export function createInteractableLayer(options = {}) {
|
||||
getPointSizeMultiplier = () => 1,
|
||||
getPointOpacity = null,
|
||||
getUserData = (item) => item,
|
||||
isItemVisible = () => true,
|
||||
dynamicVisuals = false,
|
||||
} = options;
|
||||
|
||||
@@ -834,6 +815,7 @@ export function createInteractableLayer(options = {}) {
|
||||
const ownedClusterRecords = [];
|
||||
const scratchDirection = new THREE.Vector3();
|
||||
const scratchCameraLocal = new THREE.Vector3();
|
||||
const scratchCameraPositionLocal = new THREE.Vector3();
|
||||
const scratchWorldPosition = new THREE.Vector3();
|
||||
const scratchScreenPosition = new THREE.Vector3();
|
||||
const viewportSize = new THREE.Vector2(1, 1);
|
||||
@@ -845,7 +827,8 @@ export function createInteractableLayer(options = {}) {
|
||||
const hoverScale = stateScale.hover ?? 1;
|
||||
const lockedScale = stateScale.locked ?? 1;
|
||||
const dimmedScale = stateScale.dimmed ?? 1;
|
||||
const depthTest = material.depthTest ?? true;
|
||||
const usesSurfaceAnchorOcclusion = Boolean(icon.badge);
|
||||
const depthTest = usesSurfaceAnchorOcclusion ? false : material.depthTest ?? true;
|
||||
const depthWrite = material.depthWrite ?? false;
|
||||
const alphaTest = material.alphaTest ?? 0.01;
|
||||
const usesDistanceScaling = sizeMode !== "fixed";
|
||||
@@ -883,11 +866,12 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
|
||||
function applyIconAnchor(material) {
|
||||
if (!usesIconAnchor) return material;
|
||||
if (!usesIconAnchor && !usesSurfaceAnchorOcclusion) return material;
|
||||
|
||||
material.defines = {
|
||||
...(material.defines || {}),
|
||||
USE_INTERACTABLE_ICON_ANCHOR: "",
|
||||
...(usesIconAnchor ? { USE_INTERACTABLE_ICON_ANCHOR: "" } : {}),
|
||||
...(usesSurfaceAnchorOcclusion ? { USE_SURFACE_ANCHOR_OCCLUSION: "" } : {}),
|
||||
};
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.interactableIconAnchor = { value: iconAnchor };
|
||||
@@ -899,6 +883,7 @@ export function createInteractableLayer(options = {}) {
|
||||
"#include <common>",
|
||||
"uniform vec2 interactableIconAnchor;",
|
||||
"uniform vec2 interactableViewportSize;",
|
||||
usesSurfaceAnchorOcclusion ? SURFACE_ANCHOR_OCCLUSION_GLSL : "",
|
||||
].join("\n"),
|
||||
)
|
||||
.replace(
|
||||
@@ -909,11 +894,18 @@ export function createInteractableLayer(options = {}) {
|
||||
" vec2 interactableAnchorOffset = vec2((0.5 - interactableIconAnchor.x) * size, (interactableIconAnchor.y - 0.5) * size);",
|
||||
" gl_Position.xy += (interactableAnchorOffset / interactableViewportSize) * 2.0 * gl_Position.w;",
|
||||
"#endif",
|
||||
"#ifdef USE_SURFACE_ANCHOR_OCCLUSION",
|
||||
" vec3 globeCenterView = modelViewMatrix[3].xyz;",
|
||||
` float globeRadius = ${CONFIG.earthRadius.toFixed(6)} * length(modelViewMatrix[0].xyz);`,
|
||||
" if (surfaceAnchorOccluded(-globeCenterView, mvPosition.xyz - globeCenterView, globeRadius)) {",
|
||||
" gl_Position = vec4(0.0, 0.0, 2.0, 1.0);",
|
||||
" }",
|
||||
"#endif",
|
||||
].join("\n"),
|
||||
);
|
||||
};
|
||||
material.customProgramCacheKey = () =>
|
||||
`interactable-icon-anchor:${iconAnchor.x.toFixed(3)}:${iconAnchor.y.toFixed(3)}`;
|
||||
`interactable-icon-anchor:${iconAnchor.x.toFixed(3)}:${iconAnchor.y.toFixed(3)}:${usesSurfaceAnchorOcclusion}`;
|
||||
return material;
|
||||
}
|
||||
|
||||
@@ -965,7 +957,7 @@ export function createInteractableLayer(options = {}) {
|
||||
const drawX = (atlasCellSize - drawWidth) / 2;
|
||||
const drawY = (atlasCellSize - drawHeight) / 2;
|
||||
|
||||
if (icon.colorable !== false && drawOptions.color) {
|
||||
if ((icon.colorable !== false || icon.badge) && drawOptions.color) {
|
||||
const tintCanvas = createCanvas(atlasCellSize, atlasCellSize);
|
||||
const tintContext = tintCanvas.getContext("2d");
|
||||
tintContext.clearRect(0, 0, atlasCellSize, atlasCellSize);
|
||||
@@ -993,7 +985,15 @@ export function createInteractableLayer(options = {}) {
|
||||
atlasCellSize,
|
||||
...drawOptions,
|
||||
};
|
||||
if (icon.coordinates !== "canvas") {
|
||||
const badgeOptions = typeof icon.badge === "function" ? icon.badge(resolvedDrawOptions) : icon.badge;
|
||||
if (badgeOptions) {
|
||||
const palette = drawMarkerBadge(context, { ...resolvedDrawOptions, ...badgeOptions });
|
||||
resolvedDrawOptions.color = palette.ink;
|
||||
resolvedDrawOptions.glow = false;
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(0.6, 0.6);
|
||||
if (icon.coordinates === "canvas") context.translate(-canvas.width / 2, -canvas.height / 2);
|
||||
} else if (icon.coordinates !== "canvas") {
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
if (icon.draw) {
|
||||
@@ -1001,7 +1001,10 @@ export function createInteractableLayer(options = {}) {
|
||||
} else {
|
||||
drawAssetIcon(context, getIconSource(resolvedDrawOptions), resolvedDrawOptions);
|
||||
}
|
||||
icon.afterDraw?.(context, resolvedDrawOptions);
|
||||
context.restore();
|
||||
context.save();
|
||||
if (icon.coordinates !== "canvas") context.translate(canvas.width / 2, canvas.height / 2);
|
||||
icon.afterDraw?.(context, { atlasCellSize, ...drawOptions });
|
||||
context.restore();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
@@ -1136,7 +1139,7 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
|
||||
function shouldUseCompactDots(camera) {
|
||||
return getCameraZoom(camera) <= COMPACT_DOT_ZOOM_THRESHOLD;
|
||||
return compactDotsEnabled && getCameraZoom(camera) <= COMPACT_DOT_ZOOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function getMarkerStableId(marker, fallbackIndex = 0) {
|
||||
@@ -1195,6 +1198,7 @@ export function createInteractableLayer(options = {}) {
|
||||
|
||||
const entries = markers
|
||||
.map((marker, index) => {
|
||||
if (!isItemVisible(marker.userData)) return null;
|
||||
const basePosition = marker.userData?.icon_base_position || marker.position;
|
||||
if (!(basePosition instanceof THREE.Vector3)) return null;
|
||||
const direction = clusterDirectionScratch.copy(basePosition).normalize().clone();
|
||||
@@ -1327,6 +1331,7 @@ export function createInteractableLayer(options = {}) {
|
||||
pointsGroup.parent.remove(pointsGroup);
|
||||
}
|
||||
pointObjects.forEach((points) => {
|
||||
points.userData?.markers?.forEach(marker => pointSlots.delete(marker));
|
||||
points.geometry?.dispose?.();
|
||||
points.material?.dispose?.();
|
||||
});
|
||||
@@ -1447,7 +1452,7 @@ export function createInteractableLayer(options = {}) {
|
||||
|
||||
const buckets = new Map();
|
||||
markers
|
||||
.filter((marker) => !marker.userData?.icon_cluster_hidden)
|
||||
.filter((marker) => !marker.userData?.icon_cluster_hidden && isItemVisible(marker.userData))
|
||||
.forEach((marker) => {
|
||||
const key = getBucketKey(marker);
|
||||
if (!buckets.has(key)) {
|
||||
@@ -1559,7 +1564,7 @@ export function createInteractableLayer(options = {}) {
|
||||
|
||||
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1, compactDotMode = false) {
|
||||
if (!overlay) return;
|
||||
if (!marker) {
|
||||
if (!marker || !isItemVisible(marker.userData)) {
|
||||
overlay.visible = false;
|
||||
return;
|
||||
}
|
||||
@@ -1651,6 +1656,7 @@ export function createInteractableLayer(options = {}) {
|
||||
icon_avoidance_key: avoidanceKey,
|
||||
state: "normal",
|
||||
};
|
||||
marker.userData.legend_filtered = !isItemVisible(marker.userData);
|
||||
markers.push(marker);
|
||||
markerById.set(String(getItemId(marker.userData)), marker);
|
||||
});
|
||||
@@ -1676,6 +1682,7 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
|
||||
function syncPointBucket(bucketKey, bucketMarkers) {
|
||||
bucketMarkers = bucketMarkers.filter(marker => isItemVisible(marker.userData));
|
||||
let points = pointObjects.find((entry) => entry.userData.bucketKey === bucketKey);
|
||||
if (!points) {
|
||||
if (bucketMarkers.length) addPointBucket(bucketKey, bucketMarkers);
|
||||
@@ -1722,6 +1729,7 @@ export function createInteractableLayer(options = {}) {
|
||||
incomingIds.add(itemId);
|
||||
let marker = markerById.get(itemId);
|
||||
const previousBucket = marker ? getBucketKey(marker) : null;
|
||||
const previousVisible = marker ? isItemVisible(marker.userData) : false;
|
||||
if (!marker) {
|
||||
marker = new THREE.Object3D();
|
||||
markers.push(marker);
|
||||
@@ -1738,7 +1746,8 @@ export function createInteractableLayer(options = {}) {
|
||||
state: marker.userData.state || "normal",
|
||||
};
|
||||
const nextBucket = getBucketKey(marker);
|
||||
if (previousBucket !== nextBucket) {
|
||||
marker.userData.legend_filtered = !isItemVisible(marker.userData);
|
||||
if (previousBucket !== nextBucket || previousVisible !== !marker.userData.legend_filtered) {
|
||||
if (previousBucket !== null) affectedBuckets.add(previousBucket);
|
||||
affectedBuckets.add(nextBucket);
|
||||
}
|
||||
@@ -1762,6 +1771,7 @@ export function createInteractableLayer(options = {}) {
|
||||
buckets.forEach((bucketMarkers, key) => syncPointBucket(key, bucketMarkers));
|
||||
}
|
||||
updatedMarkers.forEach((marker) => {
|
||||
if (!isItemVisible(marker.userData)) return;
|
||||
if (affectedBuckets.has(getBucketKey(marker))) return;
|
||||
const slot = pointSlots.get(marker);
|
||||
if (!slot) return;
|
||||
@@ -2001,11 +2011,11 @@ export function createInteractableLayer(options = {}) {
|
||||
height = window.innerHeight,
|
||||
frontFacingDotThreshold = 0,
|
||||
} = {}) {
|
||||
if (!earth || !camera || !pointer) return [];
|
||||
if (!visible || !earth || !camera || !pointer) return [];
|
||||
|
||||
scratchCameraLocal.copy(camera.position);
|
||||
earth.worldToLocal(scratchCameraLocal);
|
||||
scratchCameraLocal.normalize();
|
||||
scratchCameraPositionLocal.copy(camera.position);
|
||||
earth.worldToLocal(scratchCameraPositionLocal);
|
||||
scratchCameraLocal.copy(scratchCameraPositionLocal).normalize();
|
||||
|
||||
const pointerX = ((pointer.x + 1) / 2) * width;
|
||||
const pointerY = ((1 - pointer.y) / 2) * height;
|
||||
@@ -2027,9 +2037,10 @@ export function createInteractableLayer(options = {}) {
|
||||
positionAttribute.getZ(0),
|
||||
)
|
||||
.normalize();
|
||||
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
|
||||
return;
|
||||
}
|
||||
scratchWorldPosition.set(positionAttribute.getX(0), positionAttribute.getY(0), positionAttribute.getZ(0));
|
||||
if (usesSurfaceAnchorOcclusion
|
||||
? !isSurfaceAnchorVisible(scratchWorldPosition, scratchCameraPositionLocal, CONFIG.earthRadius)
|
||||
: scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) return;
|
||||
|
||||
scratchWorldPosition.set(
|
||||
positionAttribute.getX(0),
|
||||
@@ -2064,11 +2075,11 @@ export function createInteractableLayer(options = {}) {
|
||||
});
|
||||
|
||||
markers.forEach((marker) => {
|
||||
if (marker.userData?.icon_cluster_hidden) return;
|
||||
if (marker.userData?.icon_cluster_hidden || !isItemVisible(marker.userData)) return;
|
||||
scratchDirection.copy(marker.position).normalize();
|
||||
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
|
||||
return;
|
||||
}
|
||||
if (usesSurfaceAnchorOcclusion
|
||||
? !isSurfaceAnchorVisible(marker.position, scratchCameraPositionLocal, CONFIG.earthRadius)
|
||||
: scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) return;
|
||||
|
||||
scratchWorldPosition.copy(marker.position);
|
||||
earth.localToWorld(scratchWorldPosition);
|
||||
@@ -2104,6 +2115,16 @@ export function createInteractableLayer(options = {}) {
|
||||
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
|
||||
}
|
||||
|
||||
function refreshFilter() {
|
||||
markers.forEach(marker => { marker.userData.legend_filtered = !isItemVisible(marker.userData); });
|
||||
beginClusterUpdate();
|
||||
invalidateClusterTopology();
|
||||
lastClusterSignature = "";
|
||||
rebuildPointLayers();
|
||||
if (hoverOverlay) hoverOverlay.visible = false;
|
||||
if (lockedOverlay) lockedOverlay.visible = false;
|
||||
}
|
||||
|
||||
const controller = {
|
||||
refreshPositions,
|
||||
refreshVisuals,
|
||||
@@ -2121,6 +2142,7 @@ export function createInteractableLayer(options = {}) {
|
||||
markers,
|
||||
getMarkers: () => markers,
|
||||
getCount: () => markers.length,
|
||||
refreshFilter,
|
||||
isVisible: () => visible,
|
||||
setData,
|
||||
updateItems,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as THREE from "three";
|
||||
import { SATELLITE_CONFIG } from "./constants.js";
|
||||
|
||||
const EARTH_RADIUS_KM = 6378.137;
|
||||
const SURFACE_SCALE = 1.003;
|
||||
const SURFACE_OFFSET = 0.72;
|
||||
const CLUSTER_DIAMETER_KM_APPROX = 4500;
|
||||
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
|
||||
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
|
||||
const IRIDIUM_OVERLAY_COLOR = SATELLITE_CONFIG.footprintColor;
|
||||
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
|
||||
const FILL_RINGS = 12;
|
||||
const FILL_SEGMENTS = 48;
|
||||
|
||||
@@ -5,10 +5,6 @@ export function setButtonTooltip(button, text) {
|
||||
if (button instanceof HTMLElement) {
|
||||
button.title = translatedText;
|
||||
}
|
||||
const tooltip = button?.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = translatedText;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLayerButtonState(button, isActive) {
|
||||
|
||||
40
frontend/public/earth/js/legend-filters.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const hiddenByMode = new Map();
|
||||
export const LEGEND_FILTER_EVENT = "earth:legend-filter-change";
|
||||
export function isLegendItemEnabled(mode, key) {
|
||||
return !hiddenByMode.get(mode)?.has(String(key));
|
||||
}
|
||||
export function setLegendItemEnabled(mode, key, enabled) {
|
||||
key = String(key);
|
||||
if (isLegendItemEnabled(mode, key) === Boolean(enabled)) return;
|
||||
if (!hiddenByMode.has(mode)) hiddenByMode.set(mode, new Set());
|
||||
const hidden = hiddenByMode.get(mode);
|
||||
if (enabled) hidden.delete(key); else hidden.add(key);
|
||||
if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") window.dispatchEvent(new CustomEvent(LEGEND_FILTER_EVENT, { detail: { mode, key, enabled: Boolean(enabled) } }));
|
||||
}
|
||||
export function filterableLegendItems(mode, items) {
|
||||
return items.map(item => {
|
||||
if (item.onToggle) return item;
|
||||
const key = String(item.key ?? item.label);
|
||||
return { ...item, key, enabled: isLegendItemEnabled(mode, key),
|
||||
onToggle: () => setLegendItemEnabled(mode, key, !isLegendItemEnabled(mode, key)) };
|
||||
});
|
||||
}
|
||||
export function collectorLegendKey(tier) {
|
||||
return `collector:${tier === "high" || tier === "hot" ? "high" : tier === "low" || tier === "medium" ? "medium" : "idle"}`;
|
||||
}
|
||||
export function vesselLegendKeys(data) {
|
||||
const kind = data.vessel_kind || data.icon_kind || data.type || "other";
|
||||
return [kind, ...(data.anchored ? ["anchored"] : [])];
|
||||
}
|
||||
export function isVesselLegendVisible(data) {
|
||||
return vesselLegendKeys(data).every(key => isLegendItemEnabled("vessels", key));
|
||||
}
|
||||
|
||||
export function revealLegendObject(type, data = {}) {
|
||||
if (type === "cable") setLegendItemEnabled("cables", data.name || "未知线缆", true);
|
||||
if (type === "landing_point") (data.cableNames || []).forEach(name => setLegendItemEnabled("cables", name, true));
|
||||
if (type === "compute_center") setLegendItemEnabled("computeCenters", data.site_type === "supercomputer" ? "supercomputer" : "gpu_cluster", true);
|
||||
if (type === "vessel") vesselLegendKeys(data).forEach(key => setLegendItemEnabled("vessels", key, true));
|
||||
if (type === "bgp") setLegendItemEnabled("bgp", `event:${data.icon_kind || data.severity || "medium"}`, true);
|
||||
if (type === "bgp_collector") setLegendItemEnabled("bgp", collectorLegendKey(data.icon_kind || data.activity?.tier), true);
|
||||
}
|
||||
52
frontend/public/earth/js/legend-filters.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { collectorLegendKey, filterableLegendItems, isLegendItemEnabled, isVesselLegendVisible, revealLegendObject, setLegendItemEnabled } from "./legend-filters.js";
|
||||
import { createInteractableLayer } from "./interactable.js";
|
||||
|
||||
test("legend modes keep independent reversible filters, with stable keys after refresh", () => {
|
||||
const item = filterableLegendItems("test-cables", [{ key: "A", label: "Cable A" }])[0];
|
||||
item.onToggle();
|
||||
expect(isLegendItemEnabled("test-cables", "A")).toBe(false);
|
||||
expect(isLegendItemEnabled("test-vessels", "A")).toBe(true);
|
||||
const reloaded = filterableLegendItems("test-cables", [{ key: "A", label: "renamed" }])[0];
|
||||
expect(reloaded.enabled).toBe(false); reloaded.onToggle();
|
||||
expect(isLegendItemEnabled("test-cables", "A")).toBe(true);
|
||||
});
|
||||
test("anchored vessel filter composes with type; explicit selection reveals both", () => {
|
||||
setLegendItemEnabled("vessels", "cargo", false);
|
||||
setLegendItemEnabled("vessels", "anchored", false);
|
||||
expect(isVesselLegendVisible({ vessel_kind: "cargo", anchored: false })).toBe(false);
|
||||
expect(isVesselLegendVisible({ vessel_kind: "tanker", anchored: true })).toBe(false);
|
||||
expect(isVesselLegendVisible({ vessel_kind: "tanker", anchored: false })).toBe(true);
|
||||
revealLegendObject("vessel", { vessel_kind: "cargo", anchored: true });
|
||||
expect(isVesselLegendVisible({ vessel_kind: "cargo", anchored: true })).toBe(true);
|
||||
expect(["idle", "low", "medium", "high", "hot"].map(collectorLegendKey))
|
||||
.toEqual(["collector:idle", "collector:medium", "collector:medium", "collector:high", "collector:high"]);
|
||||
});
|
||||
test("marker filters exclude draw and picking without losing data or identity", () => {
|
||||
const originalDocument = globalThis.document, originalWindow = globalThis.window;
|
||||
globalThis.window = { innerWidth: 1280, innerHeight: 720, devicePixelRatio: 1 };
|
||||
globalThis.document = { createElement: () => ({ width:128, height:128, getContext: () => new Proxy({}, {
|
||||
get: (_, key) => key === "measureText" ? () => ({width:10}) : key.includes("Gradient") ? () => ({addColorStop(){}}) : () => {},
|
||||
}) }) };
|
||||
let enabled = true, layer;
|
||||
try {
|
||||
layer = createInteractableLayer({ id:"legend-marker-test", objectType:"vessel", cluster:false, avoidance:false,
|
||||
icon:{draw(ctx){ctx.fillRect(0,0,10,10);}}, isItemVisible: data => enabled || data.icon_kind !== "cargo",
|
||||
getPosition: data => ({latitude:data.latitude,longitude:data.longitude}), getKind:data=>data.kind });
|
||||
const earth = new THREE.Object3D(), camera = new THREE.PerspectiveCamera(75,1280/720,.1,5000);
|
||||
camera.position.z=350; camera.updateMatrixWorld();
|
||||
layer.attach(earth); layer.setVisible(true);
|
||||
layer.setData([{id:"a",kind:"cargo",latitude:0,longitude:-90},{id:"b",kind:"tanker",latitude:0,longitude:20}]);
|
||||
const selected=layer.getMarkers()[0];
|
||||
const rendered=()=>{let count=0;layer.group.traverse(node=>{if(node.isPoints)count+=node.userData.markers?.length||0;});return count;};
|
||||
expect(rendered()).toBe(2);
|
||||
expect(layer.getPointerIntersections({earth,camera,pointer:new THREE.Vector2()}).some(hit=>hit.object===selected)).toBe(true);
|
||||
enabled=false;layer.refreshFilter();
|
||||
expect(rendered()).toBe(1);expect(layer.getCount()).toBe(2);expect(layer.getMarkers()[0]).toBe(selected);
|
||||
expect(layer.getPointerIntersections({earth,camera,pointer:new THREE.Vector2()}).some(hit=>hit.object===selected)).toBe(false);
|
||||
layer.updateItems([{id:"a",kind:"cargo",latitude:1,longitude:-90}]);expect(rendered()).toBe(1);
|
||||
layer.updateItems([{id:"a",kind:"tanker",latitude:1,longitude:-90}]);expect(rendered()).toBe(2);
|
||||
enabled=true;layer.refreshFilter();expect(rendered()).toBe(2);expect(layer.getMarkers()[0]).toBe(selected);
|
||||
} finally {layer?.clearData();globalThis.document=originalDocument;globalThis.window=originalWindow;}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { filterableLegendItems } from "./legend-filters.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import { getEarthLocale, translateText } from "./i18n.js";
|
||||
|
||||
@@ -44,6 +45,12 @@ export function initLegend() {
|
||||
|
||||
if (!legendLocaleListenerBound) {
|
||||
legendLocaleListenerBound = true;
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest?.("[data-legend-filter-key]");
|
||||
if (!button) return;
|
||||
const item = legendItemsByMode[currentLegendMode]?.find(entry => entry.key === button.dataset.legendFilterKey);
|
||||
item?.onToggle?.();
|
||||
});
|
||||
window.addEventListener("earth:locale-change", () => {
|
||||
syncCurrentLabel(currentLegendMode);
|
||||
renderLegend(currentLegendMode);
|
||||
@@ -61,6 +68,10 @@ export function setLegendMode(mode) {
|
||||
renderLegend(nextMode);
|
||||
}
|
||||
|
||||
export function selectLayerLegend(mode) {
|
||||
if (LEGEND_MODES[mode]) setLegendMode(mode);
|
||||
}
|
||||
|
||||
export function getLegendMode() {
|
||||
return currentLegendMode;
|
||||
}
|
||||
@@ -71,7 +82,7 @@ export function refreshLegend() {
|
||||
|
||||
export function setLegendItems(mode, items) {
|
||||
if (!LEGEND_MODES[mode]) return;
|
||||
legendItemsByMode[mode] = Array.isArray(items) ? items : [];
|
||||
legendItemsByMode[mode] = filterableLegendItems(mode, Array.isArray(items) ? items : []);
|
||||
if (mode === currentLegendMode) {
|
||||
renderLegend(currentLegendMode);
|
||||
}
|
||||
@@ -99,16 +110,23 @@ function getLegendModeTitle(mode) {
|
||||
}
|
||||
|
||||
function renderLegend(mode) {
|
||||
const focused = document.activeElement?.closest?.("[data-legend-filter-key]");
|
||||
const focusedKey = focused?.dataset.legendFilterKey;
|
||||
const focusedList = focused?.parentElement;
|
||||
const items = legendItemsByMode[mode] || [];
|
||||
const html = items
|
||||
.map(
|
||||
(item) => {
|
||||
const label = escapeLegendHtml(translateText(item.label));
|
||||
const label = escapeLegendHtml(translateText(item.label)) + (Number.isFinite(item.count) ? ` · ${item.count}` : "");
|
||||
const interactive = typeof item.onToggle === "function";
|
||||
const start = interactive
|
||||
? `<button type="button" class="legend-item legend-filter" data-legend-filter-key="${escapeLegendHtml(item.key)}" aria-pressed="${item.enabled !== false}">`
|
||||
: '<div class="legend-item">';
|
||||
return `
|
||||
<div class="legend-item">
|
||||
${start}
|
||||
<span class="legend-dot legend-dot--${item.shape || "dot"}" style="background:${item.color}; color:${item.color}"></span>
|
||||
<span class="legend-label" title="${label}">${label}</span>
|
||||
</div>`;
|
||||
${interactive ? "</button>" : "</div>"}`;
|
||||
},
|
||||
)
|
||||
.join("");
|
||||
@@ -122,6 +140,10 @@ function renderLegend(mode) {
|
||||
if (mobileList) {
|
||||
mobileList.innerHTML = html;
|
||||
}
|
||||
if (focusedKey && focusedList) {
|
||||
Array.from(focusedList.querySelectorAll("[data-legend-filter-key]"))
|
||||
.find(button => button.dataset.legendFilterKey === focusedKey)?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLegendHtml(value) {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createSurfaceDrag, isGlobeClippedByViewport } from "./globe-navigation.js";
|
||||
import { LEGEND_FILTER_EVENT, revealLegendObject } from "./legend-filters.js";
|
||||
import { applyCableLegendFilter } from "./cables.js";
|
||||
import { applyComputeCenterLegendFilter } from "./compute-centers.js";
|
||||
import { applyVesselLegendFilter } from "./vessels.js";
|
||||
import { applyBGPLegendFilter } from "./bgp.js";
|
||||
import { applyCountryBoundaryLegendFilter } from "./country-boundaries.js";
|
||||
import { isSatelliteCategoryVisible, updateSatelliteLabels } from "./satellites.js";
|
||||
import { beginFrameStats, finishFrameStats } from "./frame-stats.js";
|
||||
import { createScreenSpaceDrag, globeDragRotationFactor } from "./globe-navigation.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
@@ -117,8 +125,6 @@ import {
|
||||
getShowSatellites,
|
||||
getSatelliteLegendItems,
|
||||
getSatelliteData,
|
||||
setSelectedSatelliteLegend,
|
||||
clearSelectedSatelliteLegend,
|
||||
getSatelliteCount,
|
||||
getSatelliteAt,
|
||||
selectSatellite,
|
||||
@@ -345,8 +351,8 @@ let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let targetRotation = { x: 0, y: 0 };
|
||||
let inertialVelocity = { x: 0, y: 0 };
|
||||
const surfaceDrag = createSurfaceDrag(CONFIG.earthRadius + EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET);
|
||||
let surfaceDragging = false;
|
||||
const screenSpaceDrag = createScreenSpaceDrag();
|
||||
let screenSpaceInertia = false;
|
||||
let hoveredCable = null;
|
||||
let hoveredBGP = null;
|
||||
let hoveredComputeCenter = null;
|
||||
@@ -368,7 +374,6 @@ let lastBGPClickCollector = null;
|
||||
let lastBGPClickType = null;
|
||||
let lastBGPClickPos = { x: 0, y: 0 };
|
||||
let lastVesselHoverPickTime = 0;
|
||||
let earthTexture = null;
|
||||
let animationFrameId = null;
|
||||
let initialized = false;
|
||||
let destroyed = false;
|
||||
@@ -578,20 +583,13 @@ function setGlobeDraggingUiState(active) {
|
||||
|
||||
function hasActiveGlobeInertia() {
|
||||
return (
|
||||
surfaceDrag.hasInertia() ||
|
||||
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
|
||||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY
|
||||
);
|
||||
}
|
||||
|
||||
function getDragRotationFactor() {
|
||||
const zoom = Math.max(getZoomLevel(), 0.01);
|
||||
const scale = THREE.MathUtils.clamp(
|
||||
Math.pow(zoom, -CONFIG.dragRotationZoomExponent),
|
||||
CONFIG.dragRotationScaleMin,
|
||||
CONFIG.dragRotationScaleMax,
|
||||
);
|
||||
return CONFIG.dragRotationFactorBase * scale;
|
||||
return globeDragRotationFactor(getZoomLevel(), camera, renderer.domElement.getBoundingClientRect(), CONFIG);
|
||||
}
|
||||
|
||||
function getTargetSwitchZoomOptions(requestedZoom) {
|
||||
@@ -679,7 +677,6 @@ function clearRuntimeSelection() {
|
||||
lockedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
setLockedSatelliteIndex(null);
|
||||
clearSelectedSatelliteLegend();
|
||||
}
|
||||
|
||||
export function clearLockedObject() {
|
||||
@@ -1158,7 +1155,7 @@ function getMotionSatelliteCandidates() {
|
||||
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
|
||||
for (let index = 0; index < satelliteCount; index += 1) {
|
||||
const position = satPositions[index]?.current;
|
||||
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
|
||||
if (!position || !isSatelliteCategoryVisible(index) || !isSatelliteFrontFacing(index, camera)) continue;
|
||||
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
|
||||
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
|
||||
if (scratchSatelliteScreenPosition.z < -1 || scratchSatelliteScreenPosition.z > 1) continue;
|
||||
@@ -1794,7 +1791,6 @@ function showSatelliteInfo(props, coords) {
|
||||
footprintModel = "Iridium 外圈半透明覆盖";
|
||||
}
|
||||
|
||||
setSelectedSatelliteLegend(props);
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendMode("satellites");
|
||||
showInfoCard("satellite", {
|
||||
@@ -2860,6 +2856,7 @@ function updateBGPHud(bgpResult) {
|
||||
}
|
||||
|
||||
function presentSelectedObject(candidate) {
|
||||
revealLegendObject(candidate.type, candidate.object?.userData);
|
||||
const targetProvider = () => document.getElementById(
|
||||
document.body.classList.contains("layout-mode-mobile") ? "earth-mobile-popup" : "info-panel",
|
||||
);
|
||||
@@ -3019,7 +3016,7 @@ function ensureBGPCruiseAdapter() {
|
||||
cruiseBGPAdapter = createBGPCruiseAdapter({
|
||||
camera,
|
||||
earth: () => getEarth(),
|
||||
getMarkers: () => getBGPAnomalyMarkers(),
|
||||
getMarkers: () => getBGPAnomalyMarkers().filter(marker => !marker.userData.legend_filtered),
|
||||
connector: ensureCalloutConnector(),
|
||||
focusView: focusTargetSwitchView,
|
||||
setMarkerLocked: (marker) => {
|
||||
@@ -3132,19 +3129,19 @@ function createInteractableCruiseItem(candidate) {
|
||||
}
|
||||
|
||||
function getComputeCenterCruiseItems() {
|
||||
return getComputeCenterMarkers().map((marker) =>
|
||||
return getComputeCenterMarkers().filter(marker => !marker.userData.legend_filtered).map((marker) =>
|
||||
createInteractableCruiseItem({ type: "compute_center", object: marker }),
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
function getVesselCruiseItems() {
|
||||
return getVesselMarkers().map((marker) =>
|
||||
return getVesselMarkers().filter(marker => !marker.userData.legend_filtered).map((marker) =>
|
||||
createInteractableCruiseItem({ type: "vessel", object: marker }),
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
function getCableCruiseItems() {
|
||||
return getCableLines().map((cable) =>
|
||||
return getCableLines().filter(marker => !marker.userData.legend_filtered).map((cable) =>
|
||||
createInteractableCruiseItem({ type: "cable", object: cable }),
|
||||
).filter(Boolean);
|
||||
}
|
||||
@@ -3848,6 +3845,7 @@ function applyCableVisualState() {
|
||||
allCables.forEach((cable) => {
|
||||
const cableId = cable.userData.cableId;
|
||||
const state = getCableState(cableId);
|
||||
cable.material.userData.cableSelected = state === CABLE_STATE.LOCKED;
|
||||
|
||||
const hasFocus =
|
||||
(lockedObjectType === "cable" && lockedObject) ||
|
||||
@@ -3883,7 +3881,7 @@ function applyCableVisualState() {
|
||||
((origColor & 255) / 255) * brightness,
|
||||
);
|
||||
} else {
|
||||
cable.material.opacity = 1;
|
||||
cable.material.opacity = CABLE_CONFIG.visual.normalOpacity;
|
||||
cable.material.color.setHex(cable.userData.originalColor);
|
||||
}
|
||||
}
|
||||
@@ -3908,11 +3906,10 @@ function getSatellitePointerIntersections(event) {
|
||||
}
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const shaderSize = satPoints.material?.uniforms?.size?.value;
|
||||
const dotSizePx = Number.isFinite(shaderSize)
|
||||
? shaderSize / dpr
|
||||
: SATELLITE_CONFIG.dotBaseSize;
|
||||
// Visual point size may shrink; preserve the pre-refactor hit tolerance.
|
||||
const dotSizePx = SATELLITE_CONFIG.dotBaseSize * Math.pow(
|
||||
CONFIG.defaultCameraZ / Math.max(camera.position.length(), 1), 0.55,
|
||||
);
|
||||
const pickRadiusPx = Math.max(12, Math.min(30, dotSizePx * 4));
|
||||
const pickRadiusSq = pickRadiusPx * pickRadiusPx;
|
||||
const hits = [];
|
||||
@@ -3923,7 +3920,7 @@ function getSatellitePointerIntersections(event) {
|
||||
const satelliteCount = Math.min(getSatelliteData().length, satPositions.length);
|
||||
for (let index = 0; index < satelliteCount; index++) {
|
||||
const position = satPositions[index]?.current;
|
||||
if (!position || !isSatelliteFrontFacing(index, camera)) continue;
|
||||
if (!position || !isSatelliteCategoryVisible(index) || !isSatelliteFrontFacing(index, camera)) continue;
|
||||
|
||||
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
|
||||
scratchSatelliteScreenPosition.copy(scratchSatelliteWorldPosition).project(camera);
|
||||
@@ -4427,6 +4424,28 @@ export function init() {
|
||||
});
|
||||
cleanupFns.push(() => { setComputeTopicQuery(null); computeTopic?.destroy(); computeTopic = null; });
|
||||
initLegend();
|
||||
bindListener(window, LEGEND_FILTER_EVENT, ({ detail }) => {
|
||||
const handlers = {
|
||||
cables: [applyCableLegendFilter, getCableLegendItems],
|
||||
computeCenters: [applyComputeCenterLegendFilter, getComputeCenterLegendItems],
|
||||
vessels: [applyVesselLegendFilter, getVesselLegendItems],
|
||||
bgp: [applyBGPLegendFilter, getBGPLegendItems],
|
||||
countryBoundaries: [applyCountryBoundaryLegendFilter, getCountryBoundaryLegendItems],
|
||||
};
|
||||
const handler = handlers[detail?.mode];
|
||||
if (!handler) return;
|
||||
handler[0]();
|
||||
if (lockedObject?.userData?.legend_filtered) clearLockedObjectAndInfo();
|
||||
if (getCurrentCruiseBGPMarker()?.userData?.legend_filtered
|
||||
|| getCurrentMotionSharedCruiseBGPMarker()?.userData?.legend_filtered) interruptCruisePresentation();
|
||||
clearTransientHoverState();
|
||||
setLegendItems(detail.mode, handler[1]());
|
||||
});
|
||||
bindListener(window, "earth:satellite-filter-change", () => {
|
||||
if (lockedSatelliteIndex !== null && !isSatelliteCategoryVisible(lockedSatelliteIndex)) clearLockedObjectAndInfo();
|
||||
if (hoveredSatelliteIndex !== null && !isSatelliteCategoryVisible(hoveredSatelliteIndex)) clearTransientHoverState();
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
});
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
setLegendItems("satellites", getSatelliteLegendItems());
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
@@ -4528,7 +4547,8 @@ function setupMotionControl() {
|
||||
export function applyMotionRotate(axisOrDirection, directionOrIntensity = 1, maybeIntensity = 1) {
|
||||
const earth = getEarth();
|
||||
if (!earth) return false;
|
||||
surfaceDrag.stop();
|
||||
screenSpaceDrag.stop();
|
||||
screenSpaceInertia = false;
|
||||
const axisCall = axisOrDirection === "horizontal" || axisOrDirection === "vertical";
|
||||
const axis = axisCall ? axisOrDirection : "horizontal";
|
||||
const direction = axisCall ? directionOrIntensity : axisOrDirection;
|
||||
@@ -5705,6 +5725,7 @@ function getFrontFacingCables(cableLines) {
|
||||
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
|
||||
|
||||
return cableLines.filter((cable) => {
|
||||
if (!cable.visible) return false;
|
||||
if (!cable.userData.localCenter) {
|
||||
return true;
|
||||
}
|
||||
@@ -5764,18 +5785,11 @@ function onMouseMove(event) {
|
||||
isLongDrag = true;
|
||||
}
|
||||
|
||||
if (surfaceDragging) {
|
||||
surfaceDrag.move(earth, camera, { x: event.clientX, y: event.clientY },
|
||||
renderer.domElement.getBoundingClientRect(), event.timeStamp || performance.now());
|
||||
targetRotation = { x: earth.rotation.x, y: earth.rotation.y };
|
||||
} else {
|
||||
const factor = getDragRotationFactor();
|
||||
const deltaX = (event.clientX - previousMousePosition.x) * factor;
|
||||
const deltaY = (event.clientY - previousMousePosition.y) * factor;
|
||||
targetRotation.x += deltaY;
|
||||
targetRotation.y += deltaX;
|
||||
inertialVelocity = { x: deltaY, y: deltaX };
|
||||
}
|
||||
const factor = getDragRotationFactor();
|
||||
const deltaX = (event.clientX - previousMousePosition.x) * factor;
|
||||
const deltaY = (event.clientY - previousMousePosition.y) * factor;
|
||||
screenSpaceDrag.move(deltaX, deltaY);
|
||||
inertialVelocity = { x: deltaY, y: deltaX };
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
if (!document.body.classList.contains("layout-mode-mobile")) {
|
||||
clearCountryBoundaryHover();
|
||||
@@ -6067,14 +6081,10 @@ function onMouseDown(event) {
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
dragStartPointer = { ...previousMousePosition };
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
surfaceDrag.stop();
|
||||
surfaceDragging = false;
|
||||
screenSpaceDrag.stop();
|
||||
screenSpaceInertia = true;
|
||||
if (earth) {
|
||||
targetRotation = { x: earth.rotation.x, y: earth.rotation.y };
|
||||
surfaceDragging = isGlobeClippedByViewport(earth, camera,
|
||||
CONFIG.earthRadius + EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET) &&
|
||||
surfaceDrag.begin(earth, camera, { x: event.clientX, y: event.clientY },
|
||||
renderer.domElement.getBoundingClientRect(), event.timeStamp || performance.now());
|
||||
}
|
||||
clearDocumentSelection();
|
||||
setGlobeDraggingUiState(true);
|
||||
@@ -6084,9 +6094,7 @@ function onMouseDown(event) {
|
||||
|
||||
function onMouseUp(event = {}, cancelInertia = false) {
|
||||
const cancelled = cancelInertia || event.type === "pointercancel";
|
||||
surfaceDrag.end(event.timeStamp || performance.now(), cancelled);
|
||||
if (cancelled) inertialVelocity = { x: 0, y: 0 };
|
||||
surfaceDragging = false;
|
||||
isDragging = false;
|
||||
setGlobeDraggingUiState(false);
|
||||
clearDocumentSelection();
|
||||
@@ -6116,7 +6124,7 @@ function onPointerDown(event) {
|
||||
};
|
||||
activeDragPointerId = null;
|
||||
onMouseUp();
|
||||
surfaceDrag.stop();
|
||||
screenSpaceDrag.stop();
|
||||
inertialVelocity = { x: 0, y: 0 };
|
||||
return;
|
||||
}
|
||||
@@ -6443,6 +6451,7 @@ function onClick(event) {
|
||||
|
||||
function animate() {
|
||||
if (destroyed) return;
|
||||
const statsStart = beginFrameStats();
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
@@ -6451,7 +6460,7 @@ function animate() {
|
||||
const hasInertia = hasActiveGlobeInertia();
|
||||
|
||||
if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth && !isDragging && activeTouchPoints.size === 0 && !hasInertia) {
|
||||
earth.rotation.y += getAutoRotationSpeed() * (deltaTime / 16);
|
||||
earth.rotateY(getAutoRotationSpeed() * (deltaTime / 16));
|
||||
|
||||
// Keep the drag target aligned with autorotation only when the user is not
|
||||
// actively dragging and there is no residual inertial motion to preserve.
|
||||
@@ -6462,29 +6471,32 @@ function animate() {
|
||||
}
|
||||
|
||||
if (earth) {
|
||||
if ((isDragging && surfaceDragging) || surfaceDrag.advance(earth, deltaTime)) {
|
||||
// Pointer movement has already placed the grabbed surface point exactly.
|
||||
targetRotation.x = earth.rotation.x;
|
||||
targetRotation.y = earth.rotation.y;
|
||||
} else if (isDragging) {
|
||||
earth.rotation.x += (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
|
||||
earth.rotation.y += (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
|
||||
if (isDragging) {
|
||||
screenSpaceDrag.advance(earth, camera, DRAG_SMOOTHING_FACTOR);
|
||||
targetRotation = { x: earth.rotation.x, y: earth.rotation.y };
|
||||
} else if (
|
||||
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
|
||||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY
|
||||
) {
|
||||
// Continue rotating after release and gradually decay the motion.
|
||||
targetRotation.x += inertialVelocity.x * (deltaTime / 16);
|
||||
targetRotation.y += inertialVelocity.y * (deltaTime / 16);
|
||||
earth.rotation.x +=
|
||||
(targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
|
||||
earth.rotation.y +=
|
||||
(targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
|
||||
if (screenSpaceInertia) {
|
||||
screenSpaceDrag.move(inertialVelocity.y * (deltaTime / 16), inertialVelocity.x * (deltaTime / 16));
|
||||
screenSpaceDrag.advance(earth, camera, DRAG_SMOOTHING_FACTOR);
|
||||
targetRotation = { x: earth.rotation.x, y: earth.rotation.y };
|
||||
} else {
|
||||
// Keep the gesture-control rotation path independent of pointer drag.
|
||||
targetRotation.x += inertialVelocity.x * (deltaTime / 16);
|
||||
targetRotation.y += inertialVelocity.y * (deltaTime / 16);
|
||||
earth.rotation.x += (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR;
|
||||
earth.rotation.y += (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR;
|
||||
}
|
||||
inertialVelocity.x *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
|
||||
inertialVelocity.y *= Math.pow(INERTIA_DAMPING, deltaTime / 16);
|
||||
} else {
|
||||
inertialVelocity.x = 0;
|
||||
inertialVelocity.y = 0;
|
||||
screenSpaceDrag.stop();
|
||||
screenSpaceInertia = false;
|
||||
targetRotation.x = earth.rotation.x;
|
||||
targetRotation.y = earth.rotation.y;
|
||||
}
|
||||
@@ -6536,6 +6548,7 @@ function animate() {
|
||||
!pinchGesture,
|
||||
);
|
||||
updateSatellitePointSize();
|
||||
updateSatelliteLabels(renderer);
|
||||
updateRelatedSatelliteHighlights();
|
||||
updateCelestialLayer(new Date(), camera);
|
||||
const currentSunDirection = getSunDirection();
|
||||
@@ -6571,6 +6584,7 @@ function animate() {
|
||||
repositionCruiseConnector();
|
||||
repositionMotionConnector();
|
||||
renderer.render(scene, camera);
|
||||
finishFrameStats(statsStart, renderer);
|
||||
const timing = document.documentElement.dataset;
|
||||
if (!timing.earthFirstFrameMs) timing.earthFirstFrameMs = performance.now().toFixed(1);
|
||||
if (!timing.earthSurfaceReadyMs && isEarthTextureReady()) {
|
||||
@@ -6586,7 +6600,8 @@ function animate() {
|
||||
export function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
surfaceDrag.stop();
|
||||
screenSpaceDrag.stop();
|
||||
screenSpaceInertia = false;
|
||||
stopVesselRealtime();
|
||||
currentLoadToken += 1;
|
||||
isDataLoading = false;
|
||||
|
||||
53
frontend/public/earth/js/marker-badges.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Reference-aligned badge surfaces shared by all finite ground-object layers.
|
||||
// Painted into the existing texture atlas: no DOM nodes or per-marker meshes.
|
||||
const REFERENCE_BADGES = {
|
||||
'#14b8a6': { top: '#14b8a6', bottom: '#0d9488', border: '#5eead4', ink: '#042f2e' },
|
||||
'#a855f7': { top: '#a855f7', bottom: '#7e22ce', border: '#d8b4fe', ink: '#3b0764' },
|
||||
'#f59e0b': { top: '#f59e0b', bottom: '#d97706', border: '#fef3c7', ink: '#451a03' },
|
||||
};
|
||||
|
||||
function blend(color, target, amount) {
|
||||
const value = Number.parseInt(color.slice(1), 16);
|
||||
const end = Number.parseInt(target.slice(1), 16);
|
||||
return '#' + [16, 8, 0].map(shift => Math.round(((value >> shift) & 255) * (1 - amount) + ((end >> shift) & 255) * amount).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function markerBadgePalette(color = '#38bdf8') {
|
||||
const normalized = /^#[0-9a-f]{6}$/i.test(color) ? color.toLowerCase() : '#38bdf8';
|
||||
return REFERENCE_BADGES[normalized] || {
|
||||
top: normalized,
|
||||
bottom: blend(normalized, '#020617', 0.25),
|
||||
border: blend(normalized, '#ffffff', 0.64),
|
||||
ink: blend(normalized, '#020617', 0.87),
|
||||
};
|
||||
}
|
||||
|
||||
export function drawMarkerBadge(context, { color, atlasCellSize = 128, glow = false }) {
|
||||
const palette = markerBadgePalette(color);
|
||||
const scale = atlasCellSize / 128;
|
||||
context.save();
|
||||
context.scale(scale, scale);
|
||||
const gradient = context.createLinearGradient(22, 18, 108, 110);
|
||||
gradient.addColorStop(0, palette.top);
|
||||
gradient.addColorStop(1, palette.bottom);
|
||||
context.shadowColor = glow ? palette.top : 'rgba(0,0,0,0.65)';
|
||||
context.shadowBlur = glow ? 17 : 11;
|
||||
context.shadowOffsetY = glow ? 0 : 6;
|
||||
context.beginPath();
|
||||
context.roundRect(20, 18, 88, 88, 19);
|
||||
context.fillStyle = gradient;
|
||||
context.fill();
|
||||
context.shadowBlur = 0;
|
||||
context.shadowOffsetY = 0;
|
||||
context.lineWidth = 2.8;
|
||||
context.strokeStyle = palette.border;
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(38, 23);
|
||||
context.lineTo(89, 23);
|
||||
context.strokeStyle = 'rgba(255,255,255,0.23)';
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
return palette;
|
||||
}
|
||||
69
frontend/public/earth/js/marker-badges.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import * as THREE from 'three';
|
||||
import { markerBadgePalette } from './marker-badges.js';
|
||||
import { createInteractableLayer, getInteractableCompactDotsEnabled, setInteractableCompactDotsEnabled } from './interactable.js';
|
||||
|
||||
test('reference teal, purple and amber palettes retain their exact plate, rim and ink colors', () => {
|
||||
expect(markerBadgePalette('#14b8a6')).toEqual({ top: '#14b8a6', bottom: '#0d9488', border: '#5eead4', ink: '#042f2e' });
|
||||
expect(markerBadgePalette('#a855f7')).toEqual({ top: '#a855f7', bottom: '#7e22ce', border: '#d8b4fe', ink: '#3b0764' });
|
||||
expect(markerBadgePalette('#f59e0b').border).toBe('#fef3c7');
|
||||
});
|
||||
|
||||
test('compact dots are optional; complete badges keep marker identity, anchors and batched rendering', () => {
|
||||
const previousDocument = globalThis.document, previousWindow = globalThis.window;
|
||||
const previousCompact = getInteractableCompactDotsEnabled();
|
||||
const canvases = [];
|
||||
globalThis.window = { innerWidth: 1280, innerHeight: 720, devicePixelRatio: 1 };
|
||||
globalThis.document = { createElement() {
|
||||
const trace = [];
|
||||
const canvas = { width: 128, height: 128, trace, getContext() { return new Proxy({}, {
|
||||
get(_target, key) { if (key === 'createLinearGradient' || key === 'createRadialGradient') return () => ({ addColorStop() {} });
|
||||
if (key === 'measureText') return () => ({ width: 10 });
|
||||
return (...args) => { trace.push([key, ...args]); };
|
||||
},
|
||||
}); } };
|
||||
canvases.push(canvas); return canvas;
|
||||
} };
|
||||
let layer;
|
||||
try {
|
||||
setInteractableCompactDotsEnabled(false);
|
||||
layer = createInteractableLayer({ id: 'badge-regression', pointSize: 36, cluster: false,
|
||||
colors: { normal: '#14b8a6' }, icon: { badge: { color: '#14b8a6' }, colorable: false, draw(context) { context.fillRect(-10, -10, 20, 20); } },
|
||||
getPosition: item => ({ latitude: item.latitude, longitude: item.longitude }),
|
||||
});
|
||||
const earth = new THREE.Object3D(), camera = new THREE.PerspectiveCamera(75, 1280 / 720, 0.1, 5000);
|
||||
camera.position.z = 350; camera.updateMatrixWorld();
|
||||
layer.attach(earth); layer.setVisible(true);
|
||||
layer.setData([{ id: 'same-object', latitude: 0, longitude: -90 }]);
|
||||
const marker = layer.getMarkers()[0], anchor = marker.position.clone();
|
||||
layer.updateVisualState(null, null, camera);
|
||||
const points = layer.group.children.find(group => group.children.some(child => child.isPoints)).children.find(child => child.isPoints);
|
||||
expect(points.material.depthTest).toBe(false);
|
||||
const shader = { uniforms: {}, vertexShader: THREE.ShaderLib.points.vertexShader };
|
||||
points.material.onBeforeCompile(shader);
|
||||
expect(shader.vertexShader).toContain("surfaceAnchorOccluded");
|
||||
expect(points.material.defines.USE_SURFACE_ANCHOR_OCCLUSION).toBe("");
|
||||
const pointer = new THREE.Vector2(0, 0);
|
||||
expect(layer.getPointerIntersections({ earth, camera, pointer }).length).toBe(1);
|
||||
earth.rotation.y = Math.PI;
|
||||
earth.updateMatrixWorld(true);
|
||||
expect(layer.getPointerIntersections({ earth, camera, pointer }).length).toBe(0);
|
||||
earth.rotation.y = 0;
|
||||
earth.updateMatrixWorld(true);
|
||||
const badgeTexture = points.material.map;
|
||||
expect(badgeTexture.image.trace.some(call => call[0] === 'roundRect')).toBe(true);
|
||||
setInteractableCompactDotsEnabled(true);
|
||||
layer.updateVisualState(null, null, camera);
|
||||
let dotTexture;
|
||||
layer.group.traverse(object => { if (object.isPoints && object.userData.markers?.length) dotTexture = object.material.map; });
|
||||
expect(dotTexture.image.trace.some(call => call[0] === 'roundRect')).toBe(false);
|
||||
expect(layer.getMarkers()[0]).toBe(marker);
|
||||
expect(marker.position.distanceTo(anchor)).toBe(0);
|
||||
layer.setMarkerState(marker, 'locked');
|
||||
expect(marker.userData.state).toBe('locked');
|
||||
} finally {
|
||||
layer?.clearData();
|
||||
setInteractableCompactDotsEnabled(previousCompact);
|
||||
globalThis.document = previousDocument; globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
36
frontend/public/earth/js/satellite-display.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { globeZoomForDistance } from "./globe-navigation.js";
|
||||
|
||||
export const SATELLITE_CATEGORIES = Object.freeze([
|
||||
{key:"equatorial",label:"赤道轨道(0-30°)",color:"#3399ff",min:0,max:30},
|
||||
{key:"low",label:"低倾角轨道(30-60°)",color:"#ff801a",min:30,max:60},
|
||||
{key:"medium",label:"中倾角轨道(60-90°)",color:"#00e680",min:60,max:90},
|
||||
{key:"high",label:"高倾角轨道(90-120°)",color:"#cc33ff",min:90,max:120},
|
||||
{key:"retrograde",label:"逆行轨道(120-180°)",color:"#ff3366",min:120,max:180.00001},
|
||||
{key:"other",label:"其他",color:"#94a3b8"},
|
||||
]);
|
||||
export function satelliteCategory(properties = {}) {
|
||||
const angle = properties.inclination == null ? NaN : Number(properties.inclination);
|
||||
return SATELLITE_CATEGORIES.find(item => angle >= item.min && angle < item.max)
|
||||
|| SATELLITE_CATEGORIES.at(-1);
|
||||
}
|
||||
// Measured reference cores: about 2px at 150%, and 6–7px at 500%.
|
||||
// The texture's solid disc occupies 84% of the PointsMaterial square.
|
||||
export const SATELLITE_POINT_STYLE = Object.freeze({
|
||||
minSize: 2.4,
|
||||
maxSize: 8.0,
|
||||
farZoom: 1.5,
|
||||
nearZoom: 5.0,
|
||||
});
|
||||
export function satellitePixelSize(cameraDistance, earthRadius, defaultCameraDistance = earthRadius * 3) {
|
||||
const zoom = globeZoomForDistance(cameraDistance, earthRadius, defaultCameraDistance);
|
||||
const { minSize, maxSize, farZoom, nearZoom } = SATELLITE_POINT_STYLE;
|
||||
const t = Math.max(0, Math.min(1, (zoom - farZoom) / (nearZoom - farZoom)));
|
||||
return minSize + t * t * (3 - 2 * t) * (maxSize - minSize);
|
||||
}
|
||||
export function satelliteLabel(properties = {}) {
|
||||
const id = properties.norad_cat_id;
|
||||
return {
|
||||
name: String(properties.name || (id != null ? `NORAD ${id}` : "卫星")),
|
||||
detail: String(properties.satellite_model || (id != null ? `NORAD ${id}` : "")),
|
||||
};
|
||||
}
|
||||
60
frontend/public/earth/js/satellite-display.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { satelliteCategory, satellitePixelSize, satelliteLabel } from "./satellite-display.js";
|
||||
import { createSatelliteLabels } from "./satellite-labels.js";
|
||||
import { distanceForGlobeZoom } from "./globe-navigation.js";
|
||||
import { summarizeFrames, summarizeFrameWindows } from "./frame-stats.js";
|
||||
|
||||
test("real inclination categories preserve boundaries and unknowns", () => {
|
||||
for (const [angle,key] of [[0,"equatorial"],[29.9,"equatorial"],[30,"low"],[60,"medium"],[90,"high"],[120,"retrograde"],[180,"retrograde"],[181,"other"],[null,"other"]]) {
|
||||
expect(satelliteCategory({inclination:angle}).key).toBe(key);
|
||||
}
|
||||
});
|
||||
test("reference screen cores stay about 2px at 150% and 6–7px at 500%", () => {
|
||||
const sizeAt = zoom => satellitePixelSize(distanceForGlobeZoom(zoom, 100, 300), 100, 300);
|
||||
expect(sizeAt(1.5) * 0.84).toBeCloseTo(2.016);
|
||||
expect(sizeAt(5) * 0.84).toBeCloseTo(6.72);
|
||||
expect(sizeAt(4)).toBeGreaterThan(6);
|
||||
let previous = sizeAt(0.5);
|
||||
for (let zoom = 0.5; zoom <= 5; zoom += 0.01) {
|
||||
const size = sizeAt(zoom);
|
||||
expect(size).toBeGreaterThanOrEqual(previous);
|
||||
expect(size - previous).toBeLessThan(0.025);
|
||||
previous = size;
|
||||
}
|
||||
expect(satelliteLabel({ name: "STARLINK-3148", norad_cat_id: 49736 })).toEqual({ name: "STARLINK-3148", detail: "NORAD 49736" });
|
||||
});
|
||||
test("FPS uses actual frame intervals and does not hide long foreground frames", () => {
|
||||
expect(summarizeFrames([{interval:10,cpu:4},{interval:10,cpu:6}])).toEqual({fps:100,mean:10,cpu:5,p95:10});
|
||||
expect(summarizeFrames([{interval:10,cpu:4},{interval:500,cpu:400}]).fps).toBeLessThan(4);
|
||||
});
|
||||
test("near labels start at 400%, stay capped, use real text, and honor filters", () => {
|
||||
const oldDocument=globalThis.document;
|
||||
function element(){return {children:[],style:{},hidden:false,isConnected:true,textContent:"",append(...nodes){this.children.push(...nodes);},setAttribute(){},remove(){this.isConnected=false;}};}
|
||||
const container=element(); globalThis.document={createElement:element,getElementById:()=>container};
|
||||
const labels=createSatelliteLabels();
|
||||
try {
|
||||
const object=new THREE.Object3D(); const camera=new THREE.PerspectiveCamera(75,1280/720,.1,5000);
|
||||
const features=Array.from({length:100},(_,index)=>({properties:{name:`STARLINK-${index}`,norad_cat_id:index}}));
|
||||
const positions=features.map((_,index)=>{const x=(index%10-4.5)*1.2,y=(Math.floor(index/10)-4.5)*1.2;return {current:new THREE.Vector3(x,y,Math.sqrt(108**2-x*x-y*y))};});
|
||||
const args={camera,object,renderer:{domElement:{clientWidth:1280,clientHeight:720}},features,positions,isVisible:()=>true};
|
||||
camera.position.z=distanceForGlobeZoom(3.99,100,300);camera.updateMatrixWorld();labels.update(args);
|
||||
expect(container.children.length).toBe(0);
|
||||
camera.position.z=distanceForGlobeZoom(4,100,300);camera.updateMatrixWorld();labels.update(args);
|
||||
const root=container.children[0],visible=root.children.filter(node=>!node.hidden);
|
||||
expect(visible.length).toBeGreaterThan(0);expect(visible.length).toBeLessThanOrEqual(36);
|
||||
expect(visible[0].children[1].textContent).toStartWith("NORAD ");
|
||||
labels.update({...args,isVisible:()=>false});expect(root.children.every(node=>node.hidden)).toBe(true);
|
||||
camera.position.z=distanceForGlobeZoom(3,100,300);camera.updateMatrixWorld();labels.update(args);expect(root.hidden).toBe(true);
|
||||
} finally {labels.dispose();globalThis.document=oldDocument;}
|
||||
});
|
||||
|
||||
test("multi-second FPS weights frame count by elapsed time instead of averaging rates", () => {
|
||||
expect(summarizeFrameWindows([{ duration: 1000, count: 100 }, { duration: 2000, count: 60 }])).toEqual({ fps: 160000 / 3000, seconds: 3 });
|
||||
});
|
||||
|
||||
test("reference palette keeps real inclination ownership", () => {
|
||||
expect(satelliteCategory({ inclination: 53 }).color).toBe("#ff801a");
|
||||
expect(satelliteCategory({ inclination: 10 }).color).toBe("#3399ff");
|
||||
expect(satelliteCategory({ inclination: 98 }).color).toBe("#cc33ff");
|
||||
});
|
||||
51
frontend/public/earth/js/satellite-footprint-geometry.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// The ellipse topology is constant; the shader projects its UVs to Earth.
|
||||
export function createSatelliteFootprintGeometry(THREE, columns, rows) {
|
||||
const positions = [], uvs = [], indices = [], indexMap = [];
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
const row = [];
|
||||
const v = y / rows, cross = v * 2 - 1;
|
||||
for (let x = 0; x <= columns; x++) {
|
||||
const u = x / columns, along = u * 2 - 1;
|
||||
if (along * along + cross * cross > 1) { row.push(-1); continue; }
|
||||
row.push(positions.length / 3);
|
||||
positions.push(along, cross, 0);
|
||||
uvs.push(u, v);
|
||||
}
|
||||
indexMap.push(row);
|
||||
}
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < columns; x++) {
|
||||
const a = indexMap[y][x], b = indexMap[y][x + 1];
|
||||
const c = indexMap[y + 1][x], d = indexMap[y + 1][x + 1];
|
||||
if (a < 0 || b < 0 || c < 0 || d < 0) continue;
|
||||
indices.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export const SATELLITE_FOOTPRINT_VERTEX_SHADER = /* glsl */ `
|
||||
uniform vec3 uSurfaceCenter;
|
||||
uniform vec3 uSurfaceAlong;
|
||||
uniform vec3 uSurfaceCross;
|
||||
uniform float uMajorKm;
|
||||
uniform float uMinorKm;
|
||||
uniform float uWorldUnitsPerKm;
|
||||
uniform float uSurfaceRadius;
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec2 offset = (uv * 2.0 - 1.0) * vec2(uMajorKm, uMinorKm);
|
||||
vec3 surfacePosition = normalize(uSurfaceCenter
|
||||
+ (uSurfaceAlong * offset.x + uSurfaceCross * offset.y) * uWorldUnitsPerKm)
|
||||
* uSurfaceRadius;
|
||||
vec4 worldPosition = modelMatrix * vec4(surfacePosition, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { createSatelliteFootprintGeometry } from "./satellite-footprint-geometry.js";
|
||||
|
||||
test("coverage keeps the full 260 by 170 ellipse grid and valid UV topology", () => {
|
||||
const geometry = createSatelliteFootprintGeometry(THREE, 260, 170);
|
||||
const position = geometry.attributes.position, uv = geometry.attributes.uv;
|
||||
expect(position.count).toBeGreaterThan(34000);
|
||||
expect(geometry.index.count).toBeGreaterThan(200000);
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
const x = position.getX(i), y = position.getY(i);
|
||||
expect(x * x + y * y).toBeLessThanOrEqual(1.000001);
|
||||
expect(uv.getX(i) * 2 - 1).toBeCloseTo(x, 6);
|
||||
expect(uv.getY(i) * 2 - 1).toBeCloseTo(y, 6);
|
||||
}
|
||||
for (const index of geometry.index.array) expect(index).toBeLessThan(position.count);
|
||||
geometry.dispose();
|
||||
});
|
||||
98
frontend/public/earth/js/satellite-labels.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as THREE from "three";
|
||||
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||
import { globeZoomForDistance } from "./globe-navigation.js";
|
||||
import { satelliteLabel } from "./satellite-display.js";
|
||||
import { isSurfaceAnchorVisible } from "./surface-marker-occlusion.js";
|
||||
|
||||
const MAX_LABELS = SATELLITE_CONFIG.labelMaxCount;
|
||||
const SCAN_INTERVAL_MS = SATELLITE_CONFIG.labelScanIntervalMs;
|
||||
export function createSatelliteLabels() {
|
||||
let root = null, featuresRef = null, metadata = [], nextScan = 0;
|
||||
let slots = [], active = [], previous = new Set();
|
||||
const projection = new THREE.Matrix4(), view = new THREE.Matrix4();
|
||||
const cameraLocal = new THREE.Vector3(), projected = new THREE.Vector3();
|
||||
const hide = () => { if (root && !root.hidden) root.hidden = true; };
|
||||
function ensureRoot() {
|
||||
if (root?.isConnected) return;
|
||||
root = document.createElement("div");
|
||||
root.className = "satellite-labels";
|
||||
root.setAttribute("aria-hidden", "true");
|
||||
slots = Array.from({length:MAX_LABELS}, () => {
|
||||
const element = document.createElement("div"); element.className = "satellite-map-label";
|
||||
const name = document.createElement("span"), detail = document.createElement("small");
|
||||
element.append(name, detail); element.hidden = true; root.append(element);
|
||||
return { element, name, detail, index: -1, x: NaN, y: NaN };
|
||||
});
|
||||
document.getElementById("container")?.append(root);
|
||||
}
|
||||
return {
|
||||
hide,
|
||||
dispose() { root?.remove(); root = null; active = []; featuresRef = null; metadata = []; previous.clear(); },
|
||||
update({ camera, object, renderer, features, positions, isVisible, priority = -1 }) {
|
||||
if (!object?.visible || globeZoomForDistance(camera.position.length(), CONFIG.earthRadius, CONFIG.defaultCameraZ) < SATELLITE_CONFIG.labelMinZoom - 1e-6) { hide(); nextScan = 0; return; }
|
||||
ensureRoot(); if (root.hidden) root.hidden = false;
|
||||
if (featuresRef !== features) {
|
||||
featuresRef = features; metadata = features.map(feature => satelliteLabel(feature.properties));
|
||||
previous.clear(); nextScan = 0;
|
||||
}
|
||||
object.updateWorldMatrix(true, false);
|
||||
cameraLocal.copy(camera.position); object.worldToLocal(cameraLocal);
|
||||
view.multiplyMatrices(camera.matrixWorldInverse, object.matrixWorld);
|
||||
projection.multiplyMatrices(camera.projectionMatrix, view);
|
||||
const width = renderer.domElement.clientWidth, height = renderer.domElement.clientHeight;
|
||||
const screen = index => {
|
||||
const point = positions[index]?.current;
|
||||
if (!point || !isVisible(index)) return null;
|
||||
projected.copy(point).applyMatrix4(projection);
|
||||
if (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 0.65 || Math.abs(projected.y) > 0.65) return null;
|
||||
if (!isSurfaceAnchorVisible(point, cameraLocal, CONFIG.earthRadius)) return null;
|
||||
return { index, x:(projected.x * .5 + .5) * width, y:(.5 - projected.y * .5) * height };
|
||||
};
|
||||
const now = performance.now();
|
||||
if (now >= nextScan) {
|
||||
nextScan = now + SCAN_INTERVAL_MS;
|
||||
const candidates = [];
|
||||
for (let index = 0; index < features.length; index++) {
|
||||
const point = positions[index]?.current;
|
||||
if (!point || point.dot(cameraLocal) <= 0) continue;
|
||||
const candidate = screen(index);
|
||||
if (candidate) {
|
||||
candidate.score = (candidate.x-width/2)**2 + (candidate.y-height/2)**2
|
||||
- (previous.has(index) ? width*height : 0) - (index === priority ? width*height*2 : 0);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
candidates.sort((a,b)=>a.score-b.score);
|
||||
const occupied = [], chosen = [];
|
||||
const limit = Math.min(MAX_LABELS, Math.max(8, Math.floor(width * height / 23000)));
|
||||
for (const candidate of candidates) {
|
||||
const text = metadata[candidate.index];
|
||||
const labelWidth = Math.min(130, Math.max(text.name.length, text.detail.length) * 7 + 8);
|
||||
const rect = {left:candidate.x-labelWidth/2, right:candidate.x+labelWidth/2, top:candidate.y-36, bottom:candidate.y-2};
|
||||
if (occupied.some(other => rect.left < other.right+6 && rect.right > other.left-6 && rect.top < other.bottom+4 && rect.bottom > other.top-4)) continue;
|
||||
occupied.push(rect); chosen.push(candidate.index);
|
||||
if (chosen.length === limit) break;
|
||||
}
|
||||
active = chosen; previous = new Set(chosen);
|
||||
slots.forEach((slot,i) => {
|
||||
const index = chosen[i];
|
||||
slot.element.hidden = index === undefined;
|
||||
if (index === undefined) return;
|
||||
if (slot.index !== index) {
|
||||
slot.index = index; slot.name.textContent = metadata[index].name; slot.detail.textContent = metadata[index].detail;
|
||||
}
|
||||
});
|
||||
}
|
||||
active.forEach((index, i) => {
|
||||
const point = screen(index), slot = slots[i];
|
||||
if (slot.element.hidden !== !point) slot.element.hidden = !point;
|
||||
if (!point) return;
|
||||
const x = Math.round(point.x), y = Math.round(point.y - 8);
|
||||
if (slot.x !== x || slot.y !== y) {
|
||||
slot.element.style.transform = `translate(${x}px,${y}px) translate(-50%,-100%)`;
|
||||
slot.x = x; slot.y = y;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
50
frontend/public/earth/js/satellite-motion.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Smooth rendering between propagated orbit samples; never propagate synthetic
|
||||
// orbits or lerp satellite identities. Radial normalization avoids chord sinking.
|
||||
export function createSatelliteMotion() {
|
||||
let from = new Float32Array(), target = new Float32Array(), rendered = new Float32Array();
|
||||
let fromRadii = new Float64Array(), targetRadii = new Float64Array();
|
||||
let startedAt = 0, duration = 1, lastAlpha = -1;
|
||||
function sample(now, output) {
|
||||
if (!target.length) return false;
|
||||
const alpha = Math.max(0, Math.min(1, (now - startedAt) / duration));
|
||||
if (alpha === lastAlpha) return false;
|
||||
for (let i = 0; i < target.length; i += 3) {
|
||||
const a = fromRadii[i / 3];
|
||||
const b = targetRadii[i / 3];
|
||||
const x = from[i] + (target[i] - from[i]) * alpha;
|
||||
const y = from[i + 1] + (target[i + 1] - from[i + 1]) * alpha;
|
||||
const z = from[i + 2] + (target[i + 2] - from[i + 2]) * alpha;
|
||||
const length = Math.sqrt(x * x + y * y + z * z);
|
||||
const scale = length > 1e-8 ? (a + (b - a) * alpha) / length : 0;
|
||||
rendered[i] = output[i] = x * scale;
|
||||
rendered[i + 1] = output[i + 1] = y * scale;
|
||||
rendered[i + 2] = output[i + 2] = z * scale;
|
||||
}
|
||||
lastAlpha = alpha;
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
push(values, now, intervalMs) {
|
||||
if (target.length !== values.length || !target.length) {
|
||||
from = new Float32Array(values); target = new Float32Array(values); rendered = new Float32Array(values);
|
||||
fromRadii = new Float64Array(values.length / 3); targetRadii = new Float64Array(values.length / 3);
|
||||
} else {
|
||||
sample(now, rendered);
|
||||
from.set(rendered); target.set(values);
|
||||
for (let i = 0; i < target.length; i += 3) {
|
||||
const dot = from[i] * target[i] + from[i + 1] * target[i + 1] + from[i + 2] * target[i + 2];
|
||||
// A large discontinuity means data/altitude was replaced, not motion.
|
||||
if (dot <= 0) from.set(target.subarray(i, i + 3), i);
|
||||
}
|
||||
}
|
||||
// Radii change at propagation cadence, not at display refresh cadence.
|
||||
for (let i = 0; i < target.length; i += 3) {
|
||||
fromRadii[i / 3] = Math.hypot(from[i], from[i + 1], from[i + 2]);
|
||||
targetRadii[i / 3] = Math.hypot(target[i], target[i + 1], target[i + 2]);
|
||||
}
|
||||
startedAt = now; duration = Math.max(1, intervalMs); lastAlpha = -1;
|
||||
},
|
||||
sample,
|
||||
reset() { from = new Float32Array(); target = new Float32Array(); rendered = new Float32Array(); fromRadii = new Float64Array(); targetRadii = new Float64Array(); lastAlpha = -1; },
|
||||
};
|
||||
}
|
||||
35
frontend/public/earth/js/satellite-motion.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { createSatelliteMotion } from './satellite-motion.js';
|
||||
|
||||
test('orbit samples move smoothly at render cadence without sinking inside their radius', () => {
|
||||
const motion=createSatelliteMotion(), out=new Float32Array(3);
|
||||
motion.push(new Float32Array([100,0,0]),0,250); motion.sample(0,out);
|
||||
motion.push(new Float32Array([0,100,0]),0,250);
|
||||
let previous=0;
|
||||
for(let time=0;time<=250;time+=10){
|
||||
motion.sample(time,out);
|
||||
expect(Math.hypot(...out)).toBeCloseTo(100,4);
|
||||
expect(out[1]).toBeGreaterThanOrEqual(previous); previous=out[1];
|
||||
}
|
||||
expect(out[1]).toBeCloseTo(100,5);
|
||||
expect(motion.sample(300,out)).toBe(false);
|
||||
});
|
||||
|
||||
test('a new worker sample rebases from the displayed position without a jump',()=>{
|
||||
const motion=createSatelliteMotion(),out=new Float32Array(3);
|
||||
motion.push(new Float32Array([100,0,0]),0,250); motion.sample(0,out);
|
||||
motion.push(new Float32Array([90,44,0]),0,250); motion.sample(100,out);
|
||||
const before=out.slice();
|
||||
motion.push(new Float32Array([80,60,0]),100,250); motion.sample(100,out);
|
||||
expect([...out]).toEqual([...before]);
|
||||
motion.sample(116,out); expect(out[1]).toBeGreaterThan(before[1]);
|
||||
});
|
||||
|
||||
test('reset, count changes and discontinuous replacement do not blend unrelated satellites',()=>{
|
||||
const motion=createSatelliteMotion(),out=new Float32Array(6);
|
||||
motion.push(new Float32Array([100,0,0]),0,250); motion.sample(0,out);
|
||||
motion.reset(); motion.push(new Float32Array([0,100,0,0,0,120]),100,250); motion.sample(100,out);
|
||||
expect([...out]).toEqual([0,100,0,0,0,120]);
|
||||
motion.push(new Float32Array([0,-100,0,0,0,120]),200,250); motion.sample(200,out);
|
||||
expect([...out]).toEqual([0,-100,0,0,0,120]);
|
||||
});
|
||||
56
frontend/public/earth/js/satellite-orbit-visual.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export const ORBIT_VISUAL = Object.freeze({
|
||||
sampleSeconds: 10,
|
||||
flowRepeats: 16,
|
||||
flowSpeed: 0.45,
|
||||
});
|
||||
|
||||
export function openOrbitSampleTimes(periodSeconds) {
|
||||
const step = Math.min(ORBIT_VISUAL.sampleSeconds, periodSeconds / 2);
|
||||
const segments = Math.max(2, Math.ceil(periodSeconds / step));
|
||||
// Keep the original sampling grid, including its natural final sample just
|
||||
// past one revolution. Only the artificial closing chord is omitted.
|
||||
return Array.from({ length: segments + 1 }, (_, index) => step * index);
|
||||
}
|
||||
|
||||
export function createOrbitFlowGeometry(points) {
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
const distance = new Float32Array(points.length);
|
||||
points.forEach((point, index) => {
|
||||
point.toArray(positions, index * 3);
|
||||
if (index) distance[index] = distance[index - 1] + point.distanceTo(points[index - 1]);
|
||||
});
|
||||
const length = distance[distance.length - 1] || 1;
|
||||
for (let i = 0; i < distance.length; i++) distance[i] /= length;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('orbitProgress', new THREE.BufferAttribute(distance, 1));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createOrbitFlowMaterial(color) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: { uColor: { value: new THREE.Color(color) }, uTime: { value: 0 } },
|
||||
transparent: true, depthTest: true, depthWrite: false, toneMapped: false,
|
||||
vertexShader: `
|
||||
attribute float orbitProgress;
|
||||
varying float vProgress;
|
||||
void main() {
|
||||
vProgress = orbitProgress;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uTime;
|
||||
varying float vProgress;
|
||||
void main() {
|
||||
// Increasing progress is increasing prediction time / travel direction.
|
||||
float phase = fract(vProgress * ${ORBIT_VISUAL.flowRepeats.toFixed(1)} - uTime * ${ORBIT_VISUAL.flowSpeed.toFixed(2)});
|
||||
float pulse = smoothstep(0.45, 0.95, phase) * (1.0 - smoothstep(0.95, 1.0, phase));
|
||||
gl_FragColor = vec4(mix(uColor, vec3(1.0), pulse * 0.65), 0.56 + 0.44 * pulse);
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
32
frontend/public/earth/js/satellite-orbit-visual.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { openOrbitSampleTimes, createOrbitFlowGeometry } from "./satellite-orbit-visual.js";
|
||||
|
||||
test("prediction retains a full orbit and its natural endpoint without a forced closing chord", () => {
|
||||
for (const period of [5573.7, 86400]) {
|
||||
const times = openOrbitSampleTimes(period);
|
||||
expect(times[0]).toBe(0);
|
||||
expect(times.at(-1)).toBeGreaterThanOrEqual(period);
|
||||
expect(times.at(-1) - period).toBeLessThan(10);
|
||||
// A drifting orbit ends near its start, but must keep its measured endpoint.
|
||||
const points = times.map(time => new THREE.Vector3(Math.cos(time / period * 2 * Math.PI), Math.sin(time / period * 2 * Math.PI), time / period * 0.01));
|
||||
const geometry = createOrbitFlowGeometry(points);
|
||||
const positions = geometry.attributes.position;
|
||||
expect(positions.count).toBe(points.length);
|
||||
const endpoint = new THREE.Vector3().fromBufferAttribute(positions, positions.count - 1);
|
||||
expect(endpoint.z).toBeCloseTo(points.at(-1).z, 7);
|
||||
expect(new THREE.Vector3().fromBufferAttribute(positions, 0).distanceTo(endpoint)).toBeLessThan(0.02);
|
||||
for (let i = 1; i < times.length; i++) expect(times[i]).toBeGreaterThan(times[i - 1]);
|
||||
geometry.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("flow progress follows distance and chronological direction across unequal orbit segments", () => {
|
||||
const points = [new THREE.Vector3(0,0,0),new THREE.Vector3(1,0,0),new THREE.Vector3(1,3,0)];
|
||||
const geometry = createOrbitFlowGeometry(points);
|
||||
expect([...geometry.attributes.orbitProgress.array]).toEqual([0,0.25,1]);
|
||||
geometry.dispose();
|
||||
const degenerate = createOrbitFlowGeometry([points[0],points[0]]);
|
||||
expect([...degenerate.attributes.orbitProgress.array]).toEqual([0,0]);
|
||||
degenerate.dispose();
|
||||
});
|
||||
37
frontend/public/earth/js/satellite-trail-geometry.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Short SGP4 tails are almost straight. Collapse redundant ribbon segments
|
||||
// only below a conservative screen-error bound for every supported zoom.
|
||||
export function canUseTrailChord(state, length, minimumCameraRadius, projectionScale, maxErrorPx = 0.2) {
|
||||
const count = state.trailCount;
|
||||
if (count < 2) return false;
|
||||
const first = state.trail[(state.trailIndex - count + length) % length];
|
||||
const last = state.trail[(state.trailIndex - 1 + length) % length];
|
||||
if (!first || !last) return false;
|
||||
const dx=last.x-first.x, dy=last.y-first.y, dz=last.z-first.z;
|
||||
const chordSq=dx*dx+dy*dy+dz*dz;
|
||||
if (chordSq < 1e-8) return false;
|
||||
let errorSq=0, radiusSq=0;
|
||||
for(let i=0;i<count;i++) {
|
||||
const point=state.trail[(state.trailIndex-count+i+length)%length];
|
||||
if(!point) return false;
|
||||
radiusSq=Math.max(radiusSq,point.x*point.x+point.y*point.y+point.z*point.z);
|
||||
// Preserve chronological placement, not merely distance to the line.
|
||||
const t=i/(count-1);
|
||||
const ex=point.x-first.x-dx*t, ey=point.y-first.y-dy*t, ez=point.z-first.z-dz*t;
|
||||
errorSq=Math.max(errorSq,ex*ex+ey*ey+ez*ez);
|
||||
}
|
||||
const safeDepth=minimumCameraRadius-Math.sqrt(radiusSq);
|
||||
if(safeDepth<=1) return false;
|
||||
const tolerance=maxErrorPx*safeDepth/projectionScale;
|
||||
return errorSq<=tolerance*tolerance;
|
||||
}
|
||||
|
||||
export function markActiveAttribute(attribute, components) {
|
||||
if (attribute.clearUpdateRanges) {
|
||||
attribute.clearUpdateRanges();
|
||||
attribute.addUpdateRange(0, Math.max(1,components));
|
||||
} else {
|
||||
attribute.updateRange.offset=0;
|
||||
attribute.updateRange.count=Math.max(1,components);
|
||||
}
|
||||
attribute.needsUpdate=true;
|
||||
}
|
||||
16
frontend/public/earth/js/satellite-trail-geometry.test.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { expect,test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { canUseTrailChord,markActiveAttribute } from "./satellite-trail-geometry.js";
|
||||
function arc(radius,angle) { return {trail:Array.from({length:10},(_,i)=>new THREE.Vector3(radius*Math.cos(i/9*angle),radius*Math.sin(i/9*angle),0)),trailIndex:0,trailCount:10}; }
|
||||
test("only subpixel short arcs collapse; curved or near-camera trails retain segments",()=>{
|
||||
expect(canUseTrailChord(arc(104,.0025),10,110,2500)).toBe(true);
|
||||
expect(canUseTrailChord(arc(104,.03),10,110,2500)).toBe(false);
|
||||
expect(canUseTrailChord(arc(124,.0025),10,110,2500)).toBe(false);
|
||||
expect(canUseTrailChord(arc(104,0),10,110,2500)).toBe(false);
|
||||
expect(canUseTrailChord(arc(104,.0025),10,110,100000)).toBe(false);
|
||||
});
|
||||
test("upload range covers only active ribbon vertices",()=>{
|
||||
const attr=new THREE.InstancedBufferAttribute(new Float32Array(300),3);
|
||||
markActiveAttribute(attr,30);expect(attr.updateRanges).toEqual([{start:0,count:30}]);
|
||||
markActiveAttribute(attr,60);expect(attr.updateRanges).toEqual([{start:0,count:60}]);
|
||||
});
|
||||
26
frontend/public/earth/js/surface-marker-occlusion.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Decide visibility at the geographic anchor, so a front-side billboard is
|
||||
// never sliced by terrain depth while anchors behind the globe stay hidden.
|
||||
export function isSurfaceAnchorVisible(point, camera, radius) {
|
||||
const dx = point.x - camera.x, dy = point.y - camera.y, dz = point.z - camera.z;
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
if (distance === 0) return true;
|
||||
const projection = (camera.x * dx + camera.y * dy + camera.z * dz) / distance;
|
||||
const discriminant = projection * projection - (camera.lengthSq() - radius * radius);
|
||||
if (discriminant < 0) return true;
|
||||
const nearest = -projection - Math.sqrt(discriminant);
|
||||
return nearest <= 0 || nearest >= distance - 0.001;
|
||||
}
|
||||
|
||||
export const SURFACE_ANCHOR_OCCLUSION_GLSL = `
|
||||
bool surfaceAnchorOccluded(vec3 cameraRelative, vec3 anchorRelative, float radius) {
|
||||
vec3 ray = anchorRelative - cameraRelative;
|
||||
float distanceToAnchor = length(ray);
|
||||
if (distanceToAnchor < 0.001) return false;
|
||||
ray /= distanceToAnchor;
|
||||
float projection = dot(cameraRelative, ray);
|
||||
float discriminant = projection * projection - (dot(cameraRelative, cameraRelative) - radius * radius);
|
||||
if (discriminant < 0.0) return false;
|
||||
float nearest = -projection - sqrt(discriminant);
|
||||
return nearest > 0.0 && nearest < distanceToAnchor - 0.001;
|
||||
}
|
||||
`;
|
||||
17
frontend/public/earth/js/surface-marker-occlusion.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import * as THREE from "three";
|
||||
import { isSurfaceAnchorVisible } from "./surface-marker-occlusion.js";
|
||||
|
||||
test("whole badges remain visible at a clear limb anchor but disappear behind Earth", () => {
|
||||
for (const distance of [115, 300, 575]) {
|
||||
const camera = new THREE.Vector3(0, 0, distance);
|
||||
const horizon = Math.acos(100 / distance);
|
||||
const point = angle => new THREE.Vector3(Math.sin(angle), 0, Math.cos(angle)).multiplyScalar(100.48);
|
||||
expect(isSurfaceAnchorVisible(point(horizon - 0.01), camera, 100)).toBe(true);
|
||||
expect(isSurfaceAnchorVisible(point(horizon + 0.2), camera, 100)).toBe(false);
|
||||
expect(isSurfaceAnchorVisible(point(Math.PI), camera, 100)).toBe(false);
|
||||
const rotation = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.7, 1.2, 0.3));
|
||||
expect(isSurfaceAnchorVisible(point(horizon - 0.01).applyQuaternion(rotation), camera.clone().applyQuaternion(rotation), 100)).toBe(true);
|
||||
expect(isSurfaceAnchorVisible(point(Math.PI).applyQuaternion(rotation), camera.clone().applyQuaternion(rotation), 100)).toBe(false);
|
||||
}
|
||||
});
|
||||
186
frontend/public/earth/js/toolbar-hover.js
Normal file
@@ -0,0 +1,186 @@
|
||||
export const ZOOM_MENU_CLOSE_MS = 220;
|
||||
const ROOT_LEAVE_DELAY_MS = 200;
|
||||
|
||||
export function createToolbarHoverState({ onChange, schedule = setTimeout, cancel = clearTimeout }) {
|
||||
let expanded = false, pinned = false, zoomOpen = false, zoomClosing = false;
|
||||
let inRoot = false, inZoom = false, forceRootClose = false;
|
||||
let rootTimer = null, zoomTimer = null;
|
||||
let lastState = "";
|
||||
const emit = () => {
|
||||
const key = `${expanded}:${pinned}:${zoomOpen}:${zoomClosing}`;
|
||||
if (key === lastState) return;
|
||||
lastState = key;
|
||||
onChange({ expanded, pinned, zoomOpen, zoomClosing });
|
||||
};
|
||||
const cancelRootTimer = () => { if (rootTimer !== null) cancel(rootTimer); rootTimer = null; };
|
||||
const cancelZoomTimer = () => { if (zoomTimer !== null) cancel(zoomTimer); zoomTimer = null; };
|
||||
const finishZoomClose = () => {
|
||||
zoomTimer = null;
|
||||
zoomClosing = false;
|
||||
if (forceRootClose || (!pinned && !inRoot)) expanded = false;
|
||||
forceRootClose = false;
|
||||
emit();
|
||||
};
|
||||
const closeZoom = () => {
|
||||
if (!zoomOpen) return;
|
||||
cancelRootTimer();
|
||||
zoomOpen = false;
|
||||
zoomClosing = true;
|
||||
cancelZoomTimer();
|
||||
zoomTimer = schedule(finishZoomClose, ZOOM_MENU_CLOSE_MS);
|
||||
emit();
|
||||
};
|
||||
const openZoom = () => {
|
||||
forceRootClose = false;
|
||||
cancelRootTimer();
|
||||
cancelZoomTimer();
|
||||
expanded = true;
|
||||
zoomOpen = true;
|
||||
zoomClosing = false;
|
||||
emit();
|
||||
};
|
||||
const close = () => {
|
||||
pinned = false;
|
||||
forceRootClose = true;
|
||||
cancelRootTimer();
|
||||
if (zoomOpen) closeZoom();
|
||||
else if (!zoomClosing) { expanded = false; forceRootClose = false; }
|
||||
emit();
|
||||
};
|
||||
emit();
|
||||
return {
|
||||
enterHub() {
|
||||
forceRootClose = false;
|
||||
inRoot = true;
|
||||
cancelRootTimer();
|
||||
expanded = true;
|
||||
emit();
|
||||
},
|
||||
move(region) {
|
||||
inRoot = region.inRoot;
|
||||
inZoom = region.inZoom;
|
||||
if (region.onZoomTrigger || ((zoomOpen || zoomClosing) && inZoom)) {
|
||||
openZoom();
|
||||
return;
|
||||
}
|
||||
if (zoomOpen && !inZoom) closeZoom();
|
||||
if (zoomClosing) return;
|
||||
if (inRoot || pinned) cancelRootTimer();
|
||||
else if (expanded && rootTimer === null) {
|
||||
rootTimer = schedule(() => {
|
||||
rootTimer = null;
|
||||
if (!pinned && !inRoot && !zoomOpen && !zoomClosing) expanded = false;
|
||||
emit();
|
||||
}, ROOT_LEAVE_DELAY_MS);
|
||||
}
|
||||
},
|
||||
togglePinned() {
|
||||
if (pinned) close();
|
||||
else { pinned = true; expanded = true; cancelRootTimer(); emit(); }
|
||||
},
|
||||
openZoom,
|
||||
closeZoom,
|
||||
close,
|
||||
isOpen: () => expanded,
|
||||
isZoomOpen: () => zoomOpen,
|
||||
isPinned: () => pinned,
|
||||
dispose() { cancelRootTimer(); cancelZoomTimer(); },
|
||||
};
|
||||
}
|
||||
|
||||
function contains(rect, point, padding = 0) {
|
||||
return point.x >= rect.left - padding && point.x <= rect.right + padding
|
||||
&& point.y >= rect.top - padding && point.y <= rect.bottom + padding;
|
||||
}
|
||||
|
||||
export function zoomHoverBounds(trigger, menu) {
|
||||
return {
|
||||
left: Math.min(trigger.left, menu.left), right: Math.max(trigger.right, menu.right),
|
||||
top: Math.min(trigger.top, menu.top), bottom: Math.max(trigger.bottom, menu.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
export function bindToolbarHover({ cluster, hub, zoomGroup, zoomTrigger, zoomMenu, listen }) {
|
||||
zoomMenu.style.setProperty("--zoom-menu-duration", `${ZOOM_MENU_CLOSE_MS}ms`);
|
||||
const state = createToolbarHoverState({ onChange({ expanded, pinned, zoomOpen, zoomClosing }) {
|
||||
cluster.classList.toggle("is-expanded", expanded);
|
||||
cluster.classList.toggle("is-collapsed", !expanded);
|
||||
cluster.dataset.pinned = String(pinned);
|
||||
hub.setAttribute("aria-expanded", String(expanded));
|
||||
zoomGroup.classList.toggle("open", zoomOpen);
|
||||
zoomGroup.classList.toggle("is-closing", zoomClosing);
|
||||
zoomTrigger.setAttribute("aria-expanded", String(zoomOpen));
|
||||
} });
|
||||
let keyboardFocus = false;
|
||||
let suppressZoomFocus = false;
|
||||
const toolbarScale = () => Number.parseFloat(getComputedStyle(cluster).getPropertyValue("--toolbar-scale")) || 1;
|
||||
const rootBounds = () => {
|
||||
const scale = toolbarScale();
|
||||
const rect = hub.getBoundingClientRect();
|
||||
const bounds = { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
||||
const x = (rect.left + rect.right) / 2, y = (rect.top + rect.bottom) / 2;
|
||||
// Use final fan positions, not transient animation positions or hidden
|
||||
// popover bounds, so opening animations cannot shrink the wake region.
|
||||
cluster.querySelectorAll(".earth-toolbar-orb").forEach(orb => {
|
||||
const button = orb.querySelector(":scope > button");
|
||||
if (!button?.offsetWidth) return;
|
||||
const centerX = x + (parseFloat(orb.style.getPropertyValue("--orb-x")) || 0) * scale;
|
||||
const centerY = y + (parseFloat(orb.style.getPropertyValue("--orb-y")) || 0) * scale;
|
||||
bounds.left = Math.min(bounds.left, centerX - button.offsetWidth / 2);
|
||||
bounds.right = Math.max(bounds.right, centerX + button.offsetWidth / 2);
|
||||
bounds.top = Math.min(bounds.top, centerY - button.offsetHeight / 2);
|
||||
bounds.bottom = Math.max(bounds.bottom, centerY + button.offsetHeight / 2);
|
||||
});
|
||||
return bounds;
|
||||
};
|
||||
listen(hub, "pointerenter", event => { if (event.pointerType !== "touch") state.enterHub(); });
|
||||
listen(hub, "focus", () => state.enterHub());
|
||||
listen(hub, "click", event => { event.preventDefault(); event.stopPropagation(); state.togglePinned(); });
|
||||
listen(zoomTrigger, "pointerenter", event => { if (event.pointerType !== "touch") state.openZoom(); });
|
||||
listen(zoomTrigger, "click", event => { event.preventDefault(); event.stopPropagation(); state.openZoom(); });
|
||||
listen(document, "pointermove", event => {
|
||||
if (event.pointerType === "touch" || !state.isOpen()) return;
|
||||
keyboardFocus = false;
|
||||
const point = { x: event.clientX, y: event.clientY };
|
||||
state.move({
|
||||
inRoot: contains(rootBounds(), point, 12 * toolbarScale()),
|
||||
inZoom: contains(zoomHoverBounds(zoomTrigger.getBoundingClientRect(), zoomMenu.getBoundingClientRect()), point, 6 * toolbarScale()),
|
||||
onZoomTrigger: zoomTrigger.contains(event.target),
|
||||
});
|
||||
});
|
||||
listen(cluster, "focusin", event => {
|
||||
if (suppressZoomFocus || !event.target.matches?.(":focus-visible")) return;
|
||||
keyboardFocus = true;
|
||||
state.enterHub();
|
||||
state.move({ inRoot: true, inZoom: zoomGroup.contains(event.target), onZoomTrigger: zoomGroup.contains(event.target) });
|
||||
});
|
||||
listen(cluster, "focusout", event => {
|
||||
if (!keyboardFocus) return;
|
||||
const next = event.relatedTarget;
|
||||
state.move({ inRoot: Boolean(next && cluster.contains(next)), inZoom: Boolean(next && zoomGroup.contains(next)) });
|
||||
});
|
||||
cluster.querySelectorAll(".earth-toolbar-orb > button").forEach(button => {
|
||||
if (button === zoomTrigger) return;
|
||||
listen(button, "click", () => { if (state.isPinned()) state.close(); });
|
||||
});
|
||||
listen(document, "pointerdown", event => {
|
||||
keyboardFocus = false;
|
||||
if (cluster.contains(event.target)) return;
|
||||
state.close();
|
||||
});
|
||||
listen(document, "pointerout", event => {
|
||||
if (!event.relatedTarget) state.move({ inRoot: false, inZoom: false });
|
||||
});
|
||||
listen(window, "blur", () => state.close());
|
||||
return {
|
||||
...state,
|
||||
closeZoom() {
|
||||
state.closeZoom();
|
||||
if (zoomMenu.contains(document.activeElement)) {
|
||||
suppressZoomFocus = true;
|
||||
zoomTrigger.focus({ preventScroll: true });
|
||||
suppressZoomFocus = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
75
frontend/public/earth/js/toolbar-hover.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createToolbarHoverState, zoomHoverBounds, ZOOM_MENU_CLOSE_MS } from "./toolbar-hover.js";
|
||||
|
||||
function fixture() {
|
||||
let now = 0, id = 0, view;
|
||||
const jobs = new Map(), history = [];
|
||||
const controller = createToolbarHoverState({
|
||||
onChange(state) { view = state; history.push({ time: now, ...state }); },
|
||||
schedule(callback, delay) { jobs.set(++id, { callback, at: now + delay }); return id; },
|
||||
cancel(timer) { jobs.delete(timer); },
|
||||
});
|
||||
const advance = duration => {
|
||||
const end = now + duration;
|
||||
while (true) {
|
||||
const next = [...jobs].filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
now = next[1].at; jobs.delete(next[0]); next[1].callback();
|
||||
}
|
||||
now = end;
|
||||
};
|
||||
return { controller, advance, get view() { return view; }, history };
|
||||
}
|
||||
const trigger = { inRoot: true, inZoom: true, onZoomTrigger: true };
|
||||
const zoom = { inRoot: false, inZoom: true };
|
||||
const ring = { inRoot: true, inZoom: false };
|
||||
const outside = { inRoot: false, inZoom: false };
|
||||
|
||||
test("the hover bridge spans the entire trigger/menu gap", () => {
|
||||
expect(zoomHoverBounds({left: 100, right: 146, top: 300, bottom: 346},
|
||||
{left: 102, right: 144, top: 130, bottom: 284})).toEqual({left:100,right:146,top:130,bottom:346});
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.move(trigger);
|
||||
for (let step = 0; step < 20; step++) { f.controller.move(zoom); f.advance(100); }
|
||||
expect(f.view).toMatchObject({ expanded:true, zoomOpen:true, pinned:false });
|
||||
});
|
||||
|
||||
test("leaving zoom into the ring closes only the submenu", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.move(trigger); f.controller.move(ring);
|
||||
expect(f.view).toMatchObject({expanded:true,zoomOpen:false,zoomClosing:true});
|
||||
f.advance(ZOOM_MENU_CLOSE_MS);
|
||||
expect(f.view).toMatchObject({expanded:true,zoomOpen:false,zoomClosing:false});
|
||||
});
|
||||
|
||||
test("leaving both regions closes zoom before collapsing the unpinned ring", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.move(trigger); f.controller.move(zoom); f.controller.move(outside);
|
||||
f.advance(ZOOM_MENU_CLOSE_MS - 1);
|
||||
expect(f.view).toMatchObject({expanded:true,zoomClosing:true});
|
||||
f.advance(1);
|
||||
expect(f.view).toMatchObject({expanded:false,zoomClosing:false});
|
||||
});
|
||||
|
||||
test("a pinned ring survives leaving zoom and leaving the complete toolbar", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.togglePinned(); f.controller.move(trigger); f.controller.move(outside);
|
||||
f.advance(1000);
|
||||
expect(f.view).toMatchObject({expanded:true,pinned:true,zoomOpen:false,zoomClosing:false});
|
||||
});
|
||||
|
||||
test("reentering while zoom closes cancels stale close callbacks", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.move(trigger); f.controller.move(outside);
|
||||
f.advance(100); f.controller.move(zoom); f.advance(1000);
|
||||
expect(f.view).toMatchObject({expanded:true,zoomOpen:true,zoomClosing:false});
|
||||
});
|
||||
|
||||
test("returning to the ring during submenu exit keeps the ring awake", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.move(trigger); f.controller.move(outside);
|
||||
f.advance(100); f.controller.move(ring); f.advance(1000);
|
||||
expect(f.view).toMatchObject({expanded:true,zoomOpen:false,zoomClosing:false});
|
||||
});
|
||||
|
||||
test("explicit collapse and teardown cancel pending hover work", () => {
|
||||
const f = fixture(); f.controller.enterHub(); f.controller.togglePinned(); f.controller.move(trigger); f.controller.togglePinned();
|
||||
f.advance(ZOOM_MENU_CLOSE_MS);
|
||||
expect(f.view).toMatchObject({expanded:false,pinned:false,zoomOpen:false});
|
||||
f.controller.enterHub(); f.controller.move(outside); f.controller.dispose(); f.advance(1000);
|
||||
expect(f.view.expanded).toBe(true);
|
||||
});
|
||||
@@ -263,27 +263,6 @@ function getHudScale() {
|
||||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
}
|
||||
|
||||
function clampPanelToContainer(panel, container) {
|
||||
if (!(panel instanceof HTMLElement) || !(container instanceof HTMLElement)) return;
|
||||
if (panel.dataset.dragged !== "true") return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const margin = Math.round(PANEL_RESIZE_MARGIN_PX * getHudScale());
|
||||
const maxLeft = Math.max(0, containerRect.width - panelRect.width - margin);
|
||||
const maxTop = Math.max(0, containerRect.height - panelRect.height - margin);
|
||||
const currentLeft = panelRect.left - containerRect.left;
|
||||
const currentTop = panelRect.top - containerRect.top;
|
||||
const clampedLeft = Math.min(maxLeft, Math.max(0, currentLeft));
|
||||
const clampedTop = Math.min(maxTop, Math.max(0, currentTop));
|
||||
|
||||
panel.style.left = `${clampedLeft}px`;
|
||||
panel.style.top = `${clampedTop}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.bottom = "auto";
|
||||
panel.style.transform = "none";
|
||||
}
|
||||
|
||||
function setupResizeHandle() {
|
||||
const { panel } = getElements();
|
||||
const container = document.getElementById("container");
|
||||
@@ -378,10 +357,6 @@ function updateToggleButton(visible) {
|
||||
const title = visible ? "关闭 Live 新闻" : "打开 Live 新闻";
|
||||
toggleBtn.title = translateText(title);
|
||||
toggleBtn.setAttribute("aria-label", translateText(title));
|
||||
const tooltip = toggleBtn.querySelector(".earth-toolbar-tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.textContent = translateText(title);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsToggle(visible) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isVesselLegendVisible } from "./legend-filters.js";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
|
||||
@@ -6,6 +7,7 @@ import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
let trackRequestVersion = 0;
|
||||
let vesselDataByKey = new Map();
|
||||
let vesselSnapshotGeneration = 0;
|
||||
let vesselRealtimeActive = false;
|
||||
@@ -176,6 +178,7 @@ function appendCurrentMarkerTrackPoint(points, marker) {
|
||||
|
||||
const vesselIconLayer = createInteractableLayer({
|
||||
id: "vessels",
|
||||
isItemVisible: isVesselLegendVisible,
|
||||
objectType: "vessel",
|
||||
renderOrder: VESSEL_RENDER_ORDER,
|
||||
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
|
||||
@@ -261,6 +264,7 @@ export function clearVesselSelection() {
|
||||
}
|
||||
|
||||
function clearVesselTrack() {
|
||||
trackRequestVersion += 1;
|
||||
if (activeTrackLine?.parent) {
|
||||
activeTrackLine.parent.remove(activeTrackLine);
|
||||
}
|
||||
@@ -443,6 +447,7 @@ export function stopVesselRealtime() {
|
||||
|
||||
export async function showVesselTrack(marker, earth) {
|
||||
clearVesselTrack();
|
||||
const requestVersion = trackRequestVersion;
|
||||
if (!marker?.userData?.mmsi || !earth) return null;
|
||||
|
||||
const response = await fetch(PATHS.vesselTrackApi(marker.userData.mmsi));
|
||||
@@ -450,6 +455,7 @@ export async function showVesselTrack(marker, earth) {
|
||||
throw new Error(`Vessel track HTTP ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (requestVersion !== trackRequestVersion || marker.userData.legend_filtered || !showVessels) return null;
|
||||
const coordinates = payload?.features?.[0]?.geometry?.coordinates || [];
|
||||
if (coordinates.length < 2) return null;
|
||||
|
||||
@@ -480,16 +486,18 @@ export async function showVesselTrack(marker, earth) {
|
||||
|
||||
export function getVesselLegendItems() {
|
||||
return [
|
||||
{ label: "货轮", color: VESSEL_CONFIG.colors.cargo, shape: "vessel" },
|
||||
{ label: "油轮", color: VESSEL_CONFIG.colors.tanker, shape: "vessel" },
|
||||
{ label: "客船", color: VESSEL_CONFIG.colors.passenger, shape: "vessel" },
|
||||
{ label: "渔船", color: VESSEL_CONFIG.colors.fishing, shape: "vessel" },
|
||||
{ label: "军舰", color: VESSEL_CONFIG.colors.military, shape: "vessel" },
|
||||
{ label: "停泊/低速", color: VESSEL_CONFIG.colors.other, shape: "dot" },
|
||||
{ label: "其他船只", color: VESSEL_CONFIG.colors.other, shape: "vessel" },
|
||||
{ key: "cargo", label: "货轮", color: VESSEL_CONFIG.colors.cargo, shape: "vessel" },
|
||||
{ key: "tanker", label: "油轮", color: VESSEL_CONFIG.colors.tanker, shape: "vessel" },
|
||||
{ key: "passenger", label: "客船", color: VESSEL_CONFIG.colors.passenger, shape: "vessel" },
|
||||
{ key: "fishing", label: "渔船", color: VESSEL_CONFIG.colors.fishing, shape: "vessel" },
|
||||
{ key: "military", label: "军舰", color: VESSEL_CONFIG.colors.military, shape: "vessel" },
|
||||
{ key: "anchored", label: "停泊/低速", color: VESSEL_CONFIG.colors.other, shape: "dot" },
|
||||
{ key: "other", label: "其他船只", color: VESSEL_CONFIG.colors.other, shape: "vessel" },
|
||||
];
|
||||
}
|
||||
|
||||
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
|
||||
vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||
}
|
||||
|
||||
export function applyVesselLegendFilter() { vesselIconLayer.refreshFilter(); }
|
||||
|
||||
@@ -45,6 +45,10 @@ import { ControlGroup as TactileControlGroup } from '../../components/tactile-ui
|
||||
import { useToast } from '../components/ui/toast'
|
||||
import { DetailPanel, EmptyState, PageFrame, Panel, StatCell, StatusText, SummaryStrip } from '../patterns/patterns'
|
||||
import { ComputeComparisonSettings } from './ComputeComparisonSettings'
|
||||
import {
|
||||
COLLECTION_QUEUE_ACTIVE_STATUSES, isStaleTaskUpdate, patchCollectionTask, upsertCollectionTask,
|
||||
type CollectionQueueItem, type CollectionQueueStatus,
|
||||
} from './datasource-task-queue'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
type Tone = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'running' | 'ai'
|
||||
@@ -103,28 +107,6 @@ type DatasourceFilters = {
|
||||
dataStatus: string
|
||||
}
|
||||
|
||||
type CollectionQueueStatus = 'queued' | 'running' | 'cancelling' | 'success' | 'failed' | 'skipped' | 'cancelled'
|
||||
|
||||
type CollectionQueueItem = {
|
||||
key: string
|
||||
sourceId: string
|
||||
source?: string
|
||||
name: string
|
||||
taskId?: number | string | null
|
||||
taskType?: string
|
||||
status: CollectionQueueStatus
|
||||
phase?: string
|
||||
phaseMessage?: string
|
||||
progress?: number | null
|
||||
recordsProcessed?: number | null
|
||||
totalRecords?: number | null
|
||||
reason?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type DatasourceMetricBaseline = {
|
||||
taskId: string
|
||||
sourceId: string
|
||||
@@ -146,7 +128,6 @@ const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||||
const DATASOURCE_FILTER_STORAGE_KEY = 'planet.admin.datasource.filters'
|
||||
const DATASOURCE_FILTER_QUERY_KEYS = ['product', 'module', 'is_active', 'run_status', 'data_status']
|
||||
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled', 'canceled', 'stopped'])
|
||||
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
||||
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
|
||||
const TASK_INACTIVE_STATUSES = new Set(['success', 'completed', 'failed', 'error', 'cancelled', 'canceled', 'stopped', 'idle'])
|
||||
|
||||
@@ -426,10 +407,6 @@ function queueItemKey(item: AnyRecord) {
|
||||
return source ? `source-name:${taskType}:${source}` : `queue:${taskType}:${Date.now()}`
|
||||
}
|
||||
|
||||
function isSameQueueTaskType(left?: string, right?: string) {
|
||||
return text(left, 'collect') === text(right, 'collect')
|
||||
}
|
||||
|
||||
function isActiveQueueStatus(status: CollectionQueueStatus) {
|
||||
return COLLECTION_QUEUE_ACTIVE_STATUSES.has(status)
|
||||
}
|
||||
@@ -3219,49 +3196,22 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}, [])
|
||||
|
||||
const upsertCollectionQueueItem = useCallback((item: CollectionQueueItem) => {
|
||||
setCollectionQueue((current) => {
|
||||
const index = current.findIndex((existing) => (
|
||||
existing.key === item.key
|
||||
|| (item.taskId && existing.taskId === item.taskId)
|
||||
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.sourceId && existing.sourceId === item.sourceId)
|
||||
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && item.source && existing.source === item.source)
|
||||
))
|
||||
if (index < 0) return [item, ...current]
|
||||
const next = [...current]
|
||||
next[index] = { ...next[index], ...item, key: next[index].key, createdAt: next[index].createdAt || item.createdAt }
|
||||
return next
|
||||
})
|
||||
setCollectionQueue(current => upsertCollectionTask(current, item))
|
||||
}, [])
|
||||
|
||||
const patchCollectionQueueFromTask = useCallback((payload: AnyRecord) => {
|
||||
const sourceId = text(payload.datasource_id || payload.source_id || payload.id, '')
|
||||
const source = text(payload.collector_name || payload.source, '')
|
||||
const taskId = payload.task_id as number | string | null | undefined
|
||||
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
||||
const payloadTaskType = text(payload.task_type, '')
|
||||
setCollectionQueue((current) => current.map((item) => {
|
||||
const taskTypeMatched = !payloadTaskType || isSameQueueTaskType(item.taskType, payloadTaskType)
|
||||
const matched = Boolean(taskId && item.taskId === taskId)
|
||||
|| (taskTypeMatched && Boolean(sourceId && item.sourceId === sourceId))
|
||||
|| (taskTypeMatched && Boolean(source && item.source === source))
|
||||
if (!matched) return item
|
||||
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
||||
return {
|
||||
...item,
|
||||
taskId: taskId ?? item.taskId,
|
||||
taskType: text(payload.task_type, item.taskType || ''),
|
||||
sourceId: sourceId || item.sourceId,
|
||||
source: source || item.source,
|
||||
status,
|
||||
phase: text(payload.phase, item.phase || ''),
|
||||
phaseMessage: text(payload.phase_message, item.phaseMessage || ''),
|
||||
progress: typeof payload.progress === 'number' ? payload.progress : item.progress,
|
||||
recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : item.recordsProcessed,
|
||||
totalRecords: typeof payload.total_records === 'number' ? payload.total_records : item.totalRecords,
|
||||
error: text(payload.error_message, item.error || ''),
|
||||
updatedAt: Date.now(),
|
||||
completedAt: terminal ? Date.now() : item.completedAt,
|
||||
}
|
||||
setCollectionQueue(current => patchCollectionTask(current, {
|
||||
sourceId: text(payload.datasource_id || payload.source_id || payload.id, '') || undefined,
|
||||
source: text(payload.collector_name || payload.source, '') || undefined,
|
||||
taskId: payload.task_id as number | string | null | undefined,
|
||||
taskType: text(payload.task_type, '') || undefined,
|
||||
status: queueStatusFromTask(payload.status || payload.phase, payload.is_running),
|
||||
phase: payload.phase == null ? undefined : text(payload.phase, ''),
|
||||
phaseMessage: payload.phase_message == null ? undefined : text(payload.phase_message, ''),
|
||||
progress: typeof payload.progress === 'number' ? payload.progress : undefined,
|
||||
recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : undefined,
|
||||
totalRecords: typeof payload.total_records === 'number' ? payload.total_records : undefined,
|
||||
error: payload.error_message == null ? undefined : text(payload.error_message, ''),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
@@ -3391,14 +3341,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
return {
|
||||
...state,
|
||||
rows: state.rows.map((row) => {
|
||||
if (!isSameDatasourceRow(row, sourceId, source)) return row
|
||||
if (!isSameDatasourceRow(row, sourceId, source) || isStaleTaskUpdate(row.task_id, payload.task_id, activeDatasourceTaskStatus(row), taskStatus)) return row
|
||||
const merged = applyLiveMetric(row, { ...row, ...rowPatch, id: row.id, source: row.source })
|
||||
return normalizeDatasourceTableRecord(merged)
|
||||
}),
|
||||
}
|
||||
}))
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source) || isStaleTaskUpdate(current.task_id, payload.task_id, activeDatasourceTaskStatus(current), taskStatus)) return current
|
||||
const merged = applyLiveMetric(current, { ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
return normalizeDatasourceTableRecord(merged)
|
||||
})
|
||||
@@ -3414,7 +3364,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
completedDatasourceTasksRef.current.add(pendingKey)
|
||||
const pendingEntry = pendingDatasourceTasksRef.current[pendingKey]
|
||||
? [pendingKey, pendingDatasourceTasksRef.current[pendingKey]] as const
|
||||
: Object.entries(pendingDatasourceTasksRef.current).find(([, item]) => isSameDatasourceRow({ id: item.sourceId, source: item.source }, sourceId, source))
|
||||
: !payload.task_id ? Object.entries(pendingDatasourceTasksRef.current).find(([, item]) => !item.taskId && isSameDatasourceRow({ id: item.sourceId, source: item.source }, sourceId, source)) : undefined
|
||||
const pending = pendingEntry?.[1]
|
||||
const lookupId = sourceId || pending?.sourceId || source
|
||||
if (!lookupId) return
|
||||
|
||||
69
frontend/src/admin/pages/datasource-task-queue.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type CollectionQueueStatus = 'queued' | 'running' | 'cancelling' | 'success' | 'failed' | 'skipped' | 'cancelled'
|
||||
|
||||
export type CollectionQueueItem = {
|
||||
key: string
|
||||
sourceId: string
|
||||
source?: string
|
||||
name: string
|
||||
taskId?: number | string | null
|
||||
taskType?: string
|
||||
status: CollectionQueueStatus
|
||||
phase?: string
|
||||
phaseMessage?: string
|
||||
progress?: number | null
|
||||
recordsProcessed?: number | null
|
||||
totalRecords?: number | null
|
||||
reason?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type QueueUpdate = Partial<CollectionQueueItem> & Pick<CollectionQueueItem, 'status'>
|
||||
export const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
||||
const taskId = (value: unknown) => value == null ? '' : String(value)
|
||||
|
||||
function sameAttempt(existing: CollectionQueueItem, update: Partial<CollectionQueueItem>) {
|
||||
const existingId = taskId(existing.taskId)
|
||||
const incomingId = taskId(update.taskId)
|
||||
if (existing.taskType && update.taskType && existing.taskType !== update.taskType) return false
|
||||
if (existingId) return Boolean(incomingId) && existingId === incomingId
|
||||
// Only an active, ID-less placeholder may bind to its server task.
|
||||
if (!COLLECTION_QUEUE_ACTIVE_STATUSES.has(existing.status)) return false
|
||||
if (existing.sourceId && update.sourceId) return existing.sourceId === update.sourceId
|
||||
return Boolean(existing.source && update.source && existing.source === update.source)
|
||||
}
|
||||
|
||||
function mergeAttempt(existing: CollectionQueueItem, update: QueueUpdate, now: number): CollectionQueueItem {
|
||||
// Delayed polling/WS events cannot resurrect or reclassify a finished attempt.
|
||||
if (!COLLECTION_QUEUE_ACTIVE_STATUSES.has(existing.status) && update.status !== existing.status) return existing
|
||||
const merged = { ...existing }
|
||||
for (const [key, value] of Object.entries(update)) {
|
||||
if (value !== undefined && key !== 'createdAt' && key !== 'key') Object.assign(merged, { [key]: value })
|
||||
}
|
||||
merged.key = taskId(merged.taskId) ? `task:${merged.taskType || 'collect'}:${taskId(merged.taskId)}` : existing.key
|
||||
merged.updatedAt = now
|
||||
merged.completedAt = COLLECTION_QUEUE_ACTIVE_STATUSES.has(merged.status) ? undefined : existing.completedAt ?? now
|
||||
return merged
|
||||
}
|
||||
|
||||
export function upsertCollectionTask(current: CollectionQueueItem[], item: CollectionQueueItem) {
|
||||
const index = current.findIndex(existing => sameAttempt(existing, item))
|
||||
if (index < 0) return [item, ...current]
|
||||
return current.map((existing, i) => i === index ? mergeAttempt(existing, item, item.updatedAt) : existing)
|
||||
}
|
||||
|
||||
export function patchCollectionTask(current: CollectionQueueItem[], update: QueueUpdate, now = Date.now()) {
|
||||
return current.map(existing => sameAttempt(existing, update) ? mergeAttempt(existing, update, now) : existing)
|
||||
}
|
||||
|
||||
// The datasource row is the latest attempt, while the queue holds all attempts.
|
||||
export function isStaleTaskUpdate(currentId: unknown, incomingId: unknown, currentStatus?: string, incomingStatus?: string) {
|
||||
const current = taskId(currentId), incoming = taskId(incomingId)
|
||||
if (!current) return false
|
||||
if (!incoming) return true
|
||||
const terminal = ['success', 'completed', 'failed', 'error', 'cancelled', 'canceled', 'skipped', 'stopped']
|
||||
if (current === incoming && currentStatus && incomingStatus && terminal.includes(currentStatus) && !terminal.includes(incomingStatus)) return true
|
||||
return /^\d+$/.test(current) && /^\d+$/.test(incoming) && Number(incoming) < Number(current)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
ImageUp,
|
||||
ListChecks,
|
||||
Pause,
|
||||
Pickaxe,
|
||||
Play,
|
||||
@@ -56,7 +57,7 @@ export const tactileIconPresets = {
|
||||
send: Send,
|
||||
settings: SlidersHorizontal,
|
||||
start: Play,
|
||||
status: RefreshCw,
|
||||
status: ListChecks,
|
||||
stop: Square,
|
||||
test: Unplug,
|
||||
plug: Plug,
|
||||
|
||||
60
frontend/tests/datasource-task-queue.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { patchCollectionTask, upsertCollectionTask, isStaleTaskUpdate, type CollectionQueueItem } from '../src/admin/pages/datasource-task-queue'
|
||||
|
||||
const item = (id: number | string | null, status: CollectionQueueItem['status'] = 'failed', type = 'collect'): CollectionQueueItem => ({
|
||||
key: id == null ? `source:${type}:20` : `task:${type}:${id}`, taskId: id, sourceId: '20', source: 'celestrak_tle', name: 'CelesTrak', taskType: type,
|
||||
status, error: status === 'failed' ? `failure-${id}` : undefined, createdAt: 10, updatedAt: 20, completedAt: status === 'failed' ? 20 : undefined,
|
||||
})
|
||||
|
||||
test('a retry creates a new queue attempt and leaves every failed attempt untouched', () => {
|
||||
const history = [item(97), item(96), item(95)]
|
||||
let queue = upsertCollectionTask(history, item(98, 'queued'))
|
||||
queue = patchCollectionTask(queue, { taskId: '98', sourceId: '20', source: 'celestrak_tle', status: 'running', progress: 40 }, 100)
|
||||
expect(queue).toHaveLength(4)
|
||||
expect(queue[0].taskId).toBe('98')
|
||||
expect(queue[0].status).toBe('running')
|
||||
expect(queue.slice(1)).toEqual(history)
|
||||
queue = patchCollectionTask(queue, { taskId: 98, sourceId: '20', status: 'failed', error: 'new failure' }, 120)
|
||||
expect(queue.slice(1)).toEqual(history)
|
||||
expect(queue[0].completedAt).toBe(120)
|
||||
})
|
||||
|
||||
test('late websocket and upsert events cannot resurrect or reclassify a completed attempt', () => {
|
||||
const failed = item(97)
|
||||
expect(patchCollectionTask([failed], { taskId: 97, sourceId: '20', status: 'running' }, 200)).toEqual([failed])
|
||||
expect(upsertCollectionTask([failed], item('97', 'queued'))).toEqual([failed])
|
||||
expect(patchCollectionTask([failed], { taskId: 97, status: 'success' }, 200)).toEqual([failed])
|
||||
expect(patchCollectionTask([failed], { taskId: 97, status: 'failed', error: 'more detail' }, 200)[0].completedAt).toBe(20)
|
||||
})
|
||||
|
||||
test('same-source concurrent attempts and different task types are independent', () => {
|
||||
const queue = upsertCollectionTask([item(98, 'running')], item(99, 'queued'))
|
||||
expect(queue).toHaveLength(2)
|
||||
expect(patchCollectionTask(queue, { taskId: 98, sourceId: '20', status: 'failed' })[0].status).toBe('queued')
|
||||
expect(patchCollectionTask([item(98, 'running')], { taskId: 98, taskType: 'clear_data', status: 'success' })[0].status).toBe('running')
|
||||
})
|
||||
|
||||
test('only an active ID-less placeholder binds by source; ID-less updates cannot touch real tasks', () => {
|
||||
const placeholder = item(null, 'queued')
|
||||
const bound = patchCollectionTask([placeholder, item(97)], { taskId: 98, sourceId: '20', status: 'running' }, 50)
|
||||
expect(bound[0].key).toBe('task:collect:98')
|
||||
expect(bound[0].createdAt).toBe(10)
|
||||
expect(bound[1]).toEqual(item(97))
|
||||
expect(patchCollectionTask(bound, { sourceId: '20', status: 'failed' }, 60)).toEqual(bound)
|
||||
const differentSource = { ...placeholder, sourceId: '21' }
|
||||
expect(patchCollectionTask([differentSource], { taskId: 99, sourceId: '20', source: 'celestrak_tle', status: 'running' })).toEqual([differentSource])
|
||||
})
|
||||
|
||||
test('old poll responses cannot replace the datasource row for a newer task', () => {
|
||||
expect(isStaleTaskUpdate(98, '97')).toBe(true)
|
||||
expect(isStaleTaskUpdate(98, null)).toBe(true)
|
||||
expect(isStaleTaskUpdate(98, '98')).toBe(false)
|
||||
expect(isStaleTaskUpdate(98, 99)).toBe(false)
|
||||
expect(isStaleTaskUpdate(null, 99)).toBe(false)
|
||||
})
|
||||
|
||||
test('late running updates for the same completed task do not reactivate the datasource row', () => {
|
||||
expect(isStaleTaskUpdate(98, '98', 'failed', 'running')).toBe(true)
|
||||
expect(isStaleTaskUpdate(98, '98', 'success', 'queued')).toBe(true)
|
||||
expect(isStaleTaskUpdate(98, '99', 'failed', 'queued')).toBe(false)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.75.2"
|
||||
version = "0.75.4"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
2
rules.md
@@ -540,7 +540,7 @@ Current icons:
|
||||
|
||||
| File | Used in | Description |
|
||||
|------|---------|-------------|
|
||||
| `marker-landing-point.svg` | `cables.js` | Cable landing point pin |
|
||||
| `marker-landing-point.svg` | `cables.js` | Flat landing-point micro-core |
|
||||
| `bgp-collector.svg` | `bgp.js` | BGP collector marker |
|
||||
| `bgp-glow-dot.svg` | `bgp.js` | Base radial glow dot |
|
||||
| `bgp-event-ring.svg` | `bgp.js` | Event ring overlay |
|
||||
|
||||