Compare commits

...

7 Commits

Author SHA1 Message Date
linkong
f22079d33a release: bump version to 0.46.3 2026-04-30 14:46:19 +08:00
linkong
9f737fdb89 release: bump version to 0.46.2 2026-04-30 14:30:12 +08:00
linkong
7418ce2fc1 release: bump version to 0.46.1 2026-04-30 09:41:08 +08:00
rayd1o
b1a5934b80 release: bump version to 0.46.0 2026-04-30 04:42:29 +08:00
rayd1o
ba54545ac7 release: bump version to 0.45.0 2026-04-29 23:43:54 +08:00
linkong
9dafbf4f6e release: bump version to 0.44.2 2026-04-29 18:11:37 +08:00
linkong
a87537e903 release: bump version to 0.44.1 2026-04-29 18:07:35 +08:00
74 changed files with 4562 additions and 1098 deletions

View File

@@ -63,6 +63,8 @@ ls docs/technical/zh/ # 查看现有文档
- 采集器、数据源、凭证、设置页、连接检查、scheduler、后端 API 变化:更新相关后端文档,优先检查 `docs/technical/zh/backend-collectors.md` 和 datasource/settings 专题文档。
- 如果某个旧 plan 的假设已经被当前实现推翻,在对应 `docs/plans/*.md` 增加现状修正或更新该段,不要让计划文档继续给出相反方向。
- 新增 technical 文档后,如果需要被发现,更新 `docs/technical/zh/README.md`
- 如果 technical 文档需要在公开 Docs 页面显示,或从 technical README 链接进入,必须同步更新 `frontend/src/pages/Docs/docs-content.ts``DOCS_METADATA`。前端使用这份白名单,`docs/technical/{zh,en}/` 中存在 `.md` 文件并不会自动生成路由。
- 公开 technical 文档必须按同名文件维护中英文双语版本:`docs/technical/zh/<name>.md``docs/technical/en/<name>.md`。如果某篇文档刻意只保留单语,完成说明中必须明确写出原因。
- 对本次变更提取旧词做 stale search例如旧 tab 名、旧路由职责、旧认证假设、改名前 UI 文案:
```bash
@@ -90,6 +92,7 @@ rg -n "旧文案|旧路由职责|旧认证假设" docs/technical docs/plans
- 中文写作,技术术语保留英文原文
- `docs/technical/zh/` 中的文档不得用英文原文占位;如果存在 `docs/technical/en/` 对应文件,禁止逐字复制成中文文件
- 中文文档内部链接应指向 `docs/technical/zh/...`,除非明确引用英文专属文档
- 公开文档的 Markdown 链接显示文字应使用可读标题,不要直接暴露 `manual.md``earth-frontend-context.md` 这类裸文件名
**文档结构模板**
@@ -141,6 +144,62 @@ PY
rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh --pcre2
```
- 检查公开文档链接已进入 Docs 前端白名单。凡是 `docs/technical/{zh,en}/README.md` 中链接到的 technical `.md`,都必须存在于 `DOCS_METADATA`
```bash
python - <<'PY'
import re
from pathlib import Path
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
known = set(re.findall(r"'([^']+\.md)':\s*\{", metadata))
known.add("README.md")
missing = []
for readme in [Path("docs/technical/zh/README.md"), Path("docs/technical/en/README.md")]:
if not readme.exists():
continue
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text()):
path = Path(href)
if "docs/technical/" not in href:
continue
filename = path.name
if filename not in known:
missing.append(f"{readme}: {filename}")
if missing:
raise SystemExit("docs README links missing DOCS_METADATA: " + ", ".join(missing))
print("docs README links are whitelisted")
PY
```
- 检查公开文档双语同名文件齐备。除 `README.md` 外,所有白名单文档都应同时存在 zh/en 文件,除非本次说明中明确豁免:
```bash
python - <<'PY'
import re
from pathlib import Path
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
filenames = sorted(set(re.findall(r"'([^']+\.md)':\s*\{", metadata)) - {"README.md"})
missing = []
for filename in filenames:
for lang in ("zh", "en"):
path = Path("docs/technical") / lang / filename
if not path.exists():
missing.append(str(path))
if missing:
raise SystemExit("missing bilingual docs: " + ", ".join(missing))
print("public docs have zh/en file pairs")
PY
```
- 检查公开文档里没有用裸 `.md` 文件名当链接标题。这个命令在 polished public docs 中应无输出:
```bash
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
```
```bash
# 对文档中提到的关键路径做快速验证
ls <mentioned_paths>
@@ -167,4 +226,7 @@ rg -n "\]\(([^)]+)\)" docs/technical/zh/<doc>.md
- 不要在文档中引用 PR 号、issue 号、或当前对话——这些会随时间失效
- 代码片段保持简洁,只保留说明问题的关键部分,省略无关样板代码
- 如果某个变更已有文档记录,优先在原文档中追加,而不是新建
- 公开 technical 文档没有注册 `DOCS_METADATA`Docs 页面不会显示;不要只创建 `.md` 文件就结束。
- 公开 technical 文档默认需要 zh/en 同名文件,不要只补一个语言版本。
- 链接可见文字使用文档标题或语义标题,不要使用裸文件名。
- 文档是给未来的开发者看的,假设读者熟悉项目但不了解这次改动的背景

View File

@@ -54,6 +54,8 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
- Collector, datasource, credential, settings, connectivity, scheduler, or API changes must update the relevant backend docs, especially `docs/technical/zh/backend-collectors.md` and any datasource/settings-specific doc.
- When a change turns an old plan assumption into current behavior, update the relevant `docs/plans/*.md` with a status note instead of leaving contradictory instructions.
- If adding a new technical document, add it to `docs/technical/zh/README.md` when it should be discoverable from the technical docs index.
- If a technical document should be visible in the public Docs page or linked from a technical README, register it in `frontend/src/pages/Docs/docs-content.ts` under `DOCS_METADATA`. The frontend uses this whitelist; files under `docs/technical/{zh,en}/` are not automatically routable.
- For every public technical doc, keep the bilingual file pair in sync by filename: `docs/technical/zh/<name>.md` and `docs/technical/en/<name>.md`. If the content is intentionally Chinese-only or English-only, state that intentionally in the final note.
- Search docs for stale terms introduced by the change, for example old tab names, old route responsibilities, obsolete auth assumptions, or renamed UI labels.
4. Write the doc in Chinese:
@@ -100,6 +102,62 @@ rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh
This command should return no matches.
Check that public docs are whitelisted in the frontend Docs registry. Any `.md` linked from `docs/technical/{zh,en}/README.md` and located under `docs/technical/{zh,en}/` must have a matching `DOCS_METADATA` key:
```bash
python - <<'PY'
import re
from pathlib import Path
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
known = set(re.findall(r"'([^']+\.md)':\s*\{", metadata))
known.add("README.md")
missing = []
for readme in [Path("docs/technical/zh/README.md"), Path("docs/technical/en/README.md")]:
if not readme.exists():
continue
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text()):
path = Path(href)
if "docs/technical/" not in href:
continue
filename = path.name
if filename not in known:
missing.append(f"{readme}: {filename}")
if missing:
raise SystemExit("docs README links missing DOCS_METADATA: " + ", ".join(missing))
print("docs README links are whitelisted")
PY
```
Check bilingual parity for public docs. Every whitelisted document except `README.md` should exist in both language directories unless intentionally documented otherwise:
```bash
python - <<'PY'
import re
from pathlib import Path
metadata = Path("frontend/src/pages/Docs/docs-content.ts").read_text()
filenames = sorted(set(re.findall(r"'([^']+\.md)':\s*\{", metadata)) - {"README.md"})
missing = []
for filename in filenames:
for lang in ("zh", "en"):
path = Path("docs/technical") / lang / filename
if not path.exists():
missing.append(str(path))
if missing:
raise SystemExit("missing bilingual docs: " + ", ".join(missing))
print("public docs have zh/en file pairs")
PY
```
Check that Markdown links do not expose raw filenames as user-facing titles. This should return no matches for polished public docs:
```bash
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
```
If checking many links, prefer deterministic extraction:
```bash
@@ -118,6 +176,9 @@ rg -n "old label|old route purpose|obsolete provider assumption" docs/technical
- Do not leave a Chinese doc with only an English title and English first-screen content.
- When an English counterpart exists in `docs/technical/en/`, never duplicate it byte-for-byte into `docs/technical/zh/`.
- Internal links inside `docs/technical/zh/` should point to `docs/technical/zh/...` for Chinese docs, unless intentionally linking to an English-only file.
- Public technical documents must be registered in `frontend/src/pages/Docs/docs-content.ts` before considering them available in the Docs UI.
- Public technical documents should have both zh and en files with the same filename, unless intentionally exempted.
- Markdown link text in public docs should be a readable title, not a raw filename such as `manual.md`.
- Do not reference PR numbers, issue numbers, or the current conversation.
- Do not write changelog-style lists like "changed A, changed B, changed C" without the constraints and tradeoffs behind those changes.
- Keep code snippets concise and relevant.
@@ -133,4 +194,7 @@ Updated:
Verified:
- no identical en/zh docs
- no language-less docs/technical links in zh docs
- public docs are registered in DOCS_METADATA
- public docs have zh/en file pairs
- no raw `.md` filenames as public link titles
```

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
**
!pyproject.toml
!uv.lock
!aiprovider/
!aiprovider/**
aiprovider/.env
aiprovider/.env.*
!aiprovider/.env.example
**/__pycache__/
**/*.pyc
**/*.pyo

View File

@@ -1 +1 @@
0.44.0
0.46.3

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
@@ -18,9 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
COPY . /app
COPY aiprovider /app/aiprovider
EXPOSE 8010

View File

@@ -398,6 +398,11 @@ async def list_datasources(
"task_id": running_task.id if running_task else None,
"progress": running_task.progress if running_task else None,
"phase": running_task.phase if running_task else None,
"phase_progress": running_task.phase_progress if running_task else None,
"phase_message": running_task.phase_message if running_task else None,
"phase_current": running_task.phase_current if running_task else None,
"phase_total": running_task.phase_total if running_task else None,
"phase_unit": running_task.phase_unit if running_task else None,
"records_processed": running_task.records_processed if running_task else None,
"total_records": running_task.total_records if running_task else None,
}
@@ -626,6 +631,11 @@ async def trigger_datasource(
"message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?",
"task_id": running_task.id,
"phase": running_task.phase,
"phase_progress": running_task.phase_progress,
"phase_message": running_task.phase_message,
"phase_current": running_task.phase_current,
"phase_total": running_task.phase_total,
"phase_unit": running_task.phase_unit,
"progress": running_task.progress,
"records_processed": running_task.records_processed,
"total_records": running_task.total_records,
@@ -709,13 +719,29 @@ async def get_task_status(
task = await get_running_task(db, datasource.id)
if not task:
return {"is_running": False, "task_id": None, "progress": None, "phase": None, "status": "idle"}
return {
"is_running": False,
"task_id": None,
"progress": None,
"phase": None,
"phase_progress": None,
"phase_message": None,
"phase_current": None,
"phase_total": None,
"phase_unit": None,
"status": "idle",
}
return {
"is_running": task.status == "running",
"task_id": task.id,
"progress": task.progress,
"phase": task.phase,
"phase_progress": task.phase_progress,
"phase_message": task.phase_message,
"phase_current": task.phase_current,
"phase_total": task.phase_total,
"phase_unit": task.phase_unit,
"records_processed": task.records_processed,
"total_records": task.total_records,
"status": task.status,

View File

@@ -27,7 +27,9 @@ async def list_tasks(
offset = (page - 1) * page_size
query = """
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
FROM collection_tasks ct
JOIN data_sources ds ON ct.datasource_id = ds.id
WHERE 1=1
@@ -66,6 +68,14 @@ async def list_tasks(
"completed_at": to_iso8601_utc(t[5]),
"records_processed": t[6],
"error_message": t[7],
"phase": t[8],
"phase_progress": t[9],
"phase_message": t[10],
"phase_current": t[11],
"phase_total": t[12],
"phase_unit": t[13],
"total_records": t[14],
"progress": t[15],
}
for t in tasks
],
@@ -81,7 +91,9 @@ async def get_task(
result = await db.execute(
text("""
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
FROM collection_tasks ct
JOIN data_sources ds ON ct.datasource_id = ds.id
WHERE ct.id = :id
@@ -105,6 +117,14 @@ async def get_task(
"completed_at": to_iso8601_utc(task[5]),
"records_processed": task[6],
"error_message": task[7],
"phase": task[8],
"phase_progress": task[9],
"phase_message": task[10],
"phase_current": task[11],
"phase_total": task[12],
"phase_unit": task[13],
"total_records": task[14],
"progress": task[15],
}

View File

@@ -146,7 +146,12 @@ async def init_db():
text(
"""
ALTER TABLE collection_tasks
ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued'
ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued',
ADD COLUMN IF NOT EXISTS phase_progress DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS phase_message VARCHAR(255),
ADD COLUMN IF NOT EXISTS phase_current BIGINT,
ADD COLUMN IF NOT EXISTS phase_total BIGINT,
ADD COLUMN IF NOT EXISTS phase_unit VARCHAR(30)
"""
)
)

View File

@@ -1,6 +1,6 @@
"""Collection Task model"""
from sqlalchemy import Column, DateTime, Integer, String, Text, Float
from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Text, Float
from sqlalchemy.sql import func
from app.db.session import Base
@@ -13,6 +13,11 @@ class CollectionTask(Base):
datasource_id = Column(Integer, nullable=False, index=True)
status = Column(String(20), nullable=False) # pending, running, success, failed, cancelled
phase = Column(String(30), default="queued")
phase_progress = Column(Float)
phase_message = Column(String(255))
phase_current = Column(BigInteger)
phase_total = Column(BigInteger)
phase_unit = Column(String(30))
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
records_processed = Column(Integer, default=0)

View File

@@ -54,6 +54,11 @@ class BaseCollector(ABC):
"task_id": self._current_task.id,
"status": self._current_task.status,
"phase": self._current_task.phase,
"phase_progress": self._current_task.phase_progress,
"phase_message": self._current_task.phase_message,
"phase_current": self._current_task.phase_current,
"phase_total": self._current_task.phase_total,
"phase_unit": self._current_task.phase_unit,
"progress": progress,
"records_processed": self._current_task.records_processed,
"total_records": self._current_task.total_records,
@@ -80,12 +85,52 @@ class BaseCollector(ABC):
await self._publish_task_update(force=force)
async def set_phase(self, phase: str):
async def set_phase(self, phase: str, *, message: str | None = None, reset_progress: bool = True):
if self._current_task and self._db_session:
self._current_task.phase = phase
self._current_task.phase_message = message
if reset_progress:
self._current_task.phase_progress = None
self._current_task.phase_current = None
self._current_task.phase_total = None
self._current_task.phase_unit = None
await self._db_session.commit()
await self._publish_task_update(force=True)
async def update_phase_progress(
self,
*,
current: int | None = None,
total: int | None = None,
unit: str | None = None,
message: str | None = None,
progress: float | None = None,
commit: bool = False,
force: bool = False,
):
"""Update progress for the current phase without changing task totals."""
if not self._current_task or not self._db_session:
return
if progress is None and current is not None and total and total > 0:
progress = (current / total) * 100
if progress is not None:
self._current_task.phase_progress = max(0.0, min(float(progress), 100.0))
if current is not None:
self._current_task.phase_current = max(0, int(current))
if total is not None:
self._current_task.phase_total = max(0, int(total))
if unit is not None:
self._current_task.phase_unit = unit
if message is not None:
self._current_task.phase_message = message
if commit:
await self._db_session.commit()
await self._publish_task_update(force=force)
@abstractmethod
async def fetch(self) -> List[Dict[str, Any]]:
"""Fetch raw data from source"""
@@ -251,7 +296,7 @@ class BaseCollector(ABC):
await self._publish_task_update(force=True)
try:
await self.set_phase("fetching")
await self.set_phase("fetching", message="正在拉取原始数据")
raw_data = await self.fetch()
task.total_records = len(raw_data)
await db.commit()
@@ -260,15 +305,20 @@ class BaseCollector(ABC):
if self.fail_on_empty and not raw_data:
raise RuntimeError(f"Collector {self.name} returned no data")
await self.set_phase("transforming")
await self.set_phase("transforming", message="正在转换采集数据")
data = self.transform(raw_data)
snapshot_id = await self._create_snapshot(db, task_id, data, start_time)
await self.set_phase("saving")
await self.set_phase("saving", message="正在保存采集数据")
records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id)
task.status = "success"
task.phase = "completed"
task.phase_progress = 100.0
task.phase_message = "采集完成"
task.phase_current = records_count
task.phase_total = records_count
task.phase_unit = "records"
task.records_processed = records_count
task.progress = 100.0
task.completed_at = datetime.now(UTC)
@@ -285,6 +335,7 @@ class BaseCollector(ABC):
await db.rollback()
task.status = "cancelled"
task.phase = "cancelled"
task.phase_message = "采集已取消"
task.error_message = "Collection cancelled by operator and rolled back"
task.completed_at = datetime.now(UTC)
if snapshot_id is not None:
@@ -301,6 +352,7 @@ class BaseCollector(ABC):
await db.rollback()
task.status = "failed"
task.phase = "failed"
task.phase_message = str(e)
task.error_message = str(e)
task.completed_at = datetime.now(UTC)
if snapshot_id is not None:

View File

@@ -108,6 +108,11 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 IPtoASN 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
@@ -135,7 +140,14 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
return
last_emit["value"] = aggregated
last_emit["t"] = now
await self.update_progress(min(aggregated, total_expected), commit=True)
current = min(aggregated, total_expected)
await self.update_phase_progress(
current=current,
total=total_expected,
unit="bytes",
message="正在下载 IPtoASN 数据",
)
await self.update_progress(current, commit=True)
batches = await asyncio.gather(
*(
@@ -148,6 +160,12 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
)
)
if total_expected > 0:
await self.update_phase_progress(
current=total_expected,
total=total_expected,
unit="bytes",
message="IPtoASN 数据下载完成",
)
await self.update_progress(total_expected, commit=True, force=True)
rows: list[dict[str, Any]] = []

View File

@@ -39,12 +39,23 @@ class NRODelegatedPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 NRO delegated 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_phase_progress(
current=min(downloaded, total),
total=total,
unit="bytes",
message="正在下载 NRO delegated 数据",
)
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(

View File

@@ -40,12 +40,23 @@ class OpenGeoFeedPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 OpenGeoFeed 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_phase_progress(
current=min(downloaded, total),
total=total,
unit="bytes",
message="正在下载 OpenGeoFeed 数据",
)
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.services.collectors.top500 import TOP500Collector
from app.services.collectors.base import BaseCollector, HTTPCollector
from app.models.task import CollectionTask
class TestBaseCollector:
@@ -19,6 +20,31 @@ class TestBaseCollector:
assert collector.module == "L1"
assert collector.frequency_hours == 4
@pytest.mark.asyncio
async def test_update_phase_progress_tracks_phase_fields(self, mock_db_session):
"""Test phase-level progress updates independently from record totals"""
collector = TOP500Collector()
task = CollectionTask(datasource_id=1, status="running", phase="fetching")
collector._current_task = task
collector._db_session = mock_db_session
with patch.object(collector, "_publish_task_update", new=AsyncMock()) as publish:
await collector.update_phase_progress(
current=512,
total=1024,
unit="bytes",
message="Downloading dataset",
commit=True,
)
assert task.phase_progress == 50.0
assert task.phase_current == 512
assert task.phase_total == 1024
assert task.phase_unit == "bytes"
assert task.phase_message == "Downloading dataset"
mock_db_session.commit.assert_awaited_once()
publish.assert_awaited_once()
class TestTOP500Collector:
"""Tests for TOP500Collector"""

View File

@@ -121,6 +121,25 @@ class TestCollectionTaskModel:
)
assert task.records_processed == 100
def test_task_with_phase_progress(self):
"""Test collection task phase-level progress fields"""
task = CollectionTask(
datasource_id=1,
status="running",
phase="fetching",
phase_progress=42.5,
phase_message="Downloading dataset",
phase_current=1024,
phase_total=4096,
phase_unit="bytes",
)
assert task.phase == "fetching"
assert task.phase_progress == 42.5
assert task.phase_message == "Downloading dataset"
assert task.phase_current == 1024
assert task.phase_total == 4096
assert task.phase_unit == "bytes"
def test_task_error_message(self):
"""Test collection task with error message"""
task = CollectionTask(

View File

@@ -86,6 +86,12 @@ CREATE TABLE collection_tasks (
id BIGSERIAL PRIMARY KEY,
datasource_id INTEGER NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
status task_status NOT NULL DEFAULT 'pending',
phase VARCHAR(30) DEFAULT 'queued',
phase_progress FLOAT,
phase_message VARCHAR(255),
phase_current BIGINT,
phase_total BIGINT,
phase_unit VARCHAR(30),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
records_processed INTEGER DEFAULT 0,

View File

@@ -8,6 +8,9 @@ services:
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -10,6 +10,7 @@ services:
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -8,6 +8,95 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.46.3] — 2026-04-30
Released: 2026-04-30
### 🐛 Fixes
- 优化 Starlink footprint 显示后的地球拖拽性能,避免旋转地球时每帧重建 footprint 大网格,同时保持现有视觉效果不变。
- 恢复点击线缆后的呼吸透明度动画,让 locked / hover 线缆重新使用既有 pulse 配置。
---
## [0.46.2] — 2026-04-30
Released: 2026-04-30
### 🐛 Fixes
- 修复 Earth 启动时高清材质、云图和图层可见性绕过 `startupPriority` 的问题,统一由启动队列按文档顺序加载。
- 修复保存为关闭的高清材质/图层仍会先加载再关闭的问题,并保持海陆基座作为国界线图层的常驻底图。
- 修复搜索跳转会误关媒体面板、船只轨迹末端不贴合当前船只、Iridium footprint 被地表层遮挡等 Earth 交互问题。
### 📝 Documentation
- 更新 Earth 图层顺序、样式参考、使用手册和 AIS 聚合计划,补齐中英文说明与后续接入策略。
---
## [0.46.1] — 2026-04-30
Released: 2026-04-30
### 🐛 Fixes
- 修复新增 technical docs 文件存在但未进入 Docs 前端白名单时,侧栏不显示且 Markdown 链接无法解析到 `/docs/<slug>` 的问题。
- 补齐数据源/采集器连接验证与 Earth Interactable 使用说明的英文文档,保证公开 Docs 切换 EN 时同名页面可访问。
- 清理中英文 technical docs 中裸 `.md` 文件名链接标题,改为面向读者的语义标题。
### 📝 Documentation
- 将 Docs 前端白名单、公开文档双语配对、裸文件名链接标题三项检查写入 Claude 与 Codex 的 docs 技能流程。
---
## [0.46.0] — 2026-04-30
Released: 2026-04-30
### ✨ Highlights
- Earth 新增通用 Interactable 图标层船只、算力中心、BGP 事件与观测站统一使用批量 Points、屏幕拾取、状态 glow 和状态缩放。
- BGP 事件保留向外扩散圈,观测站保留雷达扫描层,并与 Interactable 主图标解耦到稳定的地表渲染层级。
- 登陆点回归黄色球形 Sprite贴近海缆层级并保持更稳定的地表显示和遮挡表现。
### 🔧 Improvements
- 新增 SVG asset 到 canvas texture 的 Interactable 资产加载路径,支持统一图标资源、缓存和可选染色。
- 同坐标 Interactable 自动做地表切向避让,降低重叠物件无法选择的问题。
- 优化 Earth toolbar 初始尺寸注入,避免首次显示原始尺寸后再跳到缩放尺寸。
- 补充 Interactable 计划、使用说明、图层顺序和 Earth 前端上下文文档。
- 修复船只 hover/locked 状态仅发光但放大反馈不明显的问题,将已有状态缩放接入通用图标层。
---
## [0.45.0] — 2026-04-29
### ✨ Highlights
- 采集任务新增阶段级量化进度,`fetching` 可展示百分比、阶段说明和字节下载量。
- AI Provider 启动链路支持从 `aiprovider/.env``~/.zshrc` 注入运行期配置,并避免密钥/模型变化触发镜像重建。
- AI Provider Docker build context 收敛到服务必需文件,`uv sync` 接入 BuildKit 缓存以减少重复下载。
### 🔧 Improvements
- IPtoASN、OpenGeoFeed、NRO delegated 下载型采集器接入真实字节进度上报。
- 数据源页、采集中任务弹窗和任务历史页展示阶段摘要,并在 tooltip 中保留完整进度细节。
- 调整 Earth 船只默认高度偏移,进一步贴近地表展示。
---
## [0.44.2] — 2026-04-29
### 📝 Documentation
- 补充 Earth 船只图层技术文档,记录分桶 `THREE.Points` 批量渲染、同尺寸交互 overlay 和屏幕空间 picking 的设计约束。
- 同步 Earth 渲染图层顺序和样式参考,明确 AIS 船只 renderOrder、depthTest、图标尺寸、航向分桶与 hover 命中半径。
- 更新船只渲染性能计划状态,标注 `0.44.1` 已落地的实现与后续全球 AIS / LOD 演进方向。
---
## [0.44.1] — 2026-04-29
### 🐛 Fixes
- 修复 Earth 船只图层拖动不跟手的问题,将普通船只从独立 Sprite 切换为按航向分桶的批量 Points 渲染。
- 修正船只 hover/click 拾取错位,改为屏幕空间命中检测并在拖拽/惯性期间跳过 hover 拾取。
- 统一 AIS 船只普通态与交互态方向,并让 hover/locked glow 与普通图标保持同尺寸覆盖。
- 恢复船只深度测试并收敛默认图标尺寸,避免北部岛屿/冰面附近出现明显压盖陆地的视觉问题。
---
## [0.44.0] — 2026-04-29
### ✨ Highlights

View File

@@ -26,6 +26,8 @@
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
- [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)

View File

@@ -0,0 +1,313 @@
# Earth Interactable Layer Plan
## 背景
状态Phase 1 已经开始落地Phase 2 的 BGP 事件 / 观测站迁移和 Phase 3 的算力中心迁移也已完成。`frontend/public/earth/js/interactable.js` 已新增AIS 船只、BGP 事件、BGP 观测站和算力中心图层已经改为通过 `createInteractableLayer()` 使用通用批量 `Points`、hover / locked overlay、默认 glow、状态更新、asset icon 预加载、屏幕空间 picking、固定 / 距离缩放和跨 Interactable 同坐标避让。登陆点因 `THREE.Points` 边缘深度裁切和贴地层级要求,已退回专用 `THREE.Sprite` 黄色球路径,并与海缆同高度同 renderOrder。后续阶段聚焦把可复用的扩圈 / 雷达扇形动画正式沉淀成 `animations` 扩展。
当前实现说明和接入示例见:
- [earth-interactable-usage.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
当前 AIS 船只图层已经形成了一个适合作为基准的交互图标模式:
- 普通态使用批量 `THREE.Points` 渲染,避免每个对象一个 `Sprite` 带来的 draw call 和透明排序压力。
- hover / locked 态使用单点 overlay 叠加 glow不改变普通批次交互反馈清晰且成本低。
- moving / anchored 船只通过 canvas 点纹理表达不同形状moving 船只还按航向分桶。
- 拾取走屏幕空间命中,拖动和惯性期间跳过高频 hover picking。
- 图层高度贴近地表,仅保留很小的深度余量,避免“浮在表面层”的观感。
这个模式不应该只服务船只。后续 BGP 事件、BGP 观测站、算力中心、新闻事件、告警、地面传感器等都可能需要“图标类可交互元素”。如果每个图层继续各写一套 icon、glow、hover、locked、动画、picking 和图例逻辑,视觉会漂移,性能策略也会重复分叉。登陆点已经验证为例外:需要完整贴地且不被球面边缘裁切时,专用 Sprite 路径比通用 `Points` 更合适。
目标是把船只图层的成功做法抽象成一个通用接口:业务图层只描述“要画什么、在哪里、怎么交互”,底层统一负责批量渲染、默认 glow、状态 overlay、动画槽位、拾取和生命周期。
## 目标
1. 建立统一的 Earth 交互图标接口,作为未来地表图标类元素的默认入口。
2. 以 AIS 船只 glow 为默认 glow 视觉,其它图标默认沿用同一套 glow 质感。
3. 保留图标颜色、状态颜色、hover 放大、locked 强调、dimmed 聚焦、动画扩展等能力。
4. 支持 canvas / SVG / image icon不强行要求所有图标都可重着色。
5. 保持船只当前性能路线:批量绘制普通态,少量 overlay 处理交互态。
6. 给 BGP 事件扩圈、BGP 观测站雷达扇形等补充动画留出正式扩展点。
## 非目标
- 不在第一阶段重写所有 Earth 图层。
- 不把卫星、海缆、国家边界、真实地形这类非图标图层纳入同一个接口。
- 不为了抽象牺牲业务图标的差异表达例如船只航向、BGP 事件严重级别、观测站雷达扫掠。
- 不要求图片图标支持运行时重着色;图片图标只能通过预制多状态图片或 overlay tint 做有限表达。
## 核心设计
建议新增一个通用模块,例如:
```text
frontend/public/earth/js/interactable.js
```
它导出一个工厂或注册函数:
```js
createInteractableLayer({
id,
earth,
renderOrder,
altitudeOffset,
icon,
scale,
glow,
colors,
states,
animations,
picking,
data,
getPosition,
getKind,
getRotation,
getPayload,
});
```
业务模块仍保留自己的数据加载、图例、详情卡字段和业务语义。例如 `vessels.js` 负责 AIS 数据和船型映射,但 icon 渲染、hover overlay、locked overlay、默认 glow 和屏幕空间 picking 可以逐步迁入 `interactable.js`
## 参数草案
| 参数 | 类型 / 示例 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `id` | `"vessels"` | 必填 | 图层唯一标识,用于 debug、picking、legend 和状态缓存。 |
| `earth` | `THREE.Object3D` | 必填 | 图层挂载目标,通常是 Earth root。 |
| `renderOrder` | `4.4` | `4` | 普通 icon 批次和 overlay 的基础渲染顺序。 |
| `altitudeOffset` | `0.2` | `0.2` | 图层高度,语义为 `CONFIG.earthRadius + altitudeOffset`。地表图标默认贴近真实地形基础层。 |
| `icon` | `{ type, source, draw, size, bins }` | 必填 | 图标来源。支持 canvas draw、SVG URL、image URL、内置 shape。 |
| `icon.fitSize` | `60``{ width: 60, height: 60 }` | `atlasCellSize` | asset 图标在 atlas canvas 内的最大绘制尺寸,默认居中等比 contain。SVG / 图片文件只负责原始形状,不需要为了显示大小手写 transform。 |
| `scale` | `{ base, min, max }` | `{ base: 1 }` | 基础缩放和距离稳定范围。当前船只可映射到 `VESSEL_POINT_SIZE` / `baseScale`。 |
| `sizeMode` | `"fixed" / "distance"` | `"fixed"` | 是否固定屏幕像素尺寸;非 fixed 时按相机到地表距离做比例缩放。 |
| `sizeScale` | `{ min, max, referenceFov }` | `{ min: 0.12, max: 3, referenceFov: 75 }` | `sizeMode !== "fixed"` 时的缩放限制和参考视角。 |
| `glow.enabled` | `true / false` | `true` | 是否启用默认 glow。默认 glow 以船只 hover / locked overlay 为基准。 |
| `glow.intensity` | `0.0 - 2.0` | `1` | glow 强度,内部映射到 canvas `shadowBlur`、opacity 或 shader uniform。 |
| `glow.colorMode` | `"state" / "icon" / "fixed"` | `"state"` | glow 颜色来源,默认跟随状态颜色。 |
| `hover.scale` | `1.0 - 2.0` | `1.18` | hover 放大倍率。当前船只保持同尺寸 glow overlay接口仍保留放大能力供其它图层使用。 |
| `hover.mode` | `"scale" / "glow-only" / "custom"` | `"scale"` | hover 反馈方式。船只可用 `"glow-only"`,其它图标默认放大。 |
| `colors.normal` | `"#4A90D9"` | icon 原色 | 普通态颜色。只有可上色 icon 生效。 |
| `colors.hover` | `"#7dd3fc"` | normal | hover 态颜色。 |
| `colors.locked` | `"#ffffff"` | hover | locked 态颜色。 |
| `colors.dimmed` | `"#9B9B9B"` | normal | 聚焦其它对象时的弱化颜色。 |
| `colors.byKind` | `{ cargo: "#4A90D9" }` | `{}` | 按业务类型着色如船型、BGP 严重级别。 |
| `colorable` | `true / false` | 由 icon 类型推断 | canvas shape 和 SVG mask 通常可上色;图片默认不可上色。 |
| `opacity` | `{ normal, hover, locked, dimmed }` | 船只当前值 | 各状态透明度。 |
| `rotation` | `{ enabled, bins, getAngle }` | disabled | 是否按角度分桶,例如船只按 COG 分 32 桶。 |
| `animations` | `IconAnimationSpec[]` | `[]` | 补充动画列表,例如扩圈、雷达扇形、脉冲、轨迹尾迹。 |
| `picking.radiusPx` | `22` | `20` | 屏幕空间命中半径。 |
| `picking.throttleMs` | `100` | `80` | hover picking 节流。 |
| `picking.skipWhileDragging` | `true` | `true` | 拖动和惯性期间跳过 hover picking。 |
| `zIndexPolicy` | `"surface-icon"` | `"surface-icon"` | 预设层级策略,避免每个业务图层手写高度和 renderOrder。 |
| `avoidance.enabled` | `true / false` | `true` | 是否参与跨 Interactable 的同坐标避让。默认开启,同一经纬度下的图标会沿地表切平面小幅排开,方便辨认和选择。 |
| `avoidance.radius` | `number` | `1.1` | 同坐标避让的第一圈半径,单位为地球本地坐标单位。 |
| `avoidance.precision` | `number` | `4` | 经纬度归并精度,默认约等于只处理几乎完全重叠的图标。 |
| `legend` | `{ label, color, shape }[]` | `[]` | 可选图例声明,业务层也可以继续自己导出。 |
| `metadata` | object | `{}` | 业务扩展数据,不参与渲染但参与 tooltip / info-card / search。 |
## Icon 规格
图标输入建议分三类:
```js
{
type: "canvas-shape",
size: 128,
draw(ctx, state) {
// draw triangle / dot / custom shape
},
}
```
```js
{
type: "svg-mask",
source: "/earth/assets/icons/bgp-event-dot.svg",
colorable: true,
}
```
```js
{
type: "image",
source: "/earth/assets/icons/vendor-logo.png",
colorable: false,
stateSources: {
hover: "/earth/assets/icons/vendor-logo-hover.png",
},
}
```
颜色策略:
- `canvas-shape` 默认可上色,适合船只、事件点、雷达站这类符号。
- `svg-mask` 如果能作为 mask 使用,则可上色;如果是完整多色 SVG则按图片处理。
- `image` 默认不可上色;需要状态变化时使用 `stateSources` 或额外 glow / ring。
## 默认 Glow 规范
默认 glow 以当前船只 overlay 为视觉基准:
- 普通态尽量不启用 glow保持地图干净。
- hover / locked 态叠加同位置 overlay。
- glow 颜色默认跟随状态颜色或业务类型颜色。
- glow blur 应该稳定,不随 camera zoom 夸张膨胀。
- 允许通过 `glow.intensity` 控制强度,但不要让业务图层各自发明完全不同的光晕语言。
建议内部把 glow 拆成两个层次:
1. `textureGlow`canvas texture 里的 `shadowBlur`,适合小图标 hover / locked。
2. `effectGlow`:额外 ring / halo / pulse适合告警、BGP 事件和锁定强调。
## 状态模型
通用状态至少包含:
| 状态 | 触发 | 默认表现 |
| --- | --- | --- |
| `normal` | 普通显示 | 批量 Points使用 normal 颜色和 opacity。 |
| `hover` | 指针悬停 | 默认放大并显示 glow船只可配置为同尺寸 glow-only。 |
| `locked` | 点击锁定 / 详情打开 | 强 glow、更高 opacity可选 ring 或 pulse。 |
| `dimmed` | 聚焦其它对象 | 降低 opacity保留上下文。 |
| `hidden` | 图层关闭或过滤 | 不参与绘制和 picking。 |
| `alert` | 业务告警 | 可叠加动画,不替代 locked 状态。 |
状态更新需要增量化:只在 hover 目标、locked 目标、过滤条件、数据版本或相机距离阈值变化时更新,不在每帧遍历全部 icon 写材质属性。
## 动画扩展
动画不直接塞进 icon 基础参数,而是作为 `animations` 列表注册。每个动画声明自己的 geometry / material / update 策略:
```js
{
type: "expanding-ring",
when: ["alert", "locked"],
color: "state",
radiusPx: [10, 42],
durationMs: 1400,
opacity: [0.8, 0],
}
```
```js
{
type: "radar-sweep",
when: ["normal", "hover", "locked"],
angleDeg: 72,
rotationMs: 2600,
opacity: 0.36,
}
```
首批建议内置动画:
| 动画 | 用例 | 说明 |
| --- | --- | --- |
| `pulse-ring` | locked、告警点 | 原地呼吸环,强调选中对象。 |
| `expanding-ring` | BGP 事件 | 向外扩散的事件波纹。 |
| `radar-sweep` | BGP 观测站 | 扇形扫描,可持续旋转。 |
| `orbiting-dot` | 数据流 / collector 活跃态 | 小点绕 icon 环绕,表达活动状态。 |
| `trail` | 移动目标 | 可选短尾迹,船只或飞机类目标使用。 |
动画必须支持批量或分组绘制,避免为每个对象创建独立的高频更新对象。只有 locked / hover / 少量 alert 对象可以使用单对象 overlay。
## 渲染策略
### 普通态
普通态优先使用分桶 `THREE.Points`
- 按 icon 类型、可上色策略、旋转分桶、纹理 key 分组。
- 每组一个 `BufferGeometry`,存 `position``color`、必要的 `payloadIndex`
- `PointsMaterial.sizeAttenuation = false`,保持屏幕尺寸稳定。
- `depthTest = true``depthWrite = false`,避免遮挡关系破坏地表。
### 交互态
hover / locked 使用少量 overlay
- overlay 复用 `THREE.Points` 单点对象或小型 ring mesh。
- overlay texture 从统一 cache 获取。
- overlay 更新只写当前 hover / locked 的 position、texture、opacity、size。
### 高密度升级
当某类图标超过分桶 Points 的舒适区,才考虑升级:
- `InstancedBufferGeometry` billboard。
- 自定义 shader 支持 per-instance rotation / scale / opacity。
- 视口 bbox / LOD / cluster。
这个升级不应该改变业务接口,只替换底层 renderer。
## Picking 策略
沿用船只当前方向:
- 默认屏幕空间 picking而不是 Three.js 对每个 Sprite / Points 做 raycast。
- 每个 icon 保留世界坐标和业务 payload。
- 每次 pointer move 将候选点投影到屏幕,按半径和深度判断命中。
- 拖动、惯性旋转、相机剧烈变化期间跳过 hover picking。
- click 时允许做一次更精确的 picking。
后续可以按图层或经纬度网格增加空间索引,减少候选点数量。
## 与现有图层的迁移路径
### Phase 1抽出船只基准能力
-`vessels.js` 提取 texture cache、canvas icon draw、overlay glow、分桶 Points 创建、状态增量更新。
- 保持 `vessels.js` 的公开 API 不变:`loadVessels()``toggleVessels()``getVesselMarkers()` 等继续可用。
- 新模块先只服务船只,确保视觉没有回退。
### Phase 2迁移 BGP 事件和观测站
- BGP 事件使用 `canvas-shape`,已接入 `Interactable`
- 严重级别映射到 `colors.byKind`,并通过通用 `getPointSizeMultiplier` 保留严重级别尺寸倍率。
- 当前扩圈效果保留在 BGP 业务动画中,并跟随 `Interactable` marker 位置更新。
- BGP 观测站主图标已接入 `Interactable`,活跃度映射到颜色和 `getPointSizeMultiplier`
- BGP 观测站 halo / 覆盖扇形继续由 BGP 业务动画表达扫描,并跟随 `Interactable` marker 位置更新。
### Phase 3迁移算力中心并评估登陆点
- 算力中心保留现有业务 icon但接入统一 hover / locked / glow。已完成
- 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。
- TODO登陆点暂不迁移到完整 Interactable。后续若要统一交互接口优先考虑 Sprite-backed adapter只对齐 `getMarkers()``getPointerIntersections()``setMarkerState()``updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。
- 检查图例、搜索和 info-card 是否只依赖业务 payload而不是依赖渲染对象类型。
### Phase 4形成 Earth 图标层规范
-`docs/technical/zh/earth-frontend-context.md` 记录当前实现入口。
-`docs/technical/zh/earth-layer-style-reference.md` 记录默认 glow、状态颜色、默认高度和动画参数。
-`docs/technical/zh/earth-render-layer-order.md` 记录 surface icon renderOrder 范围。
## 风险与约束
- 过早抽象可能让船只这种高质量基准被平均化,因此第一阶段必须以船只视觉不回退为验收标准。
- 图片 icon 不可上色,接口需要明确 `colorable = false` 的行为,避免业务层误以为颜色一定生效。
- 动画如果默认开启过多,会重新引入 overdraw 和每帧更新压力;默认只给 hover / locked 或少量 alert 使用。
- 地形开启时,贴地 icon 需要在高度、`depthTest``polygonOffset` 和 renderOrder 之间保持平衡。
- 统一 glow 不等于所有图标一模一样;业务可以调强度和颜色,但不应破坏整体视觉语言。
## 验收标准
1. 船只迁入通用接口后普通态、hover、locked、航向、颜色、轨迹和 picking 行为保持一致。
2. 新增一个 BGP 事件示例图层配置,不需要复制船只渲染代码即可得到 icon、glow、hover 和扩圈动画。
3. 新增一个 BGP 观测站示例图层配置,不需要自写独立动画循环即可得到雷达扇形。
4. 关闭图层后对应 icon、overlay、动画和 picking 全部停止。
5. 高密度数据下普通态仍走批量绘制hover / locked 只更新少量 overlay。
6. 文档同步说明默认高度、默认 glow、状态模型和动画扩展点。
## 相关文件
| 文件 | 当前角色 | 未来关系 |
| --- | --- | --- |
| `frontend/public/earth/js/vessels.js` | 船只基准实现,包含分桶 Points、hover / locked overlay、默认 glow 形态 | Phase 1 的抽象来源 |
| `frontend/public/earth/js/constants.js` | 保存船只高度、颜色、透明度、轨迹参数 | 后续可加入通用 surface icon 默认配置 |
| `frontend/public/earth/js/bgp.js` | BGP 事件和观测站视觉逻辑 | BGP 事件和观测站主图标已接入 Interactable扩圈、halo 和覆盖扇形仍保留业务动画 |
| `frontend/public/earth/js/compute-centers.js` | 算力中心 icon 和交互 | 已通过 Interactable 接入统一 Points、overlay、glow 和 picking |
| `frontend/public/earth/js/cables.js` | 登陆点 icon 和海缆线 | 登陆点当前使用专用 `THREE.Sprite` 黄色球,不再走 Interactable海缆线仍独立渲染 |
| `frontend/public/earth/js/main.js` | 当前集中处理 hover、click、locked 和 info-card 入口 | 后续需要接入通用 icon picking 结果 |
| `docs/technical/zh/earth-layer-style-reference.md` | 当前视觉参数参考 | 实现后同步默认 glow 和通用参数 |
| `docs/technical/zh/earth-render-layer-order.md` | 当前层级参考 | 实现后同步 surface icon 层级范围 |

View File

@@ -0,0 +1,261 @@
# AIS 多源采集、冲突记录与聚合接口计划
**状态**:规划中
**创建日期**2026-04-30
**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成
## 已确认决策
| 项目 | 决策 |
|-----|------|
| AISStream 接入方式 | 单独实现 WebSocket 采集器,不塞进现有 BarentsWatch HTTP collector |
| 采集器职责 | 连接上游、标准化字段、写入原始观测,不直接决定最终展示值 |
| 去重合并位置 | 放在聚合服务和聚合 API 中,而不是散落在每个 collector 的保存逻辑里 |
| 冲突处理 | 先记录冲突事实和当前选择原因,后续再开放用户规则配置 |
| 默认可信度 | 同类 AIS 数据源优先按 `delivery_mode` 评估:`realtime_stream` 优于 `batch_stream`,再优于 `polling``snapshot` |
| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 |
## 背景
当前 AIS 链路以 BarentsWatch 为主。它是 HTTP polling 模式,覆盖挪威附近海域,适合作为稳定的免费起点,但不适合承担全球实时船只数据的全部职责。后续接入 AISStream 后,会出现同一个 MMSI 被多个来源同时上报的情况:
- 位置、航速、航向可能在多个来源之间存在秒级差异。
- 船名、IMO、呼号、船型、尺寸等静态字段可能不完整甚至互相冲突。
- WebSocket 或其他实时流通常更接近实时,但也可能断流或批量延迟。
- 如果每个 collector 自己做去重合并,规则会分散、不可审计,也很难让用户后续配置“某个字段信任哪个来源”。
因此 v1 不应让采集器直接覆盖最终船只表。更稳的方式是先保留观测事实,再由聚合接口统一给出当前展示视图。
## 目标架构
```mermaid
flowchart LR
A[BarentsWatch HTTP collector] --> D[AIS raw observations]
B[AISStream WebSocket collector] --> D
C[Custom mapped vessel_ais sources] --> D
D --> E[AIS aggregation service]
E --> F[Conflict records]
E --> G[GeoJSON vessels API]
E --> H[Vessel detail API]
I[Aggregation strategy config] --> E
```
### 原始观测层
原始观测层保存每个来源看到的事实。建议模型包含:
| 字段 | 用途 |
|-----|------|
| `target_schema` | 例如 `vessel_ais` |
| `source` | 例如 `barentswatch_vessels``aisstream_vessels` |
| `entity_key` | AIS 使用 MMSI |
| `delivery_mode` | `realtime_stream``batch_stream``polling``snapshot` |
| `transport` | `websocket``sse``http``file` 等 |
| `observed_at` | 上游数据时间,优先使用 AIS 消息时间 |
| `collected_at` | 本系统接收或采集时间 |
| `normalized_payload` | 标准化后的 AIS JSON |
| `raw_payload` | 可选,保存原始或裁剪后的上游记录 |
`delivery_mode``transport` 不应混为一谈。WebSocket 是传输方式streaming 是交付模式。聚合可信度主要看 `delivery_mode``transport` 只作为辅助信息。
### 冲突记录层
聚合服务发现同一个实体、同一个字段存在多个非空不同值时,写入冲突记录。冲突记录不代表错误,只代表“有多个可用候选值”。
```json
{
"target_schema": "vessel_ais",
"entity_key": "257123000",
"field": "name",
"candidates": {
"barentswatch_vessels": "OSLO TRADER",
"aisstream_vessels": "OSLO TRADER II"
},
"selected_source": "aisstream_vessels",
"selected_value": "OSLO TRADER II",
"selected_reason": "delivery_mode_priority",
"resolved_by": "system",
"status": "open"
}
```
第一阶段只需要记录冲突和当前选择原因,不需要做人工逐条确认。后续 UI 的目标也不是让用户处理每条冲突,而是把冲突沉淀成字段级规则。
## 聚合规则
### 字段分类
| 类型 | 字段 | 默认策略 |
|-----|------|----------|
| 动态位置 | `lat``lon``sog``cog``heading``nav_status` | 优先最新 `observed_at`,同时间再按来源优先级 |
| 静态身份 | `name``callsign``imo``flag` | 非空优先,再按字段策略或来源优先级 |
| 静态规格 | `vessel_type``vessel_type_name``length``width``draught` | 非空优先;冲突时记录候选值 |
| 元信息 | `field_sources``conflict_count``selected_reasons` | 聚合接口生成,便于调试和后续 UI 展示 |
### 默认优先级
默认优先级应使用两个维度:
```yaml
delivery_mode_priority:
- realtime_stream
- batch_stream
- polling
- snapshot
transport_priority:
- websocket
- sse
- http
- file
```
`delivery_mode_priority` 是主判断。比如 AISStream 如果提供实时推送,应标记为 `realtime_stream + websocket`BarentsWatch 当前是 `polling + http`
### 断流保护
实时流不能永久凭身份占优。聚合时需要 freshness 窗口:
```yaml
freshness:
realtime_stream_seconds: 900
polling_seconds: 3600
```
如果 `aisstream_vessels` 最近 15 分钟没有该 MMSI 的新观测,而 BarentsWatch 轮询源有更新位置,则位置类字段应采用 BarentsWatch 的更新观测,并记录选择原因 `newest_observation``freshness_fallback`
## 聚合接口
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`
```text
GET /api/v1/visualization/geo/vessels
GET /api/v1/visualization/vessels/{mmsi}
GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts
```
GeoJSON properties 建议增加:
```json
{
"mmsi": 257123000,
"name": "OSLO TRADER",
"lat": 59.91,
"lon": 10.73,
"received_at": "2026-04-30T10:00:00Z",
"field_sources": {
"name": "aisstream_vessels",
"lat": "aisstream_vessels",
"lon": "aisstream_vessels",
"vessel_type": "barentswatch_vessels"
},
"selected_reasons": {
"name": "delivery_mode_priority",
"lat": "newest_observation",
"vessel_type": "non_empty_priority"
},
"conflict_count": 2
}
```
## 开放配置计划
### Phase 1 — 内置默认策略和只读解释
- 实现后端默认策略。
- 聚合接口返回 `field_sources``selected_reasons``conflict_count`
- 冲突记录可查询,但不允许用户修改。
- 保持现有前端船只图层接口形状基本兼容,新增字段只作为调试和后续 UI 输入。
### Phase 2 — 系统设置中的 JSON/YAML 策略配置
新增系统设置项,例如:
```yaml
collector_aggregation:
vessel_ais:
source_priority:
- aisstream_vessels
- barentswatch_vessels
field_rules:
name:
mode: source_priority
vessel_type:
mode: source_priority
source_priority:
- barentswatch_vessels
- aisstream_vessels
lat:
mode: newest
lon:
mode: newest
```
配置校验要求:
- 未知 source 只警告,不阻断保存,便于先配置后启用。
- 未知 field 必须拒绝,避免拼写错误悄悄失效。
- 动态位置字段默认不允许被固定来源永久锁死,除非显式开启高级选项。
- 空值不覆盖非空值是全局保护,不建议开放关闭。
### Phase 3 — 冲突治理 UI
基于冲突记录提供页面或 drawer
- 查看某个 MMSI 的冲突字段。
- 查看每个字段的候选来源和值。
- 查看当前选择原因。
- 将一次人工选择保存成字段规则,而不是只处理单条冲突。
- 支持恢复默认策略。
## AISStream 采集器计划
AISStream 采集器单独实现,建议命名为 `aisstream_vessels`。它的职责是:
- 维护 WebSocket 连接、订阅范围和重连。
- 将上游 AIS 消息标准化为 `vessel_ais` payload。
- 标记 `delivery_mode = realtime_stream``transport = websocket`
- 写入原始观测层。
- 不直接 upsert 最终展示数据。
配置应放入采集器设置,而不是硬编码:
```yaml
aisstream_vessels:
api_key: "${AISSTREAM_API_KEY}"
bounding_boxes:
- [[-180, -90], [180, 90]]
message_types:
- PositionReport
- ShipStaticData
```
## 实施顺序
1. 新增原始观测模型和冲突记录模型。
2. 实现 AIS 聚合服务,先从现有 `vessel_position` / `vessel_static` 兼容读取,再逐步切换到原始观测层。
3.`/geo/vessels``/vessels/{mmsi}` 改为走聚合服务。
4. 改造 BarentsWatch 保存逻辑,让它写入原始观测,同时保留现有表作为兼容缓存。
5. 实现 AISStream WebSocket collector。
6. 接入系统设置中的聚合策略配置。
7. 做冲突治理 UI。
## 测试计划
- 同一来源同一 `mmsi + observed_at + lat + lon` 重复记录只聚合一次。
- 多来源同一 MMSI 的位置字段优先选择最新观测。
- 实时流和轮询源同时间冲突时,实时流优先。
- 实时流过期后,更新的轮询源可以接管动态字段。
- 静态字段不会被空值覆盖。
- 静态字段冲突会写入冲突记录。
- 字段级配置可以覆盖默认来源优先级。
- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。
## 相关文件
- [实时船只监控系统计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-tracking-plan.md)
- [自定义 API 数据源与 LLM 映射系统计划](/home/ray/dev/linkong/planet/docs/plans/datasource-custom-api-mapping-plan.md)
- [BarentsWatch AIS collector](/home/ray/dev/linkong/planet/backend/app/services/collectors/vessel_ais.py)
- [船只模型](/home/ray/dev/linkong/planet/backend/app/models/vessel.py)
- [可视化 API](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)

View File

@@ -1,5 +1,17 @@
# Earth Vessel Rendering Performance Plan
## 当前状态
该计划的前端核心部分已经在 `0.44.1` 落地,但最终实现不是原文设想的 `InstancedBufferGeometry` quad而是更稳的分桶 `THREE.Points` 方案:
- 普通船只按 moving / anchored 和 `VESSEL_COURSE_BINS` 航向分桶,使用 `PointsMaterial` 批量绘制。
- 航行船只仍是带方向的三角形,停泊或低速船只仍是圆点。
- hover / locked 不再放大成世界尺寸 Sprite而是在原点位叠加同尺寸单点 glow overlay。
- picking 改为屏幕空间命中,拖动和惯性期间跳过 hover picking。
- 普通态关闭 glow交互态才显示 glow降低 overdraw 并让默认地图更干净。
后续如果需要全球 AIS 或更高船只密度,再评估是否从分桶 `Points` 升级到真正 instanced quad 或视口 bbox / LOD。
## 背景
Earth 船只图层已经形成了一套较好的视觉语言:
@@ -8,10 +20,10 @@ Earth 船只图层已经形成了一套较好的视觉语言:
- 标记按航向旋转
- 停泊或低速船只使用圆点
- 不同船型使用不同颜色
- hover / locked 状态有放大、透明度和聚焦反馈
- hover / locked 状态有 glow、透明度和聚焦反馈
- 标记带有轻微 glow / soft edge和 Earth HUD 的观感一致
当前性能问题不应通过降级成普通 `Points` 来解决。目标是在保留现有观赏性的前提下,把底层从“每艘船一个 Sprite 对象”优化为批量绘制和轻量交互。
当前性能问题不应通过降级成无方向、无船型语义的普通小点来解决。目标是在保留现有观赏性的前提下,把底层从“每艘船一个 Sprite 对象”优化为批量绘制和轻量交互。
## 当前问题判断
@@ -106,11 +118,18 @@ Earth 船只图层已经形成了一套较好的视觉语言:
目标是减少屏幕空间重叠面积,而不是改变符号设计。
## Phase 3保留视觉的批量渲染
## Phase 3保留视觉的批量渲染(已落地为分桶 Points
正式方案是把每艘船的视觉从 `THREE.Sprite` 迁移为 instanced sprite batch。
原设想是把每艘船的视觉从 `THREE.Sprite` 迁移为 instanced sprite batch。实际落地时选择了更稳的分桶 `THREE.Points`
### 1. 使用 instanced quad
- 不依赖自定义 shader。
- 不依赖 `Points` 自带 raycaster。
- 用 canvas 纹理保留三角、圆点、船型颜色和航向。
- 用 hover / locked 单点 overlay 保留交互 glow。
如果未来全球 AIS 导致分桶 `Points` 仍不够,再升级到 instanced quad。
### 1. 原候选方案instanced quad
每艘船仍然显示为带贴图/软边的 billboard但底层使用
@@ -130,7 +149,7 @@ Earth 船只图层已经形成了一套较好的视觉语言:
这样 draw call 从“每艘船一个”变为“每类船只一个”。
### 2. per-instance attributes
### 2. 原候选方案:per-instance attributes
每个 instance 存:
@@ -142,9 +161,21 @@ Earth 船只图层已经形成了一套较好的视觉语言:
- state
- mmsi / data index
hover、locked、dimmed 通过更新少量 instance attribute 实现,不再逐个修改 material。
hover、locked、dimmed 通过更新少量 instance attribute 实现,不再逐个修改 material。
### 3. 复刻当前视觉
### 3. 当前落地方案:分桶 `THREE.Points`
当前实现按以下方式复刻视觉:
- moving 船只按 `VESSEL_COURSE_BINS` 做航向分桶。
- anchored / slow 船只使用圆点分桶。
- 每个分桶生成一组 `THREE.Points`,共享 `PointsMaterial` 和 canvas 点纹理。
- `VESSEL_CONFIG.colors` 仍通过 vertex colors 表示船型颜色。
- hover / locked 在原位置叠加同尺寸单点 overlay普通态不带 glow交互态才带 glow。
这样 draw call 从“每艘船一个”变为“每个形状 / 航向分桶一组”,同时避免自定义 shader 的兼容风险。
### 4. 复刻当前视觉
视觉上继续使用当前 canvas texture 或等效 shader
@@ -213,5 +244,6 @@ hover、locked、dimmed 通过更新少量 instance attribute 实现,不再逐
1. 先做 Phase 1快速恢复地球拖动手感。
2. 再做 Phase 2减少每帧 JS 写操作。
3. 最后做 Phase 3 和 Phase 4,把船只迁移到 instanced sprite batch
3. Phase 3 和 Phase 4 已按分桶 `THREE.Points` + 屏幕空间 picking 落地
4. Phase 5 等全球船只数据或数量压力出现后再推进。
5. 如果分桶 `THREE.Points` 达到瓶颈,再评估 instanced quad。

View File

@@ -12,7 +12,7 @@
| 船只规模 | BarentsWatch 阶段全部显示;全球数据接入后按需加船型过滤(默认 Cargo + Tanker + Passenger |
| 更新频率 | 准实时:前端 5 分钟轮询,后端 Collector 每分钟拉取写库 |
| 历史轨迹 | 保留(`vessel_position` 表保留 24h后期按需扩展 |
| 推送方式 | HTTP 轮询(不用 WebSocket换实时数据源后再评估升级 |
| 推送方式 | 前端展示仍可先用 HTTP 拉取聚合结果AISStream 等实时源应单独实现 WebSocket 采集器 |
---
@@ -36,13 +36,19 @@
- 字段mmsi, lat, lon, sog, cog, heading, nav_status, name, vessel_type, flag
- 刷新频率:数据约 3060s 更新一次,可随意轮询
### TODO付费数据源接入
### TODO多源 AIS 与实时流接入
- [ ] 接入 AISStream WebSocket 采集器,作为 BarentsWatch 覆盖不足的实时补充
- [ ] 将 BarentsWatch、AISStream、自定义 `vessel_ais` 映射源统一写入原始观测层
- [ ] 通过聚合接口做去重、字段合并、冲突记录和默认来源选择
- [ ] 开放字段级聚合策略配置,让用户决定不同字段优先信任哪个来源
- [ ] 评估 AISHub 订阅(全球覆盖,约 $30/月),接入全球实时流
- [ ] 评估 MarineTraffic API tier对比 AISHub 数据质量与成本
- [ ] 实现多数据源适配器,通过 `datasource_config` 切换
- [ ] 真实高频 AIS 稳定接入后,评估将 `vessel_position` 迁移为 TimescaleDB hypertable保留 Postgres 原生分区作为备选)
多源 AIS 的详细设计见 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)。
---
## 二、实施计划
@@ -151,12 +157,13 @@ GeoJSON Feature 格式:
#### 1.4 更新机制
**HTTP 轮询**(不使用 WebSocket
**前端聚合结果拉取 + 后端实时采集**
- 前端 `setInterval(fetchVessels, 5 * 60 * 1000)` 定期拉取最新快照
- 后端 Collector 每 60s 从 BarentsWatch 拉取并写库,`vessel_latest` 物化视图随时可查
- WebSocket 留给告警/事件驱动场景BGP、系统通知不混入周期性位置刷新
- 换用 AISHub / MarineTraffic 实时流后,届时再评估是否升级为 WebSocket delta push
- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集
- AISStream 等实时源以独立 WebSocket collector 写入原始观测层
- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值
- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源
---

View File

@@ -17,12 +17,16 @@ What belongs here:
- Earth layer style property index
- Backend runtime control
- Collector status
- Collector settings and connectivity validation
- Earth Interactable integration
- Collection format conventions
## Entry Points
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
- [manual.md](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable
What does not belong here:
@@ -32,4 +36,4 @@ What does not belong here:
Those belong in:
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)
- [Plans Index](/home/ray/dev/linkong/planet/docs/plans/README.md)

View File

@@ -0,0 +1,325 @@
# Collector Settings and Connectivity Validation
## Background
The console now separates the "data source catalog" from "collector configuration":
- `/datasources`
- Lists all data sources, including built-in and custom sources.
- Clicking a name only opens an information drawer.
- Focuses on status, manual collection, and running collection tasks.
- `/settings?tab=collector_credentials`
- Displays as "Collector Settings".
- Owns endpoint, headers, base parameters, and credentials.
- Every collector exposes a connection button for health checks.
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to collector settings instead of being scattered across the data source list and system settings.
## User-Facing Rules
Connection state is not a frontend styling state. The backend derives it from the current configuration checksum and previously validated records.
A built-in collector is considered "connected" when either condition is true:
- The current configuration has successfully collected data.
- The user clicked the connection button for the current configuration and backend validation succeeded.
If endpoint, headers, base configuration, or credential fingerprint changes after the last successful validation, the state returns to "needs reconnection".
## Frontend Entry Points
### Data Source Catalog
Files:
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx)
- [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css)
Current behavior:
- Built-in and custom data sources are merged into a `UnifiedDataSource` list.
- The table only keeps view, collect, and status actions.
- Clicking the name opens a read-only drawer.
- The drawer shows:
- Whether the source is built in
- Whether it is enabled
- Module, priority, and frequency
- Endpoint
- Headers
- Base configuration
- Whether credentials are required
- When tasks are running, the top progress area shows a clickable `Collecting N` pill.
- Clicking `Collecting N` opens a task list modal with per-task progress.
`data-source-bulk-toolbar__running-pill` is the styling entry point for the "Collecting" pill. It is aligned with other status tags, while hover treatment, arrow affordance, and blue outline indicate interactivity.
### Collector Settings
File:
- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx)
Current behavior:
- The `collector_credentials` tab is displayed as "Collector Settings".
- A select lists all built-in collectors.
- The only button beside the select is a plug icon for health checks.
- Status tags below the select show:
- `Credentials required` / `No credentials required`
- Module
- `Enabled` / `Disabled`
- `Unchecked` / `Available` / `Unavailable`
- Whether the endpoint is overridden
- Collectors that require credentials place the credential card above base configuration.
- Collectors without credentials only show base configuration.
The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action.
## Backend APIs
### Data Source Configuration List
```http
GET /api/v1/datasources/configs/all
```
Returns a merged view of YAML default data sources and database overrides. This route must be declared before `/configs/{config_id}`; otherwise FastAPI treats `all` as a path parameter and returns 422.
Returned fields include:
- `name`
- `default_url`
- `endpoint`
- `is_overridden`
- `is_active`
- `source_type`
- `auth_type`
- `headers`
- `config`
- `config_id`
- `description`
Before returning `config`, internal connectivity validation fields are removed so the frontend does not display validation metadata as user configuration.
### Built-In Collector Connection Status
```http
POST /api/v1/datasources/configs/builtin/connection-status
```
Purpose:
- Accept a candidate configuration.
- Compute its checksum.
- Determine whether the current configuration is already connected.
The current frontend mostly performs an immediate check through the connection button and does not strongly depend on this endpoint. It remains the backend basis for future save-button disabling and restoring initial page state.
### Built-In Collector Connectivity Validation
```http
POST /api/v1/datasources/configs/builtin/connect
```
Purpose:
- Free collectors request the endpoint directly.
- Credentialed collectors go through their credential provider.
- Successful validation writes a system-level connection record.
Successful responses include:
- `success`
- `connected`
- `checksum`
- `stage`
- `message`
- `response_time_ms`
- `credential_provider`
- `credential_source`
### BarentsWatch AIS Connectivity Validation
```http
POST /api/v1/settings/integrations/barentswatch/connect
GET /api/v1/settings/integrations/barentswatch/connectivity
```
BarentsWatch uses separate endpoints because draft credentials must be validated before saving:
- Use draft `client_id` / `client_secret` to fetch a token.
- Use that token to request the AIS endpoint.
- After success, write a built-in collector connection record using the draft credential fingerprint.
## Connectivity Validation Service
File:
- [datasource_connectivity.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_connectivity.py)
Core responsibilities:
- Compute built-in collector configuration checksums.
- Read credentials from environment variables and `~/.zshrc`.
- Determine whether the current configuration is already connected.
- Run endpoint health checks.
- Save successful connection records.
### Checksum Inputs
The checksum includes:
- Collector name
- Endpoint
- Auth type
- Headers
- Config after removing internal validation fields
- Credential provider
- Credential fingerprint
The credential fingerprint is a hash of credential content. Plaintext credentials are not written into connection records.
### Connection Records
Successful connection records are written to `SystemSetting`:
```text
category = datasource_connectivity_validations
```
The payload uses collector source as the key:
```json
{
"barentswatch_vessels": {
"checksum": "...",
"status": "success",
"validated_at": "2026-04-29T00:00:00+00:00",
"status_code": 200,
"credential_source": "datasource_config",
"connected_by": "connection_button"
}
}
```
`connected_by` currently has two sources:
- `connection_button`: the user manually clicked the connection button.
- `collection`: a collection task completed successfully, so the system recorded the current effective configuration as connected.
### Successful Collection Means Connected
After a successful collection, the scheduler writes a connection record:
- [scheduler.py](/home/ray/dev/linkong/planet/backend/app/services/scheduler.py)
This prevents collectors that already have data from asking the user to validate again. Reconnection is only required when the configuration checksum changes.
## BarentsWatch AIS Credential Chain
Files:
- [barentswatch.py](/home/ray/dev/linkong/planet/backend/app/services/barentswatch.py)
- [vessel_ais.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/vessel_ais.py)
Resolution priority:
1. `DataSourceConfig.auth_config`
2. `DataSourceConfig.config`
3. Environment variables
4. `~/.zshrc`
Supported environment variables:
```bash
export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
Historical misspellings are also supported:
```bash
export BARRENTSWATCH_CLIENT_ID="..."
export BARRENTSWATCH_CLIENT_SECRET="..."
```
Token request rules:
- Token URL: `https://id.barentswatch.no/connect/token`
- `Content-Type`: `application/x-www-form-urlencoded`
- Body:
- `grant_type=client_credentials`
- `client_id`
- `client_secret`
- `scope=ais`
AIS request rules:
- Default endpoint: `https://live.ais.barentswatch.no/v1/latest/combined`
- Header: `Authorization: Bearer <access_token>`
`VesselAISCollector` no longer reads environment variables directly. It goes through `resolve_barentswatch_config()` and `fetch_barentswatch_access_token()` so settings, connectivity validation, and collection do not fork into three credential flows.
## Credential Guide
File:
- [credential_guides.py](/home/ray/dev/linkong/planet/backend/app/services/credential_guides.py)
APIs:
```http
GET /api/v1/settings/credential-guides/{provider}
POST /api/v1/settings/credential-guides/{provider}/generate
POST /api/v1/settings/credential-guides/{provider}/reset
```
Currently supported:
- `barentswatch`
The default guide includes the official BarentsWatch tutorial:
```text
https://developer.barentswatch.no/docs/tutorial
```
If the user clicks that the tutorial is not useful, the backend sends the default prompt to AI Provider, generates a new Chinese tutorial, and saves it to `SystemSetting`:
```text
category = collector_credential_guides
```
Reset deletes the custom tutorial and restores the default guide.
## Save Rules
When built-in collector configuration is saved, the internal `connectivity_validation` field is removed so validation state does not mix with user configuration.
BarentsWatch `client_secret` has special handling:
- The input shows a masked preview.
- If the submitted value still matches the masked preview, the backend keeps the old secret.
- If a new value is submitted, the secret is replaced.
- The previous separate "clear current secret" checkbox is no longer provided.
## Test Coverage
Related tests:
- [test_vessels.py](/home/ray/dev/linkong/planet/backend/tests/test_vessels.py)
Added coverage:
- BarentsWatch credentials can be parsed from `~/.zshrc`.
- When environment variables are empty, `resolve_barentswatch_config()` can fall back to `~/.zshrc`.
- Vessel data conversion and GeoJSON output remain compatible.
## Current Provider Coverage
Credential providers currently supported:
- `barentswatch`
- `spacetrack`
Other collectors with `requires_credentials=true` return that their credential chain has not been wired yet, and the frontend shows `Unavailable`.

View File

@@ -187,7 +187,7 @@ Current reality:
- that is expected, because incidents are aggregated and de-noised
- but incident-first rendering makes the Earth view look too quiet unless there is another always-available activity layer
Implementation detail for the recommended `activity layer` is expanded in [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
Implementation detail for the recommended `activity layer` is expanded in the [BGP Region Aggregation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md).
So the immediate next milestone is:

View File

@@ -4,8 +4,8 @@ This document describes the current real structure of the Earth display frontend
Related references:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
- [Project Rules](/home/ray/dev/linkong/planet/rules.md)
- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Current Goal
@@ -249,4 +249,4 @@ Therefore:
For console structure, see:
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)

View File

@@ -0,0 +1,270 @@
# Earth Interactable Usage
`Interactable` is the shared rendering entry point for icon-like interactive elements on the Earth surface. It extracts the pattern proven by the vessel layer into reusable behavior: normal state uses batched `THREE.Points`, hover and locked states use small overlays, picking uses screen-space hit testing, icon assets are normalized into canvas textures, and the shared layer handles glow, state, size, ground rendering, and same-coordinate avoidance.
Currently integrated layers:
| Layer | Business File | Icon Source | Extra Animation |
| --- | --- | --- | --- |
| AIS vessels | `frontend/public/earth/js/vessels.js` | canvas draw, moving triangle / anchored dot | Vessel tracks are still maintained by the business layer |
| Compute centers | `frontend/public/earth/js/compute-centers.js` | `assets/icons/compute-*.svg` | Estimated-location `?` badge is added through `icon.afterDraw()` |
| BGP events | `frontend/public/earth/js/bgp.js` | canvas draw, symbol by event type | Expanding rings are still maintained by the BGP business layer |
| BGP observers | `frontend/public/earth/js/bgp.js` | `assets/icons/bgp-broadcast-pin.svg` | Halo, activity core, coverage wedge, and radar sweep remain in the BGP business layer |
Landing sites were previously attempted on Interactable, but pin-style SVGs were fragmented by `THREE.Points` depth testing near the Earth edge. They now use a dedicated `THREE.Sprite` path with a yellow flat-sphere texture generated by canvas. The old SVG assets remain in `assets/icons/`, but landing sites no longer depend on SVG at runtime.
## Why Interactable Exists
Before this layer, each surface icon layer could easily reimplement its own version of:
- icon texture generation
- hover / locked state
- glow styling
- picking radius
- zoom-dependent size strategy
- overlap avoidance for identical coordinates
When this logic is scattered across business files, visual behavior drifts and later tuning becomes layer-by-layer repair. The boundary of `Interactable` is: the shared layer owns how icons remain stable on Earth and how they are selected; the business layer owns where data comes from, what the icon means, what detail cards show, and whether extra animation exists.
## Entry Point
```javascript
import { createInteractableLayer } from "./interactable.js";
```
Core call shape:
```javascript
const layer = createInteractableLayer({
id: "example",
objectType: "example_object",
renderOrder: 4.4,
altitudeOffset: 0.2,
pointSize: 34,
icon: {
draw(context, options) {
// draw canvas icon
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.kind || "default",
});
```
Business modules usually expose only a thin wrapper:
```javascript
export function getExampleMarkers() {
return layer.getMarkers();
}
export function getExamplePointerIntersections(options) {
return layer.getPointerIntersections(options);
}
export function setExampleMarkerState(marker, state = "normal") {
layer.setMarkerState(marker, state);
}
export function updateExampleVisualState(lockedObjectType, lockedObject, camera) {
layer.updateVisualState(lockedObjectType, lockedObject, camera);
}
```
## Configuration
| Option | Default | Description |
| --- | --- | --- |
| `id` | required | Unique layer id used for group name, avoidance registration, and debug. |
| `objectType` | `id` | Business type written to `marker.userData.type`; the main interaction layer uses it to identify locked objects. |
| `renderOrder` | `4` | Base render order for normal points and hover / locked overlays. |
| `altitudeOffset` | `0.2` | Business altitude, used as `CONFIG.earthRadius + altitudeOffset` for the original surface position. |
| `pointSize` | `32` | Base screen pixel size used by both normal points and overlays. |
| `sizeMode` | `"fixed"` | Fixed screen size by default; non-`"fixed"` modes scale by camera distance. |
| `sizeScale` | `{ referenceFov: 75, min: 0.12, max: 3 }` | Scaling bounds when `sizeMode !== "fixed"`. |
| `atlasCellSize` | `128` | Canvas texture cell size for icons. |
| `colors` | `{}` | Supports `normal`, flattened kind keys, and `byKind`. |
| `opacity` | `{ normal: 0.88, dimmed: 0.26, hover: 0.98, locked: 1 }` | Opacity per state. |
| `stateScale` | `{ hover: 1, locked: 1, dimmed: 1 }` | Size multiplier per state. |
| `pulse` | `{}` | Optional locked-state breathing scale, with `enabled`, `speed`, and `amplitude`. |
| `avoidance` | `{ enabled: true, precision: 4, radius: 1.1, step: 0.35 }` | Same-coordinate avoidance across Interactable layers. |
| `icon` | required | Icon source, supporting canvas draw, SVG / image asset, state asset, anchor, and post-processing. |
| `getPosition(item)` | required | Returns `{ latitude, longitude }` or `THREE.Vector3`. |
| `getKind(item)` | `item.type || "default"` | Returns a business kind for color and texture buckets. |
| `getRotationBin(marker)` | `0` | Returns a rotation bucket, such as 32 heading buckets for vessels. |
| `getBucketKey(marker)` | `String(getRotationBin(marker))` | Returns a texture / geometry bucket key. |
| `getPointSizeMultiplier(marker)` | `1` | Per-marker size multiplier. BGP events use severity; observers use activity. |
| `getUserData(item)` | `item` | Business fields written onto the marker. |
## Icon Configuration
`icon.anchor` is optional and defaults to `{ x: 0.5, y: 0.5 }`, meaning the texture center aligns with the marker coordinate. It is only suitable for small visual anchor offsets. If the icon body is large and must remain fully visible at the Earth edge, such as the old landing-site pin, it should not be forced through `THREE.Points + depthTest`; the body will be clipped by Earth depth.
### Canvas Icons
Canvas icons fit vessels and BGP events where symbols need to be drawn dynamically by state or rotation:
```javascript
const vesselIconLayer = createInteractableLayer({
id: "vessels",
objectType: "vessel",
pointSize: 34,
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
if (!marker.userData.anchored) {
context.rotate((rotationBin / 32) * Math.PI * 2);
}
context.fillStyle = color;
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
context.beginPath();
context.moveTo(0, -37);
context.lineTo(28, 32);
context.lineTo(0, 17);
context.lineTo(-28, 32);
context.closePath();
context.fill();
},
},
getRotationBin: getCourseBin,
getBucketKey: (marker) => `${marker.userData.anchored ? "anchored" : "moving"}:${getCourseBin(marker)}`,
});
```
When `icon.coordinates !== "canvas"`, `Interactable` translates the context to the atlas center first. Vessel-style icons that already draw around center coordinates do not need to declare `coordinates`.
### SVG / Image Asset Icons
Asset icons fit facilities such as compute centers and BGP observers:
```javascript
const computeCenterIconLayer = createInteractableLayer({
id: "computeCenters",
objectType: "compute_center",
pointSize: 36,
atlasCellSize: 128,
icon: {
coordinates: "canvas",
colorable: false,
fitSize: 60,
glowBlur: 16,
getSource({ marker, item }) {
const siteType = marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return COMPUTE_CENTER_ICON_SOURCES[siteType];
},
afterDraw(context, { marker, item }) {
if (marker?.userData?.is_estimated ?? item?.is_estimated) {
drawComputeCenterEstimatedBadge(context, true);
}
},
},
});
```
Asset conventions:
- SVG / image files live in `frontend/public/earth/assets/icons/` and are referenced as `/earth/assets/icons/name.svg`.
- Original SVGs should keep a standard `viewBox` and paths; avoid hard-coding transform only for display size.
- Display size is controlled by `icon.fitSize`; it can be a number, `{ width, height }`, or a function.
- If `icon.colorable !== false` and state colors are provided, the shared layer first draws the asset to a temporary canvas and then tints it with `source-in`.
- Multicolor images or SVGs that should not be tinted must set `colorable: false`.
## Lifecycle
Typical load flow:
```javascript
export async function loadExampleLayer(_scene, earth) {
clearExampleData(earth);
const markerData = await fetchExampleData();
await layer.preloadAssets(markerData);
layer.setData(markerData);
layer.attach(earth);
layer.setVisible(showExampleLayer);
return { totalCount: layer.getCount() };
}
```
Method responsibilities:
| Method | Description |
| --- | --- |
| `preloadAssets(items)` | Collects asset sources that may be used by normal / hover / locked states and preloads them with browser `Image`. Canvas-drawn icons can skip this. |
| `setData(items)` | Clears old points, creates markers, registers avoidance, and rebuilds `THREE.Points` by bucket. |
| `attach(parent)` | Mounts the layer group onto the Earth root. |
| `setVisible(next)` | Controls visibility for the group, points, and overlays. |
| `setMarkerState(marker, state)` | Sets `normal` / `hover` and other states, then invalidates visual state. |
| `updateVisualState(focusType, focusObject, camera)` | Updates normal opacity / size and refreshes hover / locked overlays. |
| `getPointerIntersections(options)` | Runs screen-space picking and returns hits sorted by pixel distance. |
| `clearData(parent)` | Unregisters avoidance, disposes geometry / material, clears markers, and removes the group from the parent. |
## Picking Integration
`Interactable` does not depend on the default Three.js raycast for `Points`. The main interaction layer passes Earth, camera, pointer, and hit radius:
```javascript
const intersects = getVesselPointerIntersections({
earth,
camera,
pointer,
radiusPx: 22,
width: window.innerWidth,
height: window.innerHeight,
});
```
The shared layer:
1. Converts the camera position into Earth-local coordinates.
2. Skips markers on the back side.
3. Projects marker world position into screen coordinates.
4. Uses `radiusPx` for pixel-distance hits.
5. Returns the nearest candidate objects.
Earth dragging, inertia, and hover throttling still belong to `main.js` because they depend on global input state.
## Same-Coordinate Avoidance
Avoidance is enabled by default and applies to all layers created through `createInteractableLayer()`. The shared layer builds an `icon_avoidance_key` from latitude / longitude or `THREE.Vector3`, then arranges markers with the same key into a small circle along the surface tangent plane.
Key points:
- `icon_base_position` keeps the original business position.
- Avoidance only changes rendering and picking position. It does not change business latitude / longitude.
- When a single marker returns to its original position, it uses the business surface position computed from `altitudeOffset`.
- When multiple markers share coordinates, the first ring uses `avoidance.radius`; later rings add `avoidance.step`.
If a business layer must stay exactly on the original point, disable avoidance explicitly:
```javascript
createInteractableLayer({
id: "strict-layer",
avoidance: { enabled: false },
});
```
## Business Animation Boundary
`Interactable` currently owns only the icon body and common hover / locked overlays. Complex animations remain in business modules, but should follow the Interactable marker position:
- BGP event expanding rings are independent ring sprites created by `bgp.js`, updated every frame with `position.copy(marker.position)`.
- BGP observer halo, status core, coverage halo, and coverage wedge are managed by `bgp.js`; the icon body is managed by Interactable.
- Vessel tracks remain in `vessels.js` because they depend on track data loaded after a click.
This boundary avoids pushing every animation type into the shared interface too early. If multiple layers reuse the same animation type later, it can move into an Interactable `animations` extension.
## New Layer Checklist
1. Prepare marker data in the business file and keep required business fields.
2. Choose an icon type: canvas draw, SVG / image asset, or dynamic `getSource()`.
3. Configure `pointSize`, `icon.fitSize`, `colors`, `opacity`, and `stateScale`.
4. Provide `getPointSizeMultiplier()` if business-specific size variation is needed.
5. Provide `getRotationBin()` and a stable `getBucketKey()` if rotation exists.
6. During load, call `preloadAssets()` before `setData()`, `attach()`, and `setVisible()`.
7. Wire `getPointerIntersections()` in `main.js` and reuse the existing hover / locked state update flow.
8. Record altitude, `renderOrder`, `pointSize`, and animation ordering in the layer style index and render order documents.

View File

@@ -1,6 +1,6 @@
# Earth Layer Style Property Index
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.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md).
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
@@ -74,6 +74,8 @@ This document records the material, color, opacity, line width, radius offset, a
## Land/Ocean Base and Country Borders
The land/ocean base is an Earth base-map asset and preloads at startup; the "Border Lines" layer toggle only controls normal border lines, hover lines, and interactive hover.
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input |
@@ -89,11 +91,11 @@ This document records the material, color, opacity, line width, radius offset, a
| Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line |
| Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity |
| Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover |
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.24` | Normal border line radius |
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; slightly above HD texture `0.10` and below terrain base `0.16` to reduce floating |
| Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level |
| Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange |
| Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity |
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.32` | Hover line radius |
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | Hover line radius; close to the surface but above normal border lines |
| Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level |
| Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity |
| Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` |
@@ -140,17 +142,18 @@ This document records the material, color, opacity, line width, radius offset, a
| 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.48` | Aligns with compute center marker height |
| Landing point icon texture size | `CABLE_CONFIG.landingPoint.textureSize` | `256` | Canvas size for solid map-pin icon |
| Landing point icon aspect ratio | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
| Landing point icon anchor | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`, aligns pin tip to landing point lat/lon |
| Landing point base scale | `CABLE_CONFIG.landingPoint.baseScale` | `12` | Matches compute center sprite height |
| Landing point radius offset | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 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` |
| Landing point scale maximum | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | Maximum multiplier at far distance; current minimum zoom reaches roughly `2.50` |
| Landing point atlas size | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | Canvas flat shaded sphere texture size |
| Landing point color | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| Landing point opacity | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| Landing point renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | Aligns with compute center surface level |
| 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 |
| 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 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | Dim state weak amber self-emission |
| Dimmed landing point opacity | `landingPointVisual.dimmed.opacity` | `0.78` | Dim state opacity; no longer uses low alpha blending with dark base |
## Satellites, Trails, and Footprints
@@ -168,18 +171,34 @@ This document records the material, color, opacity, line width, radius offset, a
| Satellite trail line width | `SATELLITE_CONFIG.trailLineWidth` | `3` | Ribbon shader uniform |
| 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` | Footprint fill |
| Footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Starlink footprint fill and Iridium coverage ring; must stay above land / texture / terrain surface layers |
## AIS Vessels
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Vessel radius offset | `VESSEL_CONFIG.altitudeOffset` | `0.2` | Normal marker position, close to the real terrain base layer |
| Vessel track radius offset | `VESSEL_CONFIG.track.altitudeOffset` | `0.2` | Selected vessel track line, aligned to the vessel marker radius; the frontend anchors the track endpoint to the current marker position |
| Vessel renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | Normal marker and interactive overlay |
| Vessel track renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | Below vessel markers |
| Vessel point pixel size | local `VESSEL_POINT_SIZE` | `34` | Shared size for normal markers and hover / locked overlays |
| Vessel texture canvas size | local `VESSEL_ATLAS_CELL_SIZE` | `128` | Canvas point texture |
| Course bucket count | local `VESSEL_COURSE_BINS` | `32` | Moving vessels are bucketed by COG to reduce draw calls while preserving direction |
| Vessel hover picking throttle | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
| Vessel screen hit radius | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` screen-space picking |
## Compute Centers
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| Compute center radius offset | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | Marker position |
| Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Supercomputer marker |
| GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
| Hover scale | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | Hover state |
| Locked scale | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | Locked state |
| Compute center point size | local `COMPUTE_CENTER_POINT_SIZE` | `36` | Shared Interactable base size for normal markers and hover / locked overlays |
| Compute center asset fit size | local `COMPUTE_CENTER_ICON_FIT_SIZE` | `60` | Maximum SVG asset draw size inside the `128x128` atlas canvas, controlled by `icon.fitSize` |
| Compute center base opacity | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | Normal `PointsMaterial.opacity` |
| Supercomputer marker scale | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | Legacy Sprite scale; not directly used by the current Interactable path |
| GPU cluster marker scale | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | Legacy Sprite scale; not directly used by the current Interactable path |
| 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 |
@@ -190,12 +209,16 @@ This document records the material, color, opacity, line width, radius offset, a
| Name | Variable | Current Value | Location / Notes |
| --- | --- | --- | --- |
| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `2.1` | Anomaly marker |
| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | Collector marker |
| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Anomaly sprite |
| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector plane |
| BGP event radius offset | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP event Interactable marker |
| BGP collector radius offset | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP collector Interactable marker, aligned with the vessel layer |
| BGP event point size | local `BGP_EVENT_POINT_SIZE` | `34` | Event Interactable base size, adjusted by severity through `getPointSizeMultiplier()` |
| BGP event symbol draw size | local `BGP_EVENT_SYMBOL_SIZE` | `60` | Event canvas symbol draw size inside the `128x128` atlas |
| BGP collector point size | local `BGP_COLLECTOR_POINT_SIZE` | `36` | Collector Interactable base size, adjusted by activity through `getPointSizeMultiplier()` |
| BGP collector asset fit size | local `BGP_COLLECTOR_ICON_FIT_SIZE` | `60` | Maximum `bgp-broadcast-pin.svg` draw size inside the atlas canvas |
| Event base scale | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | Event ring anchor |
| Collector base scale | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | Collector halo / coverage animation anchor |
| Hover / dim scale | `hoverScale / dimmedScale` | `1.16 / 0.92` | Interaction states |
| Normal event opacity | `BGP_CONFIG.opacity.normal` | `0.78` | Anomaly sprite |
| Normal event opacity | `BGP_CONFIG.opacity.normal` | `0.78` | BGP event Interactable normal state |
| Hover opacity | `BGP_CONFIG.opacity.hover` | `1.0` | Hover state |
| Dimmed opacity | `BGP_CONFIG.opacity.dimmed` | `0.24` | Dim state |
| Collector opacity | `BGP_CONFIG.opacity.collector` | `0.62` | Collector state |

View File

@@ -6,8 +6,8 @@ Note: the layer control panel order and the registration / startup load order ar
| Order type | Current sequence | Notes |
| --- | --- | --- |
| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Borders → Grid | Controlled by `displayOrder`, sorted by operational relevance. |
| Registration / startup load order | Grid → Borders → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. |
| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance. |
| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. |
## Surface Layer Stack
@@ -26,7 +26,7 @@ Note: the layer control panel order and the registration / startup load order ar
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset` | Raycast disabled | Only needs to stay above HD texture. |
| 2.29 | Country border hover glow | `country-boundaries.js` | Hover radius + glow offset | `depthTest: false`, raycast disabled | Additive glow to reinforce border edge and terrain hover visibility. |
| 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`, raycast disabled | Neon red-orange hover line; China and Taiwan share the same highlight group. |
| 3 | Satellite footprint fill | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested, Group renderOrder stays 0 | Footprint above country borders, below compute centers and satellites. |
| 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. |
| 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. |
| 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. |
| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Below satellite dots. |
@@ -42,7 +42,7 @@ Note: the layer control panel order and the registration / startup load order ar
| HD texture on | Restores HD texture and the remembered terrain / day/night states. |
| Terrain on | Displayed above HD texture, but below country border hover, footprints, satellites, and other emphasis layers. |
| Cloud layer | Only controls cloud mesh visibility. |
| Country borders | Controls border line and hover line visibility; land/ocean base fill exists independently as the Earth base map. |
| Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. |
## Interaction Rules

View File

@@ -4,8 +4,8 @@ This document records the current product boundary, data rationale, and implemen
Related context:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)

View File

@@ -4,8 +4,8 @@ This document describes the current real structure of the console frontend. The
Related references:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
- [Project Rules](/home/ray/dev/linkong/planet/rules.md)
- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Current Goal
@@ -263,7 +263,7 @@ These principles have been repeatedly validated in the project:
For detailed experience, see:
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
## Recommended Change Approach
@@ -290,4 +290,4 @@ Therefore:
For Earth-related structure, see:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)

View File

@@ -7,7 +7,7 @@ This manual is for daily use, demos, development integration, and local operatio
- Console: admin backend (login required)
- Docs: public developer documentation and manual
For the shortest path to getting started, see [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
## Entry Overview
@@ -55,6 +55,30 @@ Parameters:
| `--allow-lan` | Enable LAN access |
| `--verbose` | Show more command output during execution |
### AI Provider Environment and Builds
AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup.
Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes:
```bash
./planet.sh restart -a
```
For complex shell expansion in `~/.zshrc`, opt in explicitly:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
To ignore `~/.zshrc` during troubleshooting:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build.
### Stop
```bash
@@ -179,7 +203,7 @@ Earth is used to observe in a single globe view:
- Submarine cables and landing points
- Compute centers
- AIS vessels
- Country borders, grid lines, HD texture, cloud layer, terrain
- Border lines, grid lines, HD texture, cloud layer, terrain
- Live news streams and situational news
- Search and focused object details
@@ -190,7 +214,7 @@ The right-side layer panel toggles visualization layers on or off.
Common layers include:
- Grid lines
- Country borders
- Border lines
- HD texture
- Atmospheric cloud layer
- Submarine cables
@@ -215,7 +239,7 @@ Current legend modes include:
- Cables
- Satellites
- Country borders
- Border lines
- Compute centers
- BGP
- AIS vessels
@@ -561,9 +585,9 @@ When something goes wrong, follow this sequence:
## Related Docs
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [earth-layer-style-reference.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
- [backend-system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)

View File

@@ -24,6 +24,12 @@ This script checks and syncs common dependencies, and generates if missing:
- `aiprovider/.env`
- `frontend/.env.local`
Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the AI Provider container. After changing model, key, or base URL, restart only AI Provider:
```bash
./planet.sh restart -a
```
## 1. Start Services
From the repository root:
@@ -188,7 +194,7 @@ This shuts down the frontend, backend, AI Provider, PostgreSQL, and Redis.
## Next Steps
- Full usage guide: [manual.md](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
- Console structure: [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- Earth structure: [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- Backend collectors: [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- Full usage guide: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
- Console structure: [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- Earth structure: [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- Backend collectors: [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)

View File

@@ -21,9 +21,10 @@
## 使用入口
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
- [manual.md](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
- [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例
不适合放入这里的内容:
@@ -33,4 +34,4 @@
这些应放入:
- [docs/plans/README.md](/home/ray/dev/linkong/planet/docs/plans/README.md)
- [计划文档索引](/home/ray/dev/linkong/planet/docs/plans/README.md)

View File

@@ -224,7 +224,7 @@ if datasource.last_status == "success":
相关实现见:
- [datasource_connectivity.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_connectivity.py)
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
## 八、相关代码文件
@@ -314,7 +314,7 @@ POST /api/v1/settings/credential-guides/{provider}/reset
更多细节见:
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
## 十一、数据使用场景

View File

@@ -187,7 +187,7 @@ Earth info-card 策略:
- 这是预期行为,因为 incident 是聚合和去噪后的结果
- 但 incident-first 渲染会让 Earth 显得过于安静,除非有另一层始终可用的 activity layer
推荐 `activity layer` 的实现细节在 [bgp-region-aggregation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md) 中展开。
推荐 `activity layer` 的实现细节在 [BGP 区域聚合计划](/home/ray/dev/linkong/planet/docs/plans/earth-bgp-region-aggregation-plan.md) 中展开。
因此最近的里程碑是:

View File

@@ -4,8 +4,8 @@
相关规则建议一起参考:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
- [项目规则](/home/ray/dev/linkong/planet/rules.md)
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
## 当前目标
@@ -142,7 +142,7 @@ React 路由入口:
新闻巡航摘要计划见:
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
- [Earth 新闻巡航摘要计划](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
## 当前样式分层
@@ -249,15 +249,40 @@ Earth 图层按钮现在不应再只有“开/关”两态,而应支持:
AIS 船只图层入口:
- [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js)
- [interactable.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/interactable.js)
船只图层当前负责:
- 请求 `/api/v1/visualization/geo/vessels`
- 将 BarentsWatch AIS GeoJSON 转为 Three.js sprite
- 将 BarentsWatch AIS GeoJSON 转为地球局部坐标 marker 数据
- 通过 `createInteractableLayer()` 注册 Interactable 图标层
- 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker
- 按船型映射颜色
- 根据航行/停泊状态绘制三角形或圆点纹理
- 用单点 `THREE.Points` overlay 承载 hover / locked glow
- 支持 hover、lock、轨迹加载和视觉聚焦
船只图层不再是“每艘船一个 `THREE.Sprite`”。原始 Sprite 方案在拖动地球时会把透明对象排序、draw call 和对象级 raycast 成本全部放到主交互路径上;即使 BarentsWatch 免费 AIS 当前只覆盖挪威周边,也会让地球拖动明显不跟手。
当前设计把普通船只拆成少量批次:
- moving / anchored 分开。
- moving 船只按 `VESSEL_COURSE_BINS` 做航向分桶。
- 每个批次是一组 `THREE.PointsMaterial`,位置和颜色写入 `BufferGeometry` attribute。
- 普通态不带 glowhover / locked 时才在相同点位叠加带 glow 的单点 overlay。
方向标准以 AIS `course / cog` 为准:从正北开始顺时针。普通态和交互态都通过同一套 canvas 旋转规则生成纹理,避免 hover 后箭头方向和原 marker 不一致。
船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()``main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成:
1. 拖动地球或惯性旋转时跳过 hover picking。
2. 对 hover picking 做轻量节流。
3. 只保留正面船只作为候选。
4. 将候选船只投影到屏幕坐标。
5.`VESSEL_POINTER_RADIUS_PX` 做像素距离命中,并取最近船只。
这样 picking 位置和用户看到的屏幕 marker 对齐,也避免 `Points` 自带 raycaster 在固定屏幕尺寸图标上的命中半径错位。
图例系统已经注册 `vessels` 模式:
- [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js)
@@ -270,6 +295,24 @@ AIS 船只图层入口:
图例项颜色来自 `VESSEL_CONFIG.colors`,不要在 `legend.css` 里重新定义业务颜色。新增船型时,应优先改 `vessels.js``constants.js` 的船型映射,再同步图例项。
`interactable.js` 是后续地表图标类图层的共用入口。它当前已经承载船只、BGP 事件、BGP 观测站和算力中心图层的批量 `Points`、texture cache、hover / locked overlay、默认 glow、状态增量更新和屏幕空间 pickingBGP 事件的向外扩散圈、BGP 观测站的 halo / 覆盖扇形仍由 `bgp.js` 保留业务动画,但图标本体和 pointer 命中已经接入通用层。新增小型、中心对齐、可以参与深度测试的图标类元素时,应优先复用这个接口,而不是再次复制船只渲染逻辑。
登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset``renderOrder` 与海缆线一致避免漂在海缆之上Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。
图标资源可以继续用 canvas draw也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg``assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加估算位置的 `?` badge。
asset 图标大小由 `Interactable``icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform``drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
`Interactable` 默认使用固定屏幕像素尺寸适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 `sizeMode` 设为非 `"fixed"`,并用 `sizeScale.min / max / referenceFov` 控制缩放范围;单个 marker 的业务尺寸差异可以通过 `getPointSizeMultiplier()` 表达,例如 BGP 事件按严重级别调整点大小BGP 观测站按活跃度调整点大小。
`Interactable` 不再把图标本体额外抬离业务高度。`altitudeOffset` 就是 marker、hover glow、locked glow 和 picking 共同使用的地表高度;这样船只图标会继续贴着船只轨迹线,不会因为单独抬高显示位置而显得漂浮。后续如果要解决边缘 glow 裁切,应优先考虑 glow 纹理、overlay 尺寸或图层专属特效,而不是把通用图标层整体抬高。
跨 Interactable 的同坐标避让也在公共层处理。每个 marker 会保留 `icon_base_position` 作为业务原始位置;当多个 Interactable marker 归入同一个经纬度 key 时,公共层会把它们沿地表切平面排成小圈,并刷新已创建的 `THREE.Points` geometry。这样视觉位置和屏幕空间 picking 位置一致,不需要业务层再单独判断“算力中心和 BGP 事件重叠”这类场景。
接口细节、生命周期和接入示例见:
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
### 视角控制反馈
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护 Earth 缩放状态。滚轮缩放、缩放按钮和触屏双指捏合最终都会更新 `zoomLevel`,并通过 `showZoomStatusCapsule()` 显示当前缩放比例:

View File

@@ -0,0 +1,270 @@
# Earth Interactable 使用说明
`Interactable` 是 Earth 地表“图标类可交互元素”的通用渲染入口。它把船只图层验证过的模式抽成公共能力:普通态用批量 `THREE.Points`hover / locked 用少量 overlay拾取走屏幕空间命中图标资源统一转进 canvas texture并在公共层处理 glow、状态、尺寸、贴地渲染和同坐标避让。
当前已接入:
| 图层 | 业务文件 | 图标来源 | 补充动画 |
| --- | --- | --- | --- |
| AIS 船只 | `frontend/public/earth/js/vessels.js` | canvas draw航行三角形 / 停泊圆点 | 船只轨迹仍由业务层维护 |
| 算力中心 | `frontend/public/earth/js/compute-centers.js` | `assets/icons/compute-*.svg` | 估算位置 `?` badge 通过 `icon.afterDraw()` 叠加 |
| BGP 事件 | `frontend/public/earth/js/bgp.js` | canvas draw按事件类型绘制符号 | 向外扩散圈仍由 BGP 业务层维护 |
| BGP 观测站 | `frontend/public/earth/js/bgp.js` | `assets/icons/bgp-broadcast-pin.svg` | halo、活跃度 core、覆盖扇形和雷达扫掠仍由 BGP 业务层维护 |
登陆点曾尝试接入 Interactable但 pin 类 SVG 在地球边缘会被 `THREE.Points` 深度测试裁切成碎片;当前退回 `THREE.Sprite` 专用路径,并改为由 canvas 生成黄色扁平球纹理。旧 SVG 资产保留在 `assets/icons/` 目录中,但登陆点运行时不再依赖 SVG。
## 为什么需要 Interactable
之前每个地表图标图层都容易各写一套:
- icon texture 生成
- hover / locked 状态
- glow 样式
- picking 命中半径
- zoom 下的尺寸策略
- 同经纬度对象重叠避让
这些逻辑如果分散在业务文件里,视觉会漂移,后续调参也会变成逐图层修补。`Interactable` 的边界是:公共层负责“图标怎么在地球上稳定显示和被选中”,业务层负责“数据从哪里来、图标表达什么语义、详情卡展示什么、是否有额外动画”。
## 入口
```javascript
import { createInteractableLayer } from "./interactable.js";
```
核心调用形态:
```javascript
const layer = createInteractableLayer({
id: "example",
objectType: "example_object",
renderOrder: 4.4,
altitudeOffset: 0.2,
pointSize: 34,
icon: {
draw(context, options) {
// draw canvas icon
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.kind || "default",
});
```
业务模块通常只暴露一层薄封装:
```javascript
export function getExampleMarkers() {
return layer.getMarkers();
}
export function getExamplePointerIntersections(options) {
return layer.getPointerIntersections(options);
}
export function setExampleMarkerState(marker, state = "normal") {
layer.setMarkerState(marker, state);
}
export function updateExampleVisualState(lockedObjectType, lockedObject, camera) {
layer.updateVisualState(lockedObjectType, lockedObject, camera);
}
```
## 配置参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `id` | 必填 | 图层唯一标识,用于 group name、避让注册和 debug。 |
| `objectType` | `id` | marker 写入 `userData.type` 的业务类型,主交互层用它判断 locked 对象。 |
| `renderOrder` | `4` | 普通 points 和 hover / locked overlay 的基础渲染顺序。 |
| `altitudeOffset` | `0.2` | 业务高度,按 `CONFIG.earthRadius + altitudeOffset` 计算原始地表位置。 |
| `pointSize` | `32` | 基准屏幕像素尺寸。普通 points 和 overlay 都以它为基础。 |
| `sizeMode` | `"fixed"` | 默认固定屏幕尺寸;非 `"fixed"` 时会按相机距离做比例缩放。 |
| `sizeScale` | `{ referenceFov: 75, min: 0.12, max: 3 }` | `sizeMode !== "fixed"` 时的缩放范围。 |
| `atlasCellSize` | `128` | icon canvas texture 尺寸。 |
| `colors` | `{}` | 支持 `normal`、按 kind 的平铺 key以及 `byKind`。 |
| `opacity` | `{ normal: 0.88, dimmed: 0.26, hover: 0.98, locked: 1 }` | 各状态透明度。 |
| `stateScale` | `{ hover: 1, locked: 1, dimmed: 1 }` | 各状态尺寸倍率。 |
| `pulse` | `{}` | locked 态可选呼吸缩放,支持 `enabled``speed``amplitude`。 |
| `avoidance` | `{ enabled: true, precision: 4, radius: 1.1, step: 0.35 }` | 跨 Interactable 的同坐标避让配置。 |
| `icon` | 必填 | 图标来源,支持 canvas draw、SVG / 图片 asset、状态 asset、锚点和后处理。 |
| `getPosition(item)` | 必填 | 返回 `{ latitude, longitude }``THREE.Vector3`。 |
| `getKind(item)` | `item.type || "default"` | 返回业务类型,用于颜色和 texture 分桶。 |
| `getRotationBin(marker)` | `0` | 返回旋转分桶,例如船只按航向分 32 桶。 |
| `getBucketKey(marker)` | `String(getRotationBin(marker))` | 返回 texture / geometry 分桶 key。 |
| `getPointSizeMultiplier(marker)` | `1` | 单 marker 尺寸倍率。BGP 事件按严重级别、观测站按活跃度使用它。 |
| `getUserData(item)` | `item` | 写入 marker 的业务字段。 |
## Icon 配置
`icon.anchor` 可选,默认 `{ x: 0.5, y: 0.5 }`,表示纹理中心对齐 marker 坐标。它只适合小范围的视觉锚点偏移;如果图标主体很大、且需要在地球边缘完整显示,例如登陆点曾使用过的 pin 类图标,不应强行走 `THREE.Points + depthTest`,否则图标主体会被地球深度裁切。
### Canvas 图标
canvas 图标适合船只、BGP 事件这类需要按状态或旋转动态绘制的符号:
```javascript
const vesselIconLayer = createInteractableLayer({
id: "vessels",
objectType: "vessel",
pointSize: 34,
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
if (!marker.userData.anchored) {
context.rotate((rotationBin / 32) * Math.PI * 2);
}
context.fillStyle = color;
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
context.beginPath();
context.moveTo(0, -37);
context.lineTo(28, 32);
context.lineTo(0, 17);
context.lineTo(-28, 32);
context.closePath();
context.fill();
},
},
getRotationBin: getCourseBin,
getBucketKey: (marker) => `${marker.userData.anchored ? "anchored" : "moving"}:${getCourseBin(marker)}`,
});
```
`icon.coordinates !== "canvas"` 时,`Interactable` 会先把 context 平移到 atlas 中心;船只这类自己使用中心坐标绘制的图标不需要声明 `coordinates`
### SVG / 图片 Asset 图标
asset 图标适合算力中心、BGP 观测站这类已有 SVG 的设施图标:
```javascript
const computeCenterIconLayer = createInteractableLayer({
id: "computeCenters",
objectType: "compute_center",
pointSize: 36,
atlasCellSize: 128,
icon: {
coordinates: "canvas",
colorable: false,
fitSize: 60,
glowBlur: 16,
getSource({ marker, item }) {
const siteType = marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return COMPUTE_CENTER_ICON_SOURCES[siteType];
},
afterDraw(context, { marker, item }) {
if (marker?.userData?.is_estimated ?? item?.is_estimated) {
drawComputeCenterEstimatedBadge(context, true);
}
},
},
});
```
使用 asset 时有几个约定:
- SVG / 图片文件放在 `frontend/public/earth/assets/icons/`,以 `/earth/assets/icons/name.svg` 引用。
- 原始 SVG 应尽量保留标准 `viewBox` 和路径,不要为了显示大小写死 transform。
- 显示尺寸由 `icon.fitSize` 控制;它可以是数字、`{ width, height }`,也可以是函数。
- `icon.colorable !== false` 且提供状态颜色时,公共层会先把 asset 画到临时 canvas再用 `source-in` tint 成目标颜色。
- 多色图片或不希望被 tint 的 SVG 应设置 `colorable: false`
## 生命周期
常规加载流程:
```javascript
export async function loadExampleLayer(_scene, earth) {
clearExampleData(earth);
const markerData = await fetchExampleData();
await layer.preloadAssets(markerData);
layer.setData(markerData);
layer.attach(earth);
layer.setVisible(showExampleLayer);
return { totalCount: layer.getCount() };
}
```
各方法职责:
| 方法 | 说明 |
| --- | --- |
| `preloadAssets(items)` | 收集 normal / hover / locked 可能用到的 asset source并用浏览器 `Image` 预加载。canvas draw 图标可跳过。 |
| `setData(items)` | 清理旧 points生成 marker注册避让按 bucket 重建 `THREE.Points`。 |
| `attach(parent)` | 将图层 group 挂到 Earth root。 |
| `setVisible(next)` | 控制 group、points 和 overlay 可见性。 |
| `setMarkerState(marker, state)` | 设置 `normal` / `hover` 等状态并触发视觉状态失效。 |
| `updateVisualState(focusType, focusObject, camera)` | 更新普通态 opacity / size并刷新 hover / locked overlay。 |
| `getPointerIntersections(options)` | 屏幕空间拾取,返回按像素距离排序的命中结果。 |
| `clearData(parent)` | 注销避让、释放 geometry / material、清空 marker 并从 parent 移除 group。 |
## Picking 接入
`Interactable` 不依赖 Three.js 对 `Points` 的默认 raycast。主交互层只要把 Earth、camera、pointer 和命中半径传入:
```javascript
const intersects = getVesselPointerIntersections({
earth,
camera,
pointer,
radiusPx: 22,
width: window.innerWidth,
height: window.innerHeight,
});
```
公共层会做这些事:
1. 把相机位置转到 Earth local 坐标。
2. 跳过背面 marker。
3. 把 marker world position 投影到屏幕坐标。
4.`radiusPx` 做像素距离命中。
5. 返回最近的候选对象。
拖动地球、惯性旋转、hover 节流这些策略仍属于 `main.js`,因为它们和全局输入状态有关。
## 同坐标避让
避让默认开启,作用范围是所有通过 `createInteractableLayer()` 创建的图层。公共层会按经纬度或 `THREE.Vector3` 生成 `icon_avoidance_key`,同 key 的 marker 会沿地表切平面排成小圈。
关键点:
- `icon_base_position` 保留业务原始位置。
- 避让只改渲染位置和 picking 位置,不改业务经纬度。
- 单个 marker 回到原始位置时会直接使用 `altitudeOffset` 计算出的业务贴地位置。
- 多个 marker 同坐标时,第一圈用 `avoidance.radius`,后续每圈加 `avoidance.step`
如果某个业务图层需要严格压在原始点位,可以显式关闭:
```javascript
createInteractableLayer({
id: "strict-layer",
avoidance: { enabled: false },
});
```
## 业务动画边界
`Interactable` 当前只负责图标本体和通用 hover / locked overlay。复杂动画仍放在业务模块里但要跟随 Interactable marker 的位置:
- BGP 事件扩散圈由 `bgp.js` 创建独立 ring sprite并在每帧 `position.copy(marker.position)`
- BGP 观测站 halo、status core、coverage halo 和覆盖扇形由 `bgp.js` 管理,图标本体由 Interactable 管理。
- 船只轨迹线仍由 `vessels.js` 管理,因为它依赖点击后额外加载的轨迹数据。
这个边界能避免通用接口过早承载所有动画类型。后续如果多个图层复用同一类动画,再把它收进 Interactable 的 `animations` 扩展。
## 新图层接入清单
1. 在业务文件中准备 marker data并保留必要的业务字段。
2. 选择 icon 类型canvas draw、SVG / 图片 asset`getSource()` 动态选择。
3. 配置 `pointSize``icon.fitSize``colors``opacity``stateScale`
4. 如果需要业务尺寸差异,提供 `getPointSizeMultiplier()`
5. 如果有旋转,提供 `getRotationBin()` 和稳定的 `getBucketKey()`
6. 加载时先 `preloadAssets()`,再 `setData()``attach()``setVisible()`
7.`main.js` 接入 `getPointerIntersections()`,并复用现有 hover / locked 状态更新流程。
8. 在图层样式索引和渲染顺序文档中记录 altitude、renderOrder、pointSize 和动画层级。

View File

@@ -2,7 +2,7 @@
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
`renderOrder` 等样式属性。层级关系请配合
[earth-render-layer-order.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)
[Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)
查看。
## 命名约定
@@ -80,6 +80,8 @@
## 海陆基座与国界
海陆基座是 Earth 的底图资产随启动预加载图层面板里的“国界线”只控制普通国界线、hover 线和可交互 hover。
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
@@ -146,21 +148,18 @@
| 海缆线宽 | `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.48` | 对齐算力中心贴地表 marker 高度 |
| 登陆点 icon 贴图尺寸 | `CABLE_CONFIG.landingPoint.textureSize` | `256` | canvas 渲染 EPS 参考图的实心 map-pin中间圆孔透明镂空 |
| 登陆点 icon 宽高比 | `CABLE_CONFIG.landingPoint.iconAspectRatio` | `0.82` | `Sprite.scale.x = height * aspect` |
| 登陆点 icon 锚点 | `CABLE_CONFIG.landingPoint.anchorX / anchorY` | `0.52 / 0.276` | `Sprite.center`,将 pin 下端点对齐登陆点经纬度 |
| 登陆点基础缩放 | `CABLE_CONFIG.landingPoint.baseScale` | `12` | 对齐算力中心等地表 icon 的 sprite 高度 |
| 登陆点半径偏移 | `CABLE_CONFIG.landingPoint.altitudeOffset` | `0.2` | 与海缆线同层贴地,避免凌空 |
| 登陆点 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` |
| 登陆点缩放上限 | local `LANDING_POINT_SIZE_SCALE_MAX` | `3` | 远距离时的最大倍率;当前最小缩放约只能到 `2.50` |
| 登陆点 atlas 尺寸 | local `LANDING_POINT_ATLAS_CELL_SIZE` | `128` | canvas 扁平立体球纹理尺寸 |
| 登陆点颜色 | `CABLE_CONFIG.landingPoint.color` | `0xffaa00` | `SpriteMaterial.color` |
| 登陆点 emissive | `CABLE_CONFIG.landingPoint.emissive` | `0x442200` | 兼容旧球体材质sprite 不使用 |
| 登陆点 emissive 强度 | `CABLE_CONFIG.landingPoint.emissiveIntensity` | `0.5` | 兼容旧球体材质sprite 不使用 |
| 登陆点透明度 | `CABLE_CONFIG.landingPoint.opacity` | `1.0` | `SpriteMaterial.opacity` |
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `4.5` | 对齐算力中心地表设施层级 |
| 登陆点 renderOrder | `CABLE_CONFIG.landingPoint.renderOrder` | `1` | 与海缆线同层;`depthTest: false` 保持球体完整,背面通过相机到球心的球体遮挡判断隐藏 |
| 登陆点 dim 亮度系数 | `landingPointVisual.dimBrightness` | `0.62` | dim 状态颜色乘数 |
| 相关登陆点高亮 opacity | `landingPointVisual.related.opacityBase / opacityPulse` | `0.8 / 0.2` | 高亮脉冲 |
| 非相关登陆点颜色 | `landingPointVisual.dimmed.colorRGB` | `{ r: 180, g: 116, b: 28 }` | dim 状态颜色,避免黑色基座透出成暗洞 |
| 非相关登陆点 emissive | `landingPointVisual.dimmed.emissive` | `0x3a2200` | dim 状态弱琥珀自发光 |
| 非相关登陆点 emissive 强度 | `landingPointVisual.dimmed.emissiveIntensity` | `0.18` | dim 状态弱发光强度 |
| 非相关登陆点 opacity | `landingPointVisual.dimmed.opacity` | `0.78` | dim 状态透明度,不再用低 alpha 混黑底 |
## 卫星、轨迹和 footprint
@@ -183,19 +182,42 @@
| 卫星覆盖层 renderOrder | `SATELLITE_CONFIG.overlayRenderOrder` | `12` | locked ring / halo / orbit |
| 自发光选中点颜色 | inline default | `"#ffd25a"` | `showSelfGlowStyle()` |
| 自发光选中点透明度 | inline | `0.96` | locked dot material |
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | footprint fill |
| footprint renderOrder | local `GROUND_FOOTPRINT_RENDER_ORDER` | `3` | Starlink footprint fill 和 Iridium coverage ring必须高于地表 land / texture / terrain 层 |
| footprint group renderOrder | inline | `0` | 避免 Group 排序盖过卫星点 |
## AIS 船只
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.2` | 普通 marker 位置,贴近真实地形基础层 |
| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.2` | 选中船只轨迹线,与船只 marker 同一半径;前端会把轨迹末端锚到当前 marker 位置 |
| 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay |
| 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker |
| 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 |
| 船只纹理画布尺寸 | local `VESSEL_ATLAS_CELL_SIZE` | `128` | canvas 点纹理 |
| 航向分桶数 | local `VESSEL_COURSE_BINS` | `32` | moving 船只按 COG 分桶,降低 draw call 同时保留方向 |
| 船只 hover 拾取节流 | local `VESSEL_HOVER_PICK_INTERVAL_MS` | `100` | `main.js` hover picking |
| 船只屏幕命中半径 | local `VESSEL_POINTER_RADIUS_PX` | `22` | `main.js` 屏幕空间 picking |
| 普通船只透明度 | `VESSEL_CONFIG.marker.baseOpacity` | `0.88` | 普通 `PointsMaterial.opacity` |
| dimmed 船只透明度 | `VESSEL_CONFIG.marker.dimmedOpacity` | `0.26` | 锁定某艘船后其他批次透明度 |
| hover 船只透明度 | inline | `0.98` | hover overlay |
| locked 船只透明度 | inline | `1` | locked overlay |
| 船型颜色 | `VESSEL_CONFIG.colors.*` | cargo / tanker / passenger / fishing / military / other | `PointsMaterial.vertexColors` 和 overlay texture |
AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。航行船只保持三角形,停泊或低速船只保持圆点;普通态不带 glowhover / locked 时在同一屏幕尺寸上叠加带 glow 的单点 overlay。AIS 航向按 `course / cog` 从正北顺时针解释,普通态和交互态必须使用同一套 canvas 旋转规则。
## 算力中心
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 算力中心半径偏移 | `COMPUTE_CENTER_CONFIG.altitudeOffset` | `0.48` | marker 位置 |
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | `SpriteMaterial.opacity` |
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 超算 marker |
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | GPU marker |
| hover 缩放 | `COMPUTE_CENTER_CONFIG.marker.hoverScale` | `1.16` | hover 状态 |
| locked 缩放 | `COMPUTE_CENTER_CONFIG.marker.lockedScale` | `1.22` | locked 状态 |
| 算力中心点像素尺寸 | local `COMPUTE_CENTER_POINT_SIZE` | `36` | `Interactable` 普通 marker 与 hover / locked overlay 共享基准尺寸 |
| 算力中心 asset fit size | local `COMPUTE_CENTER_ICON_FIT_SIZE` | `60` | SVG asset 在 `128x128` atlas canvas 内的最大绘制尺寸,由 `icon.fitSize` 控制 |
| 算力中心基础透明度 | `COMPUTE_CENTER_CONFIG.marker.baseOpacity` | `0.88` | 普通 `PointsMaterial.opacity` |
| 超算 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.supercomputerScale` | `12` | 旧 Sprite 缩放参数;当前 Interactable 路径不再直接使用 |
| GPU 集群 marker 缩放 | `COMPUTE_CENTER_CONFIG.marker.gpuClusterScale` | `12` | 旧 Sprite 缩放参数;当前 Interactable 路径不再直接使用 |
| 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 |
@@ -206,12 +228,16 @@
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `2.1` | anomaly marker |
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `1.6` | collector marker |
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | anomaly sprite |
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | collector plane |
| BGP 事件半径偏移 | `BGP_CONFIG.altitudeOffset` | `0.48` | BGP 事件 Interactable marker |
| BGP collector 半径偏移 | `BGP_CONFIG.collectorAltitudeOffset` | `0.2` | BGP 观测站 Interactable marker与船只同层贴地 |
| BGP 事件点像素尺寸 | local `BGP_EVENT_POINT_SIZE` | `34` | 事件 icon 的 Interactable 基准尺寸,按严重级别通过 `getPointSizeMultiplier()` 调整 |
| BGP 事件符号绘制尺寸 | local `BGP_EVENT_SYMBOL_SIZE` | `60` | 事件 canvas 符号在 `128x128` atlas 中的绘制尺寸 |
| BGP collector 点像素尺寸 | local `BGP_COLLECTOR_POINT_SIZE` | `36` | 观测站 Interactable 基准尺寸,按活跃度通过 `getPointSizeMultiplier()` 调整 |
| BGP collector asset fit size | local `BGP_COLLECTOR_ICON_FIT_SIZE` | `60` | `bgp-broadcast-pin.svg` 在 atlas canvas 内的最大绘制尺寸 |
| 事件基础缩放 | `BGP_CONFIG.marker.eventBaseScale` | `6.2` | 事件扩散圈锚点 |
| collector 基础缩放 | `BGP_CONFIG.marker.collectorBaseScale` | `7.4` | 观测站 halo / 覆盖动画锚点 |
| hover / dim 缩放 | `hoverScale / dimmedScale` | `1.16 / 0.92` | 交互状态 |
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | anomaly sprite |
| 普通事件透明度 | `BGP_CONFIG.opacity.normal` | `0.78` | BGP 事件 Interactable 普通态 |
| hover 透明度 | `BGP_CONFIG.opacity.hover` | `1.0` | hover 状态 |
| dimmed 透明度 | `BGP_CONFIG.opacity.dimmed` | `0.24` | dim 状态 |
| collector 透明度 | `BGP_CONFIG.opacity.collector` | `0.62` | collector 状态 |
@@ -223,8 +249,8 @@
| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 |
| BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring |
| BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring |
| collector marker renderOrder | inline | `3` | `marker.renderOrder` |
| anomaly marker renderOrder | inline | `5` normal, `7` active | `marker.renderOrder` |
| collector marker renderOrder | local `BGP_COLLECTOR_RENDER_ORDER` | `4.4` | 观测站主图标,与船只同层 |
| anomaly marker renderOrder | local `BGP_EVENT_RENDER_ORDER` | `4.5` | BGP 事件主图标,与算力中心同层 |
## 天体与星空

View File

@@ -7,8 +7,8 @@
| 顺序类型 | 当前顺序 | 说明 |
| --- | --- | --- |
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
| 注册 / 启动加载顺序 | 经纬线 → 国界 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;船只和卫星默认关闭,只有可见时参与启动加载;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 |
| 注册 / 启动加载顺序 | 经纬线 → 国界线 / 海陆基座 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;启动队列会先读取保存的图层可见状态,明确关闭的普通图层不预加载,高清材质关闭时不下载贴图;国界线图层例外,海陆基座始终预加载,保存状态只控制可交互国界线和 hover;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 |
## 地表图层栈
@@ -21,17 +21,17 @@
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
| 1 | 海缆 | `cables.js` | `CABLE_CONFIG.line.renderOrder` | 海缆拾取路径 | 保持现有海缆层级。 |
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 |
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
| 3 | 卫星 footprint 填充 | `satellites.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedGroup renderOrder 保持 0 | Footprint 在国界线之上,但在算力中心和卫星之下。 |
| 3-5 | BGP 标记和覆盖层 | `bgp.js` | 各 marker 自身 renderOrder | BGP 拾取路径 | 保持现有 BGP 视觉层级。 |
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-testedIridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 |
| 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4``BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking并参与同坐标避让 | BGP 观测站主图标与船只同层BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1``CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
| 4.4 | AIS 船只 marker | `vessels.js` | `VESSEL_RENDER_ORDER``CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset` | 船只拾取路径;只取正面 marker | 航行船只用三角 sprite,停泊/低速用圆点;低于算力中心 `4.5`。 |
| 4.5 | 算力中心 | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | 算力中心拾取路径 | 地表设施,保持在卫星下方。 |
| 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参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow交互态叠加同尺寸 glow低于算力中心 `4.5`。 |
| 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking参与同坐标避让 | 地表设施,保持在卫星下方。登陆点已下沉到海缆层。 |
| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 |
| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
@@ -45,7 +45,7 @@
| 高清材质 on | 恢复高清材质,并恢复记住的地形 / 昼夜状态。 |
| 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 |
| 大气云图 | 只控制云图 mesh 显隐。 |
| 国界 | 控制国界线和 hover 线显隐;海陆基座填充独立存在,作为 Earth 基座地图使用。 |
| 国界线 off | 只隐藏可交互国界线和 hover,高亮状态会清除;海陆基座填充作为 Earth 底图保留。 |
## 交互规则
@@ -57,4 +57,4 @@
| 中国 / 台湾 hover | `CHN``TWN` 被归到同一个 hover 高亮组tooltip 仍显示鼠标实际命中的 feature。 |
| 地形 | 只作为视觉层参与,`terrain.raycast` 已禁用。 |
| 卫星 | 使用屏幕空间卫星拾取,避免 footprint 或地表层挡住卫星点击。 |
| 船只 | 使用 sprite marker 拾取,并在 `main.js` 中先过滤正面船只;点击后可加载轨迹线。 |
| 船只 | 使用对象级 sprite raycast。`main.js` 会在拖动 / 惯性期间跳过 hover picking平时将正面船只投影到屏幕坐标用像素半径命中最近船只;点击后可加载轨迹线。 |

View File

@@ -4,8 +4,8 @@
相关上下文:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)

View File

@@ -4,8 +4,8 @@
相关规则建议一起参考:
- [rules.md](/home/ray/dev/linkong/planet/rules.md)
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
- [项目规则](/home/ray/dev/linkong/planet/rules.md)
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
## 当前目标
@@ -292,7 +292,7 @@
相关后端设计见:
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
### 3. 复杂工作区页面
@@ -320,4 +320,4 @@
详细经验见:
- [frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)

View File

@@ -7,7 +7,7 @@
- 控制台:登录后的管理后台
- Docs公开开发文档与使用手册
快速启动路径见 [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。
快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。
## 入口总览
@@ -55,6 +55,52 @@
| `--allow-lan` | 允许局域网访问 |
| `--verbose` | 在执行过程中显示更多命令输出 |
### AI Provider 环境变量和构建
AI Provider 的运行期配置可以放在两处:
| 位置 | 适合内容 | 说明 |
| --- | --- | --- |
| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 会作为 `env_file` 读取 |
| `~/.zshrc` | 个人机器上的 provider、模型、密钥和代理变量 | `planet.sh` 启动时会读取常见的 `AI_*``SERVICE_*``PYTHON_IMAGE``UV_IMAGE`、代理变量 |
推荐写法:
```bash
export AI_PROVIDER=minimax
export AI_PROVIDER_API=anthropic-messages
export AI_BASE_URL=https://api.example.com/anthropic
export AI_API_KEY=sk-change-me
export AI_MODEL=MiniMax-M2.7
export AI_PROVIDER_SERVICE_TOKEN=change_me
```
默认情况下,`planet.sh` 只静态解析 `~/.zshrc` 中简单的 `export KEY=value``KEY=value` 行,避免 shell 主题、插件或交互初始化拖慢启动。如果变量依赖复杂 shell 展开,可以显式启用 source 模式:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
如需完全忽略 `~/.zshrc`
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改 `aiprovider/.env``~/.zshrc` 中的模型、密钥、Base URL 不会触发镜像重建;重启 AI Provider 即可让容器读取新配置:
```bash
./planet.sh restart -a
```
构建较慢时,优先判断当前卡在哪一层:
| 现象 | 常见原因 | 处理方式 |
| --- | --- | --- |
| `transferring context` 很大 | Docker build context 包含前端资源、PDF、数据目录等无关文件 | 当前仓库通过 `.dockerignore` 只发送 AI Provider 必需文件 |
| `uv sync` 下载依赖较慢 | 首次构建或缓存为空,网络访问 Python 包较慢 | 等待首次构建完成;后续会复用 BuildKit 的 uv 下载缓存 |
| 改密钥后仍显示旧配置 | 容器尚未重启 | 执行 `./planet.sh restart -a` |
### 停止
```bash
@@ -178,7 +224,7 @@ Earth 用于在一个地球视图中观察:
- 卫星和轨迹
- 海缆与登陆点
- 算力中心
- 国界、经纬线、高清材质、云图、地形
- 国界线、经纬线、高清材质、云图、地形
- 新闻直播和态势新闻
- 搜索和聚焦对象详情
@@ -189,7 +235,7 @@ Earth 用于在一个地球视图中观察:
常见图层包括:
- 经纬线
- 国界
- 国界线
- 高清材质
- 大气云图
- 海缆
@@ -214,7 +260,7 @@ Earth 用于在一个地球视图中观察:
- 海缆
- 卫星
- 国界
- 国界线
- 算力中心
- BGP
- AIS 船只
@@ -583,10 +629,10 @@ source ~/.zshrc && bun run build
## 相关文档
- [quickstart.md](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)
- [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- [earth-layer-style-reference.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md)
- [backend-system-service-control.md](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- [datasource-collector-settings-connectivity.md](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)
- [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- [Earth 图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md)
- [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md)
- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)

View File

@@ -46,6 +46,8 @@ compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
@@ -57,6 +59,58 @@ compute_ai_provider_build_fingerprint() {
速度提升约 10 倍大量小文件场景误报率相同mtime+size 变化 ≡ 文件被修改)。
`.env``.env.*` 被排除在 fingerprint 外。它们属于运行期配置,不应该因为修改模型、密钥或 Base URL 触发镜像重建。
### Docker build context 收敛
AI Provider 镜像只需要根目录的 `pyproject.toml``uv.lock``aiprovider/` 代码。仓库中还包含前端静态大图、PDF、历史数据和 Unreal 资料,如果 build context 使用整个仓库,`transferring context` 会浪费大量时间。
当前通过根目录 `.dockerignore` 收敛上下文:
```dockerignore
**
!pyproject.toml
!uv.lock
!aiprovider/
!aiprovider/**
aiprovider/.env
aiprovider/.env.*
!aiprovider/.env.example
```
Dockerfile 也从全仓复制改为只复制 AI Provider 代码:
```dockerfile
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
COPY aiprovider /app/aiprovider
```
`uv sync` 使用 BuildKit cache mount 后,首次构建仍可能受网络影响;后续构建会复用 `/root/.cache/uv`,依赖下载不再重复从零开始。
### 运行期配置来源
`planet.sh` 启动 AI Provider 前会生成临时 env-file并把它传给 Compose 或手动 `docker run` fallback。配置优先来自
1. `aiprovider/.env`
2. `~/.zshrc` 中简单的 `export AI_...=...``AI_...=...`
默认解析是静态的,只覆盖 AI Provider、镜像、代理相关变量避免执行交互 shell 初始化。如果确实需要复杂 shell 展开,可以显式启用:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
如果排查时需要忽略个人 shell 配置:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
### 跳过重建的原理
fingerprint 一致时不执行 `docker compose build`,而是:
@@ -151,4 +205,7 @@ PY
## 相关文件
- `planet.sh` — 全量修改
- `.dockerignore` — 收敛 AI Provider Docker build context
- `aiprovider/Dockerfile` — 只复制 AI Provider 代码,并为 `uv sync` 启用 BuildKit cache mount
- `docker-compose.yml` / `docker-compose.simple.yml` — 读取 `planet.sh` 生成的运行期 env-file
- `scripts/compute_aiprovider_dependency_fingerprint.py` — 依赖 fingerprint未改动

View File

@@ -24,6 +24,12 @@
- `aiprovider/.env`
- `frontend/.env.local`
AI Provider 的个人配置也可以放在 `~/.zshrc``planet.sh` 会读取简单的 `export AI_...=...``AI_...=...` 行,并在启动 AI Provider 时传给容器。修改模型、密钥或 Base URL 后,通常只需要重启 AI Provider
```bash
./planet.sh restart -a
```
## 1. 启动服务
在仓库根目录执行:
@@ -188,7 +194,7 @@ ss -ltnp | grep -E ':3000|:8000'
## 下一步
- 完整操作说明见 [manual.md](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
- 控制台结构见 [frontend-admin-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- Earth 结构见 [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- 后端采集器见 [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- 完整操作说明见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
- 控制台结构见 [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- Earth 结构见 [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- 后端采集器见 [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)

View File

@@ -16,12 +16,19 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.44.0`
- `dev` 当前开发分支历史推导到:`0.46.3`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.46.3` | bugfix | `dev` | `pending` | 优化 Starlink footprint 拖拽性能,避免旋转地球时重复重建覆盖网格,并恢复线缆点击呼吸动画 |
| `0.46.2` | bugfix | `dev` | `pending` | 修复 Earth 启动加载顺序、图层 localStorage 恢复、国界线底图语义、媒体面板、船只轨迹和 Iridium footprint 显示问题,并补充 AIS 聚合计划 |
| `0.46.1` | bugfix | `dev` | `pending` | 修复新增 Docs 技术文档未进前端白名单导致页面不可访问的问题,补齐英文文档并固化白名单/双语/裸文件标题检查 |
| `0.46.0` | feature | `dev` | `pending` | Earth 新增通用 Interactable 图标层统一船只、算力中心、BGP 事件/观测站交互图标,并优化登陆点与 toolbar 初始渲染 |
| `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context |
| `0.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 |
| `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 |
| `0.44.0` | feature | `dev` | `pending` | 重构数据源目录与采集器设置,新增 BarentsWatch AIS 连接教程、Earth 船只/缩放体验优化和仪表盘前端重启 |
| `0.43.1` | bugfix | `dev` | `pending` | 修正全量 restart 后 AI Provider 启动提示语义,避免把预期未就绪描述成异常 |
| `0.43.0` | feature | `dev` | `pending` | 新增 Earth 船舶追踪、自定义数据源映射、外部集成配置中心、Markdown 渲染器增强,并整理规则/技能文档加载约束 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.44.0",
"version": "0.46.3",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ffffff" viewBox="0 0 16 16">
<path d="M3.05 3.05a7 7 0 0 0 0 9.9.5.5 0 0 1-.707.707 8 8 0 0 1 0-11.314.5.5 0 0 1 .707.707zm2.122 2.122a4 4 0 0 0 0 5.656.5.5 0 1 1-.708.708 5 5 0 0 1 0-7.072.5.5 0 0 1 .708.708zm5.656-.708a.5.5 0 0 1 .708 0 5 5 0 0 1 0 7.072.5.5 0 1 1-.708-.708 4 4 0 0 0 0-5.656.5.5 0 0 1 0-.708zm2.122-2.12a.5.5 0 0 1 .707 0 8 8 0 0 1 0 11.313.5.5 0 0 1-.707-.707 7 7 0 0 0 0-9.9.5.5 0 0 1 0-.707zM6 8a2 2 0 1 1 2.5 1.937V15.5a.5.5 0 0 1-1 0V9.937A2 2 0 0 1 6 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -1,16 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- GPU cluster marker: database/cylinder stack icon. -->
<!-- Color: #2dd4bf (teal) per COMPUTE_CENTER_CONFIG.colors.gpu_cluster -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<!-- Outer cylinder: top ellipse cap + side rect + bottom half-ellipse -->
<!-- Inner groove ring: smaller cylinder shape overlaid at same color (subtle shape layering) -->
<g fill="#2dd4bf">
<rect x="46" y="46" width="36" height="28"/>
<ellipse cx="64" cy="46" rx="18" ry="8"/>
<path d="M 82,74 A 18,8 0 0,1 46,74 Z"/>
<rect x="52" y="58" width="24" height="6"/>
<ellipse cx="64" cy="58" rx="12" ry="4.5"/>
<path d="M 76,64 A 12,4.5 0 0,1 52,64 Z"/>
</g>
<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>

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -1,10 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Supercomputer marker: flat-screen monitor with neck and base stand. -->
<!-- Color: #38bdf8 (sky-blue) per COMPUTE_CENTER_CONFIG.colors.supercomputer -->
<!-- States: normal, estimated (adds a "?" badge drawn separately at canvas level) -->
<g fill="#38bdf8">
<rect x="40" y="42" width="48" height="30" rx="7"/>
<rect x="58" y="74" width="12" height="8" rx="3"/>
<rect x="50" y="84" width="28" height="5" rx="2.5"/>
</g>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" viewBox="0 0 16 16">
<path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v7A1.5 1.5 0 0 0 1.5 10H6v1H1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5v-1h4.5A1.5 1.5 0 0 0 16 8.5v-7A1.5 1.5 0 0 0 14.5 0h-13Zm0 1h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM12 12.5a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm2 0a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0ZM1.5 12h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1ZM1 14.25a.25.25 0 0 1 .25-.25h5.5a.25.25 0 1 1 0 .5h-5.5a.25.25 0 0 1-.25-.25Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-geo-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 4a4 4 0 1 1 4.5 3.969V13.5a.5.5 0 0 1-1 0V7.97A4 4 0 0 1 4 3.999zm2.493 8.574a.5.5 0 0 1-.411.575c-.712.118-1.28.295-1.655.493a1.319 1.319 0 0 0-.37.265.301.301 0 0 0-.057.09V14l.002.008a.147.147 0 0 0 .016.033.617.617 0 0 0 .145.15c.165.13.435.27.813.395.751.25 1.82.414 3.024.414s2.273-.163 3.024-.414c.378-.126.648-.265.813-.395a.619.619 0 0 0 .146-.15.148.148 0 0 0 .015-.033L12 14v-.004a.301.301 0 0 0-.057-.09 1.318 1.318 0 0 0-.37-.264c-.376-.198-.943-.375-1.655-.493a.5.5 0 1 1 .164-.986c.77.127 1.452.328 1.957.594C12.5 13 13 13.4 13 14c0 .426-.26.752-.544.977-.29.228-.68.413-1.116.558-.878.293-2.059.465-3.34.465-1.281 0-2.462-.172-3.34-.465-.436-.145-.826-.33-1.116-.558C3.26 14.752 3 14.426 3 14c0-.599.5-1 .961-1.243.505-.266 1.187-.467 1.957-.594a.5.5 0 0 1 .575.411z"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -24,16 +24,16 @@
}
.earth-toolbar {
--toolbar-scale: 1;
--toolbar-orb-size: calc(46px * var(--toolbar-scale));
--toolbar-hub-size: calc(58px * var(--toolbar-scale));
--toolbar-arc-width: calc(420px * var(--toolbar-scale));
--toolbar-arc-height: calc(160px * var(--toolbar-scale));
--toolbar-inner-arc-width: calc(260px * var(--toolbar-scale));
--toolbar-inner-arc-height: calc(56px * var(--toolbar-scale));
--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)));
position: relative;
width: min(620px, calc(100vw - 40px));
height: calc(200px * var(--toolbar-scale));
height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale)));
display: flex;
align-items: center;
justify-content: center;
@@ -155,7 +155,8 @@
height: var(--toolbar-orb-size);
min-width: var(--toolbar-orb-size);
min-height: var(--toolbar-orb-size);
border-radius: 50%;
aspect-ratio: 1 / 1;
border-radius: 9999px;
overflow: hidden;
}
@@ -164,7 +165,8 @@
height: var(--toolbar-hub-size);
min-width: var(--toolbar-hub-size);
min-height: var(--toolbar-hub-size);
border-radius: 50%;
aspect-ratio: 1 / 1;
border-radius: 9999px;
overflow: hidden;
color: var(--hud-title);
}

View File

@@ -44,6 +44,87 @@
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">

View File

@@ -1,10 +1,13 @@
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group();
const bgpEventOverlayGroup = new THREE.Group();
const bgpCollectorRadarGroup = new THREE.Group();
const collectorMarkers = [];
const anomalyMarkers = [];
const activeEventCountByCollector = new Map();
@@ -14,7 +17,6 @@ let totalAnomalyCount = 0;
let totalIncidentCount = 0;
let textureCache = null;
let eventRingTextureCache = null;
let collectorTextureCache = null;
const eventTextureCache = new Map();
let activeEventOverlay = null;
let activeCollectorOverlayContext = null;
@@ -22,17 +24,27 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
});
const collectorWorldPosition = new THREE.Vector3();
const collectorSurfaceNormal = new THREE.Vector3();
const collectorNorthPole = new THREE.Vector3(0, 1, 0);
const collectorFallbackForward = new THREE.Vector3(0, 0, 1);
const collectorNorthTangent = new THREE.Vector3();
const collectorEastTangent = new THREE.Vector3();
const collectorOrientationMatrix = new THREE.Matrix4();
const colorScratchA = new THREE.Color();
const colorScratchB = new THREE.Color();
const COLLECTOR_SCAN_SPEED_RAD = 0.00018;
const COLLECTOR_SCAN_REBUILD_MS = 80;
const MATERIAL_ACCESS_POINT_PATH = "M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2";
const BGP_EVENT_RENDER_ORDER = 4.5;
const BGP_EVENT_POINT_SIZE = 34;
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_SOURCE = "/earth/assets/icons/bgp-broadcast-pin.svg";
const BGP_COLLECTOR_HOVER_SCALE = 1.08;
const BGP_COLLECTOR_LOCKED_SCALE = 1.12;
const BGP_COLLECTOR_PULSE_AMPLITUDE = 0.14;
bgpOverlayGroup.name = "bgp-overlay-root";
bgpEventOverlayGroup.name = "bgp-event-overlay-layer";
bgpCollectorRadarGroup.name = "bgp-collector-radar-layer";
bgpOverlayGroup.add(bgpEventOverlayGroup);
bgpOverlayGroup.add(bgpCollectorRadarGroup);
function getMarkerTexture() {
if (textureCache) return textureCache;
@@ -85,48 +97,6 @@ function getEventRingTexture() {
return eventRingTextureCache;
}
function getCollectorTexture() {
if (collectorTextureCache) return collectorTextureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
collectorTextureCache = new THREE.Texture(canvas);
return collectorTextureCache;
}
context.clearRect(0, 0, 128, 128);
context.strokeStyle = BGP_CONFIG.collectorIcon.ringStroke;
context.lineWidth = BGP_CONFIG.collectorIcon.ringLineWidth;
context.beginPath();
context.arc(64, 64, BGP_CONFIG.collectorIcon.ringRadius, 0, Math.PI * 2);
context.stroke();
context.save();
context.translate(16, 16);
context.scale(4, 4);
const path = new Path2D(MATERIAL_ACCESS_POINT_PATH);
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = BGP_CONFIG.collectorIcon.pathLineWidth;
context.strokeStyle = BGP_CONFIG.collectorIcon.pathStroke;
context.stroke(path);
context.fillStyle = BGP_CONFIG.collectorIcon.pathFill;
context.shadowBlur = 0;
context.fill(path);
context.fillStyle = BGP_CONFIG.collectorIcon.centerFill;
context.beginPath();
context.arc(12, 12, BGP_CONFIG.collectorIcon.centerRadius, 0, Math.PI * 2);
context.fill();
context.restore();
collectorTextureCache = new THREE.CanvasTexture(canvas);
return collectorTextureCache;
}
function getEventSymbolKind(anomalyType) {
const value = String(anomalyType || "").toLowerCase();
if (value.includes("origin")) return "triangle";
@@ -266,6 +236,169 @@ function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
}
function severityColorHex(severity) {
return `#${getSeverityColor(severity).toString(16).padStart(6, "0")}`;
}
function colorNumberHex(colorNumber) {
return `#${Number(colorNumber || 0xffffff).toString(16).padStart(6, "0")}`;
}
function drawBGPEventIcon(context, { marker, color = "#ffffff", glow = false }) {
const kind = getEventSymbolKind(
marker?.userData?.incident_type || marker?.userData?.anomaly_type,
);
context.save();
context.fillStyle = color;
context.strokeStyle = color;
context.lineJoin = "round";
context.lineCap = "round";
context.shadowColor = color;
context.shadowBlur = glow ? 14 : 0;
const inset = (128 - BGP_EVENT_SYMBOL_SIZE) / 2;
context.translate(inset, inset);
context.scale(BGP_EVENT_SYMBOL_SIZE / 128, BGP_EVENT_SYMBOL_SIZE / 128);
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);
}
context.restore();
}
const bgpEventIconLayer = createInteractableLayer({
id: "bgp-events",
objectType: "bgp",
renderOrder: BGP_EVENT_RENDER_ORDER,
altitudeOffset: BGP_CONFIG.altitudeOffset,
pointSize: BGP_EVENT_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.keys(BGP_CONFIG.severityColors).map((severity) => [
severity,
severityColorHex(severity),
]),
),
normal: severityColorHex("medium"),
},
opacity: {
normal: BGP_CONFIG.opacity.normal,
hover: BGP_CONFIG.opacity.hover,
locked: BGP_CONFIG.opacity.lockedMax,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_CONFIG.marker.hoverScale,
locked: BGP_CONFIG.marker.hoverScale,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.eventSpeed,
amplitude: BGP_CONFIG.pulse.lockedAmplitude,
},
icon: {
coordinates: "canvas",
draw: drawBGPEventIcon,
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => normalizeSeverity(item.severity),
getBucketKey: (marker) => [
getEventSymbolKind(marker.userData?.incident_type || marker.userData?.anomaly_type),
normalizeSeverity(marker.userData?.severity),
].join(":"),
getPointSizeMultiplier: (marker) => getSeverityScale(marker.userData?.severity),
getUserData: (item) => ({
...item,
baseScale: BGP_CONFIG.marker.eventBaseScale * getSeverityScale(item.severity),
baseColor: getSeverityColor(item.severity),
pulseOffset: Math.random() * Math.PI * 2,
}),
});
const bgpCollectorIconLayer = createInteractableLayer({
id: "bgp-collectors",
objectType: "bgp_collector",
renderOrder: BGP_COLLECTOR_RENDER_ORDER,
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
pointSize: BGP_COLLECTOR_POINT_SIZE,
colors: {
byKind: Object.fromEntries(
Object.entries(BGP_CONFIG.collectorHeatColors).map(([tier, color]) => [
tier,
colorNumberHex(color),
]),
),
normal: colorNumberHex(BGP_CONFIG.collectorColor),
},
opacity: {
normal: BGP_CONFIG.collectorIcon.idleOpacity,
hover: 0.98,
locked: 1,
dimmed: BGP_CONFIG.opacity.dimmed,
},
stateScale: {
hover: BGP_COLLECTOR_HOVER_SCALE,
locked: BGP_COLLECTOR_LOCKED_SCALE,
dimmed: BGP_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: BGP_CONFIG.pulse.collectorSpeed,
amplitude: BGP_COLLECTOR_PULSE_AMPLITUDE,
},
icon: {
coordinates: "canvas",
fitSize: BGP_COLLECTOR_ICON_FIT_SIZE,
glowBlur: 16,
source: BGP_COLLECTOR_ICON_SOURCE,
},
getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => getCollectorActivityProfile(item).tier,
getBucketKey: (marker) => {
const scaleBoost = marker.userData?.activity?.scaleBoost ?? 1;
return `${marker.userData?.activity?.tier || "idle"}:${scaleBoost.toFixed(2)}`;
},
getPointSizeMultiplier: (marker) => marker.userData?.activity?.scaleBoost ?? 1,
getUserData: (item) => {
const activity = getCollectorActivityProfile(item);
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
return {
...item,
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
};
},
});
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
@@ -281,7 +414,7 @@ function getCollectorDistanceScale(marker, camera) {
if (!marker || !camera || BGP_CONFIG.sizeStabilization?.enabled === false) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: BGP_CONFIG.collectorAltitudeOffset,
altitudeOffset: BGP_COLLECTOR_ALTITUDE_OFFSET,
referenceFov: 75,
min: Number(BGP_CONFIG.sizeStabilization?.collectorMin ?? 0.6),
max: Number(BGP_CONFIG.sizeStabilization?.collectorMax ?? 1.9),
@@ -299,32 +432,6 @@ function getEventDistanceScale(marker, camera) {
});
}
function orientCollectorMarkerToSurface(marker, position) {
collectorSurfaceNormal.copy(position).normalize();
collectorNorthTangent
.copy(collectorNorthPole)
.projectOnPlane(collectorSurfaceNormal);
if (collectorNorthTangent.lengthSq() < 1e-6) {
collectorNorthTangent
.copy(collectorFallbackForward)
.projectOnPlane(collectorSurfaceNormal);
}
collectorNorthTangent.normalize();
collectorEastTangent
.copy(collectorNorthTangent)
.cross(collectorSurfaceNormal)
.normalize();
collectorOrientationMatrix.makeBasis(
collectorEastTangent,
collectorNorthTangent,
collectorSurfaceNormal,
);
marker.quaternion.setFromRotationMatrix(collectorOrientationMatrix);
}
function getCollectorActivityProfile(markerData) {
const recent24h = Number(markerData?.recent_24h_observation_count || 0);
const recent7d = Number(markerData?.recent_7d_observation_count || 0);
@@ -784,13 +891,26 @@ function clearMarkerArray(markers) {
const marker = markers.pop();
while (marker.children.length > 0) {
const child = marker.children.pop();
child.geometry?.dispose?.();
child.material?.dispose();
}
disposeCollectorEffectSprites(marker);
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
marker.material?.dispose();
bgpGroup.remove(marker);
marker.parent?.remove?.(marker);
}
}
function disposeAnomalyRingSprites() {
anomalyMarkers.forEach((marker) => {
disposeEventRingSprite(marker.userData?.ringA);
disposeEventRingSprite(marker.userData?.ringB);
delete marker.userData.ringA;
delete marker.userData.ringB;
});
}
function clearGroup(group) {
while (group.children.length > 0) {
const child = group.children[group.children.length - 1];
@@ -979,46 +1099,14 @@ function createRadialBoundaryPoints(
return points;
}
function createCollectorMarker(markerData) {
const activity = getCollectorActivityProfile(markerData);
const baseColor = activity.color;
const idleColor = blendHexColors(
BGP_CONFIG.collectorIcon.idleBaseColor,
baseColor,
BGP_CONFIG.collectorIcon.idleBlend,
);
const marker = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({
map: getCollectorTexture(),
color: idleColor,
transparent: true,
opacity: BGP_CONFIG.collectorIcon.idleOpacity,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
}),
);
const position = latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
);
marker.position.copy(position);
marker.scale.set(BGP_CONFIG.marker.collectorBaseScale * 0.88 * activity.scaleBoost, BGP_CONFIG.marker.collectorBaseScale * 1.08 * activity.scaleBoost, 1);
marker.renderOrder = 3;
marker.visible = showBGP;
orientCollectorMarkerToSurface(marker, position);
function attachCollectorEffectSprites(marker) {
const activity = marker.userData.activity;
const heatHalo = createOverlaySprite({
color: activity.color,
opacity: 0.0,
scale: activity.haloScale * 0.58,
});
heatHalo.renderOrder = 1;
marker.add(heatHalo);
const pulseHalo = createOverlaySprite({
color: activity.color,
@@ -1026,7 +1114,6 @@ function createCollectorMarker(markerData) {
scale: activity.pulseHaloScale * 0.48,
});
pulseHalo.renderOrder = 0;
marker.add(pulseHalo);
const statusCore = createOverlaySprite({
color: activity.color,
@@ -1037,9 +1124,7 @@ function createCollectorMarker(markerData) {
BGP_CONFIG.marker.collectorStatusCoreMinScale,
),
});
statusCore.position.set(0, 0, 0.02);
statusCore.renderOrder = 4;
marker.add(statusCore);
const coverageHalo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
@@ -1048,65 +1133,52 @@ function createCollectorMarker(markerData) {
});
coverageHalo.renderOrder = 0;
coverageHalo.scale.set(activity.coverageHaloScale * 0.82, activity.coverageHaloScale * 0.56, 1);
marker.add(coverageHalo);
marker.userData = {
type: "bgp_collector",
state: "normal",
baseScale: BGP_CONFIG.marker.collectorBaseScale * activity.scaleBoost,
baseColor,
idleColor,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
activity,
heatHalo,
pulseHalo,
statusCore,
coverageHalo,
...markerData,
};
marker.userData.heatHalo = heatHalo;
marker.userData.pulseHalo = pulseHalo;
marker.userData.statusCore = statusCore;
marker.userData.coverageHalo = coverageHalo;
collectorMarkers.push(marker);
bgpGroup.add(marker);
[heatHalo, pulseHalo, statusCore, coverageHalo].forEach((sprite) => {
sprite.position.copy(marker.position);
sprite.visible = showBGP;
bgpGroup.add(sprite);
});
}
function createAnomalyMarker(markerData) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventTexture(markerData.incident_type || markerData.anomaly_type),
color: getSeverityColor(markerData.severity),
transparent: true,
opacity: BGP_CONFIG.opacity.normal,
depthWrite: false,
depthTest: true,
blending: THREE.NormalBlending,
}),
);
function disposeCollectorEffectSprites(marker) {
[
marker?.userData?.heatHalo,
marker?.userData?.pulseHalo,
marker?.userData?.statusCore,
marker?.userData?.coverageHalo,
].forEach((sprite) => {
if (!sprite) return;
sprite.parent?.remove?.(sprite);
sprite.material?.dispose?.();
sprite.geometry?.dispose?.();
});
}
const position = latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
);
async function setCollectorMarkers(markerData, earth) {
collectorMarkers.forEach(disposeCollectorEffectSprites);
collectorMarkers.length = 0;
await bgpCollectorIconLayer.preloadAssets(markerData);
bgpCollectorIconLayer.setData(markerData);
bgpCollectorIconLayer.attach(earth);
bgpCollectorIconLayer.setVisible(showBGP);
const baseScale = BGP_CONFIG.marker.eventBaseScale * getSeverityScale(markerData.severity);
sprite.position.copy(position);
sprite.scale.setScalar(baseScale);
sprite.renderOrder = 5;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp",
state: "normal",
baseScale,
baseColor: getSeverityColor(markerData.severity),
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
bgpCollectorIconLayer.getMarkers().forEach((marker) => {
attachCollectorEffectSprites(marker);
collectorMarkers.push(marker);
});
}
const ringA = new THREE.Sprite(
function createEventRingSprite(marker) {
const ring = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventRingTexture(),
color: getSeverityColor(markerData.severity),
color: marker.userData.baseColor || getSeverityColor(marker.userData.severity),
transparent: true,
opacity: 0,
depthWrite: false,
@@ -1114,30 +1186,40 @@ function createAnomalyMarker(markerData) {
blending: THREE.AdditiveBlending,
}),
);
ringA.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleA);
ringA.position.set(0, 0, -0.01);
sprite.add(ringA);
ring.position.copy(marker.position);
ring.renderOrder = BGP_EVENT_RENDER_ORDER - 0.05;
ring.visible = showBGP;
return ring;
}
const ringB = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getEventRingTexture(),
color: getSeverityColor(markerData.severity),
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
}),
);
ringB.scale.setScalar(baseScale * BGP_CONFIG.ring.scaleB);
ringB.position.set(0, 0, -0.02);
sprite.add(ringB);
function disposeEventRingSprite(ring) {
if (!ring) return;
ring.parent?.remove?.(ring);
ring.material?.dispose?.();
ring.geometry?.dispose?.();
}
sprite.userData.ringA = ringA;
sprite.userData.ringB = ringB;
function attachAnomalyRingSprites(marker) {
const ringA = createEventRingSprite(marker);
const ringB = createEventRingSprite(marker);
ringB.visible = false;
marker.userData.ringA = ringA;
marker.userData.ringB = ringB;
bgpGroup.add(ringA);
bgpGroup.add(ringB);
}
anomalyMarkers.push(sprite);
bgpGroup.add(sprite);
function setAnomalyMarkers(markerData, earth) {
disposeAnomalyRingSprites();
anomalyMarkers.length = 0;
bgpEventIconLayer.setData(markerData);
bgpEventIconLayer.attach(earth);
bgpEventIconLayer.setVisible(showBGP);
bgpEventIconLayer.getMarkers().forEach((marker) => {
attachAnomalyRingSprites(marker);
anomalyMarkers.push(marker);
});
}
function dedupeAnomalies(features) {
@@ -1304,17 +1386,18 @@ export async function loadBGPAnomalies(scene, earth) {
totalIncidentCount = selectedEventData.totalIncidentCount;
activeEventCountByCollector.clear();
spreadCollectorPositions(
const collectorMarkersData = spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
).forEach(createCollectorMarker);
);
await setCollectorMarkers(collectorMarkersData, earth);
if (selectedEventData.mode === "incident") {
dedupeIncidents(selectedEventData.features).forEach(createAnomalyMarker);
} else {
dedupeAnomalies(selectedEventData.features).forEach(createAnomalyMarker);
}
const eventMarkers =
selectedEventData.mode === "incident"
? dedupeIncidents(selectedEventData.features)
: dedupeAnomalies(selectedEventData.features);
setAnomalyMarkers(eventMarkers, earth);
applyCollectorCounts();
if (!bgpGroup.parent) {
@@ -1360,7 +1443,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
);
let scale = marker.userData.baseScale * getCollectorDistanceScale(marker, camera);
let opacity = BGP_CONFIG.collectorIcon.idleOpacity;
let haloOpacity = 0.0;
let pulseOpacity = 0.0;
let coverageOpacity = 0.0;
@@ -1374,14 +1456,12 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
opacity = 0.96;
haloOpacity = 0.05;
pulseOpacity = 0.024;
coverageOpacity = 0.036;
markerColor = 0xcff2ff;
} else if (isHovered) {
scale *= 1.08;
opacity = 0.88;
haloOpacity = 0.03;
pulseOpacity = 0.014;
coverageOpacity = 0.02;
@@ -1392,7 +1472,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
);
} else if (hasLockedLayer) {
scale *= 0.98;
opacity = BGP_CONFIG.collectorIcon.idleOpacity;
haloOpacity = 0.0;
pulseOpacity = 0.0;
coverageOpacity = 0.0;
@@ -1401,12 +1480,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
scale *= 1 + 0.05 * pulse;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
if (marker.userData.heatHalo) {
marker.userData.heatHalo.position.copy(marker.position);
marker.userData.heatHalo.material.opacity = haloOpacity;
marker.userData.heatHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.heatHalo.scale.setScalar(
@@ -1414,6 +1489,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
);
}
if (marker.userData.pulseHalo) {
marker.userData.pulseHalo.position.copy(marker.position);
marker.userData.pulseHalo.material.opacity = pulseOpacity;
marker.userData.pulseHalo.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
marker.userData.pulseHalo.scale.setScalar(
@@ -1421,6 +1497,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
);
}
if (marker.userData.statusCore) {
marker.userData.statusCore.position.copy(marker.position);
marker.userData.statusCore.material.opacity =
isLocked ? 0.58 : isHovered ? 0.4 : hasLockedLayer ? 0.0 : 0.18;
marker.userData.statusCore.material.color.setHex(marker.userData.baseColor || BGP_CONFIG.collectorColor);
@@ -1433,6 +1510,7 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
);
}
if (marker.userData.coverageHalo) {
marker.userData.coverageHalo.position.copy(marker.position);
marker.userData.coverageHalo.material.opacity = coverageOpacity;
marker.userData.coverageHalo.scale.set(
marker.userData.activity?.coverageHaloScale * 0.82 * (1 + pulse * 0.012),
@@ -1442,6 +1520,20 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
}
});
const focusedCollector =
lockedObjectType === "bgp"
? collectorMarkers.find(
(marker) => marker.userData.collector === lockedObject?.userData?.collector,
)
: lockedObjectType === "bgp_collector"
? lockedObject
: null;
bgpCollectorIconLayer.updateVisualState(
focusedCollector ? "bgp_collector" : lockedObjectType,
focusedCollector || lockedObject,
camera,
);
anomalyMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
const isLinkedCollectorLocked =
@@ -1459,7 +1551,6 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
const iconAnchorScale =
marker.userData.baseScale * getEventDistanceScale(marker, camera);
let scale = iconAnchorScale;
let opacity = BGP_CONFIG.opacity.normal;
let markerColor = marker.userData.baseColor || getSeverityColor(marker.userData.severity);
const isIncidentMarker = marker.userData.source === "bgp_incident";
let ringBaseOpacity = isIncidentMarker
@@ -1468,49 +1559,38 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
markerColor = 0xfff1a8;
ringBaseOpacity *= 1.2;
} else if (isCruise) {
scale *= 1 + BGP_CONFIG.pulse.lockedAmplitude * pulse;
opacity = 0.9 + 0.1 * pulse;
ringBaseOpacity *= 1.2;
} else if (isHovered) {
scale *= BGP_CONFIG.marker.hoverScale;
opacity = 0.9;
ringBaseOpacity *= 1.05;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.marker.dimmedScale;
opacity = 0.22;
markerColor = 0x7d8ca3;
ringBaseOpacity = 0.02;
} else {
scale *= 1 + BGP_CONFIG.pulse.normalAmplitude * pulse;
opacity = isIncidentMarker ? 0.7 : 0.62;
}
marker.scale.setScalar(scale);
marker.material.color.setHex(markerColor);
marker.material.opacity = opacity;
marker.visible = showBGP;
marker.renderOrder = isActive ? 7 : 3;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const ringPhaseA = (now * BGP_CONFIG.ring.speed + marker.userData.pulseOffset) % 1;
const applyRingState = (ring, phase, maxScale) => {
if (!ring) return;
const progress = Math.max(0, Math.min(1, phase));
const minScale = 1.28;
const desiredWorldScale =
iconAnchorScale * (minScale + progress * (maxScale - minScale));
const parentScale = Math.max(scale, 0.0001);
const localRingScale = desiredWorldScale / parentScale;
scale * (minScale + progress * (maxScale - minScale));
const fadeIn = Math.max(0, Math.min(1, (progress - 0.08) / 0.14));
const fadeOut = 1 - progress;
const visibility = fadeIn * fadeOut;
ring.scale.setScalar(localRingScale);
ring.position.copy(marker.position);
ring.scale.setScalar(desiredWorldScale);
ring.material.color.setHex(markerColor);
ring.material.opacity = showBGP ? ringBaseOpacity * visibility : 0;
ring.visible = showBGP;
ring.renderOrder = isActive ? BGP_EVENT_RENDER_ORDER + 0.15 : BGP_EVENT_RENDER_ORDER - 0.05;
};
applyRingState(marker.userData.ringA, ringPhaseA, BGP_CONFIG.ring.scaleA);
@@ -1519,6 +1599,8 @@ export function updateBGPVisualState(lockedObjectType, lockedObject, camera, cru
marker.userData.ringB.visible = false;
}
});
bgpEventIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}
export function setBGPMarkerState(marker, state = "normal") {
@@ -1526,15 +1608,19 @@ export function setBGPMarkerState(marker, state = "normal") {
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return;
}
marker.userData.state = state;
if (marker.userData.type === "bgp") {
bgpEventIconLayer.setMarkerState(marker, state);
return;
}
bgpCollectorIconLayer.setMarkerState(marker, state);
}
export function clearBGPSelection() {
collectorMarkers.forEach((marker) => {
marker.userData.state = "normal";
bgpCollectorIconLayer.setMarkerState(marker, "normal");
});
anomalyMarkers.forEach((marker) => {
marker.userData.state = "normal";
bgpEventIconLayer.setMarkerState(marker, "normal");
});
clearBGPEventOverlay();
}
@@ -1542,6 +1628,8 @@ export function clearBGPSelection() {
export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
bgpCollectorIconLayer.clearData(earth);
bgpEventIconLayer.clearData(earth);
clearBGPEventOverlay();
activeEventCountByCollector.clear();
totalAnomalyCount = 0;
@@ -1559,11 +1647,21 @@ export function toggleBGP(show) {
showBGP = Boolean(show);
bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP;
bgpCollectorIconLayer.setVisible(showBGP);
bgpEventIconLayer.setVisible(showBGP);
collectorMarkers.forEach((marker) => {
marker.visible = showBGP;
[
marker.userData.heatHalo,
marker.userData.pulseHalo,
marker.userData.statusCore,
marker.userData.coverageHalo,
].forEach((sprite) => {
if (sprite) sprite.visible = showBGP;
});
});
anomalyMarkers.forEach((marker) => {
marker.visible = showBGP;
marker.userData.ringA && (marker.userData.ringA.visible = showBGP);
marker.userData.ringB && (marker.userData.ringB.visible = false);
});
}
@@ -1579,6 +1677,14 @@ export function getBGPAnomalyMarkers() {
return anomalyMarkers;
}
export function getBGPAnomalyPointerIntersections(options = {}) {
return bgpEventIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorPointerIntersections(options = {}) {
return bgpCollectorIconLayer.getPointerIntersections(options);
}
export function getBGPCollectorMarkers() {
return collectorMarkers;
}
@@ -1644,11 +1750,11 @@ export function showBGPEventOverlay(marker, earth) {
latLonToVector3(
region.latitude,
region.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1,
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.1,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.add(halo);
bgpEventOverlayGroup.add(halo);
overlayItems.push(halo);
});
@@ -1687,11 +1793,11 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
latLonToVector3(
marker.userData.displayLatitude ?? marker.userData.latitude,
marker.userData.displayLongitude ?? marker.userData.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.15,
CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET - 0.15,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.add(halo);
bgpCollectorRadarGroup.add(halo);
const pulseHalo = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
@@ -1700,7 +1806,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
});
pulseHalo.position.copy(halo.position);
pulseHalo.renderOrder = 1;
bgpOverlayGroup.add(pulseHalo);
bgpCollectorRadarGroup.add(pulseHalo);
const innerRing = createOverlaySprite({
color: BGP_CONFIG.collectorColor,
opacity: 0.12,
@@ -1708,7 +1814,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
});
innerRing.position.copy(halo.position);
innerRing.renderOrder = 3;
bgpOverlayGroup.add(innerRing);
bgpCollectorRadarGroup.add(innerRing);
const overlayItems = [halo, pulseHalo, innerRing];
const anchorLatitude = marker.userData.displayLatitude ?? marker.userData.latitude;
@@ -1723,8 +1829,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
const startBearing = (sectorRotation - sectorHalfWidth) * (180 / Math.PI);
const endBearing = (sectorRotation + sectorHalfWidth) * (180 / Math.PI);
const coverageColor = marker.userData.baseColor || BGP_CONFIG.collectorColor;
const boundaryAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.4;
const boundaryAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.44;
const fillAltitude = CONFIG.earthRadius + BGP_COLLECTOR_ALTITUDE_OFFSET + 0.4;
const leftBoundaryPoints = createRadialBoundaryPoints(
anchorLatitude,
anchorLongitude,
@@ -1765,7 +1871,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.12,
);
sectorFill.renderOrder = 2;
bgpOverlayGroup.add(sectorFill);
bgpCollectorRadarGroup.add(sectorFill);
overlayItems.push(sectorFill);
const outerArc = createCoverageBoundaryLine(
@@ -1774,7 +1880,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.9,
);
outerArc.renderOrder = 3;
bgpOverlayGroup.add(outerArc);
bgpCollectorRadarGroup.add(outerArc);
overlayItems.push(outerArc);
const leftBoundary = createCoverageBoundaryLine(
@@ -1783,7 +1889,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.76,
);
leftBoundary.renderOrder = 3;
bgpOverlayGroup.add(leftBoundary);
bgpCollectorRadarGroup.add(leftBoundary);
overlayItems.push(leftBoundary);
const rightBoundary = createCoverageBoundaryLine(
@@ -1792,7 +1898,7 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
0.76,
);
rightBoundary.renderOrder = 3;
bgpOverlayGroup.add(rightBoundary);
bgpCollectorRadarGroup.add(rightBoundary);
overlayItems.push(rightBoundary);
activeEventOverlay = overlayItems;
@@ -1808,7 +1914,8 @@ export function showBGPCollectorCoverageOverlay(marker, earth, options = {}) {
export function clearBGPEventOverlay() {
activeEventOverlay = null;
activeCollectorOverlayContext = null;
clearGroup(bgpOverlayGroup);
clearGroup(bgpEventOverlayGroup);
clearGroup(bgpCollectorRadarGroup);
}
function updateCollectorOverlayScan(lockedObjectType, lockedObject) {

View File

@@ -20,80 +20,110 @@ export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
let landingPointTexture = null;
const _lpEarthWorldPos = new THREE.Vector3();
const _lpWorldPos = new THREE.Vector3();
const _lpCameraRel = new THREE.Vector3();
const _lpPointRel = new THREE.Vector3();
const _lpCameraToPoint = new THREE.Vector3();
const LANDING_POINT_SPRITE_HEIGHT = 3;
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 createLandingPointTexture() {
const size = CABLE_CONFIG.landingPoint.textureSize;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const iconPath = new Path2D(
[
"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",
].join(" "),
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;
const color = new THREE.Color(
(dimColor.r * brightness) / 255,
(dimColor.g * brightness) / 255,
(dimColor.b * brightness) / 255,
);
return `#${color.getHexString()}`;
}
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.translate(size * 0.12, size * 0.02);
ctx.scale(size / 1000, size / 1000);
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;
ctx.fillStyle = "#ffffff";
ctx.fill(iconPath);
context.clearRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(400, 320, 86, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
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;
}
function getLandingPointTexture() {
if (!landingPointTexture) {
landingPointTexture = createLandingPointTexture();
}
async function getLandingPointTexture() {
if (landingPointTexture) return landingPointTexture;
landingPointTexture = createLandingPointBallTexture();
return landingPointTexture;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getLandingPointDistanceScale(point, camera) {
if (
!point ||
!camera ||
CABLE_CONFIG.landingPointSizeStabilization?.enabled === false
) return 1;
function getLandingPointDistanceScale(camera) {
if (!camera) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: CABLE_CONFIG.landingPoint.altitudeOffset,
referenceFov: CABLE_CONFIG.landingPointSizeStabilization?.referenceFov || 75,
min: CABLE_CONFIG.landingPointSizeStabilization?.min ?? 0.12,
max: CABLE_CONFIG.landingPointSizeStabilization?.max ?? 3.0,
referenceFov: LANDING_POINT_SIZE_REFERENCE_FOV,
min: LANDING_POINT_SIZE_SCALE_MIN,
max: LANDING_POINT_SIZE_SCALE_MAX,
});
}
function setLandingPointScale(point, camera = null) {
const height = LANDING_POINT_SPRITE_HEIGHT * getLandingPointDistanceScale(camera);
point.scale.set(height * LANDING_POINT_SPRITE_ASPECT, height, 1);
}
function setLandingPointMaterialState(point, { color, opacity }) {
point.material.color.set(color);
point.material.opacity = opacity;
}
function disposeMaterial(material) {
if (!material) return;
@@ -122,22 +152,6 @@ function disposeObject(object, parent) {
}
}
function setLandingPointMaterialState(point, { color, opacity, emissive, emissiveIntensity }) {
point.material.color.set(color);
point.material.opacity = opacity;
if (point.material.emissive && emissive !== undefined) {
point.material.emissive.setHex(emissive);
}
if ("emissiveIntensity" in point.material && emissiveIntensity !== undefined) {
point.material.emissiveIntensity = emissiveIntensity;
}
}
function setLandingPointScale(point, heightScale) {
const aspect = CABLE_CONFIG.landingPoint.iconAspectRatio;
point.scale.set(heightScale * aspect, heightScale, 1);
}
function getCableColor(properties) {
if (properties.color) {
if (
@@ -426,7 +440,7 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
clearLandingPoints(earthObj);
let validCount = 0;
const markerTexture = await getLandingPointTexture();
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
@@ -460,20 +474,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
const marker = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getLandingPointTexture(),
map: markerTexture,
color: CABLE_CONFIG.landingPoint.color,
transparent: true,
opacity: CABLE_CONFIG.landingPoint.opacity,
depthTest: false,
depthWrite: false,
alphaTest: 0.01,
}),
);
marker.material.userData.sharedMap = true;
marker.renderOrder = CABLE_CONFIG.landingPoint.renderOrder;
marker.center.set(
CABLE_CONFIG.landingPoint.anchorX,
CABLE_CONFIG.landingPoint.anchorY,
);
marker.center.set(0.5, 0.5);
marker.position.copy(position);
marker.userData = {
type: "landingPoint",
@@ -481,15 +493,18 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
cableNames: properties.cable_names || [],
country: properties.country || "未知国家",
status: properties.status || "Unknown",
baseScale: CABLE_CONFIG.landingPoint.baseScale,
latitude: lat,
longitude: lon,
landing_visual_state: "normal",
};
setLandingPointScale(marker, CABLE_CONFIG.landingPoint.baseScale);
setLandingPointScale(marker);
earthObj.add(marker);
landingPoints.push(marker);
validCount++;
}
const validCount = landingPoints.length;
setEarthStatValue("landing-point-count", `${validCount}`);
if (!silent) {
@@ -619,10 +634,8 @@ function isFacingCamera(lp, camera) {
const distance = Math.sqrt(distanceSq);
_lpCameraToPoint.multiplyScalar(1 / distance);
// The pin sprite is rendered without depth testing so its full shape does
// not get sliced by the globe. Instead, hide it when the camera-to-anchor
// segment is occluded by a slightly inflated globe, matching the behavior of
// the BGP and compute-center markers near the limb.
// Sprite rendering keeps the full pin visible. Hide it when the anchor point
// is occluded by the globe so back-side landing points do not bleed through.
const occlusionRadius =
CONFIG.earthRadius + CABLE_CONFIG.landingPoint.altitudeOffset * 0.45;
const cameraProjection = _lpCameraRel.dot(_lpCameraToPoint);
@@ -638,75 +651,52 @@ function isFacingCamera(lp, camera) {
}
export function applyLandingPointVisualState(lockedCableName, dimAll = false, camera = null) {
const pulse =
(Math.sin(Date.now() * CABLE_CONFIG.landingPointVisual.pulseSpeed) + 1) * 0.5;
const brightness = CABLE_CONFIG.landingPointVisual.dimBrightness;
const relatedNames = Array.isArray(lockedCableName)
? lockedCableName.filter(Boolean)
: lockedCableName
? [lockedCableName]
: [];
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isVisible = cablesVisible && (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);
if (isRelated) {
const pulse = getLandingPointPulse();
setLandingPointMaterialState(lp, {
color: 0xffd27a,
emissive: 0x7a4a00,
emissiveIntensity:
CABLE_CONFIG.landingPointVisual.related.emissiveIntensityBase +
0.2 +
pulse * (CABLE_CONFIG.landingPointVisual.related.emissiveIntensityPulse + 0.2),
opacity: Math.max(
0.92,
CABLE_CONFIG.landingPointVisual.related.opacityBase +
pulse * CABLE_CONFIG.landingPointVisual.related.opacityPulse,
),
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(
lp,
(CABLE_CONFIG.landingPointVisual.related.scaleBase +
pulse * CABLE_CONFIG.landingPointVisual.related.scalePulse) *
baseScale *
distanceScale,
);
lp.userData.landing_visual_state = "related";
} else {
const dimColor = CABLE_CONFIG.landingPointVisual.dimmed.colorRGB;
const r = dimColor.r * brightness;
const g = dimColor.g * brightness;
const b = dimColor.b * brightness;
setLandingPointMaterialState(lp, {
color: new THREE.Color(r / 255, g / 255, b / 255),
emissive: CABLE_CONFIG.landingPointVisual.dimmed.emissive,
emissiveIntensity: CABLE_CONFIG.landingPointVisual.dimmed.emissiveIntensity,
color: getLandingPointDimColor(),
opacity: CABLE_CONFIG.landingPointVisual.dimmed.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
lp.userData.landing_visual_state = isVisible ? "dimmed" : "hidden";
}
});
}
export function resetLandingPointVisualState(camera = null) {
landingPoints.forEach((lp) => {
lp.visible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
const isVisible = cablesVisible && (camera ? isFacingCamera(lp, camera) : true);
lp.visible = isVisible;
setLandingPointScale(lp, camera);
setLandingPointMaterialState(lp, {
color: CABLE_CONFIG.landingPoint.color,
emissive: CABLE_CONFIG.landingPoint.emissive,
emissiveIntensity: CABLE_CONFIG.landingPoint.emissiveIntensity,
opacity: CABLE_CONFIG.landingPoint.opacity,
});
const distanceScale = getLandingPointDistanceScale(lp, camera);
const baseScale = lp.userData?.baseScale || CABLE_CONFIG.landingPoint.baseScale;
setLandingPointScale(lp, baseScale * distanceScale);
lp.userData.landing_visual_state = isVisible ? "normal" : "hidden";
});
}

View File

@@ -1,12 +1,15 @@
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
import { createInteractableLayer } from "./interactable.js";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
const textureCache = new Map();
const COMPUTE_CENTER_POINT_SIZE = 36;
const COMPUTE_CENTER_ICON_FIT_SIZE = 60;
const COMPUTE_CENTER_ATLAS_CELL_SIZE = 128;
const COMPUTE_CENTER_ICON_SOURCES = {
supercomputer: "/earth/assets/icons/compute-supercomputer.svg",
gpu_cluster: "/earth/assets/icons/compute-gpu-cluster.svg",
infrastructure: "/earth/assets/icons/compute-hdd-network.svg",
};
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
@@ -69,71 +72,13 @@ function spreadComputeCenterPositions(markers) {
return markers;
}
function createMarkerTexture(siteType, isEstimated = false) {
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
if (textureCache.has(textureKey)) {
return textureCache.get(textureKey);
}
const color =
COMPUTE_CENTER_CONFIG.colors[siteType] ||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
const centerX = 64;
const centerY = 64;
const baseFill = color;
function fillPath(draw, options = {}) {
const { fillStyle = color } = options;
context.save();
context.fillStyle = fillStyle;
context.beginPath();
draw();
context.fill();
context.restore();
}
context.clearRect(0, 0, 128, 128);
if (siteType === "supercomputer") {
fillPath(() => {
context.roundRect(40, 42, 48, 30, 7);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.roundRect(58, 74, 12, 8, 3);
context.roundRect(50, 84, 28, 5, 2.5);
}, {
fillStyle: baseFill,
});
} else {
fillPath(() => {
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
context.rect(46, 46, 36, 28);
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
context.rect(52, 58, 24, 6);
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
}
function drawComputeCenterEstimatedBadge(context, isEstimated = false) {
if (isEstimated) {
fillPath(() => {
context.arc(94, 36, 12, 0, Math.PI * 2);
}, {
fillStyle: "rgba(15,23,42,0.92)",
});
context.save();
context.fillStyle = "rgba(15,23,42,0.92)";
context.beginPath();
context.arc(94, 36, 12, 0, Math.PI * 2);
context.fill();
context.fillStyle = "rgba(255,255,255,0.98)";
context.font = "bold 18px sans-serif";
context.textAlign = "center";
@@ -141,76 +86,74 @@ function createMarkerTexture(siteType, isEstimated = false) {
context.fillText("?", 94, 36);
context.restore();
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function normalizeSiteType(siteType) {
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
}
function getBaseScale(siteType) {
return siteType === "supercomputer"
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
}
function getDistanceScale(marker, camera) {
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
return 1;
}
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
referenceFov: 75,
min: COMPUTE_CENTER_CONFIG.sizeStabilization.min,
max: COMPUTE_CENTER_CONFIG.sizeStabilization.max,
});
}
function clearGroup(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
group.remove(child);
}
}
function createComputeCenterMarker(markerData) {
const siteType = markerData.site_type;
const material = new THREE.SpriteMaterial({
map: createMarkerTexture(siteType, Boolean(markerData.is_estimated)),
transparent: true,
depthWrite: false,
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
});
const marker = new THREE.Sprite(material);
const baseScale = getBaseScale(siteType);
marker.position.copy(
latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = COMPUTE_CENTER_RENDER_ORDER;
marker.visible = showComputeCenters;
marker.userData = {
...markerData,
site_type: siteType,
type: "compute_center",
baseScale,
state: "normal",
const computeCenterIconLayer = createInteractableLayer({
id: "computeCenters",
objectType: "compute_center",
renderOrder: COMPUTE_CENTER_RENDER_ORDER,
altitudeOffset: COMPUTE_CENTER_CONFIG.altitudeOffset,
pointSize: COMPUTE_CENTER_POINT_SIZE,
atlasCellSize: COMPUTE_CENTER_ATLAS_CELL_SIZE,
colors: {
byKind: COMPUTE_CENTER_CONFIG.colors,
normal: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
opacity: {
normal: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedOpacity,
hover: 0.98,
locked: 1,
},
stateScale: {
hover: COMPUTE_CENTER_CONFIG.marker.hoverScale,
locked: COMPUTE_CENTER_CONFIG.marker.lockedScale,
dimmed: COMPUTE_CENTER_CONFIG.marker.dimmedScale,
},
pulse: {
enabled: true,
speed: COMPUTE_CENTER_CONFIG.marker.pulseSpeed,
amplitude: COMPUTE_CENTER_CONFIG.marker.pulseAmplitude,
},
icon: {
coordinates: "canvas",
colorable: false,
fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
glowBlur: 16,
getSource({ marker, item }) {
const siteType =
marker?.userData?.site_type || item?.site_type || "gpu_cluster";
return (
COMPUTE_CENTER_ICON_SOURCES[siteType] ||
COMPUTE_CENTER_ICON_SOURCES.infrastructure
);
},
afterDraw(context, { marker, item }) {
drawComputeCenterEstimatedBadge(
context,
Boolean(marker?.userData?.is_estimated ?? item?.is_estimated),
);
},
},
getPosition: (item) => ({
latitude: item.displayLatitude,
longitude: item.displayLongitude,
}),
getKind: (item) => item.site_type || "gpu_cluster",
getBucketKey: (marker) =>
[
marker.userData?.site_type || "gpu_cluster",
marker.userData?.is_estimated ? "estimated" : "precise",
].join(":"),
getUserData: (item) => ({
...item,
pulseOffset: Math.random() * Math.PI * 2,
};
computeCenterGroup.add(marker);
computeCenterMarkers.push(marker);
return marker;
}
}),
});
export function formatComputeCenterTypeLabel(siteType) {
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
@@ -252,11 +195,11 @@ export function getComputeCenterLegendItems() {
}
export function getComputeCenterMarkers() {
return computeCenterMarkers;
return computeCenterIconLayer.getMarkers();
}
export function getComputeCenterCount() {
return computeCenterMarkers.length;
return computeCenterIconLayer.getCount();
}
export function getComputeCenterSupercomputerCount() {
@@ -268,35 +211,27 @@ export function getComputeCenterGPUClusterCount() {
}
export function getComputeCenterStatusSummary() {
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
if (getComputeCenterCount() === 0) return "暂无算力中心数据";
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
}
export function setComputeCenterMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "compute_center") return;
marker.userData.state = state;
computeCenterIconLayer.setMarkerState(marker, state);
}
export function clearComputeCenterSelection() {
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
getComputeCenterMarkers().forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
}
export function clearComputeCenterData(earth) {
computeCenterMarkers.length = 0;
supercomputerCount = 0;
gpuClusterCount = 0;
clearGroup(computeCenterGroup);
if (earth && computeCenterGroup.parent === earth) {
earth.remove(computeCenterGroup);
}
computeCenterIconLayer.clearData(earth);
}
export function toggleComputeCenters(show) {
showComputeCenters = Boolean(show);
computeCenterGroup.visible = showComputeCenters;
computeCenterMarkers.forEach((marker) => {
marker.visible = showComputeCenters;
});
computeCenterIconLayer.setVisible(showComputeCenters);
}
export function getShowComputeCenters() {
@@ -313,64 +248,37 @@ export async function loadComputeCenters(_scene, earth) {
clearComputeCenterData(earth);
spreadComputeCenterPositions(
const markerData = spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
.filter(Boolean),
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
.forEach((markerData) => {
const marker = createComputeCenterMarker(markerData);
if (!marker) return;
if (marker.userData.site_type === "supercomputer") {
supercomputerCount += 1;
} else {
gpuClusterCount += 1;
}
});
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
if (earth && !computeCenterGroup.parent) {
earth.add(computeCenterGroup);
}
computeCenterGroup.visible = showComputeCenters;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
supercomputerCount += 1;
} else {
gpuClusterCount += 1;
}
});
await computeCenterIconLayer.preloadAssets(markerData);
computeCenterIconLayer.setData(markerData);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);
return {
totalCount: computeCenterMarkers.length,
totalCount: getComputeCenterCount(),
supercomputerCount,
gpuClusterCount,
summary: getComputeCenterStatusSummary(),
};
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
const now = Date.now();
computeCenterMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
const state = marker.userData?.state || "normal";
const pulse =
1 +
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
let scaleMultiplier = 1;
if (isLocked) {
opacity = 1;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
} else if (state === "hover") {
opacity = 0.98;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
} else if (hasFocus) {
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
}
const distanceScale = getDistanceScale(marker, camera);
marker.material.opacity = showComputeCenters ? opacity : 0;
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
marker.visible = showComputeCenters;
});
export function getComputeCenterPointerIntersections(options) {
return computeCenterIconLayer.getPointerIntersections(options);
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}

View File

@@ -209,7 +209,7 @@ export const PATHS = {
};
export const VESSEL_CONFIG = {
altitudeOffset: 0.56,
altitudeOffset: 0.2,
maxRenderedMarkers: 5000,
marker: {
baseScale: 7.5,
@@ -232,7 +232,7 @@ export const VESSEL_CONFIG = {
max: 2.4,
},
track: {
altitudeOffset: 0.7,
altitudeOffset: 0.2,
color: 0x7dd3fc,
opacity: 0.82,
},
@@ -293,39 +293,20 @@ export const CABLE_CONFIG = {
renderOrder: 1,
},
landingPoint: {
altitudeOffset: 0.48,
textureSize: 256,
iconAspectRatio: 0.82,
anchorX: 0.52,
anchorY: 0.276,
baseScale: 12,
altitudeOffset: 0.2,
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5,
opacity: 1.0,
renderOrder: 4.5,
},
landingPointSizeStabilization: {
enabled: true,
referenceFov: 75,
min: 0.12,
max: 3.0,
renderOrder: 1,
},
landingPointVisual: {
pulseSpeed: 0.003,
dimBrightness: 0.62,
related: {
emissiveIntensityBase: 0.5,
emissiveIntensityPulse: 0.5,
opacityBase: 0.8,
opacityPulse: 0.2,
scaleBase: 1.2,
scalePulse: 0.3,
},
dimmed: {
colorRGB: { r: 180, g: 116, b: 28 },
emissive: 0x3a2200,
emissiveIntensity: 0.18,
opacity: 0.78,
},
},
@@ -364,8 +345,8 @@ export const SATELLITE_CONFIG = {
export const BGP_CONFIG = {
defaultFetchLimit: 200,
maxRenderedMarkers: 200,
altitudeOffset: 2.1,
collectorAltitudeOffset: 1.6,
altitudeOffset: 0.48,
collectorAltitudeOffset: 0.2,
marker: {
eventBaseScale: 6.2,
collectorBaseScale: 7.4,

View File

@@ -19,6 +19,7 @@ import {
import {
toggleTerrain,
setDayNightEnabled,
toggleClouds,
toggleGridLines,
getShowGridLines,
} from "./earth.js";
@@ -53,7 +54,7 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import { getShowCountryBoundaries } from "./country-boundaries.js";
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
import {
toggleComputeCenters,
getShowComputeCenters,
@@ -136,6 +137,7 @@ let focusViewAnimationToken = 0;
let earthSettingsDefaults = null;
let lastZoomStatusUpdateTime = 0;
let earthSettingsState = null;
let deferredLayerVisibilitySettings = null;
let layerRegistry = new Map();
let layerPanelInitialized = false;
let layoutMode = "desktop";
@@ -269,7 +271,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
setMobileDrawerOpen("layer-toggles", false);
}
if (except !== "media" && isTVPanelVisible()) {
if (except !== "media" && except !== "search" && isTVPanelVisible()) {
setTVPanelVisible(false);
}
}
@@ -670,6 +672,14 @@ function shouldIncludeLayerInStartupLoad(definition) {
return false;
}
const persistedVisible = getPersistedLayerVisibilityOverride(definition.id);
if (typeof persistedVisible === "boolean") {
if (definition.startupMode === "preload" && definition.startupAlwaysLoad) {
return true;
}
return persistedVisible;
}
if (definition.startupMode === "preload") {
return true;
}
@@ -677,6 +687,14 @@ function shouldIncludeLayerInStartupLoad(definition) {
return Boolean(definition?.getVisible?.());
}
function getPersistedLayerVisibilityOverride(layerId) {
if (!layerId) return null;
const layerVisibility =
deferredLayerVisibilitySettings || earthSettingsState?.shared?.layerVisibility;
const persistedVisible = layerVisibility?.[layerId];
return typeof persistedVisible === "boolean" ? persistedVisible : null;
}
function clampEarthZoomLevel(nextZoom) {
const parsedZoom = Number.parseFloat(nextZoom);
if (!Number.isFinite(parsedZoom)) {
@@ -1127,7 +1145,7 @@ function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = tr
return defaultEarthZoom;
}
async function applyEarthSettings(settings) {
async function applyEarthSettings(settings, { applyLayers = true } = {}) {
if (!settings) return;
earthSettingsState = cloneEarthSettings(settings);
@@ -1161,12 +1179,31 @@ async function applyEarthSettings(settings) {
applyToCurrentView: true,
});
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
applyImmediateLayerVisibilityHints(layerVisibility);
deferredLayerVisibilitySettings = layerVisibility;
return;
}
deferredLayerVisibilitySettings = null;
await applyLayerVisibilitySettings(settings.shared.layerVisibility, {
persist: false,
silent: true,
});
}
export async function applyDeferredLayerVisibilitySettings(options = {}) {
const layerVisibility = deferredLayerVisibilitySettings;
deferredLayerVisibilitySettings = null;
if (!layerVisibility) return;
await applyLayerVisibilitySettings(layerVisibility, {
persist: false,
silent: true,
...options,
});
}
function resetEarthSettings() {
const defaults = cloneEarthSettings(captureEarthSettingsDefaults());
earthSettingsState = cloneEarthSettings(defaults);
@@ -1307,8 +1344,15 @@ async function setCountryBoundariesLayerEnabled(button, enabled, { persist = tru
}
}
function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setHighResTextureEnabled(enabled, { suppressStatus: silent });
async function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "高清材质加载中...",
});
}
await setHighResTextureEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
@@ -1319,8 +1363,15 @@ function setHighResTextureLayerEnabled(button, enabled, { persist = true, silent
return enabled;
}
function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
async function setAtmosphereCloudsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
if (enabled) {
setLayerButtonState(button, {
active: false,
loading: true,
tooltip: "大气云图加载中...",
});
}
await setAtmosphereCloudsEnabled(enabled, { suppressStatus: silent });
setLayerButtonState(button, {
active: enabled,
loading: false,
@@ -1449,6 +1500,44 @@ async function applyLayerVisibilitySettings(layerVisibility = {}, options = {})
}
}
function applyImmediateLayerVisibilityHints(layerVisibility = {}) {
if (typeof layerVisibility.gridLines === "boolean") {
setGridLinesLayerEnabled(getLayerButton("gridLines"), layerVisibility.gridLines, {
persist: false,
silent: true,
});
}
if (typeof layerVisibility.countryBoundaries === "boolean") {
toggleCountryBoundaries(layerVisibility.countryBoundaries, {
showLandFill: true,
});
setLayerButtonState(getLayerButton("countryBoundaries"), {
active: layerVisibility.countryBoundaries,
loading: false,
tooltip: layerVisibility.countryBoundaries ? "隐藏国界线" : "显示国界线",
});
}
if (layerVisibility.earthHighResTexture === false) {
void setHighResTextureEnabled(false, { suppressStatus: true });
setLayerButtonState(getLayerButton("earthHighResTexture"), {
active: false,
loading: false,
tooltip: "显示高清材质",
});
}
if (typeof layerVisibility.atmosphereClouds === "boolean") {
toggleClouds(layerVisibility.atmosphereClouds);
setLayerButtonState(getLayerButton("atmosphereClouds"), {
active: layerVisibility.atmosphereClouds,
loading: false,
tooltip: layerVisibility.atmosphereClouds ? "隐藏大气云图" : "显示大气云图",
});
}
}
function getBuiltinLayerDefinitions() {
return [
{
@@ -1472,13 +1561,14 @@ function getBuiltinLayerDefinitions() {
id: "countryBoundaries",
buttonId: "toggle-country-boundaries",
icon: "public",
label: "国界",
label: "国界线",
meta: "Country Borders",
keywords: "国界 国家 borders countries boundary",
defaultActive: true,
displayOrder: 90,
startupPriority: 20,
startupMode: "preload",
startupAlwaysLoad: true,
startupLabel: "海陆基座",
startupMessage: "正在加载海陆基座...",
getVisible: () => getShowCountryBoundaries(),
@@ -2268,7 +2358,7 @@ function setupSettingsControls() {
});
captureEarthSettingsDefaults();
settingsApplyPromise = applyEarthSettings(loadEarthSettings());
settingsApplyPromise = applyEarthSettings(loadEarthSettings(), { applyLayers: false });
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
@@ -3301,6 +3391,8 @@ function setupToolbarHubCluster() {
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.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`);

View File

@@ -25,8 +25,11 @@ let _earthTextureOverlayMaterial = null;
let _earthShaders = [];
let _dayNightEnabled = true;
let _loadedTexture = null;
let _textureLoadPromise = null;
let _textureVisible = true;
let _earthRimGlow = null;
let _cloudTexture = null;
let _cloudTextureLoadPromise = null;
const _earthSunDirection = new THREE.Vector3(
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x,
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y,
@@ -301,28 +304,16 @@ export function createClouds(scene, earthObj) {
clouds = new THREE.Mesh(geometry, material);
clouds.name = "earth-atmosphere-clouds";
clouds.visible = showClouds;
clouds.visible = false;
earthObj.add(clouds);
textureLoader.load(
CLOUD_LAYER_CONFIG.textureUrl,
function(texture) {
material.map = texture;
material.needsUpdate = true;
},
undefined,
function(err) {
console.log('云层纹理加载失败');
}
);
return clouds;
}
export function toggleClouds(visible) {
showClouds = Boolean(visible);
if (clouds) {
clouds.visible = showClouds;
clouds.visible = showClouds && Boolean(clouds.material?.map);
}
}
@@ -330,6 +321,39 @@ export function getShowClouds() {
return showClouds;
}
export function loadCloudTexture() {
if (_cloudTexture) return Promise.resolve(_cloudTexture);
if (_cloudTextureLoadPromise) return _cloudTextureLoadPromise;
_cloudTextureLoadPromise = new Promise((resolve, reject) => {
if (!clouds?.material) {
resolve(null);
return;
}
textureLoader.load(
CLOUD_LAYER_CONFIG.textureUrl,
(texture) => {
_cloudTexture = texture;
clouds.material.map = texture;
clouds.material.needsUpdate = true;
clouds.visible = showClouds;
resolve(texture);
},
undefined,
(error) => {
console.warn("云层纹理加载失败");
reject(error);
},
);
});
_cloudTextureLoadPromise.finally(() => {
_cloudTextureLoadPromise = null;
});
return _cloudTextureLoadPromise;
}
export function createTerrain(earthObj) {
const geometry = new THREE.SphereGeometry(
CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset,
@@ -478,6 +502,7 @@ export function getClouds() {
export function clearEarthTexture() {
_loadedTexture = null;
_textureLoadPromise = null;
if (_earthTextureOverlayMaterial) {
_earthTextureOverlayMaterial.map = null;
_earthTextureOverlayMaterial.needsUpdate = true;
@@ -522,7 +547,10 @@ export function setDayNightEnabled(enabled) {
}
export function loadEarthTexture() {
return new Promise((resolve) => {
if (_loadedTexture) return Promise.resolve(_loadedTexture);
if (_textureLoadPromise) return _textureLoadPromise;
_textureLoadPromise = new Promise((resolve) => {
if (!_earthTextureOverlayMaterial) { resolve(); return; }
const urls = EARTH_MATERIAL_CONFIG.textureUrls;
@@ -549,7 +577,7 @@ export function loadEarthTexture() {
if (_earthRimGlow) {
_earthRimGlow.visible = !_textureVisible;
}
resolve();
resolve(texture);
},
null,
() => tryLoad(index + 1),
@@ -557,6 +585,11 @@ export function loadEarthTexture() {
};
tryLoad(0);
});
_textureLoadPromise.finally(() => {
_textureLoadPromise = null;
});
return _textureLoadPromise;
}
export function setEarthTextureVisible(visible) {

View File

@@ -0,0 +1,899 @@
import * as THREE from "three";
import { CONFIG } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
const assetImageCache = new Map();
const assetImageLoadPromises = new Map();
const surfaceAvoidanceBuckets = new Map();
const interactableLayerControllers = new Map();
const DEFAULT_AVOIDANCE_PRECISION = 4;
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
const DEFAULT_AVOIDANCE_STEP = 0.35;
const AVOIDANCE_RING_SLOT_COUNT = 8;
const TANGENT_EPSILON_SQ = 1e-6;
const avoidanceNorthPole = new THREE.Vector3(0, 1, 0);
const avoidanceFallbackEast = new THREE.Vector3(1, 0, 0);
const avoidanceCenterScratch = new THREE.Vector3();
const avoidanceEastScratch = new THREE.Vector3();
const avoidanceNorthScratch = new THREE.Vector3();
const avoidancePositionScratch = new THREE.Vector3();
function colorToRgbArray(colorValue, fallback = "#ffffff") {
const color = new THREE.Color(colorValue || fallback);
return [color.r, color.g, color.b];
}
function toFiniteNumber(value, fallback) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function disposeGroupChildren(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
child.geometry?.dispose?.();
group.remove(child);
}
}
function normalizePosition(position, radius) {
if (position instanceof THREE.Vector3) {
return position.clone();
}
const lat = Number(position?.latitude ?? position?.lat);
const lon = Number(position?.longitude ?? position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return latLonToVector3(lat, lon, radius);
}
function createCanvas(width, height) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(width, height);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function getAvoidanceKey(position, basePosition, precision = 4) {
if (position instanceof THREE.Vector3) {
return [
"vec",
basePosition.x.toFixed(precision),
basePosition.y.toFixed(precision),
basePosition.z.toFixed(precision),
].join(":");
}
const lat = Number(position?.latitude ?? position?.lat);
const lon = Number(position?.longitude ?? position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return ["geo", lat.toFixed(precision), lon.toFixed(precision)].join(":");
}
function notifyAvoidancePositionChanged(layerIds) {
layerIds.forEach((layerId) => {
interactableLayerControllers.get(layerId)?.refreshPositions?.();
});
}
function recomputeAvoidanceBucket(key) {
const entries = surfaceAvoidanceBuckets.get(key);
if (!entries || entries.length === 0) return;
const affectedLayerIds = new Set(entries.map((entry) => entry.layerId));
if (entries.length === 1) {
const entry = entries[0];
entry.marker.position.copy(entry.marker.userData.icon_base_position);
entry.marker.userData.icon_avoidance_index = 0;
entry.marker.userData.icon_avoidance_count = 1;
notifyAvoidancePositionChanged(affectedLayerIds);
return;
}
const count = entries.length;
entries.forEach((entry, index) => {
const basePosition =
entry.marker.userData.icon_base_position || entry.marker.position;
const altitudeRadius = basePosition.length();
const ringIndex = Math.floor(index / AVOIDANCE_RING_SLOT_COUNT);
const radius =
Math.max(0, entry.radius) + ringIndex * Math.max(0, entry.step);
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / count;
avoidanceCenterScratch.copy(basePosition).normalize();
avoidanceEastScratch
.copy(avoidanceNorthPole)
.cross(avoidanceCenterScratch);
if (avoidanceEastScratch.lengthSq() < TANGENT_EPSILON_SQ) {
avoidanceEastScratch.copy(avoidanceFallbackEast);
}
avoidanceEastScratch.normalize();
avoidanceNorthScratch
.copy(avoidanceCenterScratch)
.cross(avoidanceEastScratch)
.normalize();
avoidancePositionScratch
.copy(basePosition)
.addScaledVector(avoidanceEastScratch, Math.cos(angle) * radius)
.addScaledVector(avoidanceNorthScratch, Math.sin(angle) * radius)
.normalize()
.multiplyScalar(altitudeRadius);
entry.marker.position.copy(avoidancePositionScratch);
entry.marker.userData.icon_avoidance_index = index;
entry.marker.userData.icon_avoidance_count = count;
});
notifyAvoidancePositionChanged(affectedLayerIds);
}
function unregisterLayerAvoidance(layerId) {
const affectedKeys = new Set();
surfaceAvoidanceBuckets.forEach((entries, key) => {
const nextEntries = entries.filter((entry) => entry.layerId !== layerId);
if (nextEntries.length !== entries.length) {
affectedKeys.add(key);
}
if (nextEntries.length === 0) {
surfaceAvoidanceBuckets.delete(key);
} else {
surfaceAvoidanceBuckets.set(key, nextEntries);
}
});
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
}
function registerLayerAvoidance(layerId, markers, avoidanceConfig) {
if (avoidanceConfig.enabled === false) return;
const affectedKeys = new Set();
markers.forEach((marker) => {
const key = marker.userData?.icon_avoidance_key;
if (!key) return;
if (!surfaceAvoidanceBuckets.has(key)) {
surfaceAvoidanceBuckets.set(key, []);
}
surfaceAvoidanceBuckets.get(key).push({
layerId,
marker,
radius: toFiniteNumber(
avoidanceConfig.radius,
DEFAULT_AVOIDANCE_RADIUS,
),
step: toFiniteNumber(avoidanceConfig.step, DEFAULT_AVOIDANCE_STEP),
});
affectedKeys.add(key);
});
affectedKeys.forEach((key) => recomputeAvoidanceBucket(key));
}
function loadAssetImage(source) {
if (!source) return Promise.resolve(null);
if (assetImageCache.has(source)) {
return Promise.resolve(assetImageCache.get(source));
}
if (assetImageLoadPromises.has(source)) {
return assetImageLoadPromises.get(source);
}
const loadPromise = new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
assetImageCache.set(source, image);
assetImageLoadPromises.delete(source);
resolve(image);
};
image.onerror = () => {
assetImageLoadPromises.delete(source);
reject(new Error(`Failed to load interactable icon asset: ${source}`));
};
image.src = source;
});
assetImageLoadPromises.set(source, loadPromise);
return loadPromise;
}
export function createInteractableLayer(options = {}) {
const {
id,
objectType = id,
renderOrder = 4,
altitudeOffset = 0.2,
pointSize = 32,
sizeMode = "fixed",
sizeScale = {},
atlasCellSize = 128,
material = {},
colors = {},
opacity = {},
stateScale = {},
pulse = {},
avoidance = {},
icon,
getPosition,
getKind = (item) => item?.type || "default",
getRotationBin = () => 0,
getBucketKey = (marker) => String(getRotationBin(marker)),
getPointSizeMultiplier = () => 1,
getPointOpacity = null,
getUserData = (item) => item,
dynamicVisuals = false,
} = options;
if (!id) {
throw new Error("createInteractableLayer requires an id");
}
if (!icon?.draw && !icon?.source && !icon?.getSource) {
throw new Error(`Interactable layer ${id} requires icon.draw, icon.source, or icon.getSource`);
}
const group = new THREE.Group();
group.name = `interactable-layer:${id}`;
group.renderOrder = renderOrder;
group.userData = { type: "interactable_layer", id };
const markers = [];
const pointObjects = [];
const textureCache = new Map();
let pointsGroup = null;
let hoverOverlay = null;
let lockedOverlay = null;
let visible = false;
let lastVisualStateKey = "";
let visualStateVersion = 0;
const scratchDirection = new THREE.Vector3();
const scratchCameraLocal = new THREE.Vector3();
const scratchWorldPosition = new THREE.Vector3();
const scratchScreenPosition = new THREE.Vector3();
const viewportSize = new THREE.Vector2(1, 1);
const baseOpacity = opacity.normal ?? 0.88;
const dimmedOpacity = opacity.dimmed ?? 0.26;
const hoverOpacity = opacity.hover ?? 0.98;
const lockedOpacity = opacity.locked ?? 1;
const hoverScale = stateScale.hover ?? 1;
const lockedScale = stateScale.locked ?? 1;
const dimmedScale = stateScale.dimmed ?? 1;
const depthTest = material.depthTest ?? true;
const depthWrite = material.depthWrite ?? false;
const alphaTest = material.alphaTest ?? 0.01;
const usesDistanceScaling = sizeMode !== "fixed";
const iconAnchor = new THREE.Vector2(
Number(icon.anchor?.x ?? icon.anchor?.[0] ?? 0.5),
Number(icon.anchor?.y ?? icon.anchor?.[1] ?? 0.5),
);
const usesIconAnchor =
Math.abs(iconAnchor.x - 0.5) > 0.001 ||
Math.abs(iconAnchor.y - 0.5) > 0.001;
const avoidanceConfig = {
enabled: true,
precision: DEFAULT_AVOIDANCE_PRECISION,
radius: DEFAULT_AVOIDANCE_RADIUS,
step: DEFAULT_AVOIDANCE_STEP,
...avoidance,
};
function invalidateVisualState() {
visualStateVersion += 1;
lastVisualStateKey = "";
}
function refreshViewportSize() {
const pixelRatio = window.devicePixelRatio || 1;
viewportSize.set(
(window.innerWidth || 1) * pixelRatio,
(window.innerHeight || 1) * pixelRatio,
);
}
function applyIconAnchor(material) {
if (!usesIconAnchor) return material;
material.defines = {
...(material.defines || {}),
USE_INTERACTABLE_ICON_ANCHOR: "",
};
material.onBeforeCompile = (shader) => {
shader.uniforms.interactableIconAnchor = { value: iconAnchor };
shader.uniforms.interactableViewportSize = { value: viewportSize };
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
[
"#include <common>",
"uniform vec2 interactableIconAnchor;",
"uniform vec2 interactableViewportSize;",
].join("\n"),
)
.replace(
"#include <project_vertex>",
[
"#include <project_vertex>",
"#ifdef USE_INTERACTABLE_ICON_ANCHOR",
" vec2 interactableAnchorOffset = vec2((0.5 - interactableIconAnchor.x) * size, (interactableIconAnchor.y - 0.5) * size);",
" gl_Position.xy += (interactableAnchorOffset / interactableViewportSize) * 2.0 * gl_Position.w;",
"#endif",
].join("\n"),
);
};
material.customProgramCacheKey = () =>
`interactable-icon-anchor:${iconAnchor.x.toFixed(3)}:${iconAnchor.y.toFixed(3)}`;
return material;
}
function getMarkerColor(marker) {
const kind = marker?.userData?.icon_kind || getKind(marker?.userData);
return colors.byKind?.[kind] || colors[kind] || colors.normal || "#ffffff";
}
function getIconSource(drawOptions) {
const stateSource = icon.stateSources?.[drawOptions.state];
if (stateSource) return stateSource;
if (typeof icon.getSource === "function") {
return icon.getSource(drawOptions);
}
return icon.source;
}
function drawAssetIcon(context, source, drawOptions) {
const image = assetImageCache.get(source);
if (!image) {
icon.fallbackDraw?.(context, drawOptions);
return;
}
if (drawOptions.glow) {
context.shadowColor = drawOptions.color || "#ffffff";
context.shadowBlur = icon.glowBlur ?? 14;
}
const fitSize =
typeof icon.fitSize === "function"
? icon.fitSize(drawOptions)
: icon.fitSize;
const fitWidth =
typeof fitSize === "number"
? fitSize
: Number(fitSize?.width ?? atlasCellSize);
const fitHeight =
typeof fitSize === "number"
? fitSize
: Number(fitSize?.height ?? atlasCellSize);
const maxWidth = Number.isFinite(fitWidth) ? fitWidth : atlasCellSize;
const maxHeight = Number.isFinite(fitHeight) ? fitHeight : atlasCellSize;
const sourceWidth = image.naturalWidth || image.width || atlasCellSize;
const sourceHeight = image.naturalHeight || image.height || atlasCellSize;
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
const drawX = (atlasCellSize - drawWidth) / 2;
const drawY = (atlasCellSize - drawHeight) / 2;
if (icon.colorable !== false && drawOptions.color) {
const tintCanvas = createCanvas(atlasCellSize, atlasCellSize);
const tintContext = tintCanvas.getContext("2d");
tintContext.clearRect(0, 0, atlasCellSize, atlasCellSize);
tintContext.drawImage(image, drawX, drawY, drawWidth, drawHeight);
tintContext.globalCompositeOperation = "source-in";
tintContext.fillStyle = drawOptions.color;
tintContext.fillRect(0, 0, atlasCellSize, atlasCellSize);
context.drawImage(tintCanvas, 0, 0);
return;
}
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
function drawIconTexture(textureKey, drawOptions) {
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const canvas = document.createElement("canvas");
canvas.width = atlasCellSize;
canvas.height = atlasCellSize;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.save();
const resolvedDrawOptions = {
atlasCellSize,
...drawOptions,
};
if (icon.coordinates !== "canvas") {
context.translate(canvas.width / 2, canvas.height / 2);
}
if (icon.draw) {
icon.draw(context, resolvedDrawOptions);
} else {
drawAssetIcon(context, getIconSource(resolvedDrawOptions), resolvedDrawOptions);
}
icon.afterDraw?.(context, resolvedDrawOptions);
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function createPointTexture(bucketKey, bucketMarkers) {
const sampleMarker = bucketMarkers[0];
const rotationBin = getRotationBin(sampleMarker);
return drawIconTexture(`point:${bucketKey}`, {
marker: sampleMarker,
bucketKey,
rotationBin,
glow: false,
color: "#ffffff",
state: "normal",
});
}
function createOverlayTexture(marker, state) {
const kind = marker?.userData?.icon_kind || "default";
const rotationBin = getRotationBin(marker);
const color = getMarkerColor(marker);
const textureKey = [
"overlay",
state,
kind,
getBucketKey(marker),
rotationBin,
color,
].join(":");
return drawIconTexture(textureKey, {
marker,
bucketKey: getBucketKey(marker),
rotationBin,
glow: true,
color,
state,
});
}
function getCameraScale(camera) {
if (!usesDistanceScaling || !camera) return 1;
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset,
referenceFov: sizeScale.referenceFov ?? 75,
min: sizeScale.min ?? 0.12,
max: sizeScale.max ?? 3,
});
}
function buildPoints() {
refreshViewportSize();
pointsGroup = new THREE.Group();
pointsGroup.visible = visible;
pointsGroup.renderOrder = renderOrder;
pointsGroup.userData = { type: `${id}_points`, id };
pointObjects.length = 0;
const buckets = new Map();
markers.forEach((marker) => {
const key = getBucketKey(marker);
if (!buckets.has(key)) {
buckets.set(key, []);
}
buckets.get(key).push(marker);
});
buckets.forEach((bucketMarkers, bucketKey) => {
const count = bucketMarkers.length;
const positions = new Float32Array(count * 3);
const colorValues = new Float32Array(count * 3);
bucketMarkers.forEach((marker, index) => {
positions[index * 3] = marker.position.x;
positions[index * 3 + 1] = marker.position.y;
positions[index * 3 + 2] = marker.position.z;
const pointColor =
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
const [r, g, b] = colorToRgbArray(pointColor);
colorValues[index * 3] = r;
colorValues[index * 3 + 1] = g;
colorValues[index * 3 + 2] = b;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3));
geometry.computeBoundingSphere();
const material = applyIconAnchor(
new THREE.PointsMaterial({
map: createPointTexture(bucketKey, bucketMarkers),
size: pointSize * getPointSizeMultiplier(bucketMarkers[0]),
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
depthWrite,
depthTest,
alphaTest,
}),
);
const points = new THREE.Points(geometry, material);
points.renderOrder = renderOrder;
points.frustumCulled = false;
points.userData = {
type: `${id}_points`,
id,
bucketKey,
markers: bucketMarkers,
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
};
pointObjects.push(points);
pointsGroup.add(points);
});
group.add(pointsGroup);
}
function ensureOverlay(kind) {
const existing = kind === "locked" ? lockedOverlay : hoverOverlay;
if (existing) return existing;
refreshViewportSize();
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(3), 3),
);
const material = applyIconAnchor(
new THREE.PointsMaterial({
size: pointSize,
sizeAttenuation: false,
transparent: true,
depthWrite,
depthTest,
opacity: 1,
alphaTest,
}),
);
const overlay = new THREE.Points(geometry, material);
overlay.renderOrder = renderOrder + (kind === "locked" ? 0.2 : 0.1);
overlay.frustumCulled = false;
overlay.visible = false;
overlay.userData = { type: `${id}_${kind}_overlay`, id };
group.add(overlay);
if (kind === "locked") {
lockedOverlay = overlay;
} else {
hoverOverlay = overlay;
}
return overlay;
}
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
if (!overlay) return;
if (!marker) {
overlay.visible = false;
return;
}
const texture = createOverlayTexture(marker, state);
if (overlay.material.map !== texture) {
overlay.material.map = texture;
overlay.material.needsUpdate = true;
}
overlay.material.opacity = nextOpacity;
overlay.material.size =
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
const positionAttribute = overlay.geometry.getAttribute("position");
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
positionAttribute.needsUpdate = true;
overlay.visible = visible;
}
function clearRenderObjects() {
if (pointsGroup?.parent) {
pointsGroup.parent.remove(pointsGroup);
}
pointObjects.forEach((points) => {
points.geometry?.dispose?.();
points.material?.dispose?.();
});
hoverOverlay?.geometry?.dispose?.();
hoverOverlay?.material?.dispose?.();
hoverOverlay?.parent?.remove?.(hoverOverlay);
lockedOverlay?.geometry?.dispose?.();
lockedOverlay?.material?.dispose?.();
lockedOverlay?.parent?.remove?.(lockedOverlay);
pointsGroup = null;
pointObjects.length = 0;
hoverOverlay = null;
lockedOverlay = null;
}
function refreshPositions() {
pointObjects.forEach((points) => {
const bucketMarkers = points.userData?.markers || [];
const positionAttribute = points.geometry?.getAttribute("position");
if (!positionAttribute) return;
bucketMarkers.forEach((marker, index) => {
positionAttribute.setXYZ(
index,
marker.position.x,
marker.position.y,
marker.position.z,
);
});
positionAttribute.needsUpdate = true;
points.geometry.computeBoundingSphere();
});
invalidateVisualState();
}
function refreshVisuals() {
invalidateVisualState();
if (!pointsGroup) return;
clearRenderObjects();
buildPoints();
group.visible = visible;
}
function setData(items = []) {
invalidateVisualState();
unregisterLayerAvoidance(id);
markers.length = 0;
clearRenderObjects();
disposeGroupChildren(group);
const radius = CONFIG.earthRadius + altitudeOffset;
items.forEach((item) => {
const rawPosition = getPosition(item);
const position = normalizePosition(rawPosition, radius);
if (!position) return;
const kind = getKind(item);
const avoidanceKey = getAvoidanceKey(
rawPosition,
position,
avoidanceConfig.precision,
);
const marker = new THREE.Object3D();
marker.position.copy(position);
marker.userData = {
...getUserData(item),
type: objectType,
icon_layer_id: id,
icon_kind: kind,
icon_base_position: position.clone(),
icon_avoidance_key: avoidanceKey,
state: "normal",
};
markers.push(marker);
});
registerLayerAvoidance(id, markers, avoidanceConfig);
buildPoints();
group.visible = visible;
}
async function preloadAssets(items = []) {
if (icon.draw && !icon.source && !icon.getSource && !icon.stateSources) {
return;
}
const sources = new Set();
const states = ["normal", "hover", "locked"];
items.forEach((item) => {
const kind = getKind(item);
const marker = {
userData: {
...getUserData(item),
type: objectType,
icon_layer_id: id,
icon_kind: kind,
state: "normal",
},
};
states.forEach((state) => {
const source = getIconSource({
marker,
item,
state,
color:
colors.byKind?.[kind] ||
colors[kind] ||
colors.normal ||
"#ffffff",
});
if (source) sources.add(source);
});
});
await Promise.all(Array.from(sources).map((source) => loadAssetImage(source)));
}
function clearData(parent) {
invalidateVisualState();
unregisterLayerAvoidance(id);
markers.length = 0;
clearRenderObjects();
disposeGroupChildren(group);
if (parent && group.parent === parent) {
parent.remove(group);
}
}
function attach(parent) {
if (parent && !group.parent) {
parent.add(group);
}
group.visible = visible;
}
function setVisible(nextVisible) {
visible = Boolean(nextVisible);
invalidateVisualState();
group.visible = visible;
if (pointsGroup) {
pointsGroup.visible = visible;
}
}
function setMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== objectType) return;
if (marker.userData.state === state) return;
marker.userData.state = state;
invalidateVisualState();
}
function updateVisualState(focusType, focusObject, camera) {
refreshViewportSize();
if (!visible || markers.length === 0 || !pointsGroup) {
if (lastVisualStateKey !== "hidden") {
if (pointsGroup) pointsGroup.visible = false;
if (hoverOverlay) hoverOverlay.visible = false;
if (lockedOverlay) lockedOverlay.visible = false;
lastVisualStateKey = "hidden";
}
return;
}
pointsGroup.visible = true;
const hasFocus = focusType === objectType && focusObject;
const lockedKey = hasFocus
? focusObject?.userData?.mmsi || focusObject?.uuid || "locked"
: "none";
const stateKey = [
"visible",
focusType || "none",
lockedKey,
visualStateVersion,
].join(":");
const cameraScale = getCameraScale(camera);
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
const nextStateKey = `${stateKey}:${scaleKey}`;
if (
nextStateKey === lastVisualStateKey &&
!(pulse.enabled && hasFocus) &&
!dynamicVisuals
) return;
lastVisualStateKey = nextStateKey;
pointObjects.forEach((points) => {
const sampleMarker = points.userData?.markers?.[0];
points.visible = visible;
points.material.opacity =
getPointOpacity?.(sampleMarker) ??
(hasFocus ? dimmedOpacity : baseOpacity);
points.material.size =
pointSize *
getPointSizeMultiplier(sampleMarker) *
cameraScale *
(hasFocus ? dimmedScale : 1);
});
const hoverMarker = markers.find(
(marker) => marker.userData?.state === "hover" && marker !== focusObject,
);
updateOverlay(
ensureOverlay("hover"),
hoverMarker,
"hover",
hoverOpacity,
hoverScale * cameraScale,
);
const lockedPulse =
pulse.enabled && hasFocus
? 1 + (pulse.amplitude ?? 0) * Math.sin(Date.now() * (pulse.speed ?? 0) + (focusObject?.userData?.pulseOffset ?? 0))
: 1;
updateOverlay(
ensureOverlay("locked"),
hasFocus ? focusObject : null,
"locked",
lockedOpacity,
lockedScale * lockedPulse * cameraScale,
);
}
function getPointerIntersections({
earth,
camera,
pointer,
radiusPx = 20,
width = window.innerWidth,
height = window.innerHeight,
frontFacingDotThreshold = 0,
} = {}) {
if (!earth || !camera || !pointer) return [];
scratchCameraLocal.copy(camera.position);
earth.worldToLocal(scratchCameraLocal);
scratchCameraLocal.normalize();
const pointerX = ((pointer.x + 1) / 2) * width;
const pointerY = ((1 - pointer.y) / 2) * height;
const radiusSq = radiusPx * radiusPx;
const intersections = [];
const cameraScale = getCameraScale(camera);
markers.forEach((marker) => {
scratchDirection.copy(marker.position).normalize();
if (scratchCameraLocal.dot(scratchDirection) <= frontFacingDotThreshold) {
return;
}
scratchWorldPosition.copy(marker.position);
earth.localToWorld(scratchWorldPosition);
scratchScreenPosition.copy(scratchWorldPosition).project(camera);
if (scratchScreenPosition.z < -1 || scratchScreenPosition.z > 1) {
return;
}
const screenX = (scratchScreenPosition.x * 0.5 + 0.5) * width;
const screenY = (-scratchScreenPosition.y * 0.5 + 0.5) * height;
const pointSizeMultiplier = getPointSizeMultiplier(marker) * cameraScale;
const visualCenterX =
screenX + (0.5 - iconAnchor.x) * pointSize * pointSizeMultiplier;
const visualCenterY =
screenY + (0.5 - iconAnchor.y) * pointSize * pointSizeMultiplier;
const deltaX = visualCenterX - pointerX;
const deltaY = visualCenterY - pointerY;
const distancePxSq = deltaX * deltaX + deltaY * deltaY;
if (distancePxSq > radiusSq) return;
intersections.push({
object: marker,
point: scratchWorldPosition.clone(),
distance: camera.position.distanceTo(scratchWorldPosition),
distancePxSq,
});
});
return intersections.sort((a, b) => a.distancePxSq - b.distancePxSq);
}
interactableLayerControllers.set(id, { refreshPositions, refreshVisuals });
return {
group,
markers,
getMarkers: () => markers,
getCount: () => markers.length,
isVisible: () => visible,
setData,
preloadAssets,
clearData,
attach,
setVisible,
setMarkerState,
updateVisualState,
getPointerIntersections,
refreshVisuals,
};
}

View File

@@ -23,6 +23,10 @@ import {
loadVessels,
toggleVessels,
} from "./vessels.js";
import {
loadCloudTexture,
loadEarthTexture,
} from "./earth.js";
import {
getCountryBoundaryLegendItems,
loadCountryBoundaries,
@@ -83,6 +87,8 @@ export function registerLayerStartupTask(id, taskFactory) {
function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCountryBoundaryStartupTask();
registerEarthTextureStartupTask();
registerCloudStartupTask();
registerCableStartupTask();
registerComputeCenterStartupTask();
registerVesselStartupTask();
@@ -210,6 +216,42 @@ function registerBGPStartupTask() {
});
}
function registerEarthTextureStartupTask() {
registerLayerStartupTask("earthHighResTexture", (context) => async (layer) => {
if (!context.isEarthTextureVisible()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载地球纹理..."),
);
await context.yieldFrame(12);
try {
await loadEarthTexture();
} catch (error) {
console.warn("地球纹理加载失败:", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerCloudStartupTask() {
registerLayerStartupTask("atmosphereClouds", (context) => async (layer) => {
if (!context.isCloudsEnabled()) return;
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载大气云图..."),
);
await context.yieldFrame(12);
try {
await loadCloudTexture();
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "大气云图", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
function registerCountryBoundaryStartupTask() {
registerLayerStartupTask("countryBoundaries", (context) => async (layer) => {
context.setLoadingMessage(

View File

@@ -37,6 +37,7 @@ import {
createGridLines,
getEarth,
getEarthSurfacePickTarget,
loadCloudTexture,
loadEarthTexture,
clearEarthTexture,
setEarthSunDirection,
@@ -123,6 +124,8 @@ import {
import {
loadBGPAnomalies,
getBGPAnomalyMarkers,
getBGPAnomalyPointerIntersections as getBGPEventIconPointerIntersections,
getBGPCollectorPointerIntersections as getBGPCollectorIconPointerIntersections,
getBGPCollectorMarkers,
getBGPLegendItems,
getBGPCount,
@@ -162,6 +165,7 @@ import {
getComputeCenterCount,
getComputeCenterLegendItems,
getComputeCenterMarkers,
getComputeCenterPointerIntersections as getComputeCenterIconPointerIntersections,
getShowComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
@@ -175,6 +179,7 @@ import {
getVesselCount,
getVesselLegendItems,
getVesselMarkers,
getVesselPointerIntersections as getVesselIconPointerIntersections,
loadVessels,
setVesselMarkerState,
showVesselTrack,
@@ -199,6 +204,7 @@ import {
setDayNightEnabledExternal,
setTerrainLayerInteractable,
setDayNightInteractable,
applyDeferredLayerVisibilitySettings,
} from "./controls.js";
import {
createLayerStartupTaskMap,
@@ -258,6 +264,7 @@ let lastBGPClickTime = 0;
let lastBGPClickCollector = null;
let lastBGPClickType = null;
let lastBGPClickPos = { x: 0, y: 0 };
let lastVesselHoverPickTime = 0;
let earthTexture = null;
let animationFrameId = null;
let initialized = false;
@@ -290,12 +297,6 @@ const interactionMouse = new THREE.Vector2();
const scratchCameraToEarth = new THREE.Vector3();
const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchVesselDirection = new THREE.Vector3();
const scratchVesselWorldPosition = new THREE.Vector3();
const scratchSatelliteWorldPosition = new THREE.Vector3();
const scratchSatelliteScreenPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
@@ -310,6 +311,9 @@ const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const VESSEL_HOVER_PICK_INTERVAL_MS = 100;
const VESSEL_POINTER_RADIUS_PX = 22;
const INTERACTABLE_POINTER_RADIUS_PX = 24;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
@@ -364,6 +368,13 @@ function setGlobeDraggingUiState(active) {
document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active);
}
function hasActiveGlobeInertia() {
return (
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(
@@ -536,25 +547,63 @@ function clearTransientHoverState() {
setHoveredSatelliteIndex(null);
}
function getFrontFacingVesselMarkers(markers) {
function getVesselPointerIntersections() {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchVesselWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchVesselWorldPosition);
scratchVesselDirection
.subVectors(scratchVesselWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchVesselDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
return getVesselIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: VESSEL_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getBGPEventPointerIntersections() {
const earth = getEarth();
return getBGPEventIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function getBGPCollectorPointerIntersections() {
const earth = getEarth();
return getBGPCollectorIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
function shouldSkipVesselHoverPicking() {
return isDragging || hasActiveGlobeInertia();
}
function getVesselHoverIntersections() {
if (!getShowVessels()) {
return { checked: true, intersects: [] };
}
if (shouldSkipVesselHoverPicking()) {
return { checked: false, intersects: [] };
}
const now = performance.now();
if (now - lastVesselHoverPickTime < VESSEL_HOVER_PICK_INTERVAL_MS) {
return { checked: false, intersects: [] };
}
lastVesselHoverPickTime = now;
return {
checked: true,
intersects: getVesselPointerIntersections(),
};
}
function applyBGPHoverState(marker) {
resetTransientBGPStates();
if (!marker) {
@@ -590,14 +639,14 @@ function applyComputeCenterHoverState(marker) {
}
function resetTransientVesselStates() {
getVesselMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setVesselMarkerState(marker, "normal");
}
});
if (hoveredVessel && hoveredVessel !== lockedObject) {
setVesselMarkerState(hoveredVessel, "normal");
}
hoveredVessel = null;
}
function applyVesselHoverState(marker) {
if (isSameVessel(hoveredVessel, marker)) return;
resetTransientVesselStates();
if (!marker) {
hoveredVessel = null;
@@ -1423,23 +1472,7 @@ function formatBGPStatusFromSummary(summary) {
return "当前无活跃事件";
}
function applyEarthStatsSummary(summary) {
if (!summary) return;
updateEarthStats({
cableCount: `${summary.cableCount}`,
landingPointCount: `${summary.landingPointCount}`,
satelliteCount: `${summary.satelliteCount}`,
computeCenterCount: `${summary.computeCenterCount}`,
vesselCount: `${summary.vesselCount}`,
bgpAnomalyCount: `${summary.bgpEventCount}`,
bgpCollectorCount: `${summary.bgpCollectorCount}`,
bgpStatusSummary: formatBGPStatusFromSummary(summary),
terrainOn: getShowTerrain(),
textureQuality: "8K 卫星图",
});
}
async function loadEarthStatsSummary() {
async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
try {
const response = await fetch(PATHS.earthSummaryApi);
if (!response.ok) {
@@ -1458,7 +1491,9 @@ async function loadEarthStatsSummary() {
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
bgpCollectorCount: toCount(stats.bgp_collector_count),
};
applyEarthStatsSummary(earthStatsSummary);
if (shouldApply()) {
updateStatsSummary();
}
} catch (error) {
console.warn("全球态势聚合统计加载失败:", error);
}
@@ -2001,7 +2036,11 @@ function applyCableVisualState() {
switch (state) {
case CABLE_STATE.LOCKED:
case CABLE_STATE.HOVERED:
cable.material.opacity = 1;
cable.material.opacity = THREE.MathUtils.lerp(
CABLE_CONFIG.lockedOpacityMin,
CABLE_CONFIG.lockedOpacityMax,
pulse,
);
cable.material.color.setRGB(0.92, 0.98, 1.0);
break;
case CABLE_STATE.NORMAL:
@@ -2547,24 +2586,14 @@ async function loadData() {
await yieldFrame(18);
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
setLoadingMessage("正在读取全球态势统计...");
await loadEarthStatsSummary();
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(12);
loadEarthStatsSummary({
shouldApply: () => loadToken === currentLoadToken && !destroyed,
}).catch((error) => {
console.warn("后台刷新全球态势统计失败:", error);
});
const errors = [];
// Step 1 — Earth texture
setLoadingMessage("正在加载地球纹理...");
await yieldFrame(12);
try {
await loadEarthTexture();
} catch (err) {
// texture failure is non-fatal
}
if (loadToken !== currentLoadToken) { isDataLoading = false; return; }
await yieldFrame(16);
const startupLoaders = createLayerStartupTaskMap({
scene,
earth,
@@ -2583,6 +2612,7 @@ async function loadData() {
getShowCountryBoundaries,
getShowBGP,
isEarthTextureVisible: () => getEarthTextureVisible(),
isCloudsEnabled: () => getShowClouds(),
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
scheduleSatellitePositionWarmup,
@@ -2657,6 +2687,10 @@ async function loadData() {
hideError();
queueStatusMessage("数据已加载", "success");
}
applyDeferredLayerVisibilitySettings().catch((error) => {
console.warn("恢复 Earth 图层可见性失败:", error);
});
}
const POSITION_UPDATE_FORCE_DELTA = 250;
@@ -2780,8 +2814,17 @@ export async function setCountryBoundariesEnabled(
let _dayNightBeforeTextureOff = null;
let _terrainBeforeTextureOff = null;
export function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
export async function setHighResTextureEnabled(enabled, { suppressStatus = false } = {}) {
setEarthTextureVisible(enabled);
if (enabled) {
try {
await loadEarthTexture();
setEarthTextureVisible(true);
} catch (error) {
console.warn("高清材质加载失败:", error);
setEarthTextureVisible(false);
}
}
setSurfaceTintEnabled(!enabled);
setLandFillEnabled(true);
setLandFillSuppressed(false);
@@ -2816,8 +2859,17 @@ export function getHighResTextureEnabled() {
return getEarthTextureVisible();
}
export function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
export async function setAtmosphereCloudsEnabled(enabled, { suppressStatus = false } = {}) {
toggleClouds(enabled);
if (enabled) {
try {
await loadCloudTexture();
toggleClouds(true);
} catch (error) {
console.warn("大气云图加载失败:", error);
toggleClouds(false);
}
}
if (!suppressStatus) {
showStatusMessage(enabled ? "大气云图已显示" : "大气云图已隐藏", "info");
}
@@ -3032,41 +3084,14 @@ function getFrontFacingCables(cableLines) {
});
}
function getFrontFacingBGPMarkers(markers) {
function getComputeCenterPointerIntersections() {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchBGPWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchBGPWorldPosition);
scratchBGPDirection
.subVectors(scratchBGPWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchBGPDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function getFrontFacingComputeCenterMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchComputeCenterWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
scratchComputeCenterDirection
.subVectors(scratchComputeCenterWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
return getComputeCenterIconPointerIntersections({
earth,
camera,
pointer: interactionMouse,
radiusPx: INTERACTABLE_POINTER_RADIUS_PX,
frontFacingDotThreshold: SATELLITE_CONFIG.frontFacingDotThreshold,
});
}
@@ -3116,29 +3141,17 @@ function onMouseMove(event) {
const frontCables = getFrontFacingCables(getCableLines());
const cableIntersects = interactionRaycaster.intersectObjects(frontCables);
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
? getBGPEventPointerIntersections()
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
? getBGPCollectorPointerIntersections()
: [];
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
getComputeCenterMarkers(),
);
const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
: [];
const vesselIntersects = getShowVessels()
? interactionRaycaster.intersectObjects(
getFrontFacingVesselMarkers(getVesselMarkers()),
)
? getComputeCenterPointerIntersections()
: [];
const vesselPick = getVesselHoverIntersections();
const vesselIntersects = vesselPick.intersects;
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
@@ -3161,7 +3174,7 @@ function onMouseMove(event) {
const hoveredComputeCenterMarker =
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
const hoveredVesselMarker =
vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
if (
hoveredComputeCenter &&
@@ -3169,7 +3182,11 @@ function onMouseMove(event) {
) {
clearTransientHoverState();
}
if (hoveredVessel && !isSameVessel(hoveredVessel, hoveredVesselMarker)) {
if (
vesselPick.checked &&
hoveredVessel &&
!isSameVessel(hoveredVessel, hoveredVesselMarker)
) {
clearTransientHoverState();
}
@@ -3216,6 +3233,7 @@ function onMouseMove(event) {
);
objectTooltipShown = true;
} else if (
vesselPick.checked &&
hoveredVesselMarker &&
getShowVessels() &&
lockedObjectType !== "vessel"
@@ -3262,7 +3280,9 @@ function onMouseMove(event) {
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
resetTransientBGPStates();
resetTransientComputeCenterStates();
resetTransientVesselStates();
if (vesselPick.checked) {
resetTransientVesselStates();
}
hideInfoCard();
}
@@ -3465,27 +3485,17 @@ function onClick(event) {
const cableIntersects = interactionRaycaster.intersectObjects(
getFrontFacingCables(getCableLines()),
);
const frontFacingBGPAnomalyMarkers = getFrontFacingBGPMarkers(
getBGPAnomalyMarkers(),
);
const frontFacingBGPCollectorMarkers = getFrontFacingBGPMarkers(
getBGPCollectorMarkers(),
);
const bgpAnomalyIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPAnomalyMarkers)
? getBGPEventPointerIntersections()
: [];
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
? getBGPCollectorPointerIntersections()
: [];
const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
? getComputeCenterPointerIntersections()
: [];
const vesselIntersects = getShowVessels()
? interactionRaycaster.intersectObjects(
getFrontFacingVesselMarkers(getVesselMarkers()),
)
? getVesselPointerIntersections()
: [];
const satIntersects = getSatellitePointerIntersections(event);
@@ -3681,9 +3691,7 @@ function animate() {
const earth = getEarth();
const deltaTime = clock.getDelta() * 1000;
const hasInertia =
Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY ||
Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY;
const hasInertia = hasActiveGlobeInertia();
if (getAutoRotate() && getRotationMode() === ROTATION_MODE.ROTATE && earth) {
earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16);

View File

@@ -28,6 +28,7 @@ let lockedRingSprite = null;
let lockedDotSprite = null;
let lockedHaloMesh = null;
let lockedGroundFootprintMesh = null;
let lockedGroundFootprintFillMesh = null;
let lockedIridiumFootprintMesh = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
@@ -159,6 +160,9 @@ const GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO = 0.82;
const GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM = 60;
const GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM = 120;
const GROUND_FOOTPRINT_GAP_LENGTH_RATIO = 1.08;
const GROUND_FOOTPRINT_REBUILD_DISTANCE = 0.001;
const GROUND_FOOTPRINT_REBUILD_DISTANCE_SQ =
GROUND_FOOTPRINT_REBUILD_DISTANCE * GROUND_FOOTPRINT_REBUILD_DISTANCE;
const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3();
@@ -168,7 +172,9 @@ const scratchFootprintLateral = new THREE.Vector3();
const scratchFootprintReference = new THREE.Vector3();
const scratchFootprintVelocity = new THREE.Vector3();
const scratchFootprintTangent = new THREE.Vector3();
const scratchLastGroundFootprintPosition = new THREE.Vector3();
const satelliteSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
let hasGroundFootprintGeometry = false;
export let breathingPhase = 0;
@@ -1335,13 +1341,10 @@ export function setSatelliteCamera(camera) {
export function setSatelliteSunDirection(direction) {
if (!direction) return;
satelliteSunDirection.copy(direction).normalize();
if (lockedGroundFootprintMesh) {
const fillMesh = lockedGroundFootprintMesh.getObjectByName("footprint-fill");
if (fillMesh?.material?.uniforms?.uSunDirectionWorld) {
fillMesh.material.uniforms.uSunDirectionWorld.value.copy(
satelliteSunDirection,
);
}
if (lockedGroundFootprintFillMesh?.material?.uniforms?.uSunDirectionWorld) {
lockedGroundFootprintFillMesh.material.uniforms.uSunDirectionWorld.value.copy(
satelliteSunDirection,
);
}
}
@@ -1586,14 +1589,12 @@ function createGroundFootprintMaterial() {
},
vertexShader: `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
@@ -1617,7 +1618,6 @@ function createGroundFootprintMaterial() {
uniform vec3 uSunDirectionWorld;
uniform float uDayVisibilityBoost;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
varying vec2 vUv;
float bowtieHalfWidth(float xEast) {
@@ -1723,6 +1723,8 @@ function clearLockedSatelliteStyleVisuals() {
if (lockedGroundFootprintMesh) {
disposeObjectTree(lockedGroundFootprintMesh);
lockedGroundFootprintMesh = null;
lockedGroundFootprintFillMesh = null;
hasGroundFootprintGeometry = false;
}
if (lockedIridiumFootprintMesh) {
disposeIridiumFootprintAdapter(lockedIridiumFootprintMesh, earthObjRef);
@@ -1933,28 +1935,6 @@ function buildGroundFootprintGeometry(position) {
);
}
function toEastNorth(alongKm, crossKm) {
const offset = alongTrack
.clone()
.multiplyScalar(alongKm)
.addScaledVector(crossTrack, crossKm);
return {
xEast: offset.dot(east),
yNorth: offset.dot(north),
};
}
function fromEastNorth(xEast, yNorth) {
const offset = east
.clone()
.multiplyScalar(xEast)
.addScaledVector(north, yNorth);
return {
alongKm: offset.dot(alongTrack),
crossKm: offset.dot(crossTrack),
};
}
function bowtieHalfWidth(xEast) {
const t = THREE.MathUtils.clamp(
Math.abs(xEast) / Math.max(exclusionLengthKm, 1),
@@ -1969,6 +1949,7 @@ function buildGroundFootprintGeometry(position) {
}
const vertices = [];
const uvs = [];
const indices = [];
const indexMap = [];
@@ -2000,6 +1981,10 @@ function buildGroundFootprintGeometry(position) {
);
row.push(vertices.length / 3);
vertices.push(point.x, point.y, point.z);
uvs.push(
THREE.MathUtils.mapLinear(alongKm, -majorKm, majorKm, 0, 1),
THREE.MathUtils.mapLinear(crossKm, -minorKm, minorKm, 0, 1),
);
}
indexMap.push(row);
}
@@ -2021,29 +2006,8 @@ function buildGroundFootprintGeometry(position) {
"position",
new THREE.Float32BufferAttribute(vertices, 3),
);
const uvs = [];
for (let iy = 0; iy <= GROUND_FOOTPRINT_GRID_Y; iy += 1) {
const crossKm = THREE.MathUtils.lerp(
-minorKm,
minorKm,
iy / GROUND_FOOTPRINT_GRID_Y,
);
for (let ix = 0; ix <= GROUND_FOOTPRINT_GRID_X; ix += 1) {
const alongKm = THREE.MathUtils.lerp(
-majorKm,
majorKm,
ix / GROUND_FOOTPRINT_GRID_X,
);
if (!isInsideEllipse(alongKm, crossKm)) continue;
uvs.push(
THREE.MathUtils.mapLinear(alongKm, -majorKm, majorKm, 0, 1),
THREE.MathUtils.mapLinear(crossKm, -minorKm, minorKm, 0, 1),
);
}
}
fillGeometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
fillGeometry.setIndex(indices);
fillGeometry.computeVertexNormals();
const basisToEastNorth = {
eastAlongDot: alongTrack.dot(east),
@@ -2064,10 +2028,18 @@ function buildGroundFootprintGeometry(position) {
function updateGroundFootprintTransform(position) {
if (!lockedGroundFootprintMesh || !position || !earthObjRef) return;
if (
hasGroundFootprintGeometry &&
scratchLastGroundFootprintPosition.distanceToSquared(position) <=
GROUND_FOOTPRINT_REBUILD_DISTANCE_SQ
) {
return;
}
const geometrySet = buildGroundFootprintGeometry(position);
if (!geometrySet) return;
const fillMesh = lockedGroundFootprintMesh.getObjectByName("footprint-fill");
const fillMesh = lockedGroundFootprintFillMesh;
if (fillMesh?.geometry) fillMesh.geometry.dispose();
@@ -2089,6 +2061,8 @@ function updateGroundFootprintTransform(position) {
fillMesh.material.uniforms.uNorthCrossDot.value =
geometrySet.basisToEastNorth.northCrossDot;
}
scratchLastGroundFootprintPosition.copy(position);
hasGroundFootprintGeometry = true;
}
}
@@ -2130,6 +2104,8 @@ function showGroundFootprintStyle(position) {
fill.name = "footprint-fill";
fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER;
lockedGroundFootprintMesh.add(fill);
lockedGroundFootprintFillMesh = fill;
hasGroundFootprintGeometry = false;
earthObjRef.add(lockedGroundFootprintMesh);
updateGroundFootprintTransform(position);
}
@@ -2139,7 +2115,7 @@ function showIridiumReservedStyle(position) {
lockedIridiumFootprintMesh = createIridiumFootprintAdapter({
earthObj: earthObjRef,
earthRadiusWorld: CONFIG.earthRadius,
renderOrder: 0,
renderOrder: GROUND_FOOTPRINT_RENDER_ORDER,
});
updateIridiumReservedStyle(position);
}

View File

@@ -1,15 +1,17 @@
import * as THREE from "three";
import { CONFIG, PATHS, VESSEL_CONFIG } from "./constants.js";
import { getSurfaceMarkerCameraScale, latLonToVector3 } from "./utils.js";
import { createInteractableLayer } from "./interactable.js";
import { latLonToVector3 } from "./utils.js";
const vesselGroup = new THREE.Group();
const vesselMarkers = [];
const textureCache = new Map();
let showVessels = false;
let activeTrackLine = null;
const VESSEL_RENDER_ORDER = 4.4;
const VESSEL_POINT_SIZE = 34;
const VESSEL_ATLAS_CELL_SIZE = 128;
const VESSEL_COURSE_BINS = 32;
const VESSEL_TRACK_ENDPOINT_EPSILON = 0.001;
function normalizeVesselType(value, code) {
const type = String(value || "").trim().toLowerCase();
@@ -22,39 +24,24 @@ function normalizeVesselType(value, code) {
return "other";
}
function createVesselTexture(type, anchored) {
const textureKey = `${type}:${anchored ? "anchored" : "moving"}`;
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
const color = VESSEL_CONFIG.colors[type] || VESSEL_CONFIG.colors.other;
const canvas = document.createElement("canvas");
canvas.width = 96;
canvas.height = 96;
const context = canvas.getContext("2d");
context.clearRect(0, 0, 96, 96);
context.save();
context.translate(48, 48);
function drawVesselShape(context, anchored, glow, color = "#ffffff") {
context.fillStyle = color;
context.globalAlpha = anchored ? 0.55 : 0.96;
context.shadowColor = color;
context.shadowBlur = anchored ? 8 : 14;
if (glow) {
context.shadowColor = color;
context.shadowBlur = anchored ? 8 : 14;
}
context.beginPath();
if (anchored) {
context.arc(0, 0, 18, 0, Math.PI * 2);
context.arc(0, 0, 24, 0, Math.PI * 2);
} else {
context.moveTo(0, -28);
context.lineTo(21, 24);
context.lineTo(0, 13);
context.lineTo(-21, 24);
context.moveTo(0, -37);
context.lineTo(28, 32);
context.lineTo(0, 17);
context.lineTo(-28, 32);
context.closePath();
}
context.fill();
context.restore();
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function buildVesselMarkerData(feature) {
@@ -79,62 +66,88 @@ function buildVesselMarkerData(feature) {
};
}
function createVesselMarker(markerData) {
const material = new THREE.SpriteMaterial({
map: createVesselTexture(markerData.type, markerData.anchored),
transparent: true,
depthWrite: false,
opacity: VESSEL_CONFIG.marker.baseOpacity,
rotation: markerData.anchored
? 0
: THREE.MathUtils.degToRad(-markerData.course),
});
const marker = new THREE.Sprite(material);
marker.position.copy(
latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset,
),
);
marker.scale.setScalar(VESSEL_CONFIG.marker.baseScale);
marker.renderOrder = VESSEL_RENDER_ORDER;
marker.visible = showVessels;
marker.userData = {
...markerData,
type: "vessel",
vessel_kind: markerData.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
state: "normal",
};
vesselGroup.add(marker);
vesselMarkers.push(marker);
function getCourseBin(marker) {
if (marker.userData.anchored) return 0;
const course = Number(marker.userData.course || 0);
const normalized = ((course % 360) + 360) % 360;
return Math.round((normalized / 360) * VESSEL_COURSE_BINS) % VESSEL_COURSE_BINS;
}
function clearGroup(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
child.geometry?.dispose?.();
group.remove(child);
function buildTrackPoint(lon, lat) {
const latitude = Number(lat);
const longitude = Number(lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return latLonToVector3(
latitude,
longitude,
CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset,
);
}
function appendCurrentMarkerTrackPoint(points, marker) {
if (!(marker?.position instanceof THREE.Vector3)) return;
const markerPosition = marker.position.clone();
const lastPoint = points[points.length - 1];
if (!lastPoint || lastPoint.distanceToSquared(markerPosition) > VESSEL_TRACK_ENDPOINT_EPSILON) {
points.push(markerPosition);
}
}
function getDistanceScale(camera) {
return getSurfaceMarkerCameraScale(camera, {
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
referenceFov: 75,
min: VESSEL_CONFIG.sizeStabilization.min,
max: VESSEL_CONFIG.sizeStabilization.max,
});
}
const vesselIconLayer = createInteractableLayer({
id: "vessels",
objectType: "vessel",
renderOrder: VESSEL_RENDER_ORDER,
altitudeOffset: VESSEL_CONFIG.altitudeOffset,
pointSize: VESSEL_POINT_SIZE,
atlasCellSize: VESSEL_ATLAS_CELL_SIZE,
colors: {
byKind: VESSEL_CONFIG.colors,
normal: VESSEL_CONFIG.colors.other,
},
opacity: {
normal: VESSEL_CONFIG.marker.baseOpacity,
dimmed: VESSEL_CONFIG.marker.dimmedOpacity,
hover: 0.98,
locked: 1,
},
stateScale: {
hover: VESSEL_CONFIG.marker.hoverScale,
locked: VESSEL_CONFIG.marker.lockedScale,
dimmed: VESSEL_CONFIG.marker.dimmedScale,
},
icon: {
draw(context, { marker, rotationBin = 0, glow = false, color = "#ffffff" }) {
const anchored = Boolean(marker?.userData?.anchored);
if (!anchored) {
context.rotate((rotationBin / VESSEL_COURSE_BINS) * Math.PI * 2);
}
drawVesselShape(context, anchored, glow, color);
},
},
getPosition: (item) => ({
latitude: item.latitude,
longitude: item.longitude,
}),
getKind: (item) => item.type || "other",
getRotationBin: getCourseBin,
getBucketKey: (marker) => {
const anchored = Boolean(marker.userData.anchored);
const courseBin = getCourseBin(marker);
return `${anchored ? "anchored" : "moving"}:${courseBin}`;
},
getUserData: (item) => ({
...item,
vessel_kind: item.type,
baseScale: VESSEL_CONFIG.marker.baseScale,
}),
});
export function getVesselMarkers() {
return vesselMarkers;
return vesselIconLayer.getMarkers();
}
export function getVesselCount() {
return vesselMarkers.length;
return vesselIconLayer.getCount();
}
export function getShowVessels() {
@@ -143,17 +156,14 @@ export function getShowVessels() {
export function toggleVessels(show) {
showVessels = Boolean(show);
vesselGroup.visible = showVessels;
vesselMarkers.forEach((marker) => {
marker.visible = showVessels;
});
vesselIconLayer.setVisible(showVessels);
if (activeTrackLine) {
activeTrackLine.visible = showVessels;
}
}
export function clearVesselSelection() {
vesselMarkers.forEach((marker) => setVesselMarkerState(marker, "normal"));
getVesselMarkers().forEach((marker) => setVesselMarkerState(marker, "normal"));
clearVesselTrack();
}
@@ -167,17 +177,16 @@ function clearVesselTrack() {
}
export function setVesselMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "vessel") return;
marker.userData.state = state;
vesselIconLayer.setMarkerState(marker, state);
}
export function getVesselPointerIntersections(options) {
return vesselIconLayer.getPointerIntersections(options);
}
export function clearVesselData(earth) {
vesselMarkers.length = 0;
clearVesselSelection();
clearGroup(vesselGroup);
if (earth && vesselGroup.parent === earth) {
earth.remove(vesselGroup);
}
vesselIconLayer.clearData(earth);
}
export async function loadVessels(_scene, earth, options = {}) {
@@ -191,19 +200,17 @@ export async function loadVessels(_scene, earth, options = {}) {
const features = Array.isArray(payload?.features) ? payload.features : [];
clearVesselData(earth);
features
const markerData = features
.map((feature) => buildVesselMarkerData(feature))
.filter(Boolean)
.slice(0, VESSEL_CONFIG.maxRenderedMarkers)
.forEach((markerData) => createVesselMarker(markerData));
.slice(0, VESSEL_CONFIG.maxRenderedMarkers);
vesselIconLayer.setData(markerData);
if (earth && !vesselGroup.parent) {
earth.add(vesselGroup);
}
vesselGroup.visible = showVessels;
vesselIconLayer.attach(earth);
vesselIconLayer.setVisible(showVessels);
return {
totalCount: vesselMarkers.length,
totalCount: getVesselCount(),
stats: payload?.stats || {},
};
}
@@ -221,14 +228,15 @@ export async function showVesselTrack(marker, earth) {
if (coordinates.length < 2) return null;
const points = coordinates
.map(([lon, lat]) =>
latLonToVector3(
Number(lat),
Number(lon),
CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset,
),
)
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z));
.map(([lon, lat]) => buildTrackPoint(lon, lat))
.filter(
(point) =>
point &&
Number.isFinite(point.x) &&
Number.isFinite(point.y) &&
Number.isFinite(point.z),
);
appendCurrentMarkerTrackPoint(points, marker);
if (points.length < 2) return null;
const geometry = new THREE.BufferGeometry().setFromPoints(points);
@@ -257,25 +265,5 @@ export function getVesselLegendItems() {
}
export function updateVesselVisualState(lockedObjectType, lockedObject, camera) {
const hasFocus = lockedObjectType === "vessel" && lockedObject;
const distanceScale = getDistanceScale(camera);
vesselMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "vessel" && lockedObject === marker;
const state = marker.userData?.state || "normal";
let opacity = VESSEL_CONFIG.marker.baseOpacity;
let scaleMultiplier = 1;
if (isLocked) {
opacity = 1;
scaleMultiplier = VESSEL_CONFIG.marker.lockedScale;
} else if (state === "hover") {
opacity = 0.98;
scaleMultiplier = VESSEL_CONFIG.marker.hoverScale;
} else if (hasFocus) {
opacity = VESSEL_CONFIG.marker.dimmedOpacity;
scaleMultiplier = VESSEL_CONFIG.marker.dimmedScale;
}
marker.material.opacity = showVessels ? opacity : 0;
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
marker.visible = showVessels;
});
vesselIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
}

View File

@@ -30,6 +30,7 @@ import axios, { type AxiosResponse } from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress'
const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800
@@ -54,6 +55,11 @@ interface BuiltInDataSource {
task_id: number | null
progress: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed: number | null
total_records: number | null
is_free?: boolean
@@ -107,6 +113,11 @@ interface UnifiedDataSource {
is_running?: boolean
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
task_id?: number | null
created_at?: string
updated_at?: string | null
@@ -129,6 +140,11 @@ type TriggerDatasourceConflict = {
message?: string
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed?: number | null
total_records?: number | null
}
@@ -142,6 +158,11 @@ type DatasourceTaskStatus = {
task_id?: number | null
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed?: number | null
total_records?: number | null
status?: string | null
@@ -166,6 +187,11 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
is_running: source.is_running,
progress: source.progress,
phase: source.phase,
phase_progress: source.phase_progress,
phase_message: source.phase_message,
phase_current: source.phase_current,
phase_total: source.phase_total,
phase_unit: source.phase_unit,
task_id: source.task_id,
is_free: source.is_free,
requires_credentials: source.requires_credentials,
@@ -291,7 +317,7 @@ function DataSources() {
content: (
<div>
<p>{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}</p>
<p>: {taskInfo?.phase || 'running'}</p>
<p>: {getPhaseDisplay(taskInfo || {})}</p>
<p>: {typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'}</p>
<p></p>
</div>
@@ -474,7 +500,11 @@ function DataSources() {
return <Tag color={record.is_active ? 'green' : 'default'}>{record.is_active ? '启用' : '禁用'}</Tag>
}
if (record.is_running) {
return <Tag color="processing">{record.phase || '运行中'}{record.progress ? ` ${Math.round(record.progress)}%` : ''}</Tag>
return (
<Tooltip title={getPhaseDisplay(record)}>
<Tag color="processing">{getPhaseSummary(record)}</Tag>
</Tooltip>
)
}
if (!record.last_status) return <Tag></Tag>
return <Tag color={record.last_status === 'success' ? 'success' : record.last_status === 'failed' ? 'error' : 'default'}>{record.last_status}</Tag>
@@ -603,8 +633,13 @@ function DataSources() {
{source.source}{source.task_id ? ` · #${source.task_id}` : ''}
</Text>
</Space>
<Tag color="processing">{source.phase || 'running'}</Tag>
<Tooltip title={getPhaseDisplay(source)}>
<Tag color="processing">{getPhaseSummary(source)}</Tag>
</Tooltip>
</div>
{source.phase_message ? (
<Text type="secondary" style={{ fontSize: 12 }}>{source.phase_message}</Text>
) : null}
<Progress
percent={Math.round(source.progress || 0)}
size="small"
@@ -612,8 +647,9 @@ function DataSources() {
strokeColor="#1677ff"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{source.records_processed ?? 0}
{source.total_records ? ` / ${source.total_records}` : ''}
{source.phase_unit === 'bytes'
? `下载 ${formatPhaseMetric(source) || '准备中'}`
: `已处理 ${source.records_processed ?? 0}${source.total_records ? ` / ${source.total_records}` : ''}`}
</Text>
</Space>
</Card>

View File

@@ -95,6 +95,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '新闻直播采集格式', group: 'Earth', order: 15 },
en: { title: 'News Live Streams Collector Format', group: 'Earth', order: 15 },
},
'earth-interactable-usage.md': {
zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 },
en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 },
},
'frontend-admin-frontend-context.md': {
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
en: { title: 'Admin Frontend Context', group: 'Frontend', order: 20 },
@@ -111,6 +115,10 @@ const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: '系统服务控制', group: 'Backend', order: 31 },
en: { title: 'System Service Control', group: 'Backend', order: 31 },
},
'datasource-collector-settings-connectivity.md': {
zh: { title: '数据源、采集器设置与连接验证', group: 'Backend', order: 32 },
en: { title: 'Datasource Collector Settings and Connectivity', group: 'Backend', order: 32 },
},
'agents-aiprovider.md': {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },

View File

@@ -1,16 +1,26 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
import { Table, Tag, Card, Row, Col, Statistic, Button, Tooltip } from 'antd'
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress'
interface Task {
id: number
collector: string
collector?: string
datasource_name?: string
status: 'success' | 'failed' | 'running' | 'pending'
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed: number
total_records?: number | null
progress?: number | null
started_at: string
completed_at: string
duration_seconds: number
@@ -53,7 +63,20 @@ function Tasks() {
title: '收集器',
dataIndex: 'collector',
key: 'collector',
render: (c: string) => <Tag color="blue">{c}</Tag>,
render: (_: string, task: Task) => <Tag color="blue">{task.collector || task.datasource_name || '-'}</Tag>,
},
{
title: '阶段',
dataIndex: 'phase',
key: 'phase',
render: (_: string, task: Task) => {
const detail = task.phase ? getPhaseDisplay(task) : null
return detail ? (
<Tooltip title={detail}>
<Tag color={task.status === 'running' ? 'processing' : 'default'}>{getPhaseSummary(task)}</Tag>
</Tooltip>
) : '-'
},
},
{
title: '状态',

View File

@@ -0,0 +1,61 @@
export type PhaseProgressLike = {
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
}
const BYTE_UNIT_BASE = 1024
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
export function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0 B'
let size = value
let unitIndex = 0
while (size >= BYTE_UNIT_BASE && unitIndex < BYTE_UNITS.length - 1) {
size /= BYTE_UNIT_BASE
unitIndex += 1
}
const digits = unitIndex === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${BYTE_UNITS[unitIndex]}`
}
export function formatPhaseMetric(source: Pick<PhaseProgressLike, 'phase_current' | 'phase_total' | 'phase_unit'>): string | null {
const current = source.phase_current
const total = source.phase_total
if (typeof current !== 'number') return null
if (source.phase_unit === 'bytes') {
return typeof total === 'number' && total > 0
? `${formatBytes(current)} / ${formatBytes(total)}`
: formatBytes(current)
}
if (typeof total === 'number' && total > 0) {
return `${current} / ${total}${source.phase_unit ? ` ${source.phase_unit}` : ''}`
}
return `${current}${source.phase_unit ? ` ${source.phase_unit}` : ''}`
}
export function getPhaseDisplay(source: PhaseProgressLike, fallback = 'running'): string {
const phase = source.phase || fallback
const parts = [phase]
if (typeof source.phase_progress === 'number') {
parts.push(`${Math.round(source.phase_progress)}%`)
}
if (source.phase_message) {
parts.push(source.phase_message)
}
const metric = formatPhaseMetric(source)
if (metric) {
parts.push(metric)
}
return parts.join(' · ')
}
export function getPhaseSummary(source: PhaseProgressLike, fallback = 'running'): string {
const phase = source.phase || fallback
return typeof source.phase_progress === 'number'
? `${phase} ${Math.round(source.phase_progress)}%`
: phase
}

View File

@@ -6,6 +6,48 @@ SCRIPT_PATH="${(%):-%N}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
cd "$SCRIPT_DIR"
load_user_zshrc_exported_env() {
local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}"
[ "$load_mode" != "0" ] || return 0
[ "$load_mode" != "false" ] || return 0
[ -f "$HOME/.zshrc" ] || return 0
local env_line env_name env_value
if [ "$load_mode" = "source" ]; then
command -v zsh >/dev/null 2>&1 || return 0
while IFS= read -r env_line; do
env_name="${env_line%%=*}"
env_value="${env_line#*=}"
case "$env_name" in
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
export "${env_name}=${env_value}"
;;
esac
done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true)
return 0
fi
while IFS= read -r env_line; do
env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
case "$env_line" in
""|\#*) continue ;;
export\ *) env_line="${env_line#export }" ;;
esac
env_name="${env_line%%=*}"
env_value="${env_line#*=}"
[ "$env_name" != "$env_value" ] || continue
env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')"
case "$env_name" in
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
export "${env_name}=${env_value}"
;;
esac
done < "$HOME/.zshrc"
}
load_user_zshrc_exported_env
RED='\033[38;5;203m'
GREEN='\033[38;5;114m'
YELLOW='\033[38;5;221m'
@@ -68,7 +110,23 @@ AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}"
AI_PROVIDER_RECREATE_REQUIRED=0
AI_PROVIDER_RUNTIME_ENV_NAMES=(
SERVICE_NAME
SERVICE_VERSION
AI_PROVIDER
AI_PROVIDER_API
AI_BASE_URL
AI_API_KEY
AI_MODEL
AI_TIMEOUT_SECONDS
AI_HTTP_RETRY_ATTEMPTS
AI_MAX_TOKENS
AI_ANTHROPIC_VERSION
AI_ANALYSIS_SYSTEM_PROMPT
AI_PROVIDER_SERVICE_TOKEN
)
prepend_path_once() {
local path_entry="$1"
@@ -590,6 +648,8 @@ compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
@@ -633,6 +693,9 @@ run_ai_provider_container_manually() {
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
fi
if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then
env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE")
fi
docker run -d \
--name "$AI_PROVIDER_CONTAINER_NAME" \
@@ -641,6 +704,22 @@ run_ai_provider_container_manually() {
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
}
write_ai_provider_runtime_env_file() {
local env_name env_value
: > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true
for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do
if [ -n "${(P)env_name+x}" ]; then
env_value="${(P)env_name}"
printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
fi
done
export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE
}
recreate_ai_provider_container() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
@@ -1156,6 +1235,7 @@ start_ai_provider_service() {
local retry=1
set_wait_detail "启动 AI Provider"
write_ai_provider_runtime_env_file
ensure_ai_provider_image_current
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.44.0"
version = "0.46.3"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.44.0"
version = "0.46.3"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },