Compare commits

..

6 Commits

Author SHA1 Message Date
linkong
c1d651589a release: bump version to 0.76.19 2026-11-09 15:12:50 +08:00
rayd1o
36be9e3da6 release: bump version to 0.76.18 2026-10-02 05:46:33 +08:00
rayd1o
7d8822d34d release: bump version to 0.76.17 2026-10-02 03:38:52 +08:00
rayd1o
ae1a4b3942 release: bump version to 0.76.16 2026-10-02 03:22:11 +08:00
rayd1o
35e478642b release: bump version to 0.76.15 2026-10-02 03:11:41 +08:00
rayd1o
33734b08b8 release: bump version to 0.76.14 2026-10-02 02:56:14 +08:00
113 changed files with 8981 additions and 1494 deletions

View File

@@ -19,9 +19,9 @@ Do not refactor architecture, add features, or change behavior.
If the user specifies a file or directory, check only that. Otherwise check all uncommitted changes (`git diff HEAD`).
Only report issues present in **newly added or modified** lines of this diff — do not audit unchanged code.
Only report and fix issues present in **newly added or modified** lines of this diff — do not audit unchanged code, and do not rewrite unrelated files during cleanup.
## Token-Saving Rule
## Token-Saving Rule & Harness Checks
Prefer deterministic CLI checks before reading files into model context:
@@ -29,27 +29,36 @@ Prefer deterministic CLI checks before reading files into model context:
git diff --name-only HEAD
git diff --unified=0 HEAD -- <path>
git diff --check
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
```
Use the repository's focused harness checks for the touched surfaces:
- Backend Python changes: `scripts/harness/backend-rules-check.sh` (catches direct `print()`, `breakpoint()`, and debugger calls)
- Frontend changes: `scripts/harness/frontend-rules-check.sh` (catches console logs, missing button types, Card nesting, layout height-chain issues)
- Secrets / credentials: `scripts/harness/security-check.sh`
Read full files only when the focused diff does not provide enough surrounding context to make a safe edit.
## Checklist
### 1. Duplicate Logic
- Identical or near-identical code blocks appearing in multiple places
- A function/helper that already exists but is re-implemented elsewhere instead of being reused
- Repeated DOM queries, regex literals, or template strings within the same file
- Identical or near-identical code blocks appearing in multiple places within the modified scope
- A helper that already exists but is re-implemented elsewhere instead of being reused
- Repeated complex queries or template strings that cause actual maintenance hazard
- **Anti-Over-Defensiveness**: Keep one-off logic at its call site. Do not prematurely abstract single-use or 2-use trivial helpers/templates that are clearer inline.
### 2. Magic Numbers / Magic Strings
- Bare numeric literals used in calculations (offsets, timeouts, sizes, thresholds) without a named constant
- Hardcoded strings (IDs, status values, URL fragments) scattered through logic
- Exceptions: `0`, `1`, `-1`, `100`, `""` and other idiomatically clear values are fine
- Bare numeric literals used in calculations (offsets, timeouts, sizes, thresholds) where the intent is completely opaque and naming adds clear value
- Hardcoded domain strings (IDs, status values, protocol fragments) scattered through logic
- **Anti-Over-Defensiveness (AGENTS.md Quality Bar)**:
- Idiomatically clear values such as `0`, `1`, `-1`, `100`, `""` are fine inline.
- **Do NOT over-extract local single-use layout offsets, Three.js coordinates, padding, or math coefficients into module-level globals.**
- Keep one-off logic at its call site unless extraction clarifies a complex operation or lets callers share the same behavior.
### 3. Naming Issues
- Cryptic abbreviations (`or_`, `tmp2`, `x2`)
- Names that do not match actual behavior
- The same concept referred to by different names in different places
- The same concept referred to by different names in different places within the changed scope
### 4. Dead Code
- Commented-out code blocks (3+ lines)
@@ -58,12 +67,12 @@ Read full files only when the focused diff does not provide enough surrounding c
### 5. Style Inconsistencies
- Trailing whitespace
- Mixed quote styles or indentation within the same file
- Mixed quote styles or indentation within the same file (Python: 4 spaces Black style; TypeScript: 2 spaces Prettier style)
- Inconsistent blank-line usage (multiple consecutive blank lines, etc.)
### 6. Other
- Private helper functions that should be exported but are not, causing callers to duplicate the implementation
- Overly verbose conditions that can be simplified without changing logic
- Keep fixes strictly scoped to the modified files; do not rewrite external callers or expand cleanup beyond the changed scope.
## Steps
@@ -107,30 +116,39 @@ If no issues are found, output "No code smells detected. Code quality looks good
Use the Edit tool for **minimal, targeted changes**:
- **Duplicate logic**: extract to a shared constant or function; update all call sites
- **Magic number/string**: declare `const NAME = value` near the top of the relevant scope; replace all usages
- **Naming**: rename the variable/function; update all references
- **Duplicate logic**: extract to a shared constant or helper within the owned file or component scope; update call sites strictly within scope
- **Magic number/string**: declare `const NAME = value` near the top of the relevant scope only when the value is reused across places or its meaning is genuinely opaque; replace usages. Keep one-off UI/math offsets inline per `AGENTS.md`
- **Naming**: rename the variable/function; update all references within the modified scope
- **Dead code**: delete it
- **Trailing whitespace / style**: fix in place
- **Unexported helper**: add `export`; update callers to import instead of re-implementing
- **Unexported helper**: add `export` only when requested; do not modify unrelated callers
Principles:
- Only fix issues identified in the checklist — no extra improvements
- Only fix issues identified in the checklist — no extra improvements or speculative refactorings
- Keep each Edit as small as possible
- After fixing, verify the old bad pattern is gone with grep
- Prefer `apply_patch` for targeted edits; use formatters only when the repository already uses them for the touched file type
- After fixing, verify the old bad pattern is gone with grep or deterministic harness checks
- Prefer `apply_patch` or targeted edit tools; use formatters only when the repository already uses them for the touched file type
### Step 5 — Summary
### Step 5 — Verify and summarize
Run the smallest relevant harness checks for the modified surfaces:
- Python files: `uv run python -m py_compile <changed_py_files>` and `scripts/harness/backend-rules-check.sh`
- Frontend files: `scripts/harness/frontend-rules-check.sh` (and `bun run --cwd frontend build` if imports/types were adjusted)
- Formatting / whitespace: `git diff --check`
```
Cleanup complete:
Fixed N issues:
✓ earth.js — extracted duplicate vertexShader into ATMOS_VERTEX_SHADER constant
✓ main.js — extracted TOOLTIP_CURSOR_OFFSET = 14 (4 references updated)
✓ controls.js — exported updateLayerButtonState; removed duplicate implementation in main.js
✓ main.js — clarified variable naming in news event payload handler
✓ controls.js — removed unused dead import
...
Validation:
✓ git diff --check passed
✓ scripts/harness/frontend-rules-check.sh passed
Skipped (needs manual review):
! foo.js L45 — large commented-out block; confirm it is safe to delete
```
@@ -141,4 +159,6 @@ Skipped (needs manual review):
- **Do not** add new features, abstractions, or parameters
- **Do not** rewrite comments (only delete commented-out dead code)
- **Do not** touch test file logic
- **Do not** rewrite unrelated files during cleanup (per `AGENTS.md` Prohibited)
- **Do not** over-extract local single-use coordinates, layout numbers, or inline math coefficients into global constants (preventing defensive over-engineering)
- If a magic number's intent is uncertain, skip it and flag it in the summary

View File

@@ -48,18 +48,22 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
3. Write the doc:
- Explain background/problem, design decisions, constraints, and operational impact.
- Include the context, rationale, or operational impact the reader needs for this change.
- Keep code snippets short and directly relevant.
- List related files only when they help future maintainers navigate.
- Use the repository’s existing language, heading style, and naming conventions.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. Only update the glossary when introducing new, ambiguous, or cross-language public concepts; avoid unnecessary glossary churn for ordinary bug fixes or internal refactors.
4. Verify:
- Read the completed doc once for clarity and stale statements.
- Verify important referenced paths exist with `test -e` or `rg --files`.
- Run repository-specific doc checks from `docs/documentation-coverage-rules.md` when present.
- For Markdown links, check that user-facing titles are readable and not raw filenames unless the repository rules allow it.
- Run the repository's centralized documentation harness gate:
```bash
scripts/harness/docs-consistency-check.sh
```
This automatically verifies bilingual pairs, public registry whitelist, link anchors, language-scoped links, section deep-links, and stale admin route terms in a single pass.
- Run any additional domain-specific checks from `docs/documentation-coverage-rules.md` when applicable.
## Hard Constraints

View File

@@ -19,9 +19,11 @@ Do not use this skill for ordinary commits that are not being released.
## Versioning Rules
- `feature` -> bump minor and reset patch to `0` (`x.y.z` → `x.(y+1).0`; for example `0.41.2` → `0.42.0`)
- `bugfix` -> bump `+0.0.1`
- `improvement` -> bump patch `+0.0.1` (`x.y.z` → `x.y.(z+1)`)
- `bugfix` -> bump patch `+0.0.1` (`x.y.z` → `x.y.(z+1)`)
- `docs`, `maintenance`, and `refactor` do not bump by default unless the user explicitly wants a release
Mixed bugfix and small feature/UI work defaults to `improvement` unless the user explicitly chooses another release type.
When intent is mixed, prefer the user's stated release intent.
## Required Files
@@ -53,28 +55,32 @@ Do not inspect full diffs unless deciding whether changed code belongs in the re
### Step 1 — Environment check
```bash
git branch --show-current # must be on dev
git branch --show-current # check current branch
git status --short # check for unrelated uncommitted changes
cat VERSION # read current version
```
If not on `dev`, stop and tell the user. Do not proceed.
Default release branch is `dev`. If not on `dev` (e.g. on a dedicated maintenance or release branch), report the active branch and confirm before proceeding, rather than hard-failing.
If unrelated uncommitted changes exist, list them and ask the user whether to include them or stash first.
### Step 2 — Determine release type and next version
- If the user provided an explicit type (`feature` / `bugfix`), use it
- Otherwise infer from `git diff --stat HEAD`, `git diff --name-only HEAD`, focused diffs for changed code, and recent `git log`
- If the user provided an explicit type (`feature` / `improvement` / `bugfix`), use it
- Otherwise infer from `git diff --stat HEAD`, `git diff --name-only HEAD`, focused diffs for changed code, and recent `git log`:
- New features or major capabilities -> `feature`
- Mixed bugfix and small feature/UI work -> default to `improvement` (per `rules.md`)
- Bugfixes only -> `bugfix`
- Compute the next version:
- `feature`: increment minor and reset patch to `0` (e.g. `0.41.2` → `0.42.0`)
- `bugfix`: increment patch only (e.g. `0.26.2` → `0.26.3`)
- `improvement`: increment patch only (e.g. `0.76.18` → `0.76.19`)
- `bugfix`: increment patch only (e.g. `0.76.18` → `0.76.19`)
- **Show the release plan before making any changes:**
```
Release plan:
Type: bugfix
Version: 0.26.2 → 0.26.3
Type: improvement
Version: 0.76.18 → 0.76.19
Branch: dev
Will update: VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
```
@@ -117,17 +123,25 @@ Get today's date with `date +%Y-%m-%d`.
### Step 6 — Validate
Run the smallest relevant validation for the changes in scope:
Run the smallest relevant harness validation for the changes in scope:
- Python files changed: list changed Python files with `git diff --name-only HEAD -- '*.py'`, then run `python3 -m py_compile <changed_files>`
- Frontend files changed: list changed frontend files with `git diff --name-only HEAD -- frontend`, then run the project-standard check if available; otherwise skip and say so
- Version consistency: confirm VERSION, package.json, pyproject.toml, and uv.lock all show the same version
- **Version consistency**: confirm `VERSION`, `frontend/package.json`, `pyproject.toml`, and `uv.lock` all show the identical target version:
```bash
cat VERSION
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
```
- **Security scan**: run `scripts/harness/security-check.sh`
- **Python files changed**:
- Compile: list changed Python files with `git diff --name-only HEAD -- '*.py'`, then run `uv run python -m py_compile <changed_files>`
- Service rules: run `scripts/harness/backend-rules-check.sh`
- **Frontend files changed**:
- Rules: run `scripts/harness/frontend-rules-check.sh`
- Build: run `bun run --cwd frontend build` (Bun only; never skip)
- **Docs changed**: run `scripts/harness/docs-consistency-check.sh`
- **Broad / cross-subsystem changes**: run `scripts/harness/quick-check.sh`
### Step 7 — Pre-commit preview
Show what will be committed:
@@ -141,29 +155,36 @@ Confirm all required files are present and no unexpected files (debug files, `.e
### Step 8 — Commit, tag, and push
```bash
branch=$(git branch --show-current)
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
# also stage any code changes included in this release
git add <code_files>
git commit -m "release: bump version to x.x.x"
git tag vx.x.x
git push origin dev
git push origin vx.x.x
```
Commit message format is fixed: `release: bump version to x.x.x`
Push to remote:
- When remote publication is requested:
```bash
git push origin "$branch"
git push origin vx.x.x
```
- If remote publication was not requested or remote access is restricted, report the commit and tag as complete and output the push commands for manual execution.
### Step 9 — Completion summary
```
✓ Version bumped: 0.26.2 → 0.26.3
✓ Version bumped: 0.76.18 → 0.76.19
✓ CHANGELOG updated
✓ version-history updated
✓ uv.lock regenerated
✓ Validation passed
✓ commit: release: bump version to 0.26.3
✓ tag: v0.26.3
✓ Pushed to origin/dev
✓ commit: release: bump version to 0.76.19
✓ tag: v0.76.19
✓ Pushed to origin/dev (or reported pending manual push)
```
## Notes

View File

@@ -55,13 +55,19 @@ Preserve user changes already present in the worktree.
### Validation
Fast local harness validation:
For an isolated change, run the smallest check that exercises the affected
behavior, along with any gates required for that area. For example, run the
docs consistency check for a link change, or reproduce a bug at the entry
point where it occurred.
Use the quick suite when a change affects shared harness wiring or needs
regression checks across areas:
```bash
scripts/harness/quick-check.sh
```
Full local validation:
Use full validation for larger cross-subsystem changes:
```bash
scripts/harness/validate.sh
@@ -123,6 +129,8 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
focused file reads, tests, builds, linters, and harness scripts.
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
release work, and documentation unless the task requires it.
- When the user corrects a detail, apply it to the original request. Carry the
correction into the result without turning discarded ideas into new requirements.
---
@@ -144,6 +152,12 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
visual-dependent part of the work.
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
diff, direct file check, or harness result.
- Verify the outcome through the entry point the user relies on. A source check
can confirm a code convention. A UI test with a mocked API can confirm what
the user sees for that response.
- When a test fails, identify the behavior or invariant it protects. Fix the
implementation if that regressed. If the behavior is correct and the assertion
pins an obsolete detail, update the test to exercise the behavior.
- For UI and rendering changes, verify the rendered result when local tooling
allows it.
@@ -162,7 +176,14 @@ behavior and document the compatibility note in `docs/harness-audit.md` or
## Quality Bar
- Prefer existing project patterns over new abstractions.
- Prefer existing project patterns and the smallest change that satisfies the
request. Keep one-off logic at its call site unless extraction clarifies a
complex operation or lets callers share the same behavior.
- Validate and normalize external data at the boundary that owns it. Recheck a
guarantee when data crosses a new trust boundary or an operation can invalidate it.
- Before adding a retry, lock, or fallback, identify the failure it addresses
from a requirement, an existing contract, or observed behavior. Catch errors
where the caller can recover, translate them, or add context and propagate them.
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
a stable path exists.
- Centralize prompts, constants, defaults, and shared request/response handling.

View File

@@ -1 +1 @@
0.76.13
0.76.19

View File

@@ -13,6 +13,7 @@ from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings as app_settings
from app.core.logging import get_logger
from app.core.security import decode_token, get_current_user, redis_client
from app.db.session import get_db
from app.models.collected_data import CollectedData
@@ -51,6 +52,7 @@ from app.services.earth_boundaries import (
router = APIRouter()
logger = get_logger(__name__)
optional_bearer = HTTPBearer(auto_error=False)
REPO_ROOT = Path(__file__).resolve().parents[4]
EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
@@ -382,13 +384,28 @@ async def get_earth_news_sources(db: AsyncSession = Depends(get_db)):
return await get_earth_news_sources_payload(db)
def _schedule_earth_news_source_refresh() -> None:
try:
from app.services.scheduler import schedule_earth_news_refresh_for_all_regions
schedule_earth_news_refresh_for_all_regions(force=True)
except Exception as exc:
logger.warning_event(
"Failed to schedule Earth news refresh after source configuration change",
event="earth_news.rss.config_refresh_schedule_failed",
context={"error": str(exc)},
)
@router.put("/news-sources")
async def update_earth_news_sources(
payload: EarthNewsSourcesPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await save_earth_news_sources_payload(db, payload.model_dump())
result = await save_earth_news_sources_payload(db, payload.model_dump())
_schedule_earth_news_source_refresh()
return result
@router.delete("/news-sources")
@@ -397,7 +414,9 @@ async def reset_earth_news_sources(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await reset_earth_news_sources_payload(db)
result = await reset_earth_news_sources_payload(db)
_schedule_earth_news_source_refresh()
return result
@router.post("/news-sources/test")

View File

@@ -1,17 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from app.db.session import get_db
from app.services.earth_news import (
ALLOWED_NEWS_CATEGORY_KEYS,
SUPPORTED_NEWS_LOCALES,
REGION_ANCHORS,
determine_focus_region,
get_earth_news_payload,
)
router = APIRouter()
class EarthNewsRefreshRequest(BaseModel):
lat: float | None = Field(default=None, ge=-90, le=90)
lon: float | None = Field(default=None, ge=-180, le=180)
region: str | None = Field(default=None, max_length=64)
def _parse_categories(raw: str | None) -> set[str] | None:
if raw is None or not raw.strip():
return None
@@ -39,7 +48,9 @@ def _parse_limit(raw: int | None) -> int:
if raw is None:
return 12
if raw < 1:
raise HTTPException(status_code=422, detail={"message": "News limit must be greater than 0."})
raise HTTPException(
status_code=422, detail={"message": "News limit must be greater than 0."}
)
return min(raw, 100)
@@ -59,18 +70,76 @@ def _parse_locale(raw: str | None) -> str:
return requested
@router.post("/refresh", status_code=status.HTTP_202_ACCEPTED)
async def request_earth_news_refresh(payload: EarthNewsRefreshRequest):
requested_region = payload.region.strip().lower() if payload.region else None
if requested_region and requested_region not in REGION_ANCHORS:
raise HTTPException(
status_code=422,
detail={
"message": "Unsupported news region.",
"invalid_region": requested_region,
"allowed_regions": list(REGION_ANCHORS.keys()),
},
)
region = requested_region or determine_focus_region(payload.lat, payload.lon)
from app.services.scheduler import request_earth_news_region_refresh
try:
return request_earth_news_region_refresh(region)
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"code": "refresh_failed",
"message": "Could not start a news refresh. Please try again.",
},
) from exc
@router.get("/refresh/{refresh_id}")
async def get_earth_news_refresh(refresh_id: UUID):
from app.services.earth_news_refresh import get_earth_news_refresh_status
try:
result = get_earth_news_refresh_status(refresh_id.hex)
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"code": "refresh_failed",
"message": "Refresh status is temporarily unavailable. Please retry.",
},
) from exc
if result is not None:
return result
return {
"refresh_id": refresh_id.hex,
"region": None,
"status": "failed",
"error": {
"code": "expired",
"message": "Refresh status expired. Submit a new refresh.",
},
}
@router.get("/earth-feed")
async def get_earth_feed(
lat: float | None = Query(None, description="Current Earth view center latitude"),
lon: float | None = Query(None, description="Current Earth view center longitude"),
region: str | None = Query(None, description="Explicit Earth news region for UE/client integrations"),
region: str | None = Query(
None, description="Explicit Earth news region for UE/client integrations"
),
categories: str | None = Query(None, description="Comma-separated news category keys"),
sources: str | None = Query(None, description="Comma-separated news source ids"),
limit: int | None = Query(None, description="Maximum news items to return, capped at 100"),
locale: str | None = Query(None, description="Display locale, zh-CN or en-US"),
db: AsyncSession = Depends(get_db),
):
normalized_region = region.strip().lower() if isinstance(region, str) and region.strip() else None
normalized_region = (
region.strip().lower() if isinstance(region, str) and region.strip() else None
)
if normalized_region is not None and normalized_region not in REGION_ANCHORS:
raise HTTPException(
status_code=422,

View File

@@ -0,0 +1,51 @@
"""SQL objects supporting indexed BGP prefix geography lookup."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
BGP_PREFIX_GEO_FUNCTION_SQL = (
"""
CREATE OR REPLACE FUNCTION planet_bgp_try_inet(value text)
RETURNS inet
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
AS $$
BEGIN
RETURN value::inet;
EXCEPTION WHEN data_exception THEN
RETURN NULL;
END;
$$
""",
"""
CREATE OR REPLACE FUNCTION planet_bgp_try_masklen(value text)
RETURNS integer
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
AS $$
BEGIN
RETURN masklen(value::cidr);
EXCEPTION WHEN data_exception THEN
RETURN NULL;
END;
$$
""",
)
BGP_PREFIX_GEO_INDEX_NAME = "idx_bgp_prefix_geo_range_current"
BGP_PREFIX_GEO_INDEX_SQL = f"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS {BGP_PREFIX_GEO_INDEX_NAME}
ON collected_data USING gist (
planet_bgp_try_inet(metadata->>'range_start') inet_ops,
planet_bgp_try_inet(metadata->>'range_end') inet_ops
)
WHERE source IN (
'opengeofeed_prefix_geo',
'iptoasn_prefix_geo',
'nro_delegated_prefix_geo'
)
AND COALESCE(is_current, TRUE) = TRUE
"""
async def install_bgp_prefix_geo_functions(connection: AsyncConnection) -> None:
for statement in BGP_PREFIX_GEO_FUNCTION_SQL:
await connection.execute(text(statement))

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import declarative_base
from app.core.config import settings
from app.core.logging import get_logger
from app.db.bgp_prefix_geo import install_bgp_prefix_geo_functions
logger = get_logger(__name__)
@@ -219,6 +220,7 @@ async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await install_bgp_prefix_geo_functions(conn)
users_email_verified_existed = (
await conn.execute(
text(
@@ -299,8 +301,12 @@ async def init_db():
change_event_id BIGINT;
change_payload JSONB;
BEGIN
change_event_id := nextval(
pg_get_serial_sequence('earth_data_change_events', 'id')::regclass
);
change_payload := jsonb_build_object(
'event', 'earth.layer.changed',
'event_id', change_event_id,
'table', change_table,
'operation', change_operation,
'source', change_source,
@@ -311,6 +317,7 @@ async def init_db():
);
INSERT INTO earth_data_change_events (
id,
table_name,
operation,
source,
@@ -318,23 +325,15 @@ async def init_db():
payload,
occurred_at
) VALUES (
change_event_id,
change_table,
change_operation,
change_source,
NULL,
change_payload,
NOW()
)
RETURNING id INTO change_event_id;
change_payload := change_payload || jsonb_build_object(
'event_id', change_event_id
);
UPDATE earth_data_change_events
SET payload = change_payload
WHERE id = change_event_id;
PERFORM pg_notify(
'planet_earth_data_changes',
change_payload::text
@@ -440,6 +439,16 @@ async def init_db():
);
END LOOP;
ELSIF TG_OP = 'UPDATE' THEN
IF TG_TABLE_NAME = 'earth_news_items' AND NOT EXISTS (
SELECT 1
FROM new_rows AS current_row
FULL JOIN old_rows AS previous_row USING (id)
WHERE (to_jsonb(current_row) - 'last_seen_at' - 'updated_at')
IS DISTINCT FROM
(to_jsonb(previous_row) - 'last_seen_at' - 'updated_at')
) THEN
RETURN NULL;
END IF;
FOR change_source IN
SELECT DISTINCT COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME)
FROM (

View File

@@ -111,15 +111,24 @@ async def _lookup_prefix_geography(
result = await db.execute(
text(
"""
WITH candidates AS MATERIALIZED (
SELECT source, id, metadata
FROM collected_data
WHERE source IN (
'opengeofeed_prefix_geo',
'iptoasn_prefix_geo',
'nro_delegated_prefix_geo'
)
AND COALESCE(is_current, TRUE) = TRUE
AND planet_bgp_try_inet(metadata->>'range_start') <= CAST(:range_start AS inet)
AND planet_bgp_try_inet(metadata->>'range_end') >= CAST(:range_end AS inet)
)
SELECT metadata
FROM collected_data
FROM candidates
WHERE source = :source
AND COALESCE(is_current, TRUE) = TRUE
AND metadata->>'family' = :family
AND CAST(metadata->>'range_start' AS inet) <= CAST(:range_start AS inet)
AND CAST(metadata->>'range_end' AS inet) >= CAST(:range_end AS inet)
ORDER BY
masklen(CAST(metadata->>'prefix' AS cidr)) DESC NULLS LAST,
planet_bgp_try_masklen(metadata->>'prefix') DESC NULLS LAST,
id DESC
LIMIT 1
"""
@@ -146,6 +155,10 @@ async def _lookup_prefix_geography(
results: dict[str, dict[str, Any]] = {}
# The GiST range predicate is selective, but PostgreSQL 15 greatly overestimates its cost.
# Avoid spending more time compiling JIT code than fetching the small candidate set.
await db.execute(text("SET LOCAL jit = off"))
for prefix in prefix_values:
try:
network = ipaddress.ip_network(prefix, strict=False)

View File

@@ -22,6 +22,12 @@ from app.services.earth_layer_adapters import (
get_earth_update_layers_for_source,
)
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
from app.services.earth_outbox_maintenance import (
configured_retention_days,
purge_consumed_events,
release_maintenance_lock,
try_maintenance_lock,
)
logger = get_logger(__name__)
@@ -30,6 +36,7 @@ DEFAULT_DEBOUNCE_SECONDS = 0.25
DEFAULT_MAX_WAIT_SECONDS = 1.5
DELETE_FAST_FLUSH_SECONDS = 0.05
LISTEN_KEEPALIVE_SECONDS = 5.0
OUTBOX_RETENTION_CHECK_SECONDS = 60.0
OUTBOX_POLL_LIMIT = 5000
MAX_ENTITY_KEY_SAMPLES = 20
MAX_SEEN_EVENT_IDS = 20000
@@ -346,6 +353,7 @@ class EarthDbChangeListener:
self._stop_event: asyncio.Event | None = None
self._connection: asyncpg.Connection | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._next_retention_check = 0.0
def start(self) -> None:
if self._task and not self._task.done():
@@ -385,6 +393,7 @@ class EarthDbChangeListener:
)
except asyncio.TimeoutError:
await self._poll_outbox()
await self._maybe_purge_outbox()
except asyncio.CancelledError:
raise
except Exception as exc:
@@ -459,6 +468,27 @@ class EarthDbChangeListener:
context={"events": len(rows), "accepted": accepted_count},
)
async def _maybe_purge_outbox(self) -> None:
if self._connection is None or monotonic() < self._next_retention_check:
return
self._next_retention_check = monotonic() + OUTBOX_RETENTION_CHECK_SECONDS
try:
if not await try_maintenance_lock(self._connection):
return
try:
await purge_consumed_events(
self._connection,
retention_days=configured_retention_days(),
)
finally:
await release_maintenance_lock(self._connection)
except Exception as exc:
logger.exception_event(
"Earth outbox retention failed",
event="earth.outbox.retention_failed",
context={"error": str(exc)},
)
def _on_notification(
self,
_connection: asyncpg.Connection,

View File

@@ -99,7 +99,7 @@ EARTH_LAYER_ADAPTERS: tuple[EarthLayerAdapter, ...] = (
sources=frozenset({"media_news_archive", "earth_news_items"}),
tables=frozenset({"earth_news_items"}),
layers=("news",),
cache_patterns=("summary*",),
cache_patterns=(),
refresh_strategy="reload",
),
EarthLayerAdapter(

View File

@@ -2,25 +2,27 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from contextlib import suppress
from dataclasses import dataclass, replace
import hashlib
import json
import time
from typing import Any
from uuid import uuid4
from fastapi import Response
from fastapi import HTTPException, Response
from app.core.cache import _RedisClient
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__, service="earth_layer_cache")
EARTH_LAYER_CACHE_PREFIX = "earth:layer:v1"
EARTH_LAYER_LOCK_PREFIX = "earth:layer:lock:v1"
DEFAULT_LOCK_TTL_SECONDS = 10
DEFAULT_LOCK_WAIT_SECONDS = 0.2
DEFAULT_BUILD_WAIT_SECONDS = 30
DEFAULT_MAX_FEATURES = 5000
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_BBOX_PRECISION_DEGREES = 0.1
@@ -35,6 +37,7 @@ class EarthLayerCachePolicy:
max_bytes: int = DEFAULT_MAX_BYTES
lock_ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS
lock_wait_seconds: float = DEFAULT_LOCK_WAIT_SECONDS
build_wait_seconds: float = DEFAULT_BUILD_WAIT_SECONDS
@dataclass(frozen=True)
@@ -49,6 +52,9 @@ class EarthLayerCacheResult:
class EarthLayerCache:
def __init__(self) -> None:
self._client = None
self._inflight: dict[
tuple[asyncio.AbstractEventLoop, str], asyncio.Future[EarthLayerCacheResult | None]
] = {}
@property
def client(self):
@@ -82,17 +88,62 @@ class EarthLayerCache:
value = json.loads(raw)
return value if isinstance(value, dict) else None
def set_json(self, key: str, payload: dict[str, Any], ttl_seconds: int) -> None:
self.client.setex(key, ttl_seconds, json.dumps(payload, ensure_ascii=False, default=str))
def acquire_lock(self, key: str, ttl_seconds: int) -> str | None:
token = uuid4().hex
return (
token if self.client.set(self.lock_key(key), token, nx=True, ex=ttl_seconds) else None
)
def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
return bool(self.client.set(self.lock_key(key), "1", nx=True, ex=ttl_seconds))
def renew_lock(self, key: str, token: str, ttl_seconds: int) -> bool:
return bool(
self.client.eval(
"""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
""",
1,
self.lock_key(key),
token,
ttl_seconds,
)
)
def release_lock(self, key: str) -> None:
try:
self.client.delete(self.lock_key(key))
except Exception:
pass
def release_lock(self, key: str, token: str) -> None:
self.client.eval(
"""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
""",
1,
self.lock_key(key),
token,
)
def publish(
self, key: str, token: str, payload: dict[str, Any], policy: EarthLayerCachePolicy
) -> bool:
return bool(
self.client.eval(
"""
if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end
redis.call('setex', KEYS[2], ARGV[2], ARGV[4])
redis.call('setex', KEYS[3], ARGV[3], ARGV[4])
return 1
""",
3,
self.lock_key(key),
key,
self.stale_key(key),
token,
policy.fresh_ttl_seconds,
policy.stale_ttl_seconds,
json.dumps(payload, ensure_ascii=False, default=str),
)
)
def delete_pattern(self, pattern: str = f"{EARTH_LAYER_CACHE_PREFIX}:*") -> int:
keys = list(self.client.scan_iter(match=pattern))
@@ -169,56 +220,122 @@ async def resolve_layer_payload(
policy: EarthLayerCachePolicy,
builder: Callable[[], Awaitable[dict[str, Any]]],
) -> EarthLayerCacheResult:
started = time.perf_counter()
try:
cached = earth_layer_cache.get_json(key)
if cached is not None:
return _result(cached, state="hit", key=key)
lock_acquired = earth_layer_cache.acquire_lock(key, policy.lock_ttl_seconds)
if lock_acquired:
try:
payload = await _build_budgeted_payload(builder, policy)
_write_fresh_and_stale(key, payload, policy)
_log_cache_event("refresh", key, payload, started)
return _result(payload, state="refresh", key=key)
except Exception as exc:
stale = _read_stale(key)
if stale is not None:
logger.warning_event(
"Earth layer cache builder failed; returning stale payload",
event="earth_layer_cache.stale_after_builder_error",
context={"key": key, "error": str(exc)},
)
return _result(stale, state="stale", key=key)
raise
finally:
earth_layer_cache.release_lock(key)
flight_key = (asyncio.get_running_loop(), key)
while flight_key in earth_layer_cache._inflight:
stale = _read_stale(key)
if stale is not None:
return _result(stale, state="stale", key=key)
future = earth_layer_cache._inflight[flight_key]
done, _ = await asyncio.wait({future}, timeout=policy.build_wait_seconds)
if not done:
raise HTTPException(503, "Earth layer cache rebuild is still in progress")
result = future.result()
if result is not None:
state = "hit" if result.state == "refresh" else result.state
return replace(result, state=state)
await asyncio.sleep(policy.lock_wait_seconds)
cached_after_wait = earth_layer_cache.get_json(key)
if cached_after_wait is not None:
return _result(cached_after_wait, state="hit", key=key)
payload = await _build_budgeted_payload(builder, policy)
_log_cache_event("miss", key, payload, started)
return _result(payload, state="miss", key=key)
future = asyncio.get_running_loop().create_future()
earth_layer_cache._inflight[flight_key] = future
try:
result = await _resolve_layer_payload(key=key, policy=policy, builder=builder)
future.set_result(result)
return result
except asyncio.CancelledError:
# A waiting request can take over using its own still-open DB session.
future.set_result(None)
raise
except Exception as exc:
future.set_exception(exc)
future.exception()
raise
finally:
earth_layer_cache._inflight.pop(flight_key)
async def _resolve_layer_payload(
*,
key: str,
policy: EarthLayerCachePolicy,
builder: Callable[[], Awaitable[dict[str, Any]]],
) -> EarthLayerCacheResult:
started = time.perf_counter()
try:
async with asyncio.timeout(policy.build_wait_seconds):
while True:
cached = earth_layer_cache.get_json(key)
if cached is not None:
return _result(cached, state="hit", key=key)
token = earth_layer_cache.acquire_lock(key, policy.lock_ttl_seconds)
if token is not None:
break
stale = _read_stale(key)
if stale is not None:
return _result(stale, state="stale", key=key)
await asyncio.sleep(policy.lock_wait_seconds)
except TimeoutError:
raise HTTPException(503, "Earth layer cache rebuild is still in progress") from None
except Exception as exc:
try:
payload = await _build_budgeted_payload(builder, policy)
except Exception:
raise exc
logger.warning_event(
"Earth layer cache bypassed",
event="earth_layer_cache.bypass",
context={"key": key, "error": str(exc)},
)
payload = await _build_budgeted_payload(builder, policy)
return _result(payload, state="bypass", key=key)
renewal = asyncio.create_task(_renew_cache_lock(key, token, policy))
try:
try:
payload = await _build_budgeted_payload(builder, policy)
except Exception as exc:
stale = _read_stale(key)
if stale is None:
raise
logger.warning_event(
"Earth layer cache builder failed; returning stale payload",
event="earth_layer_cache.stale_after_builder_error",
context={"key": key, "error": str(exc)},
)
return _result(stale, state="stale", key=key)
try:
published = earth_layer_cache.publish(key, token, payload, policy)
except Exception as exc:
logger.warning_event(
"Earth layer cache publish failed",
event="earth_layer_cache.bypass",
context={"key": key, "error": str(exc)},
)
published = False
state = "refresh" if published else "bypass"
_log_cache_event(state, key, payload, started)
return _result(payload, state=state, key=key)
finally:
renewal.cancel()
with suppress(asyncio.CancelledError):
await renewal
try:
earth_layer_cache.release_lock(key, token)
except Exception as exc:
logger.warning_event(
"Earth layer cache lock release failed",
event="earth_layer_cache.lock_release_failed",
context={"key": key, "error": str(exc)},
)
async def _renew_cache_lock(key: str, token: str, policy: EarthLayerCachePolicy) -> None:
try:
while True:
await asyncio.sleep(policy.lock_ttl_seconds / 3)
if not earth_layer_cache.renew_lock(key, token, policy.lock_ttl_seconds):
raise RuntimeError("Earth layer cache lock ownership lost")
except Exception as exc:
logger.warning_event(
"Earth layer cache lock renewal stopped",
event="earth_layer_cache.lock_renewal_failed",
context={"key": key, "error": str(exc)},
)
def apply_payload_budget(payload: dict[str, Any], policy: EarthLayerCachePolicy) -> dict[str, Any]:
budgeted = _truncate_features(payload, policy.max_features, "feature_budget")
@@ -286,11 +403,6 @@ async def _build_budgeted_payload(
return apply_payload_budget(payload, policy)
def _write_fresh_and_stale(key: str, payload: dict[str, Any], policy: EarthLayerCachePolicy) -> None:
earth_layer_cache.set_json(key, payload, policy.fresh_ttl_seconds)
earth_layer_cache.set_json(earth_layer_cache.stale_key(key), payload, policy.stale_ttl_seconds)
def _read_stale(key: str) -> dict[str, Any] | None:
try:
return earth_layer_cache.get_json(earth_layer_cache.stale_key(key))
@@ -359,14 +471,16 @@ def _payload_size(payload: dict[str, Any]) -> int:
def _safe_key_part(value: Any) -> str:
raw = str(value).strip().lower()
return "".join(char if char.isalnum() or char in {"-", "_", ".", ","} else "_" for char in raw)[:160]
return "".join(char if char.isalnum() or char in {"-", "_", ".", ","} else "_" for char in raw)[
:160
]
def _layer_from_key(key: str) -> str:
prefix = f"{EARTH_LAYER_CACHE_PREFIX}:"
if not key.startswith(prefix):
return "unknown"
remainder = key[len(prefix):]
remainder = key[len(prefix) :]
return remainder.split(":", 1)[0]

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import codecs
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import hashlib
import html
@@ -1639,10 +1639,15 @@ async def record_earth_news_sources_health(
await db.commit()
async def get_configured_sources_for_region(db: AsyncSession | None, region: str) -> list[NewsFeedSource]:
async def get_configured_sources_for_region(
db: AsyncSession | None,
region: str,
*,
config_payload: dict[str, Any] | None = None,
) -> list[NewsFeedSource]:
if db is None:
return get_sources_for_region(region)
payload = await get_earth_news_sources_payload(db)
payload = config_payload or await get_earth_news_sources_payload(db)
sources = [
source
for raw in payload.get("sources", [])
@@ -2731,7 +2736,6 @@ async def get_earth_news_payload(
del provider_client
active_region = region if region in REGION_ANCHORS else determine_focus_region(lat, lon)
has_settings_db = db is not None and callable(getattr(db, "execute", None))
source_config_payload = await get_earth_news_sources_payload(db) if has_settings_db else None
sources = (
await get_configured_sources_for_region(db, active_region)
if has_settings_db
@@ -2750,39 +2754,7 @@ async def get_earth_news_payload(
locale=locale,
)
from app.services.earth_news_store import (
get_earth_news_feed_coverage,
get_earth_news_freshness,
list_earth_news_cruise_items,
list_earth_news_items,
upsert_earth_news_items,
)
errors: list[str] = []
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at)
if not should_supplement and callable(getattr(db, "execute", None)):
expected_feed_keys = _expected_feed_keys(sources, active_region=active_region)
if expected_feed_keys:
covered_feed_keys = await get_earth_news_feed_coverage(
db,
active_region=active_region,
recent_after=datetime.now(UTC) - timedelta(seconds=RSS_SUPPLEMENT_MAX_AGE_SECONDS),
)
should_supplement = bool(expected_feed_keys - covered_feed_keys)
if should_supplement:
if source_config_payload is None:
fetched_items, errors, health_by_source = await _fetch_rss_items_for_sources(sources, active_region=active_region)
else:
fetched_items, errors, health_by_source = await _fetch_rss_items_for_sources(
sources,
active_region=active_region,
config_payload=source_config_payload,
)
await record_earth_news_sources_health(db, health_by_source)
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
await _enqueue_unverified_locations(ranked_fetched_items)
await upsert_earth_news_items(db, ranked_fetched_items)
from app.services.earth_news_store import list_earth_news_cruise_items, list_earth_news_items
items = await _call_store_list_items(
list_earth_news_items,
@@ -2792,7 +2764,7 @@ async def get_earth_news_payload(
categories=categories,
source_ids=source_ids,
)
if not source_ids:
if items and not source_ids:
ready_sources = {
_news_item_source_id(item)
for item in items
@@ -2833,10 +2805,6 @@ async def get_earth_news_payload(
)
else:
cruise_items = items
enqueue_candidates = {item.id: item for item in [*items, *cruise_items] if item.id}
await _enqueue_unverified_locations(list(enqueue_candidates.values()))
stale = bool(errors and items)
return _build_payload(
lat=lat,
lon=lon,
@@ -2844,8 +2812,8 @@ async def get_earth_news_payload(
items=items,
cruise_items=cruise_items,
sources=sources,
errors=errors,
stale=stale,
errors=[],
stale=False,
categories=categories,
source_ids=source_ids,
limit=limit,

View File

@@ -0,0 +1,480 @@
"""Periodic RSS supplementation for the read-only Earth news feed."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from hashlib import sha256
import json
from typing import Any
from uuid import uuid4
from app.core.cache import _RedisClient
from app.core.logging import get_logger
from app.db.session import async_session_factory
from app.services import earth_news
from app.services.earth_news_store import (
get_earth_news_feed_coverage,
get_earth_news_freshness,
upsert_earth_news_items,
)
logger = get_logger(__name__, service="earth_news")
REFRESH_INTERVAL_SECONDS = 10 * 60
REFRESH_LOCK_TTL_SECONDS = 120
REFRESH_LOCK_RENEW_SECONDS = 30
SOURCE_FAILURE_COOLDOWN_SECONDS = 30 * 60
REFRESH_LOCK_PREFIX = "earth:news:rss-refresh:v1"
SOURCE_COOLDOWN_PREFIX = "earth:news:rss-cooldown:v1"
REFRESH_STATUS_PREFIX = "earth:news:rss-refresh-status:v1"
REFRESH_ACTIVE_PREFIX = "earth:news:rss-refresh-active:v1"
REFRESH_STATUS_TTL_SECONDS = 10 * 60
REFRESH_ACTIVE_TTL_SECONDS = 120
REFRESH_STATUS_HEARTBEAT_SECONDS = 30
def _refresh_status_key(refresh_id: str) -> str:
return f"{REFRESH_STATUS_PREFIX}:{refresh_id}"
def _refresh_active_key(region: str) -> str:
return f"{REFRESH_ACTIVE_PREFIX}:{region}"
def create_earth_news_refresh_status(region: str) -> tuple[dict[str, Any], bool]:
"""Create one per-region active refresh status, or return the current one."""
refresh_id = uuid4().hex
status = {"refresh_id": refresh_id, "region": region, "status": "pending"}
script = """
local active_id = redis.call('get', KEYS[1])
if active_id then
local active_status = redis.call('get', ARGV[5] .. active_id)
if active_status then
local decoded = cjson.decode(active_status)
if decoded.status == 'pending' or decoded.status == 'running' then
return {active_id, active_status, 0}
end
end
redis.call('del', KEYS[1])
end
redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[3])
redis.call('set', KEYS[2], ARGV[2], 'EX', ARGV[4])
return {ARGV[1], ARGV[2], 1}
"""
raw = _RedisClient.get_client().eval(
script,
2,
_refresh_active_key(region),
_refresh_status_key(refresh_id),
refresh_id,
json.dumps(status, separators=(",", ":")),
REFRESH_ACTIVE_TTL_SECONDS,
REFRESH_STATUS_TTL_SECONDS,
f"{REFRESH_STATUS_PREFIX}:",
)
_, encoded, created = raw
payload = json.loads(encoded)
return payload, bool(int(created))
def set_earth_news_refresh_status(
refresh_id: str,
region: str,
status: str,
*,
error: dict[str, str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"refresh_id": refresh_id,
"region": region,
"status": status,
}
if error:
payload["error"] = error
script = """
if redis.call('get', KEYS[1]) ~= ARGV[2] then return 0 end
redis.call('set', KEYS[2], ARGV[1], 'EX', ARGV[3])
if ARGV[4] == 'succeeded' or ARGV[4] == 'failed' then
redis.call('del', KEYS[1])
else
redis.call('expire', KEYS[1], ARGV[5])
end
return 1
"""
_RedisClient.get_client().eval(
script,
2,
_refresh_active_key(region),
_refresh_status_key(refresh_id),
json.dumps(payload, separators=(",", ":")),
refresh_id,
REFRESH_STATUS_TTL_SECONDS,
status,
REFRESH_ACTIVE_TTL_SECONDS,
)
return payload
def touch_earth_news_refresh_status(refresh_id: str, region: str) -> bool:
script = """
if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end
redis.call('expire', KEYS[1], ARGV[2])
redis.call('expire', KEYS[2], ARGV[3])
return 1
"""
return bool(
_RedisClient.get_client().eval(
script,
2,
_refresh_active_key(region),
_refresh_status_key(refresh_id),
refresh_id,
REFRESH_ACTIVE_TTL_SECONDS,
REFRESH_STATUS_TTL_SECONDS,
)
)
def get_earth_news_refresh_status(refresh_id: str) -> dict[str, Any] | None:
raw = _RedisClient.get_client().get(_refresh_status_key(refresh_id))
if not raw:
return None
value = json.loads(raw)
if not isinstance(value, dict):
return None
if value.get("status") in {"pending", "running"}:
region = str(value.get("region") or "")
active_id = _RedisClient.get_client().get(_refresh_active_key(region)) if region else None
if active_id != refresh_id:
return {
**value,
"status": "failed",
"error": {
"code": "expired",
"message": "Refresh status expired. Submit a new refresh.",
},
}
return value
def _lock_key(region: str) -> str:
return f"{REFRESH_LOCK_PREFIX}:{region}"
def _cooldown_key(region: str, source_id: str, feed_id: str) -> str:
identity = f"{region}\0{source_id}\0{feed_id}".encode("utf-8")
return f"{SOURCE_COOLDOWN_PREFIX}:{sha256(identity).hexdigest()}"
def _source_failure_cooldown_seconds(source: earth_news.NewsFeedSource | None) -> int:
if source is None:
return SOURCE_FAILURE_COOLDOWN_SECONDS
configured_minutes = source.health_policy.get("cooldown_minutes")
try:
return max(60, int(float(configured_minutes) * 60))
except TypeError, ValueError:
return SOURCE_FAILURE_COOLDOWN_SECONDS
def _select_feeds(
sources: list[earth_news.NewsFeedSource],
*,
active_region: str,
required_keys: set[tuple[str, str]],
redis_client: Any | None,
) -> tuple[list[earth_news.NewsFeedSource], set[tuple[str, str]]]:
selected_sources: list[earth_news.NewsFeedSource] = []
cooling_keys: set[tuple[str, str]] = set()
for source in sources:
selected_feeds = []
source_cooling = False
if redis_client is not None:
try:
source_cooling = bool(
redis_client.exists(_cooldown_key(active_region, source.id, "__source__"))
)
except Exception as exc:
logger.warning_event(
"Earth news source cooldown lookup failed",
event="earth_news.rss.cooldown_lookup_failed",
context={"region": active_region, "source_id": source.id, "error": str(exc)},
)
for feed in earth_news._source_feeds(source):
feed_key = (source.id, feed.id)
if feed_key not in required_keys:
continue
if source_cooling:
cooling_keys.add(feed_key)
continue
if redis_client is not None:
try:
if redis_client.exists(_cooldown_key(active_region, *feed_key)):
cooling_keys.add(feed_key)
continue
except Exception as exc:
logger.warning_event(
"Earth news source cooldown lookup failed",
event="earth_news.rss.cooldown_lookup_failed",
context={
"region": active_region,
"source_id": source.id,
"error": str(exc),
},
)
selected_feeds.append(feed)
if selected_feeds:
selected_sources.append(
replace(
source,
feed_url=selected_feeds[0].url,
feed_urls=tuple(feed.url for feed in selected_feeds),
feeds=tuple(selected_feeds),
)
)
return selected_sources, cooling_keys
def _record_failed_feed_cooldowns(
redis_client: Any | None,
*,
active_region: str,
health_by_source: dict[str, dict[str, Any]],
sources: list[earth_news.NewsFeedSource],
) -> None:
if redis_client is None:
return
cooldown_by_source = {source.id: source for source in sources}
for source_id, health in health_by_source.items():
feed_results = health.get("feed_results")
if isinstance(feed_results, list) and feed_results:
failed_feed_ids = [
str(result.get("feed_id") or "")
for result in feed_results
if isinstance(result, dict) and not result.get("ok") and result.get("feed_id")
]
else:
failed_feed_ids = [str(health.get("feed_id") or "__source__")]
if health.get("ok"):
failed_feed_ids = []
for feed_id in failed_feed_ids:
try:
redis_client.set(
_cooldown_key(active_region, source_id, feed_id),
"1",
ex=_source_failure_cooldown_seconds(cooldown_by_source.get(source_id)),
)
except Exception as exc:
logger.warning_event(
"Earth news source failure cooldown write failed",
event="earth_news.rss.cooldown_write_failed",
context={"region": active_region, "source_id": source_id, "error": str(exc)},
)
async def _renew_lock(redis_client: Any, key: str, token: str, lease_lost: asyncio.Event) -> None:
renew_script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
end
return 0
"""
try:
while True:
await asyncio.sleep(REFRESH_LOCK_RENEW_SECONDS)
if not redis_client.eval(renew_script, 1, key, token, REFRESH_LOCK_TTL_SECONDS):
lease_lost.set()
return
except asyncio.CancelledError:
raise
except Exception as exc:
lease_lost.set()
logger.warning_event(
"Earth news refresh lock renewal failed",
event="earth_news.rss.refresh_lock_renewal_failed",
context={"error": str(exc)},
)
async def refresh_earth_news_region(active_region: str, *, force: bool = False) -> dict[str, Any]:
"""Fetch only stale or uncovered feeds for one region, outside the request path."""
if active_region not in earth_news.REGION_ANCHORS:
raise ValueError(f"Unsupported Earth news region: {active_region}")
lock_key = _lock_key(active_region)
token = uuid4().hex
redis_client = None
lock_acquired = False
lock_available = False
try:
redis_client = _RedisClient.get_client()
lock_acquired = bool(
redis_client.set(lock_key, token, nx=True, ex=REFRESH_LOCK_TTL_SECONDS)
)
lock_available = True
except Exception as exc:
logger.warning_event(
"Earth news refresh coordination unavailable",
event="earth_news.rss.refresh_lock_unavailable",
context={"region": active_region, "error": str(exc)},
)
if not lock_available or not lock_acquired:
state = "busy" if lock_available else "coordination_unavailable"
return {"state": state, "fetched": 0, "upserted": 0}
lease_lost = asyncio.Event()
renewal: asyncio.Task[None] | None = None
if lock_acquired and redis_client is not None:
renewal = asyncio.create_task(_renew_lock(redis_client, lock_key, token, lease_lost))
try:
async with async_session_factory() as db:
source_config = await earth_news.get_earth_news_sources_payload(db)
sources = await earth_news.get_configured_sources_for_region(
db, active_region, config_payload=source_config
)
if not sources:
return {
"state": "failed",
"error_code": "source_unavailable",
"fetched": 0,
"upserted": 0,
}
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
expected_keys = earth_news._expected_feed_keys(sources, active_region=active_region)
if not expected_keys:
return {
"state": "failed",
"error_code": "source_unavailable",
"fetched": 0,
"upserted": 0,
}
now = datetime.now(UTC)
needs_full_refresh = force or earth_news._needs_rss_supplement(
item_count=item_count, newest_at=newest_at
)
covered_keys = await get_earth_news_feed_coverage(
db,
active_region=active_region,
recent_after=now - timedelta(seconds=earth_news.RSS_SUPPLEMENT_MAX_AGE_SECONDS),
)
required_keys = expected_keys if needs_full_refresh else expected_keys - covered_keys
if not required_keys:
return {
"state": "completed",
"successful_sources": 0,
"failed_sources": 0,
"fetched": 0,
"upserted": 0,
}
selected_sources, cooling_keys = _select_feeds(
sources,
active_region=active_region,
required_keys=required_keys,
redis_client=redis_client,
)
if cooling_keys:
logger.info_event(
"Skipping failed Earth news feeds during cooldown",
event="earth_news.rss.cooldown_active",
context={"region": active_region, "feed_count": len(cooling_keys)},
)
if not selected_sources:
return {
"state": "failed",
"error_code": "source_unavailable",
"fetched": 0,
"upserted": 0,
}
fetched_items, _errors, health_by_source = (
await earth_news._fetch_rss_items_for_sources(
selected_sources,
active_region=active_region,
config_payload=source_config,
)
)
if lease_lost.is_set():
await db.rollback()
return {"state": "busy", "fetched": 0, "upserted": 0}
_record_failed_feed_cooldowns(
redis_client,
active_region=active_region,
health_by_source=health_by_source,
sources=selected_sources,
)
await earth_news.record_earth_news_sources_health(db, health_by_source)
if lease_lost.is_set():
await db.rollback()
return {"state": "busy", "fetched": 0, "upserted": 0}
ranked_items = earth_news._rank_and_trim_items(
fetched_items, active_region=active_region
)
await earth_news._enqueue_unverified_locations(ranked_items)
upserted = await upsert_earth_news_items(db, ranked_items)
if lease_lost.is_set():
await db.rollback()
return {"state": "busy", "fetched": len(fetched_items), "upserted": 0}
await db.commit()
successful_sources = sum(bool(health.get("ok")) for health in health_by_source.values())
failed_sources = max(0, len(selected_sources) - successful_sources)
state = "completed" if successful_sources else "failed"
failed_statuses = {
str(health.get("status") or "")
for health in health_by_source.values()
if not health.get("ok")
}
error_code = None
if not successful_sources:
error_code = "timeout" if failed_statuses == {"timeout"} else "source_unavailable"
return {
"state": state,
"error_code": error_code,
"successful_sources": successful_sources,
"failed_sources": failed_sources,
"fetched": len(fetched_items),
"upserted": upserted,
}
except Exception as exc:
logger.warning_event(
"Earth news periodic RSS refresh failed",
event="earth_news.rss.refresh_failed",
context={"region": active_region, "error": str(exc)},
)
raise
finally:
if renewal is not None:
renewal.cancel()
with suppress(asyncio.CancelledError):
await renewal
if lock_acquired and redis_client is not None:
release_script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
"""
try:
redis_client.eval(release_script, 1, lock_key, token)
except Exception as exc:
logger.warning_event(
"Earth news refresh lock release failed",
event="earth_news.rss.refresh_lock_release_failed",
context={"region": active_region, "error": str(exc)},
)
async def refresh_earth_news_all_regions() -> None:
for active_region in earth_news.REGION_ANCHORS:
try:
await refresh_earth_news_region(active_region)
except asyncio.CancelledError:
raise
except Exception:
continue

View File

@@ -278,22 +278,25 @@ async def get_earth_news_feed_coverage(
active_region: str,
recent_after: datetime | None = None,
) -> set[tuple[str, str]]:
query = select(
EarthNewsItem.id,
EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("source_id"),
EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_id"),
news_meta = EarthNewsItem.location_meta.op("->")("news_meta")
source_id = func.coalesce(
func.nullif(news_meta.op("->>")("source_id"), ""),
func.split_part(EarthNewsItem.id, ":", 1),
)
feed_id = func.nullif(news_meta.op("->>")("feed_id"), "")
query = select(
source_id,
feed_id,
).where(source_id != "", feed_id.is_not(None)).distinct()
if active_region != "global":
query = query.where(EarthNewsItem.region.in_({"global", active_region}))
if recent_after is not None:
query = query.where(func.coalesce(EarthNewsItem.published_at, EarthNewsItem.last_seen_at) >= recent_after)
result = await db.execute(query)
coverage: set[tuple[str, str]] = set()
for item_id, source_id, feed_id in result.all():
for source_id, feed_id in result.all():
normalized_source_id = str(source_id or "").strip()
normalized_feed_id = str(feed_id or "").strip()
if not normalized_source_id and isinstance(item_id, str) and ":" in item_id:
normalized_source_id = item_id.split(":", 1)[0]
if normalized_source_id and normalized_feed_id:
coverage.add((normalized_source_id, normalized_feed_id))
return coverage
@@ -340,30 +343,49 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem])
changed += 1
continue
record.title = item.title
record.summary = item.summary
record.url = item.url
record.source = item.source
record.feed_name = item.feed_name
record.region = item.feed_region
record.homepage_url = item.homepage_url
record.published_at = item.published_at
visible_changed = False
for field_name, value in (
("title", item.title),
("summary", item.summary),
("url", item.url),
("source", item.source),
("feed_name", item.feed_name),
("region", item.feed_region),
("homepage_url", item.homepage_url),
("published_at", item.published_at),
):
if getattr(record, field_name) != value:
setattr(record, field_name, value)
visible_changed = True
record.last_seen_at = now
location_meta = dict(record.location_meta or {})
location_meta["news_meta"] = _news_meta_patch(item)
record.location_meta = location_meta
news_meta = _news_meta_patch(item)
if location_meta.get("news_meta") != news_meta:
location_meta["news_meta"] = news_meta
record.location_meta = location_meta
visible_changed = True
if item.localizations:
merged_localizations = {
**dict(record.localizations or {}),
**dict(item.localizations or {}),
}
record.content_language = item.content_language
record.localizations = merged_localizations
if record.content_language != item.content_language:
record.content_language = item.content_language
visible_changed = True
if dict(record.localizations or {}) != merged_localizations:
record.localizations = merged_localizations
visible_changed = True
if item.enrichment_status != "pending" or item.enrichment_error or item.enriched_at:
record.enrichment_status = item.enrichment_status
record.enrichment_error = item.enrichment_error
record.enriched_at = item.enriched_at
changed += 1
for field_name, value in (
("enrichment_status", item.enrichment_status),
("enrichment_error", item.enrichment_error),
("enriched_at", item.enriched_at),
):
if getattr(record, field_name) != value:
setattr(record, field_name, value)
visible_changed = True
if visible_changed:
changed += 1
await db.flush()
return changed

View File

@@ -0,0 +1,184 @@
"""Bounded retention for consumed Earth change events."""
from __future__ import annotations
import asyncio
import os
from datetime import UTC, datetime, timedelta
from typing import AsyncContextManager, Protocol
from app.core.logging import get_logger
logger = get_logger(__name__)
INDEX_NAME = "idx_earth_data_change_events_consumed_retention"
DEFAULT_RETENTION_DAYS = 1
DEFAULT_BATCH_SIZE = 1000
DEFAULT_PAUSE_SECONDS = 0.1
MAINTENANCE_LOCK_KEY = (17001613, 1)
DELETE_BATCH_SQL = """
WITH doomed AS (
SELECT id
FROM earth_data_change_events
WHERE consumed_at IS NOT NULL AND consumed_at < $1
ORDER BY consumed_at, id
LIMIT $2
FOR UPDATE SKIP LOCKED
), deleted AS (
DELETE FROM earth_data_change_events AS events
USING doomed
WHERE events.id = doomed.id
RETURNING events.id
)
SELECT count(*) FROM deleted
"""
class OutboxConnection(Protocol):
async def fetchval(self, query: str, *args: object) -> object: ...
async def fetchrow(self, query: str, *args: object) -> object: ...
async def execute(self, query: str, *args: object) -> object: ...
def transaction(self) -> AsyncContextManager[object]: ...
def configured_retention_days() -> int:
raw = os.getenv("PLANET_EARTH_OUTBOX_RETENTION_DAYS", str(DEFAULT_RETENTION_DAYS))
try:
days = int(raw)
if 1 <= days <= 3650:
return days
except ValueError:
pass
logger.warning_event(
"Invalid Earth outbox retention; using default",
event="earth.outbox.retention_invalid",
context={"value": raw},
)
return DEFAULT_RETENTION_DAYS
async def try_maintenance_lock(conn: OutboxConnection) -> bool:
return bool(await conn.fetchval("SELECT pg_try_advisory_lock($1, $2)", *MAINTENANCE_LOCK_KEY))
async def release_maintenance_lock(conn: OutboxConnection) -> None:
await conn.fetchval("SELECT pg_advisory_unlock($1, $2)", *MAINTENANCE_LOCK_KEY)
async def retention_index_state(conn: OutboxConnection) -> str:
row = await conn.fetchrow(
"""
SELECT indexes.indisvalid, indexes.indisready
FROM pg_index AS indexes
JOIN pg_class AS index_table ON index_table.oid = indexes.indexrelid
WHERE indexes.indrelid = 'earth_data_change_events'::regclass
AND index_table.relname = $1
""",
INDEX_NAME,
)
if row is None:
return "missing"
return "ready" if row["indisvalid"] and row["indisready"] else "invalid"
async def ensure_retention_index(conn: OutboxConnection) -> None:
"""Call on an autocommit connection, never inside app init_db's transaction."""
state = await retention_index_state(conn)
if state == "invalid":
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
if state != "ready":
await conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} "
"ON earth_data_change_events (consumed_at, id) WHERE consumed_at IS NOT NULL"
)
if await retention_index_state(conn) != "ready":
raise RuntimeError("Earth outbox retention index is not valid and ready")
async def purge_consumed_events(
conn: OutboxConnection,
*,
retention_days: int,
batch_size: int = DEFAULT_BATCH_SIZE,
max_batches: int = 1,
pause_seconds: float = DEFAULT_PAUSE_SECONDS,
) -> int:
if not 1 <= retention_days <= 3650:
raise ValueError("retention_days must be between 1 and 3650")
if batch_size < 1 or max_batches < 1 or pause_seconds < 0:
raise ValueError("batch_size/max_batches must be positive and pause_seconds non-negative")
if await retention_index_state(conn) != "ready":
logger.warning_event(
"Skipping Earth outbox retention because its index is unavailable",
event="earth.outbox.retention_index_unavailable",
)
return 0
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
deleted_total = 0
for batch_number in range(max_batches):
deleted = int(await conn.fetchval(DELETE_BATCH_SQL, cutoff, batch_size) or 0)
deleted_total += deleted
if deleted < batch_size:
break
if batch_number + 1 < max_batches and pause_seconds:
await asyncio.sleep(pause_seconds)
if deleted_total:
logger.info_event(
"Purged consumed Earth outbox events",
event="earth.outbox.retention_purged",
context={"deleted": deleted_total, "retention_days": retention_days},
)
return deleted_total
async def compact_consumed_events(
conn: OutboxConnection,
*,
retention_days: int,
lock_timeout_seconds: int = 10,
statement_timeout_seconds: int = 900,
) -> tuple[int, int]:
"""One-time atomic rewrite; blocks table access until the transaction commits."""
if not 1 <= retention_days <= 3650:
raise ValueError("retention_days must be between 1 and 3650")
if lock_timeout_seconds < 1 or statement_timeout_seconds < 1:
raise ValueError("timeouts must be positive")
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
async with conn.transaction():
await conn.fetchval(
"SELECT set_config('lock_timeout', $1, true)", f"{lock_timeout_seconds}s"
)
await conn.fetchval(
"SELECT set_config('statement_timeout', $1, true)", f"{statement_timeout_seconds}s"
)
await conn.execute("LOCK TABLE earth_data_change_events IN ACCESS EXCLUSIVE MODE")
await conn.execute(
"""
CREATE TEMP TABLE earth_outbox_retained ON COMMIT DROP AS
SELECT * FROM earth_data_change_events
WHERE consumed_at IS NULL OR consumed_at >= $1
""",
cutoff,
)
retained = await conn.fetchrow("""
SELECT count(*) AS total, count(*) FILTER (WHERE consumed_at IS NULL) AS pending
FROM earth_outbox_retained
""")
if retained is None:
raise RuntimeError("Unable to verify retained Earth outbox rows")
await conn.execute("TRUNCATE TABLE earth_data_change_events CONTINUE IDENTITY RESTRICT")
await conn.execute(
"INSERT INTO earth_data_change_events SELECT * FROM earth_outbox_retained"
)
restored = await conn.fetchrow("""
SELECT count(*) AS total, count(*) FILTER (WHERE consumed_at IS NULL) AS pending
FROM earth_data_change_events
""")
if restored is None or dict(retained) != dict(restored):
raise RuntimeError("Earth outbox compact verification failed; transaction rolled back")
return int(retained["total"]), int(retained["pending"])

View File

@@ -2,9 +2,11 @@
import asyncio
from datetime import UTC, datetime, timedelta
import time
from typing import Any, Dict, Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
@@ -14,19 +16,44 @@ from app.db.session import async_session_factory
from app.core.time import to_iso8601_utc
from app.models.datasource import DataSource
from app.models.task import CollectionTask
from app.services.business_logs import emit_business_log, emit_business_log_background, exception_context
from app.services.business_logs import (
emit_business_log,
emit_business_log_background,
exception_context,
)
from app.services.collectors.registry import collector_registry
from app.services.datasource_connectivity import (
build_builtin_connectivity_checksum,
get_builtin_effective_candidate,
save_connectivity_success,
)
from app.services.earth_news_refresh import (
REFRESH_INTERVAL_SECONDS as EARTH_NEWS_REFRESH_INTERVAL_SECONDS,
create_earth_news_refresh_status,
refresh_earth_news_all_regions,
refresh_earth_news_region,
set_earth_news_refresh_status,
touch_earth_news_refresh_status,
REFRESH_STATUS_HEARTBEAT_SECONDS as EARTH_NEWS_STATUS_HEARTBEAT_SECONDS,
)
from app.services.earth_news import REGION_ANCHORS
logger = get_logger(__name__)
scheduler = AsyncIOScheduler()
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
RUNNING_COLLECTOR_TASKS: dict[str, asyncio.Task[Any]] = {}
EARTH_NEWS_REFRESH_JOB_ID = "earth_news_rss_refresh"
_pending_earth_news_refreshes: dict[str, dict[str, Any]] = {}
_running_earth_news_refreshes: set[str] = set()
_rerun_earth_news_refreshes: dict[str, dict[str, Any]] = {}
EARTH_NEWS_REFRESH_WAIT_SECONDS = 300
EARTH_NEWS_REFRESH_POLL_SECONDS = 1
EARTH_NEWS_REFRESH_PUBLIC_ERRORS = {
"source_unavailable": "No news source could be refreshed for this region. Please retry.",
"timeout": "News sources timed out. Please try again.",
"refresh_failed": "Could not refresh news. Please try again.",
}
def _collector_task_name(collector_name: str) -> str:
@@ -86,7 +113,10 @@ async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
logger.info_event(
"Scheduled collector",
event="collector.schedule.updated",
context={"collector_name": datasource.source, "frequency_minutes": datasource.frequency_minutes},
context={
"collector_name": datasource.source,
"frequency_minutes": datasource.frequency_minutes,
},
)
else:
logger.info_event(
@@ -133,7 +163,11 @@ async def run_collector_task(collector_name: str):
category="collector",
service="scheduler",
module=__name__,
context={"collector_name": collector_name, "datasource_id": datasource.id, "status": "skipped"},
context={
"collector_name": collector_name,
"datasource_id": datasource.id,
"status": "skipped",
},
)
return
@@ -153,9 +187,8 @@ async def run_collector_task(collector_name: str):
if started_at is not None and started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=UTC)
is_stale = (
started_at is not None
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
is_stale = started_at is not None and (now - started_at) > timedelta(
minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES
)
if not is_stale:
logger.warning_event(
@@ -189,9 +222,7 @@ async def run_collector_task(collector_name: str):
existing_running.phase = "failed"
existing_running.completed_at = now
existing_running.error_message = (
f"{existing_error}\n{stale_reason}".strip()
if existing_error
else stale_reason
f"{existing_error}\n{stale_reason}".strip() if existing_error else stale_reason
)
await db.commit()
logger.warning_event(
@@ -231,7 +262,11 @@ async def run_collector_task(collector_name: str):
category="collector",
service="scheduler",
module=__name__,
context={"collector_name": collector_name, "datasource_id": datasource_id, "status": "running"},
context={
"collector_name": collector_name,
"datasource_id": datasource_id,
"status": "running",
},
)
task_result = await collector.run(db)
datasource = await db.get(DataSource, datasource_id)
@@ -265,7 +300,11 @@ async def run_collector_task(collector_name: str):
logger.info_event(
"Collector completed",
event="collector.run.completed",
context={"collector_name": collector_name, "datasource_id": datasource_id, "result": task_result},
context={
"collector_name": collector_name,
"datasource_id": datasource_id,
"result": task_result,
},
)
await emit_business_log(
logger,
@@ -300,7 +339,11 @@ async def run_collector_task(collector_name: str):
level="warning",
service="scheduler",
module=__name__,
context={"collector_name": collector_name, "datasource_id": datasource.id, "status": "cancelled"},
context={
"collector_name": collector_name,
"datasource_id": datasource.id,
"status": "cancelled",
},
)
raise
except Exception as exc:
@@ -312,7 +355,11 @@ async def run_collector_task(collector_name: str):
logger.exception_event(
"Collector failed",
event="collector.run.failed",
context={"collector_name": collector_name, "datasource_id": datasource.id, "error": str(exc)},
context={
"collector_name": collector_name,
"datasource_id": datasource.id,
"error": str(exc),
},
)
await emit_business_log(
logger,
@@ -324,7 +371,11 @@ async def run_collector_task(collector_name: str):
module=__name__,
context=exception_context(
exc,
{"collector_name": collector_name, "datasource_id": datasource.id, "status": "failed"},
{
"collector_name": collector_name,
"datasource_id": datasource.id,
"status": "failed",
},
),
)
@@ -349,7 +400,9 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
task.completed_at = datetime.now(UTC)
existing_error = (task.error_message or "").strip()
cleanup_error = "Marked failed automatically after stale running task cleanup"
task.error_message = f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error
task.error_message = (
f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error
)
if stale_tasks:
await db.commit()
@@ -365,6 +418,17 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
def start_scheduler() -> None:
"""Start the scheduler."""
if not scheduler.running:
scheduler.add_job(
refresh_earth_news_all_regions,
trigger=IntervalTrigger(seconds=EARTH_NEWS_REFRESH_INTERVAL_SECONDS),
id=EARTH_NEWS_REFRESH_JOB_ID,
name="Earth news RSS refresh",
replace_existing=True,
next_run_time=datetime.now(UTC),
max_instances=1,
coalesce=True,
misfire_grace_time=60,
)
scheduler.start()
logger.info_event("Scheduler started", event="scheduler.started")
@@ -376,6 +440,205 @@ def stop_scheduler() -> None:
logger.info_event("Scheduler stopped", event="scheduler.stopped")
def schedule_earth_news_region_refresh(
region: str, *, force: bool = False, refresh_id: str | None = None
) -> bool:
"""Queue one immediate, per-region RSS refresh and merge duplicate requests."""
normalized_region = str(region or "").strip().lower()
if normalized_region not in REGION_ANCHORS:
raise ValueError(f"Unsupported Earth news region: {region}")
if normalized_region in _running_earth_news_refreshes:
rerun = _rerun_earth_news_refreshes.setdefault(
normalized_region, {"force": False, "refresh_id": None}
)
rerun["force"] = rerun["force"] or force
rerun["refresh_id"] = refresh_id or rerun["refresh_id"]
return False
if normalized_region in _pending_earth_news_refreshes:
pending = _pending_earth_news_refreshes[normalized_region]
pending["force"] = pending["force"] or force
pending["refresh_id"] = refresh_id or pending["refresh_id"]
return False
_pending_earth_news_refreshes[normalized_region] = {
"force": force,
"refresh_id": refresh_id,
}
job_id = _earth_news_region_refresh_job_id(normalized_region)
try:
scheduler.add_job(
_run_earth_news_region_refresh,
trigger=DateTrigger(run_date=datetime.now(UTC)),
id=job_id,
name=f"Earth news RSS refresh: {normalized_region}",
kwargs={"region": normalized_region},
replace_existing=False,
max_instances=1,
coalesce=True,
misfire_grace_time=60,
)
except Exception:
_pending_earth_news_refreshes.pop(normalized_region, None)
raise
return True
def request_earth_news_region_refresh(region: str) -> dict[str, Any]:
normalized_region = str(region or "").strip().lower()
if normalized_region not in REGION_ANCHORS:
raise ValueError(f"Unsupported Earth news region: {region}")
status, created = create_earth_news_refresh_status(normalized_region)
if created:
try:
schedule_earth_news_region_refresh(
normalized_region,
force=True,
refresh_id=status["refresh_id"],
)
except Exception:
return set_earth_news_refresh_status(
status["refresh_id"],
normalized_region,
"failed",
error={
"code": "refresh_failed",
"message": EARTH_NEWS_REFRESH_PUBLIC_ERRORS["refresh_failed"],
},
)
return status
def schedule_earth_news_refresh_for_all_regions(*, force: bool = False) -> None:
for region in REGION_ANCHORS:
schedule_earth_news_region_refresh(region, force=force)
def _earth_news_region_refresh_job_id(region: str) -> str:
return f"{EARTH_NEWS_REFRESH_JOB_ID}:{region}"
async def _run_earth_news_region_refresh(region: str) -> None:
request = _pending_earth_news_refreshes.pop(region, {"force": False, "refresh_id": None})
force = bool(request["force"])
refresh_id = request["refresh_id"]
_running_earth_news_refreshes.add(region)
status_heartbeat: asyncio.Task[None] | None = None
try:
if refresh_id:
set_earth_news_refresh_status(refresh_id, region, "running")
if not touch_earth_news_refresh_status(refresh_id, region):
return
status_heartbeat = asyncio.create_task(
_earth_news_refresh_status_heartbeat(refresh_id, region)
)
deadline = time.monotonic() + EARTH_NEWS_REFRESH_WAIT_SECONDS
while True:
result = await refresh_earth_news_region(region, force=force)
if result["state"] != "busy":
break
if time.monotonic() >= deadline:
set_earth_news_refresh_status(
refresh_id,
region,
"failed",
error={
"code": "refresh_failed",
"message": EARTH_NEWS_REFRESH_PUBLIC_ERRORS["refresh_failed"],
},
)
return
await asyncio.sleep(EARTH_NEWS_REFRESH_POLL_SECONDS)
if result["state"] == "coordination_unavailable":
error_code = "refresh_failed"
elif result["state"] == "failed":
error_code = result.get("error_code") or "source_unavailable"
elif result["state"] == "completed" and result.get("successful_sources", 0) > 0:
set_earth_news_refresh_status(refresh_id, region, "succeeded")
return
else:
error_code = "source_unavailable"
set_earth_news_refresh_status(
refresh_id,
region,
"failed",
error={
"code": error_code,
"message": EARTH_NEWS_REFRESH_PUBLIC_ERRORS.get(
error_code, EARTH_NEWS_REFRESH_PUBLIC_ERRORS["source_unavailable"]
),
},
)
else:
await refresh_earth_news_region(region, force=force)
except asyncio.CancelledError:
if refresh_id:
set_earth_news_refresh_status(
refresh_id,
region,
"failed",
error={
"code": "refresh_failed",
"message": EARTH_NEWS_REFRESH_PUBLIC_ERRORS["refresh_failed"],
},
)
raise
except Exception as exc:
logger.warning_event(
"Earth news tracked refresh failed",
event="earth_news.rss.tracked_refresh_failed",
context={"region": region, "error": str(exc)},
)
if refresh_id:
try:
set_earth_news_refresh_status(
refresh_id,
region,
"failed",
error={
"code": "refresh_failed",
"message": EARTH_NEWS_REFRESH_PUBLIC_ERRORS["refresh_failed"],
},
)
except Exception as status_exc:
logger.warning_event(
"Earth news refresh status update failed",
event="earth_news.rss.refresh_status_update_failed",
context={"region": region, "error": str(status_exc)},
)
finally:
if status_heartbeat is not None:
status_heartbeat.cancel()
try:
await status_heartbeat
except asyncio.CancelledError:
pass
_running_earth_news_refreshes.discard(region)
rerun = _rerun_earth_news_refreshes.pop(region, None)
if rerun is not None and scheduler.running:
schedule_earth_news_region_refresh(
region,
force=rerun["force"],
refresh_id=rerun["refresh_id"],
)
async def _earth_news_refresh_status_heartbeat(refresh_id: str, region: str) -> None:
try:
while True:
await asyncio.sleep(EARTH_NEWS_STATUS_HEARTBEAT_SECONDS)
if not touch_earth_news_refresh_status(refresh_id, region):
return
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning_event(
"Earth news refresh status heartbeat failed",
event="earth_news.rss.refresh_status_heartbeat_failed",
context={"region": region, "error": str(exc)},
)
async def sync_scheduler_with_datasources() -> None:
"""Synchronize scheduler jobs with datasource table."""
async with async_session_factory() as db:
@@ -384,7 +647,10 @@ async def sync_scheduler_with_datasources() -> None:
configured_sources = {datasource.source for datasource in datasources}
for job in list(scheduler.get_jobs()):
if job.id not in configured_sources:
is_earth_news_refresh = job.id == EARTH_NEWS_REFRESH_JOB_ID or job.id.startswith(
f"{EARTH_NEWS_REFRESH_JOB_ID}:"
)
if job.id not in configured_sources and not is_earth_news_refresh:
scheduler.remove_job(job.id)
for datasource in datasources:
@@ -461,7 +727,9 @@ def run_collector_now(collector_name: str) -> bool:
return False
try:
task = asyncio.create_task(run_collector_task(collector_name), name=_collector_task_name(collector_name))
task = asyncio.create_task(
run_collector_task(collector_name), name=_collector_task_name(collector_name)
)
RUNNING_COLLECTOR_TASKS[collector_name] = task
def _cleanup_task(done_task: asyncio.Task[Any]) -> None:

View File

@@ -8,6 +8,37 @@ from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.fixture(autouse=True)
def persisted_test_logs(monkeypatch):
"""Keep application log persistence in memory, including business-log calls."""
from app.models.system_log import ObservabilityEventGroup
from app.services import persistent_logs
entries = []
groups = {}
class LogSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
def add(self, item):
entries.append(item)
if isinstance(item, ObservabilityEventGroup):
groups[item.fingerprint] = item
async def get(self, model, key):
return groups.get(key) if model is ObservabilityEventGroup else None
async def commit(self):
return None
monkeypatch.setattr(persistent_logs, "async_session_factory", LogSession)
return entries
@pytest.fixture(autouse=True)
def bgp_collector_location_cache():
"""Mirror app startup seeding for tests that call sync BGP helpers."""

View File

@@ -1,5 +1,6 @@
"""Tests for BGP observability helpers."""
import ipaddress
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@@ -21,13 +22,20 @@ from app.services.collectors.bgp_common import (
create_bgp_anomalies_for_batch,
save_bgp_observations_for_batch,
)
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
from app.services.bgp_enrichment import (
enrich_bgp_events_for_batch,
extract_bgp_network_fields,
lookup_prefix_geography,
)
from app.services.bgp_incidents import (
create_bgp_incidents_for_anomalies,
infer_related_infrastructure,
)
from app.services.bgp_collectors import build_bgp_collector_coverage
from app.api.v1.visualization import convert_bgp_incidents_to_geojson, build_incident_geography_hints
from app.api.v1.visualization import (
convert_bgp_incidents_to_geojson,
build_incident_geography_hints,
)
from app.models.bgp_anomaly import BGPAnomaly
from app.models.collected_data import CollectedData
from app.models.bgp_incident import BGPIncident
@@ -58,8 +66,7 @@ class _FakeResult:
def all(self):
if self._rows and all(isinstance(row, BGPObservation) for row in self._rows):
return [
(row.prefix, row.origin_asn, row.collector, row.collector_geo)
for row in self._rows
(row.prefix, row.origin_asn, row.collector, row.collector_geo) for row in self._rows
]
return self._rows
@@ -93,6 +100,8 @@ class _FakeAsyncSession:
self.commits = 0
async def execute(self, _stmt, _params=None):
if str(_stmt).lstrip().startswith("SET LOCAL jit"):
return _FakeResult([])
if not self._results:
return _FakeResult([])
return _FakeResult(self._results.pop(0))
@@ -107,6 +116,49 @@ class _FakeAsyncSession:
self.commits += 1
class _StrictFakeAsyncSession(_FakeAsyncSession):
async def execute(self, stmt, params=None):
if not str(stmt).lstrip().startswith("SET LOCAL jit") and not self._results:
raise AssertionError(f"Unexpected BGP query: {stmt}")
return await super().execute(stmt, params)
@pytest.mark.asyncio
async def test_prefix_geography_keeps_source_priority_and_ipv6_range_parameters():
calls = []
class GeoSession:
async def execute(self, _stmt, params=None):
if str(_stmt).lstrip().startswith("SET LOCAL jit"):
return _FakeResult([])
calls.append(dict(params))
if params["source"] == "nro_delegated_prefix_geo":
return _FakeResult(
[
{
"metadata": {
"country_code": "GB",
"prefix": "2001:db8::/32",
}
}
]
)
return _FakeResult([])
network = ipaddress.ip_network("2001:db8:1::/48")
result = await lookup_prefix_geography(GeoSession(), [str(network), "invalid-prefix"])
assert result[str(network)]["source"] == "nro_delegated_stats"
assert [call["source"] for call in calls] == [
"opengeofeed_prefix_geo",
"iptoasn_prefix_geo",
"nro_delegated_prefix_geo",
]
assert all(call["family"] == "ipv6" for call in calls)
assert all(call["range_start"] == str(network.network_address) for call in calls)
assert all(call["range_end"] == str(network.broadcast_address) for call in calls)
def test_normalize_bgp_event_from_live_payload():
event = normalize_bgp_event(
{
@@ -449,7 +501,12 @@ def test_detect_route_leak_anomalies_creates_candidate_for_divergent_long_paths(
"prefix": "203.0.113.0/24",
"origin_asn": 64496,
"as_path": [64500, 64496],
"collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9},
"collector_location": {
"country": "NL",
"city": "Amsterdam",
"latitude": 52.3,
"longitude": 4.9,
},
"enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}},
}
},
@@ -460,7 +517,12 @@ def test_detect_route_leak_anomalies_creates_candidate_for_divergent_long_paths(
"prefix": "203.0.113.0/24",
"origin_asn": 64496,
"as_path": [64510, 64520, 64530, 64540, 64496],
"collector_location": {"country": "GB", "city": "London", "latitude": 51.5, "longitude": -0.1},
"collector_location": {
"country": "GB",
"city": "London",
"latitude": 51.5,
"longitude": -0.1,
},
"enrichment": {"prefix_scope": {"regions": [{"country": "GB"}]}},
}
},
@@ -481,7 +543,9 @@ def test_detect_route_leak_anomalies_creates_candidate_for_divergent_long_paths(
def test_detect_path_flap_anomalies_creates_signal_for_repeated_state_changes():
base_timestamp = datetime(2026, 3, 27, 0, 0, tzinfo=UTC)
events = []
for index, event_type in enumerate(["announcement", "withdrawal", "announcement", "withdrawal"]):
for index, event_type in enumerate(
["announcement", "withdrawal", "announcement", "withdrawal"]
):
events.append(
{
"metadata": {
@@ -491,7 +555,12 @@ def test_detect_path_flap_anomalies_creates_signal_for_repeated_state_changes():
"prefix": "198.51.100.0/24",
"origin_asn": 64512,
"as_path": [64500 + index, 64512] if event_type == "announcement" else [],
"collector_location": {"country": "NL", "city": "Amsterdam", "latitude": 52.3, "longitude": 4.9},
"collector_location": {
"country": "NL",
"city": "Amsterdam",
"latitude": 52.3,
"longitude": 4.9,
},
"enrichment": {"prefix_scope": {"regions": [{"country": "NL"}]}},
}
}
@@ -920,7 +989,9 @@ async def test_create_bgp_incidents_for_anomalies_refreshes_existing_incident():
with patch(
"app.services.bgp_incidents.infer_related_infrastructure",
new=AsyncMock(return_value={"related_cables": [{"landing_point": "NYC"}], "related_ixps": []}),
new=AsyncMock(
return_value={"related_cables": [{"landing_point": "NYC"}], "related_ixps": []}
),
):
created = await create_bgp_incidents_for_anomalies(
db,
@@ -1132,13 +1203,17 @@ async def test_create_bgp_anomalies_for_batch_calls_incident_aggregation():
source="ris_live_bgp",
extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496},
)
db = _FakeAsyncSession([
[],
[],
[],
[previous_record],
[],
])
db = _StrictFakeAsyncSession(
[
[], # Historical BGP observations
[], # OpenGeoFeed prefix geography
[], # IPtoASN prefix geography
[], # NRO delegated prefix geography
[], # PeeringDB ASN profiles
[previous_record], # Prior collected snapshot for origin-change detection
[], # Existing anomaly entity keys
]
)
events = [
{
"reference_date": "2026-03-30T10:00:00Z",
@@ -1178,6 +1253,7 @@ async def test_create_bgp_anomalies_for_batch_calls_incident_aggregation():
assert anomaly.anomaly_type == "origin_change"
assert anomaly.prefix == "203.0.113.0/24"
incident_mock.assert_awaited_once()
assert not db._results
@pytest.mark.asyncio
@@ -1197,14 +1273,18 @@ async def test_create_bgp_anomalies_for_batch_skips_existing_entity_keys():
origin_asn=64496,
new_origin_asn=64497,
)
db = _FakeAsyncSession([
[],
[],
[],
[previous_record],
[existing_key],
[existing_anomaly],
])
db = _StrictFakeAsyncSession(
[
[], # Historical BGP observations
[], # OpenGeoFeed prefix geography
[], # IPtoASN prefix geography
[], # NRO delegated prefix geography
[], # PeeringDB ASN profiles
[previous_record], # Prior collected snapshot for origin-change detection
[existing_key],
[existing_anomaly],
]
)
events = [
{
"reference_date": "2026-03-30T10:00:00Z",
@@ -1235,6 +1315,7 @@ async def test_create_bgp_anomalies_for_batch_skips_existing_entity_keys():
assert created == 0
assert len(db.added) == 0
incident_mock.assert_awaited_once()
assert not db._results
async def _bgp_test_client(db_session):
@@ -1242,7 +1323,9 @@ async def _bgp_test_client(db_session):
yield db_session
def override_get_current_user():
return User(id=1, username="testuser", email="test@example.com", password_hash="x", role="admin")
return User(
id=1, username="testuser", email="test@example.com", password_hash="x", role="admin"
)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
@@ -1264,7 +1347,7 @@ async def test_bgp_events_api_lists_observations():
origin_asn=64496,
observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC),
)
db = _FakeAsyncSession([[observation]])
db = _StrictFakeAsyncSession([[1], [observation]])
client = await _bgp_test_client(db)
try:
@@ -1278,6 +1361,7 @@ async def test_bgp_events_api_lists_observations():
assert payload["total"] == 1
assert payload["data"][0]["collector"] == "rrc00"
assert payload["data"][0]["prefix"] == "203.0.113.0/24"
assert not db._results
@pytest.mark.asyncio
@@ -1376,7 +1460,18 @@ async def test_bgp_event_summary_api_returns_aggregates():
event_type="withdrawal",
observed_at=datetime(2026, 3, 30, 10, 5, tzinfo=UTC),
)
db = _FakeAsyncSession([[observation_one, observation_two]])
observations = [observation_one, observation_two]
db = _StrictFakeAsyncSession(
[
[len(observations)],
[len({record.collector for record in observations})],
[len({record.prefix for record in observations})],
[
(event_type, sum(record.event_type == event_type for record in observations))
for event_type in sorted({record.event_type for record in observations})
],
]
)
client = await _bgp_test_client(db)
try:
@@ -1392,6 +1487,7 @@ async def test_bgp_event_summary_api_returns_aggregates():
assert payload["prefix_count"] == 2
assert payload["by_type"]["announcement"] == 1
assert payload["by_type"]["withdrawal"] == 1
assert not db._results
@pytest.mark.asyncio

View File

@@ -0,0 +1,22 @@
from unittest.mock import AsyncMock
import pytest
from app.db.bgp_prefix_geo import (
BGP_PREFIX_GEO_FUNCTION_SQL,
BGP_PREFIX_GEO_INDEX_SQL,
install_bgp_prefix_geo_functions,
)
@pytest.mark.asyncio
async def test_fresh_database_installs_lookup_functions_without_large_index():
connection = AsyncMock()
await install_bgp_prefix_geo_functions(connection)
statements = [str(call.args[0]) for call in connection.execute.call_args_list]
assert len(statements) == len(BGP_PREFIX_GEO_FUNCTION_SQL) == 2
assert all("CREATE OR REPLACE FUNCTION" in statement for statement in statements)
assert all("CREATE INDEX" not in statement for statement in statements)
assert "CREATE INDEX CONCURRENTLY" in BGP_PREFIX_GEO_INDEX_SQL

View File

@@ -0,0 +1,15 @@
from app.services.earth_layer_adapters import (
get_earth_cache_patterns_for_source,
get_earth_update_layers_for_change,
)
def test_news_changes_do_not_invalidate_earth_summary_cache():
assert get_earth_update_layers_for_change("earth_news_items", "earth_news_items") == ["news"]
assert get_earth_cache_patterns_for_source("earth_news_items") == []
assert get_earth_cache_patterns_for_source("media_news_archive") == []
def test_counted_layers_still_invalidate_earth_summary_cache():
for source in ("aisstream_vessels", "arcgis_cables", "celestrak_tle", "top500", "ris_live_bgp"):
assert "summary*" in get_earth_cache_patterns_for_source(source)

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from fastapi import Response
from fastapi import FastAPI, HTTPException, Response
from app.api.v1 import visualization
from app.services.earth_layer_cache import (
@@ -49,6 +51,19 @@ class FakeRedis:
deleted += 1 if self.store.pop(key, None) is not None else 0
return deleted
def eval(self, script: str, numkeys: int, *args):
self._maybe_fail()
keys, values = args[:numkeys], args[numkeys:]
if self.store.get(keys[0]) != values[0]:
return 0
if numkeys == 3:
self.setex(keys[1], values[1], values[3])
self.setex(keys[2], values[2], values[3])
return 1
if "'expire'" in script:
return 1
return self.delete(keys[0])
def scan_iter(self, match: str):
self._maybe_fail()
prefix = match.rstrip("*")
@@ -120,7 +135,9 @@ async def test_resolve_layer_payload_writes_fresh_and_stale(fake_cache_client):
@pytest.mark.asyncio
async def test_resolve_layer_payload_returns_stale_when_builder_fails(fake_cache_client):
key = earth_layer_cache.key("bgp-incidents", status="active")
fake_cache_client.store[f"{key}:stale"] = json.dumps({"type": "FeatureCollection", "features": []})
fake_cache_client.store[f"{key}:stale"] = json.dumps(
{"type": "FeatureCollection", "features": []}
)
async def builder():
raise RuntimeError("db exploded")
@@ -165,6 +182,7 @@ async def test_resolve_layer_payload_bypasses_redis_failure():
previous = earth_layer_cache._client
earth_layer_cache._client = FakeRedis(fail=True)
try:
async def builder():
return {"type": "FeatureCollection", "features": [{"id": "safe"}]}
@@ -192,9 +210,13 @@ async def test_visualization_endpoint_sets_cache_headers(fake_cache_client, monk
monkeypatch.setattr(visualization, "_build_satellites_geojson", fake_build_satellites_geojson)
first_response = Response()
first = await visualization.get_satellites_geojson(limit=25, db=object(), response=first_response)
first = await visualization.get_satellites_geojson(
limit=25, db=object(), response=first_response
)
second_response = Response()
second = await visualization.get_satellites_geojson(limit=25, db=object(), response=second_response)
second = await visualization.get_satellites_geojson(
limit=25, db=object(), response=second_response
)
assert first == second
assert calls == 1
@@ -252,3 +274,284 @@ async def test_vessel_snapshot_uses_short_cache(fake_cache_client, monkeypatch):
assert calls == 1
assert first_response.headers["X-Planet-Cache"] == "refresh"
assert second_response.headers["X-Planet-Cache"] == "hit"
@pytest.mark.asyncio
async def test_concurrent_summary_requests_share_one_slow_build(monkeypatch):
started = asyncio.Event()
finish = asyncio.Event()
calls = 0
async def build_summary(db):
nonlocal calls
calls += 1
started.set()
await finish.wait()
return {"stats": {"vessel_count": 42}}
monkeypatch.setattr(visualization, "_build_visualization_geo_summary", build_summary)
app = FastAPI()
app.include_router(visualization.router, prefix="/api/v1/visualization")
app.dependency_overrides[visualization.get_db] = lambda: object()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
requests = [
asyncio.create_task(client.get("/api/v1/visualization/geo/summary")) for _ in range(6)
]
await asyncio.wait_for(started.wait(), 1)
# Keep the build open beyond the old 200 ms fallback to duplicate SQL.
await asyncio.sleep(0.25)
assert calls == 1
finish.set()
results = await asyncio.gather(*requests)
assert all(response.status_code == 200 for response in results)
assert all(response.json() == {"stats": {"vessel_count": 42}} for response in results)
assert sorted(response.headers["X-Planet-Cache"] for response in results) == [
"hit",
"hit",
"hit",
"hit",
"hit",
"refresh",
]
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
@pytest.mark.parametrize("error_type", [RuntimeError, TimeoutError])
async def test_shared_builder_error_is_not_retried(error_type):
started = asyncio.Event()
finish = asyncio.Event()
calls = 0
async def builder():
nonlocal calls
calls += 1
started.set()
await finish.wait()
raise error_type("db failed")
requests = [
asyncio.create_task(
resolve_layer_payload(
key="shared-error", policy=EarthLayerCachePolicy(60, 120), builder=builder
)
)
for _ in range(4)
]
await started.wait()
await asyncio.sleep(0)
finish.set()
results = await asyncio.gather(*requests, return_exceptions=True)
assert calls == 1
assert all(isinstance(result, error_type) for result in results)
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
async def test_concurrent_redis_failure_shares_bypass_result(fake_cache_client):
fake_cache_client.fail = True
started = asyncio.Event()
finish = asyncio.Event()
calls = 0
async def builder():
nonlocal calls
calls += 1
started.set()
await finish.wait()
return {"count": 42}
requests = [
asyncio.create_task(
resolve_layer_payload(
key="shared-bypass", policy=EarthLayerCachePolicy(60, 120), builder=builder
)
)
for _ in range(4)
]
await started.wait()
await asyncio.sleep(0)
finish.set()
results = await asyncio.gather(*requests)
assert calls == 1
assert all(result.state == "bypass" and result.payload == {"count": 42} for result in results)
@pytest.mark.asyncio
async def test_cancelled_waiter_does_not_cancel_owner():
started = asyncio.Event()
finish = asyncio.Event()
policy = EarthLayerCachePolicy(60, 120)
async def builder():
started.set()
await finish.wait()
return {"count": 1}
owner = asyncio.create_task(
resolve_layer_payload(key="cancel-waiter", policy=policy, builder=builder)
)
await started.wait()
waiter = asyncio.create_task(
resolve_layer_payload(key="cancel-waiter", policy=policy, builder=builder)
)
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
finish.set()
assert (await owner).payload == {"count": 1}
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
async def test_cancelled_owner_allows_waiter_to_use_its_own_builder():
started = asyncio.Event()
policy = EarthLayerCachePolicy(60, 120)
async def owner_builder():
started.set()
await asyncio.Event().wait()
async def waiter_builder():
return {"count": 2}
owner = asyncio.create_task(
resolve_layer_payload(key="cancel-owner", policy=policy, builder=owner_builder)
)
await started.wait()
waiter = asyncio.create_task(
resolve_layer_payload(key="cancel-owner", policy=policy, builder=waiter_builder)
)
await asyncio.sleep(0)
owner.cancel()
with pytest.raises(asyncio.CancelledError):
await owner
assert (await asyncio.wait_for(waiter, 1)).payload == {"count": 2}
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
async def test_cross_process_lock_waits_for_payload_instead_of_building(fake_cache_client):
key = "remote-owner"
fake_cache_client.store[earth_layer_cache.lock_key(key)] = "another-owner"
policy = EarthLayerCachePolicy(60, 120, lock_wait_seconds=0.005)
calls = 0
async def builder():
nonlocal calls
calls += 1
return {"count": -1}
waiter = asyncio.create_task(resolve_layer_payload(key=key, policy=policy, builder=builder))
await asyncio.sleep(0.03)
assert calls == 0
fake_cache_client.store[key] = json.dumps({"count": 3})
assert (await asyncio.wait_for(waiter, 1)).payload == {"count": 3}
assert calls == 0
@pytest.mark.asyncio
async def test_wait_timeout_does_not_bypass_other_owner(fake_cache_client):
key = "remote-timeout"
fake_cache_client.store[earth_layer_cache.lock_key(key)] = "another-owner"
calls = 0
async def builder():
nonlocal calls
calls += 1
return {}
with pytest.raises(HTTPException) as error:
await resolve_layer_payload(
key=key,
policy=EarthLayerCachePolicy(60, 120, lock_wait_seconds=0.005, build_wait_seconds=0.02),
builder=builder,
)
assert error.value.status_code == 503
assert calls == 0
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
async def test_lost_owner_cannot_publish_or_release_new_lock(fake_cache_client):
key = "lock-replaced"
policy = EarthLayerCachePolicy(60, 120)
async def builder():
fake_cache_client.store[earth_layer_cache.lock_key(key)] = "new-owner"
fake_cache_client.store[key] = json.dumps({"count": 99})
return {"count": 1}
result = await resolve_layer_payload(key=key, policy=policy, builder=builder)
assert result.payload == {"count": 1}
assert result.state == "bypass"
assert fake_cache_client.store[earth_layer_cache.lock_key(key)] == "new-owner"
assert json.loads(fake_cache_client.store[key]) == {"count": 99}
@pytest.mark.asyncio
async def test_redis_publish_failure_returns_built_payload_without_retry(fake_cache_client):
calls = 0
async def builder():
nonlocal calls
calls += 1
fake_cache_client.fail = True
return {"count": 7}
result = await resolve_layer_payload(
key="publish-error", policy=EarthLayerCachePolicy(60, 120), builder=builder
)
assert result.state == "bypass"
assert result.payload == {"count": 7}
assert calls == 1
@pytest.mark.asyncio
async def test_local_wait_timeout_leaves_owner_running():
started = asyncio.Event()
finish = asyncio.Event()
policy = EarthLayerCachePolicy(60, 120, build_wait_seconds=0.02)
async def builder():
started.set()
await finish.wait()
return {"count": 1}
owner = asyncio.create_task(
resolve_layer_payload(key="local-timeout", policy=policy, builder=builder)
)
await started.wait()
with pytest.raises(HTTPException) as error:
await resolve_layer_payload(key="local-timeout", policy=policy, builder=builder)
assert error.value.status_code == 503
assert not owner.done()
finish.set()
assert (await owner).payload == {"count": 1}
assert not earth_layer_cache._inflight
@pytest.mark.asyncio
async def test_different_keys_can_build_concurrently():
started = set()
both_started = asyncio.Event()
async def build(key):
started.add(key)
if len(started) == 2:
both_started.set()
await asyncio.wait_for(both_started.wait(), 1)
return {"key": key}
results = await asyncio.gather(
*(
resolve_layer_payload(
key=key, policy=EarthLayerCachePolicy(60, 120), builder=lambda key=key: build(key)
)
for key in ("one", "two")
)
)
assert {result.payload["key"] for result in results} == {"one", "two"}

View File

@@ -3,6 +3,7 @@ from types import SimpleNamespace
import pytest
from app.api.v1.news import get_earth_feed
from app.services.earth_news import (
NewsFeedEndpoint,
NewsFeedSource,
@@ -1201,7 +1202,6 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
db=FakeDb(),
)
assert captured["freshness_region"] == "europe"
assert captured["items_region"] == "europe"
assert captured["items_categories"] == {"business", "ecommerce"}
assert captured["items_source_ids"][0] is None
@@ -1237,71 +1237,41 @@ async def test_news_source_test_treats_type_reference_as_non_fetching():
@pytest.mark.asyncio
async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatch):
async def test_earth_news_feed_get_does_not_fetch_write_or_queue_rss(monkeypatch):
db = object()
source = NewsFeedSource(
id="test-feed",
name="Test Feed",
region="global",
homepage_url="https://example.com",
feed_url="https://example.com/rss.xml",
read_calls = 0
async def fail_side_effect(*_args, **_kwargs):
raise AssertionError("RSS feed reads must not fetch, persist, or enqueue work")
async def empty_news_items(*_args, **_kwargs):
nonlocal read_calls
read_calls += 1
return []
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_side_effect)
monkeypatch.setattr("app.services.earth_news._enqueue_unverified_locations", fail_side_effect)
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fail_side_effect)
monkeypatch.setattr(
"app.services.earth_news_store.list_earth_news_items", empty_news_items
)
item = ParsedNewsItem(
id="test-feed:init",
title="Initial RSS story",
summary="Initial summary",
url="https://example.com/init",
source="Test Feed",
feed_name="Test Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
payload = await get_earth_feed(
lat=None,
lon=None,
region=None,
categories=None,
sources=None,
limit=None,
locale=None,
db=db,
)
item.location_patch = {
"latitude": 20.0,
"longitude": 0.0,
"location_label": "全球",
"location_source": "region_anchor",
"verified": False,
"location_meta": {"target": None, "anchor": {"region": "global"}},
}
upserted = []
enqueued = []
async def fake_get_earth_news_freshness(_db, *, active_region):
return 0, None
async def fake_fetch_rss_items_for_sources(_sources, **_kwargs):
return [item], [], {"test-feed": {"source_id": "test-feed", "ok": True, "status": "ok", "count": 1}}
async def fake_upsert_earth_news_items(_db, items):
upserted.extend(items)
return len(items)
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None):
return [item]
async def fake_enqueue_target_location_job(payload, **_kwargs):
enqueued.append(payload)
return True
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
payload = await get_earth_news_payload(db=db)
assert upserted[0].id == "test-feed:init"
assert payload["items"][0]["id"] == "test-feed:init"
assert payload["items"][0]["verified"] is False
assert enqueued[0]["id"] == "test-feed:init"
assert payload["items"] == []
assert read_calls == 1
@pytest.mark.asyncio
async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
async def test_earth_news_payload_does_not_supplement_stale_database_items(monkeypatch):
db = object()
old_item = ParsedNewsItem(
id="db:old",
@@ -1314,41 +1284,17 @@ async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
homepage_url="https://example.com",
published_at=datetime(2026, 5, 14, 3, 0, tzinfo=UTC),
)
old_item.location_patch = {
"latitude": 20.0,
"longitude": 0.0,
"location_label": "全球",
"location_source": "region_anchor",
"verified": False,
"location_meta": {"target": None, "anchor": {"region": "global"}},
}
fetched = []
async def fail_fetch(*_args, **_kwargs):
raise AssertionError("stale RSS supplementation belongs to the background worker")
async def fake_get_earth_news_freshness(_db, *, active_region):
return 12, datetime(2026, 5, 14, 3, 0, tzinfo=UTC)
async def fake_fetch_rss_items_for_sources(_sources, **_kwargs):
fetched.append(True)
return [old_item], [], {"stored": {"source_id": "stored", "ok": True, "status": "ok", "count": 1}}
async def fake_upsert_earth_news_items(_db, items):
return len(items)
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None):
async def fake_list_earth_news_items(_db, *, active_region, limit, categories=None, source_ids=None):
return [old_item]
async def fake_enqueue_target_location_job(_payload, **_kwargs):
return True
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_fetch)
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
payload = await get_earth_news_payload(db=db)
payload = await get_earth_news_payload(db=db, source_ids={"stored"})
assert fetched == [True]
assert payload["items"][0]["id"] == "db:old"

View File

@@ -0,0 +1,525 @@
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import json
import pytest
from app.services.earth_news import NewsFeedEndpoint, NewsFeedSource
from app.services import earth_news_refresh
from app.services.scheduler import (
EARTH_NEWS_REFRESH_JOB_ID,
_earth_news_region_refresh_job_id,
schedule_earth_news_region_refresh,
start_scheduler,
sync_scheduler_with_datasources,
)
class FakeRedis:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def set(self, key: str, value: str, *, nx: bool = False, ex: int | None = None) -> bool:
del ex
if nx and key in self.values:
return False
self.values[key] = value
return True
def exists(self, key: str) -> bool:
return key in self.values
def eval(self, script: str, _key_count: int, key: str, token: str, *_args: object) -> int:
if self.values.get(key) != token:
return 0
if "redis.call('del'" in script:
del self.values[key]
return 1
class FakeStatusRedis:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.expirations: dict[str, int] = {}
def get(self, key: str):
return self.values.get(key)
def eval(self, script: str, key_count: int, *args):
keys = args[:key_count]
values = args[key_count:]
if "local active_id" in script:
active_key, status_key = keys
refresh_id, encoded, active_ttl, status_ttl, status_prefix = values
active_id = self.values.get(active_key)
if active_id:
active_status = self.values.get(f"{status_prefix}{active_id}")
if active_status:
status = json.loads(active_status)["status"]
if status in {"pending", "running"}:
return [active_id, active_status, 0]
self.values.pop(active_key, None)
self.values[active_key] = refresh_id
self.values[status_key] = encoded
self.expirations[active_key] = int(active_ttl)
self.expirations[status_key] = int(status_ttl)
return [refresh_id, encoded, 1]
active_key, status_key = keys
encoded, refresh_id, status_ttl, status, active_ttl = values
if self.values.get(active_key) != refresh_id:
return 0
self.values[status_key] = encoded
self.expirations[status_key] = int(status_ttl)
if status in {"succeeded", "failed"}:
self.values.pop(active_key, None)
else:
self.expirations[active_key] = int(active_ttl)
return 1
class FakeSession:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
@pytest.mark.asyncio
async def test_region_refresh_fetches_only_missing_feed_coverage(monkeypatch):
redis_client = FakeRedis()
db = FakeSession()
feeds = (
NewsFeedEndpoint("feed-a", "Feed A", "https://example.com/a.xml", region="global"),
NewsFeedEndpoint("feed-b", "Feed B", "https://example.com/b.xml", region="global"),
)
source = NewsFeedSource(
id="source-a",
name="Source A",
region="global",
feed_url=feeds[0].url,
homepage_url="https://example.com",
feeds=feeds,
)
fetched_source_feeds: list[list[str]] = []
@asynccontextmanager
async def fake_session_factory():
yield db
async def fake_get_sources_payload(_db):
return {"sources": []}
async def fake_get_sources_for_region(_db, _region, *, config_payload=None):
assert config_payload == {"sources": []}
return [source]
async def fake_freshness(_db, *, active_region):
assert active_region == "global"
return 12, datetime.now(UTC)
async def fake_coverage(_db, *, active_region, recent_after):
assert active_region == "global"
assert recent_after is not None
return {("source-a", "feed-a")}
async def fake_fetch(sources, **_kwargs):
fetched_source_feeds.extend([[feed.id for feed in item.feeds] for item in sources])
return [], [], {"source-a": {"source_id": "source-a", "ok": True, "feed_results": []}}
async def fake_record_health(_db, _health):
return None
async def fake_enqueue(_items):
return None
async def fake_upsert(_db, items):
assert items == []
return 0
monkeypatch.setattr(earth_news_refresh, "async_session_factory", fake_session_factory)
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", lambda: redis_client)
monkeypatch.setattr(
"app.services.earth_news.get_earth_news_sources_payload", fake_get_sources_payload
)
monkeypatch.setattr(
"app.services.earth_news.get_configured_sources_for_region", fake_get_sources_for_region
)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_freshness", fake_freshness)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_feed_coverage", fake_coverage)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch)
monkeypatch.setattr(
"app.services.earth_news.record_earth_news_sources_health", fake_record_health
)
monkeypatch.setattr("app.services.earth_news._enqueue_unverified_locations", fake_enqueue)
monkeypatch.setattr(earth_news_refresh, "upsert_earth_news_items", fake_upsert)
result = await earth_news_refresh.refresh_earth_news_region("global")
assert result["state"] == "completed"
assert result["successful_sources"] == 1
assert fetched_source_feeds == [["feed-b"]]
assert db.commits == 1
assert earth_news_refresh._lock_key("global") not in redis_client.values
@pytest.mark.asyncio
async def test_region_refresh_reports_failure_when_all_sources_time_out(monkeypatch):
redis_client = FakeRedis()
db = FakeSession()
feed = NewsFeedEndpoint("feed-a", "Feed A", "https://example.com/a.xml", region="global")
source = NewsFeedSource(
id="source-a",
name="Source A",
region="global",
feed_url=feed.url,
homepage_url="https://example.com",
feeds=(feed,),
)
@asynccontextmanager
async def fake_session_factory():
yield db
async def fake_get_sources_payload(_db):
return {"sources": []}
async def fake_get_sources_for_region(_db, _region, *, config_payload=None):
return [source]
async def fake_freshness(_db, *, active_region):
return 12, datetime.now(UTC)
async def fake_coverage(_db, *, active_region, recent_after):
return set()
async def fake_fetch(*_args, **_kwargs):
return (
[],
["private upstream details"],
{
"source-a": {
"source_id": "source-a",
"ok": False,
"status": "timeout",
"feed_results": [{"feed_id": "feed-a", "ok": False, "status": "timeout"}],
}
},
)
async def fake_noop(*_args, **_kwargs):
return None
async def fake_upsert(_db, _items):
return 0
monkeypatch.setattr(earth_news_refresh, "async_session_factory", fake_session_factory)
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", lambda: redis_client)
monkeypatch.setattr(
"app.services.earth_news.get_earth_news_sources_payload", fake_get_sources_payload
)
monkeypatch.setattr(
"app.services.earth_news.get_configured_sources_for_region", fake_get_sources_for_region
)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_freshness", fake_freshness)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_feed_coverage", fake_coverage)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch)
monkeypatch.setattr("app.services.earth_news.record_earth_news_sources_health", fake_noop)
monkeypatch.setattr("app.services.earth_news._enqueue_unverified_locations", fake_noop)
monkeypatch.setattr(earth_news_refresh, "upsert_earth_news_items", fake_upsert)
result = await earth_news_refresh.refresh_earth_news_region("global", force=True)
assert result["state"] == "failed"
assert result["error_code"] == "timeout"
assert db.commits == 1
@pytest.mark.asyncio
async def test_database_commit_failure_sets_refresh_failed_status(monkeypatch):
class CommitFailureSession(FakeSession):
async def commit(self) -> None:
raise RuntimeError("database commit failed with sensitive context")
redis_client = FakeRedis()
db = CommitFailureSession()
feed = NewsFeedEndpoint("feed-a", "Feed A", "https://example.com/a.xml", region="europe")
source = NewsFeedSource(
id="source-a",
name="Source A",
region="europe",
feed_url=feed.url,
homepage_url="https://example.com",
feeds=(feed,),
)
scheduler_module = __import__("app.services.scheduler", fromlist=["scheduler"])
statuses = []
@asynccontextmanager
async def fake_session_factory():
yield db
async def fake_get_sources_payload(_db):
return {"sources": []}
async def fake_get_sources_for_region(_db, _region, *, config_payload=None):
return [source]
async def fake_freshness(_db, *, active_region):
return 12, datetime.now(UTC)
async def fake_coverage(_db, *, active_region, recent_after):
return set()
async def fake_fetch(*_args, **_kwargs):
return [], [], {"source-a": {"source_id": "source-a", "ok": True, "feed_results": []}}
async def fake_noop(*_args, **_kwargs):
return None
async def fake_upsert(_db, _items):
return 1
monkeypatch.setattr(earth_news_refresh, "async_session_factory", fake_session_factory)
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", lambda: redis_client)
monkeypatch.setattr(
"app.services.earth_news.get_earth_news_sources_payload", fake_get_sources_payload
)
monkeypatch.setattr(
"app.services.earth_news.get_configured_sources_for_region", fake_get_sources_for_region
)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_freshness", fake_freshness)
monkeypatch.setattr(earth_news_refresh, "get_earth_news_feed_coverage", fake_coverage)
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch)
monkeypatch.setattr("app.services.earth_news.record_earth_news_sources_health", fake_noop)
monkeypatch.setattr("app.services.earth_news._enqueue_unverified_locations", fake_noop)
monkeypatch.setattr(earth_news_refresh, "upsert_earth_news_items", fake_upsert)
monkeypatch.setattr(scheduler_module, "touch_earth_news_refresh_status", lambda *_args: True)
monkeypatch.setattr(
scheduler_module,
"set_earth_news_refresh_status",
lambda refresh_id, region, status, *, error=None: statuses.append(
{"refresh_id": refresh_id, "region": region, "status": status, "error": error}
),
)
scheduler_module._pending_earth_news_refreshes["europe"] = {
"force": True,
"refresh_id": "refresh-commit-failure",
}
await scheduler_module._run_earth_news_region_refresh("europe")
assert [entry["status"] for entry in statuses] == ["running", "failed"]
assert statuses[-1]["error"]["code"] == "refresh_failed"
assert "sensitive context" not in repr(statuses)
assert not redis_client.values
@pytest.mark.asyncio
async def test_region_refresh_skips_when_cross_process_lock_is_held(monkeypatch):
redis_client = FakeRedis()
redis_client.values[earth_news_refresh._lock_key("global")] = "other-owner"
def fail_session_factory():
raise AssertionError("a locked refresh must not open a database session")
monkeypatch.setattr(earth_news_refresh, "async_session_factory", fail_session_factory)
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", lambda: redis_client)
result = await earth_news_refresh.refresh_earth_news_region("global")
assert result["state"] == "busy"
def test_failed_feed_enters_cooldown_without_blocking_other_sources():
redis_client = FakeRedis()
source = NewsFeedSource(
id="source-a",
name="Source A",
region="global",
feed_url="https://example.com/a.xml",
homepage_url="https://example.com",
feeds=(NewsFeedEndpoint("feed-a", "Feed A", "https://example.com/a.xml"),),
)
health = {
"source-a": {
"ok": False,
"feed_results": [{"feed_id": "feed-a", "ok": False}],
}
}
earth_news_refresh._record_failed_feed_cooldowns(
redis_client,
active_region="global",
health_by_source=health,
sources=[source],
)
selected, cooling = earth_news_refresh._select_feeds(
[source],
active_region="global",
required_keys={("source-a", "feed-a")},
redis_client=redis_client,
)
assert selected == []
assert cooling == {("source-a", "feed-a")}
@pytest.mark.asyncio
async def test_region_refresh_skips_when_redis_coordination_is_unavailable(monkeypatch):
def fail_redis_client():
raise RuntimeError("redis unavailable")
def fail_session_factory():
raise AssertionError("uncoordinated processes must not start duplicate RSS fetches")
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", fail_redis_client)
monkeypatch.setattr(earth_news_refresh, "async_session_factory", fail_session_factory)
result = await earth_news_refresh.refresh_earth_news_region("global")
assert result["state"] == "coordination_unavailable"
def test_refresh_status_deduplicates_active_region_and_bounds_completed_results(monkeypatch):
redis_client = FakeStatusRedis()
monkeypatch.setattr(earth_news_refresh._RedisClient, "get_client", lambda: redis_client)
first, created = earth_news_refresh.create_earth_news_refresh_status("global")
duplicate, duplicate_created = earth_news_refresh.create_earth_news_refresh_status("global")
assert created is True
assert duplicate_created is False
assert duplicate == first
earth_news_refresh.set_earth_news_refresh_status(first["refresh_id"], "global", "running")
running, running_created = earth_news_refresh.create_earth_news_refresh_status("global")
assert running_created is False
assert running["status"] == "running"
completed = earth_news_refresh.set_earth_news_refresh_status(
first["refresh_id"], "global", "succeeded"
)
assert completed["status"] == "succeeded"
assert earth_news_refresh.get_earth_news_refresh_status(first["refresh_id"]) == completed
second, second_created = earth_news_refresh.create_earth_news_refresh_status("global")
assert second_created is True
assert second["refresh_id"] != first["refresh_id"]
assert earth_news_refresh.get_earth_news_refresh_status(first["refresh_id"]) == completed
assert redis_client.expirations[earth_news_refresh._refresh_active_key("global")] == 120
assert (
redis_client.expirations[earth_news_refresh._refresh_status_key(second["refresh_id"])]
== 600
)
status_keys = [
key
for key in redis_client.values
if key.startswith(earth_news_refresh.REFRESH_STATUS_PREFIX)
]
assert len(status_keys) == 2
pending, pending_created = earth_news_refresh.create_earth_news_refresh_status("europe")
assert pending_created is True
redis_client.values.pop(earth_news_refresh._refresh_active_key("europe"), None)
expired = earth_news_refresh.get_earth_news_refresh_status(pending["refresh_id"])
assert expired["status"] == "failed"
assert expired["error"]["code"] == "expired"
@pytest.mark.asyncio
async def test_earth_news_refresh_scheduler_job_survives_datasource_sync(monkeypatch):
class FakeJob:
def __init__(self, job_id: str) -> None:
self.id = job_id
class FakeScheduler:
def __init__(self) -> None:
self.running = False
self.jobs = {
"obsolete": FakeJob("obsolete"),
_earth_news_region_refresh_job_id("global"): FakeJob(
_earth_news_region_refresh_job_id("global")
),
}
self.added: dict[str, object] = {}
def add_job(self, _func, **kwargs):
self.added.update(kwargs)
self.jobs[kwargs["id"]] = FakeJob(kwargs["id"])
def start(self) -> None:
self.running = True
def get_jobs(self):
return list(self.jobs.values())
def remove_job(self, job_id: str) -> None:
self.jobs.pop(job_id, None)
class FakeResult:
def scalars(self):
return self
def all(self):
return []
class FakeDb:
async def execute(self, _query):
return FakeResult()
fake_scheduler = FakeScheduler()
@asynccontextmanager
async def fake_session_factory():
yield FakeDb()
monkeypatch.setattr("app.services.scheduler.scheduler", fake_scheduler)
monkeypatch.setattr("app.services.scheduler.async_session_factory", fake_session_factory)
start_scheduler()
await sync_scheduler_with_datasources()
assert fake_scheduler.added["id"] == EARTH_NEWS_REFRESH_JOB_ID
assert fake_scheduler.added["next_run_time"] <= datetime.now(UTC)
assert EARTH_NEWS_REFRESH_JOB_ID in fake_scheduler.jobs
assert _earth_news_region_refresh_job_id("global") in fake_scheduler.jobs
assert "obsolete" not in fake_scheduler.jobs
@pytest.mark.asyncio
async def test_region_refresh_requests_are_merged_and_force_is_preserved(monkeypatch):
class FakeScheduler:
running = True
def __init__(self) -> None:
self.jobs: dict[str, tuple[object, dict[str, object]]] = {}
def add_job(self, func, **kwargs):
self.jobs[kwargs["id"]] = (func, kwargs)
scheduler_module = __import__("app.services.scheduler", fromlist=["scheduler"])
fake_scheduler = FakeScheduler()
monkeypatch.setattr(scheduler_module, "scheduler", fake_scheduler)
captured: list[tuple[str, bool]] = []
async def fake_refresh(region: str, *, force: bool = False):
captured.append((region, force))
return {"locked": False, "fetched": 0, "upserted": 0}
monkeypatch.setattr(scheduler_module, "refresh_earth_news_region", fake_refresh)
assert schedule_earth_news_region_refresh("global") is True
assert schedule_earth_news_region_refresh("global", force=True) is False
assert len(fake_scheduler.jobs) == 1
job_id = _earth_news_region_refresh_job_id("global")
func, kwargs = fake_scheduler.jobs.pop(job_id)
await func(**kwargs["kwargs"])
assert captured == [("global", True)]
assert job_id not in fake_scheduler.jobs

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from app.services import earth_db_change_listener as listener_module
from app.services.earth_db_change_listener import EarthDbChangeDispatcher, EarthDbChangeListener
from app.services.earth_outbox_maintenance import (
DELETE_BATCH_SQL,
compact_consumed_events,
ensure_retention_index,
purge_consumed_events,
)
class FakeConnection:
def __init__(self, *, index_state: str = "ready", batch_counts: list[int] | None = None):
self.index_state = index_state
self.batch_counts = list(batch_counts or [])
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.retained = {"total": 13, "pending": 3}
self.restored = dict(self.retained)
self.rolled_back = False
async def fetchrow(self, query: str, *args: object):
self.calls.append((query, args))
if "pg_index" in query:
if self.index_state == "missing":
return None
return {"indisvalid": self.index_state == "ready", "indisready": True}
if "FROM earth_outbox_retained" in query:
return self.retained
return self.restored
async def fetchval(self, query: str, *args: object):
self.calls.append((query, args))
if query == DELETE_BATCH_SQL:
return self.batch_counts.pop(0)
return True
async def execute(self, query: str, *args: object):
self.calls.append((query, args))
if "CREATE INDEX CONCURRENTLY" in query:
self.index_state = "ready"
return "OK"
def transaction(self):
connection = self
class Transaction:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, _exc, _traceback):
connection.rolled_back = exc_type is not None
return Transaction()
@pytest.mark.asyncio
async def test_retention_skips_without_index():
conn = FakeConnection(index_state="missing")
assert await purge_consumed_events(conn, retention_days=1) == 0
assert not any(query == DELETE_BATCH_SQL for query, _ in conn.calls)
@pytest.mark.asyncio
async def test_retention_batches_only_consumed_old_rows():
conn = FakeConnection(batch_counts=[1000, 19])
deleted = await purge_consumed_events(
conn, retention_days=1, batch_size=1000, max_batches=10, pause_seconds=0
)
assert deleted == 1019
calls = [args for query, args in conn.calls if query == DELETE_BATCH_SQL]
assert len(calls) == 2
assert all(isinstance(args[0], datetime) and args[0].tzinfo is UTC for args in calls)
assert "consumed_at IS NOT NULL AND consumed_at < $1" in DELETE_BATCH_SQL
assert "FOR UPDATE SKIP LOCKED" in DELETE_BATCH_SQL
@pytest.mark.asyncio
async def test_index_creation_is_concurrent_and_repairs_invalid_index():
conn = FakeConnection(index_state="invalid")
await ensure_retention_index(conn)
statements = [query for query, _ in conn.calls]
assert any("DROP INDEX CONCURRENTLY" in query for query in statements)
assert any("CREATE INDEX CONCURRENTLY" in query for query in statements)
@pytest.mark.asyncio
async def test_compact_retains_pending_and_recent_consumed_in_one_transaction():
conn = FakeConnection()
assert await compact_consumed_events(conn, retention_days=1) == (13, 3)
statements = [query for query, _ in conn.calls]
assert next(i for i, query in enumerate(statements) if query.startswith("LOCK TABLE")) < next(
i for i, query in enumerate(statements) if query.startswith("TRUNCATE TABLE")
)
assert any("consumed_at IS NULL OR consumed_at >= $1" in query for query in statements)
assert "TRUNCATE TABLE earth_data_change_events CONTINUE IDENTITY RESTRICT" in statements
assert not conn.rolled_back
@pytest.mark.asyncio
async def test_compact_verification_failure_rolls_back():
conn = FakeConnection()
conn.restored = {"total": 12, "pending": 2}
with pytest.raises(RuntimeError, match="verification failed"):
await compact_consumed_events(conn, retention_days=1)
assert conn.rolled_back
@pytest.mark.asyncio
async def test_listener_runs_retention_at_most_once_per_interval(monkeypatch):
calls: list[str] = []
async def acquire(_conn):
calls.append("acquire")
return True
async def purge(_conn, *, retention_days):
assert retention_days == 1
calls.append("purge")
async def release(_conn):
calls.append("release")
monkeypatch.setattr(listener_module, "monotonic", lambda: 100.0)
monkeypatch.setattr(listener_module, "try_maintenance_lock", acquire)
monkeypatch.setattr(listener_module, "purge_consumed_events", purge)
monkeypatch.setattr(listener_module, "release_maintenance_lock", release)
monkeypatch.setattr(listener_module, "configured_retention_days", lambda: 1)
dispatcher = EarthDbChangeDispatcher(broadcast_earth_update=lambda _: None)
listener = EarthDbChangeListener(dsn="postgresql://example/db", dispatcher=dispatcher)
listener._connection = FakeConnection()
await listener._maybe_purge_outbox()
await listener._maybe_purge_outbox()
assert calls == ["acquire", "purge", "release"]

View File

@@ -10,7 +10,7 @@ from app.core.logging import PlanetContextFilter, PlanetFormatter, get_logger
from app.core.request_context import set_request_id
from app.services import business_logs
from app.services import persistent_logs
from app.models.system_log import ObservabilityEvent, ObservabilityEventGroup
from app.models.system_log import ObservabilityEvent, ObservabilityEventGroup, SystemLog
def _capture_output(callback):
@@ -100,6 +100,23 @@ def test_business_context_redacts_nested_sensitive_values():
assert context["nested"]["safe"] == "visible"
@pytest.mark.asyncio
async def test_business_log_preserves_events_in_isolated_store(persisted_test_logs):
await business_logs.emit_business_log(
get_logger("tests.persistence"),
event="test.business.completed",
message="Business action completed",
category="test",
context={"api_key": "test-secret", "record_count": 2},
)
logs = [entry for entry in persisted_test_logs if isinstance(entry, SystemLog)]
assert len(logs) == 1
assert logs[0].event == "test.business.completed"
assert logs[0].context == {"api_key": "[REDACTED]", "record_count": 2}
assert any(isinstance(entry, ObservabilityEventGroup) for entry in persisted_test_logs)
def test_observability_fingerprint_normalizes_hls_fragments():
first = persistent_logs.build_observability_fingerprint(
source="earth-client",

View File

@@ -0,0 +1,142 @@
from types import SimpleNamespace
from uuid import UUID
import importlib
import pytest
from fastapi import HTTPException
from app.api.v1 import earth, news
@pytest.mark.asyncio
async def test_post_news_refresh_only_schedules_background_region_check(monkeypatch):
scheduled = []
def request(region: str):
scheduled.append(region)
return {"refresh_id": "refresh-europe", "region": region, "status": "pending"}
async def fail_network_refresh(*_args, **_kwargs):
raise AssertionError("the POST handler must not perform RSS requests")
monkeypatch.setattr("app.services.scheduler.request_earth_news_region_refresh", request)
monkeypatch.setattr("app.services.scheduler.refresh_earth_news_region", fail_network_refresh)
response = await news.request_earth_news_refresh(
news.EarthNewsRefreshRequest(region="Europe", lat=48.8, lon=2.3)
)
assert response == {"refresh_id": "refresh-europe", "region": "europe", "status": "pending"}
assert scheduled == ["europe"]
refresh_route = next(route for route in news.router.routes if route.path == "/refresh")
assert refresh_route.status_code == 202
@pytest.mark.asyncio
async def test_post_news_refresh_infers_region_and_rejects_unknown_region(monkeypatch):
scheduled = []
monkeypatch.setattr(
"app.services.scheduler.request_earth_news_region_refresh",
lambda region: scheduled.append(region)
or {"refresh_id": "refresh-americas", "region": region, "status": "pending"},
)
response = await news.request_earth_news_refresh(
news.EarthNewsRefreshRequest(lat=40.7, lon=-74.0)
)
assert response["region"] == "americas"
assert response["status"] == "pending"
assert scheduled == ["americas"]
with pytest.raises(HTTPException) as error:
await news.request_earth_news_refresh(news.EarthNewsRefreshRequest(region="unknown"))
assert error.value.status_code == 422
@pytest.mark.asyncio
async def test_get_news_refresh_returns_status_and_expired_shape(monkeypatch):
current = {"refresh_id": "abc", "region": "global", "status": "running"}
monkeypatch.setattr(
"app.services.earth_news_refresh.get_earth_news_refresh_status",
lambda _refresh_id: current,
)
response = await news.get_earth_news_refresh(UUID("00000000-0000-0000-0000-000000000abc"))
assert response is current
assert response["status"] == "running"
monkeypatch.setattr(
"app.services.earth_news_refresh.get_earth_news_refresh_status", lambda _id: None
)
expired = await news.get_earth_news_refresh(UUID("00000000-0000-0000-0000-000000000abc"))
assert expired == {
"refresh_id": UUID("00000000-0000-0000-0000-000000000abc").hex,
"region": None,
"status": "failed",
"error": {
"code": "expired",
"message": "Refresh status expired. Submit a new refresh.",
},
}
@pytest.mark.asyncio
async def test_database_failure_sets_terminal_failed_status_without_leaking_error(monkeypatch):
scheduler = importlib.import_module("app.services.scheduler")
statuses = []
async def fail_refresh(_region, *, force=False):
raise RuntimeError("postgres://private-token@host/database: commit failed")
monkeypatch.setattr(scheduler, "refresh_earth_news_region", fail_refresh)
monkeypatch.setattr(scheduler, "touch_earth_news_refresh_status", lambda *_args: True)
monkeypatch.setattr(
scheduler,
"set_earth_news_refresh_status",
lambda refresh_id, region, state, *, error=None: statuses.append(
{"refresh_id": refresh_id, "region": region, "status": state, "error": error}
),
)
scheduler._pending_earth_news_refreshes["europe"] = {
"force": True,
"refresh_id": "refresh-db-failure",
}
await scheduler._run_earth_news_region_refresh("europe")
assert [item["status"] for item in statuses] == ["running", "failed"]
assert statuses[-1]["error"] == {
"code": "refresh_failed",
"message": "Could not refresh news. Please try again.",
}
assert "private-token" not in repr(statuses)
@pytest.mark.asyncio
async def test_news_source_save_and_reset_schedule_immediate_refresh(monkeypatch):
scheduled = []
monkeypatch.setattr(
earth,
"save_earth_news_sources_payload",
lambda _db, _payload: _async_result({"status": "updated"}),
)
monkeypatch.setattr(
earth,
"reset_earth_news_sources_payload",
lambda _db: _async_result({"status": "reset"}),
)
monkeypatch.setattr(
"app.services.scheduler.schedule_earth_news_refresh_for_all_regions",
lambda *, force=False: scheduled.append(force),
)
await earth.update_earth_news_sources(
SimpleNamespace(model_dump=lambda: {}), _current_user=None, db=object()
)
await earth.reset_earth_news_sources(_current_user=None, db=object())
assert scheduled == [True, True]
async def _async_result(value):
return value

View File

@@ -8,6 +8,88 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.76.19] — 2026-11-09
Released: 2026-11-09
### Highlights
- 改善 Earth 桌面与移动端卡片、新闻刷新和数据更新链路,降低重复抓取、缓存重建与历史事件维护的开销。
### Added / Fixed / Improved
- 统一区域卡片的展开与胶囊展示、悬停和拖拽跟随,分别应用算力/新闻卡片展开上限;提供细线/粗线引导线及移动端交互改进,修复语言切换后状态提示标题残留。
- 新闻 GET 只读取已保存内容,RSS 补抓改为独立调度;手动刷新提供可追踪状态、超时和重试,完成展示后才提示成功。
- 图层缓存按 key 合并并发构建,续租与发布校验锁持有者;纯新闻变更不再触发 summary 缓存重建。
- BGP 前缀归属使用容错转换和可独立部署的 GiST 索引;Outbox 单次写入事件,跳过纯时间戳变更,并提供有界保留与维护脚本。
- 卫星粒子以球体遮挡判断替代点精灵深度裁切;同步中英文文档,补充回归并隔离前端验证构建产物。
---
## [0.76.18] — 2026-10-02
Released: 2026-10-02
### Highlights
- 收敛 Earth 新闻 breaking 交互、卡片 hover、面板稳定性与 ticker 玻璃扫光,并修复船只地表锚点显隐。
### Added / Fixed / Improved
- breaking 数据、ticker 当前项、面板生命周期和选中态分离;展开面板时冻结卡片 DOM,后台更新在关闭时应用。
- ticker 保留原有容器与滚动,breaking 扫光独立于静态上下边线,连续 breaking 切换不重启;卡片 hover/扫光统一复用等级色相。
- 新闻卡片内容高度、首卡间距和 cruise 选中态统一;船只 marker 复用地表锚点遮挡,保持前后显隐与 BGP/算力对象一致。
- 同步 Earth 前端状态机与渲染层中英文开发文档。
---
## [0.76.17] — 2026-10-02
Released: 2026-10-02
### Highlights
- 修复 ticker 连续 breaking 新闻切换时扫光动画重启导致的闪烁、回跳和断帧。
### Added / Fixed / Improved
- breaking 等级 class 只负责颜色,稳定的 active 状态负责动画生命周期。
- ticker 与卡片扫光方向统一为左到右,连续 breaking 切换不重置时间轴。
---
## [0.76.16] — 2026-10-02
Released: 2026-10-02
### Highlights
- 修复 ticker breaking class 每 120ms 重置导致的边框闪烁、扫光停滞和方向反转。
### Added / Fixed / Improved
- watcher 仅在当前 breaking 等级变化时更新 class,动画可以完整播放。
- ticker 与卡片扫光方向统一为左到右,ticker 扫光覆盖完整 ticker 区域。
---
## [0.76.15] — 2026-10-02
Released: 2026-10-02
### Highlights
- 将 ticker 扫光限制在上下边线以内,消除 breaking 边框被扫光伪元素带动闪烁。
### Added / Fixed / Improved
- ticker 扫光与卡片扫光保持独立横向移动,边线保持稳定,只响应 hover/等级颜色。
---
## [0.76.14] — 2026-10-02
Released: 2026-10-02
### Highlights
- 修复卡片扫光从中途出现的问题,让反射从卡片左边界完整扫到右边界。
### Added / Fixed / Improved
- 卡片反射改用内部 `background-position` 扫描,不再使用卡片外部 transform 起始。
- ticker 上下边线保持静态,横向扫光独立运行。
---
## [0.76.13] — 2026-10-02
Released: 2026-10-02

View File

@@ -94,8 +94,8 @@ elevated restart; report that separately when it has not been exercised.
| Doctor | `scripts/harness/doctor.sh` | Checks required files, required tools, optional delivery tools, and forbidden frontend lockfiles. |
| Security | `scripts/harness/security-check.sh` | Checks that environment/private-key files are not tracked and scans for high-confidence committed secret tokens. |
| Backend Rules | `scripts/harness/backend-rules-check.sh` | Checks backend app Python for direct `print()`, `breakpoint()`, and `pdb.set_trace()` debug calls so service code uses structured logging. |
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, same-category style owner warnings, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
| Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs, and the harness rules-coverage notes. |
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
| Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, and documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs. |
| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, isolated Docker bootstrap/proxy and startup tests, error-catalog consistency tests, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. |
| Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, Playwright route smoke, optional Helm checks, and opt-in Docker image smoke builds. |
@@ -161,11 +161,6 @@ PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
chips, pills, tags, status labels, small buttons, cards, panels, and toolbar
controls should reuse the shared component, shared token, or established CSS
owner for that area instead of introducing a page-local lookalike.
- `scripts/harness/frontend-rules-check.sh` warns when semantic
`badge` / `chip` / `pill` / `tag` / `status` selectors appear outside the
approved React and Earth CSS owner files. A warning means the reviewer should
either move the style into the shared owner or document why this is a genuinely
new visual family.
### Earth I18n Harness Rules
@@ -219,6 +214,12 @@ required tool is not visible there, `scripts/harness/lib.sh` asks the user's
login interactive shell (`$SHELL`, then `zsh`, then `bash`) for the command
path. This avoids hardcoding a dotfile while still covering agent environments
that do not inherit the user's normal shell setup.
Validation adds the resolved Bun directory to its process `PATH` so package
scripts can invoke Bun as well. `test_tool_resolution.py` exercises this through
the validation entry point with an isolated login-shell tool path.
The validation build and preview share a temporary output directory that is
removed after the run. This gives the smoke a consistent build while local
development tools may rebuild `frontend/dist`.
Required for full local stack operation:
@@ -267,8 +268,7 @@ modify the host Docker environment.
No automatic hooks are installed in this phase. Manual reminders:
- Run `scripts/harness/quick-check.sh` before handing off small changes.
- Run `scripts/harness/validate.sh` before larger cross-subsystem changes.
- Choose the verification scope using the [validation guidance in the agent guide](../AGENTS.md#validation).
- Run `scripts/harness/security-check.sh` after touching config, auth,
credentials, docs examples, or generated fixtures.
- Run `scripts/harness/backend-rules-check.sh` after backend service edits to
@@ -290,7 +290,7 @@ No automatic hooks are installed in this phase. Manual reminders:
2. Check `CODEMAP.md` for entry points and ownership boundaries.
3. Inspect existing tests and docs before editing.
4. Make the smallest behavior-preserving or feature-scoped change.
5. Run `scripts/harness/quick-check.sh` or a narrower documented command.
5. Run the checks for the affected behavior and any gates required for that area.
6. Update relevant docs when behavior, workflow, or operations change.
7. For rendered frontend changes, verify the affected route with Playwright or
the full harness smoke, because `bun run build` alone does not prove page
@@ -301,8 +301,6 @@ No automatic hooks are installed in this phase. Manual reminders:
1. Reproduce with a focused test or command.
2. Patch the owning module, not a caller-side workaround.
3. Run the focused regression test.
4. Run `scripts/harness/quick-check.sh` when the change is safe to validate
locally.
### Documentation Change

View File

@@ -14,6 +14,7 @@ If the same action has both a UI and a CLI path (e.g. user creation), describe t
## Scope Rules
- Focus documentation changes on the primary document that owns the affected behavior. Do not trigger speculative updates across secondary or architectural docs unless the underlying contract or operator-facing workflow actually changed.
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.
- If an English counterpart exists for user-facing docs such as `manual.md` or `quickstart.md`, update `docs/technical/en/...` enough that it does not contradict the Chinese source.
- Control console page responsibility changes must update `docs/technical/zh/frontend-admin-frontend-context.md`.
@@ -23,6 +24,7 @@ If the same action has both a UI and a CLI path (e.g. user creation), describe t
- 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.
- Search docs for stale terms introduced by the change, for example old tab names, old route responsibilities, obsolete auth assumptions, or renamed UI labels.
- Internal bug fixes or optimizations that preserve existing APIs and workflows do not require updating user manuals or creating new documentation.
## Public Docs Rules
@@ -42,86 +44,17 @@ If the same action has both a UI and a CLI path (e.g. user creation), describe t
## Recommended Checks
Run the checks that match the affected docs.
### Duplicate Bilingual Docs
Always run the centralized harness documentation gate:
```bash
python - <<'PY'
from pathlib import Path
same = []
for en in sorted(Path("docs/technical/en").glob("*.md")):
zh = Path("docs/technical/zh") / en.name
if zh.exists() and en.read_text() == zh.read_text():
same.append(en.name)
if same:
raise SystemExit("identical en/zh docs: " + ", ".join(same))
print("no identical en/zh docs")
PY
scripts/harness/docs-consistency-check.sh
```
### Language-Less Technical Links
```bash
rg -n "/home/ray/dev/linkong/planet/docs/technical/(?!zh|en)" docs/technical/zh --pcre2
```
This should return no matches.
### Public Docs Registry
```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
```
### Public Bilingual Pairs
```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
```
### Raw Filename Link Titles
```bash
rg -n "\[[^]]+\.md\]\(" docs/technical/zh docs/technical/en
```
This should return no matches for polished public docs.
This single command automatically covers:
1. Public docs whitelist and `DOCS_METADATA` synchronization (`frontend/src/pages/Docs/docs-content.ts` vs backend Gatekeeper)
2. Technical doc bilingual file pairs and identical duplicate-content detection
3. Public doc link validity and human-readable link titles (no raw `.md` filenames)
4. Language-scoped internal links (`docs/technical/zh/` links pointing to `docs/technical/zh/`)
5. Supported credential collector contracts and UI guide registration
6. Manual console route coverage against actual admin manifests
7. Documented `?section=` deep-link validity and UI route drift in active docs

View File

@@ -28,8 +28,7 @@ compatibility note, not a replacement for existing rules or architecture docs.
| `AGENTS.md` | Present | Single authoritative agent behavior guide. It references `rules.md`, `project_context.md`, harness validation, and high-risk areas. |
| `rules.md` | Present | Mandatory modular rules. Always load `core`, `security`, and `workflow`; load topic modules as needed. |
| `project_context.md` | Present | Static context. Some roadmap-era stack details are older than the current README/docs. |
| `.claude/commands/*.md` | Present | Existing command docs for cleanup, docs, goal-driven, and release workflows. |
| `.codex/skills/*.md` | Present | Existing local skills for cleanup, docs, goal-driven, and release. |
| `.codex/skills/*/SKILL.md` | Present | Local workflows for cleanup, docs, goal-driven work, and release. |
## Existing CI Gates
@@ -57,8 +56,7 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
## Release And Deploy Process
- Release workflow is documented in `.codex/skills/release/SKILL.md` and
`.claude/commands/release.md`.
- Release workflow is documented in `.codex/skills/release/SKILL.md`.
- Version-bearing files include `VERSION`, `frontend/package.json`,
`pyproject.toml`, `uv.lock`, `docs/CHANGELOG.md`, and
`docs/version-history.md`.
@@ -102,7 +100,6 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
| Admin/docs shell layouts can reintroduce brittle viewport sizing after a responsive fix. | Changed the admin and Docs route shells to use the existing `html/body/#root` 100% height chain, and added a frontend rules failure for exact `100vh` / `100vw` shell sizing in those CSS files. |
| Compact workspaces can drift back into card-in-card layouts or implicit AntD `Space` wrappers. | Added frontend rules failures for nested `Card` components, AntD imports, and `<Space>` layout primitives in active frontend source. |
| Connection-test controls can drift back into detached toolbar buttons. | Added a shared `ConnectionTestInput` suffix pattern for AI Provider and WebSearch Base URL fields, disabled WebSearch configuration/test controls when the tool is off, and made the frontend rules check fail detached AI/WebSearch connection-test buttons. |
| Same-category UI styles can fragment into page-local lookalikes. | Added same-category style owner warnings for semantic `badge`, `chip`, `pill`, `tag`, and `status` CSS selectors outside the approved shared React and Earth CSS owner files. |
| Visual fixes can go wrong when agents guess from missing screenshot paths. | Documented screenshots and images as primary visual evidence: if the path is not available, agents must search alternate attachment/local locations or report the blocker instead of inferring image content. |
| Public docs can reference stale admin section URLs. | Added docs consistency validation for documented `?section=` links and rendered smoke coverage for documented AI / collector deep links. |
| Active plan docs can preserve old admin deep-link assumptions after the technical docs are corrected. | Extended docs consistency checks to active `docs/plans/*.md` files for stale admin tab-query terms and actual `?section=` validity; corrected the docs audience split plan to current section routes. |
@@ -137,7 +134,7 @@ index is the routing layer; this table is the coverage/evidence layer.
| `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. |
| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` fails missing admin shell height-chain declarations (`.admin-theme-root`, `.admin`, `.admin__sider`, `.admin__nav-scroll`, `.admin__account`, `.admin__content`, `.admin__content-inner`), warns on suspicious `overflow: hidden`, and blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS. `frontend-smoke.mjs` checks every admin route at desktop/mobile and verifies `.admin` equals viewport height, `#root`/document/body have no vertical overflow, and desktop sidebar account/preferences stay in the first viewport. Zoom passes still cover 125% / 150% rendering. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. |
| `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native `<button>` without explicit `type`, nested Cards, AntD imports, `<Space>`, and detached connection-test buttons. Smoke exercises search, tabs, dialogs, data toggles, and connection-test actions. | Native buttons with visible text are not treated as icon-only by static checks; semantics still need review when adding custom controls. |
| `uiux` | Same-category visual surfaces should use one style system per product area. | `frontend-rules-check.sh` warns when semantic `badge`, `chip`, `pill`, `tag`, or `status` selectors appear outside approved shared React and Earth CSS owner files. `docs/HARNESS.md` also makes screenshots/images primary evidence for visual fixes and forbids guessing when an image path is unavailable. | Final visual cohesion across screenshots still needs human/Playwright review, especially for page-specific cards, panels, and toolbar controls that static selector checks cannot classify perfectly. |
| `uiux` | Related visual surfaces should use one style system per product area. | Manual visual review. | Compare changed controls with the shared component on the rendered page. |
| `uiux` | Text should fit, avoid viewport-scaled font sizes, and keep letter spacing at zero. | `frontend-rules-check.sh` fails viewport/container-width font-size units and non-zero `letter-spacing` / `letterSpacing`. `frontend-smoke.mjs` checks rendered routes for global overflow across desktop/mobile. | Per-element text clipping without page-level overflow is not exhaustively detected and needs visual review for changed screens. |
| `frontend` | Keep shared behavior in reusable components and existing project patterns. | `frontend-rules-check.sh` enforces shared `ConnectionTestInput`, route/link/search consistency, no debug output, native button safety, Tactile/Radix/lucide direction instead of AntD/Space, and whitelist-driven public Docs. `bun x tsc --noEmit` and `bun run build` verify TypeScript/build health. | Broad casts, inline styles, and overflow issues are warnings when context may be legitimate; review changed lines before accepting them. |
| `frontend` | Responsive adaptations must preserve the primary action path. | `frontend-smoke.mjs` clicks every visible admin menu entry on desktop and mobile, opens protected routes unauthenticated and authenticated, verifies root/unknown route fallback, and exercises core auth flows. | Deep feature workflows beyond smoke data, such as destructive or long-running actions, require targeted tests before behavior changes. |
@@ -154,8 +151,8 @@ index is the routing layer; this table is the coverage/evidence layer.
| `scripts/harness/doctor.sh` | Environment and repository-shape check. |
| `scripts/harness/security-check.sh` | High-confidence secret and tracked environment/key file check. |
| `scripts/harness/backend-rules-check.sh` | Backend app debug-call guard for direct stdout/debugger usage. |
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin shell one-screen height-chain declarations, admin/docs shell viewport sizing, same-category style owner warnings, viewport-font, zero-letter-spacing, and UI rules static check. |
| `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, admin-config-derived section deep-link consistency, and harness rules-coverage note check. |
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin shell one-screen height-chain declarations, admin/docs shell viewport sizing, viewport-font, zero-letter-spacing, and UI rules static check. |
| `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, and admin-config-derived section deep-link consistency. |
| `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, admin shell one-screen/overflow checks, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. |
| `scripts/harness/quick-check.sh` | Fast deterministic local validation. |
| `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. |

View File

@@ -209,6 +209,11 @@ def parse_response(self, html):
## VII. Scheduler
The `earth_news_rss_refresh` system job checks regional feeds on startup and
every 10 minutes. Homepage GET requests read saved news. Datasource schedule
synchronization preserves this system job. Freshness, locking, and cooldown
details are in the [news source guide](/home/ray/dev/linkong/planet/docs/technical/en/earth-news-sources.md).
```python
# Register all collectors into scheduled tasks at startup
def start_scheduler():

View File

@@ -31,8 +31,16 @@ flowchart LR
5. The listener merges short-window same-layer events, invalidates cache, and broadcasts `earth_updates`.
6. The outbox row is marked consumed only after successful broadcast; failed rows stay retryable.
Cache rebuilds are coalesced by key. Concurrent requests in one process share the build result or error, while cross-process cold misses wait for the Redis lock holder. Long builds renew the lease, and publishing or releasing checks ownership. If the owning request is cancelled, a waiter can take over with its own database session. Available stale data is returned immediately. Without stale data, a waiter returns HTTP 503 after the default 30 seconds. Redis coordination failures return the current build result, with concurrent requests in that process still coalesced. A failed build returns stale data when available and otherwise propagates the error.
DB changes no longer create default `earth_refresh` jobs, so they are not blocked by long same-source collection or clearing jobs. `earth_refresh` remains for manual cache clearing and non-DB refresh hints.
### Outbox Writes and Retention
The trigger obtains `event_id` from the sequence, then writes the payload containing that ID to `earth_data_change_events` once and sends `NOTIFY`. It does not update the same event after insertion. An `earth_news_items` update that only changes `last_seen_at` or `updated_at` emits no Earth change event; visible content changes still emit one. Those timestamps continue to record news-source health and freshness.
Consumed events are retained for one day by default, configurable through `PLANET_EARTH_OUTBOX_RETENTION_DAYS`. Every 60 seconds, the listener removes at most 1,000 expired consumed rows, and only when the separate retention index is valid; pending rows never match the deletion condition. A PostgreSQL advisory lock prevents concurrent cleanup across instances. The [standalone Outbox maintenance script](/home/ray/dev/linkong/planet/scripts/maintain_earth_outbox.py) creates the index with `CREATE INDEX CONCURRENTLY`, outside the application startup transaction. The script is read-only by default and also supports batched deletion and a one-time compact mode. See the [operations runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) for execution steps.
## Data Job Queue
`collection_tasks` is the unified job ledger. Workers claim `queued` jobs with PostgreSQL `FOR UPDATE SKIP LOCKED`; write jobs for the same `source` run serially, while different sources may run in parallel.

View File

@@ -321,7 +321,7 @@ The new vessel list entry point is no longer the legacy `/api/v1/visualization/g
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
```
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. Realtime updates use the global `/ws` `vessels` subscription to deliver canonical state by MMSI and update existing markers in place. Larger updates are split across frames without dropping vessels. Initial load, reconnection, and deletion reconciliation still use snapshots; see [Collector Architecture](backend-collectors.md).
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. Realtime updates use the global `/ws` `vessels` subscription to deliver canonical state by MMSI and update existing markers in place. Larger updates are split across frames without dropping vessels. Initial load, reconnection, and deletion reconciliation still use snapshots; see [Collector Architecture](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md).
## Custom REST / WebSocket Mapping Runtime

View File

@@ -1,5 +1,15 @@
# BGP Context
## Prefix geography lookup
Lookups preserve the OpenGeoFeed, IPtoASN, then NRO source order, address-family
checks, and full start/end range containment. Within each source, the original
prefix mask length and record ID determine ordering. `planet_bgp_try_inet` and
`planet_bgp_try_masklen` return NULL for invalid historical values. Database
initialization installs these lightweight functions. A separate maintenance
script installs the combined GiST range index, as described in the
[operations runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md).
## Current Goal
The BGP module is being evolved from an anomaly-only demo into a layered observability pipeline:

View File

@@ -72,7 +72,7 @@ Responsibilities:
- Layer module integration
- Earth-level state synchronization
Earth treats `/ws` `earth_updates` as refresh hints only; real data is fetched again from `/api/v1/visualization/...`. Backend database-driven refresh now has the listener clear cache and broadcast directly instead of going through the default `earth_refresh` job queue. After `database_changed`, the frontend applies the per-layer `clear_then_reload`, `reload`, or `delta` strategy. `clear_then_reload` must clear Three.js objects before a no-store refetch, and summary is only a consistency check, not a reason to skip a layer reload when the count is `0`. See [Data Jobs and Outbox Architecture](/home/ray/dev/linkong/planet/docs/technical/en/data-job-earth-sync-architecture.md) for the technical pipeline and [Business Architecture and Data Flows](/home/ray/dev/linkong/planet/docs/technical/en/platform-data-flows.md) for business data flows.
Earth treats `/ws` `earth_updates` as refresh hints only; real data is fetched again from `/api/v1/visualization/...`. Backend database-driven refresh now has the listener clear cache and broadcast directly instead of going through the default `earth_refresh` job queue. After `database_changed`, the frontend applies the per-layer `clear_then_reload`, `reload`, or `delta` strategy. News-only changes leave the summary cache intact; a batch that also changes another layer still refreshes the summary. `clear_then_reload` must clear Three.js objects before a no-store refetch, and summary is only a consistency check, not a reason to skip a layer reload when the count is `0`. See [Data Jobs and Outbox Architecture](/home/ray/dev/linkong/planet/docs/technical/en/data-job-earth-sync-architecture.md) for the technical pipeline and [Business Architecture and Data Flows](/home/ray/dev/linkong/planet/docs/technical/en/platform-data-flows.md) for business data flows.
### 3. Earth Control Layer
@@ -373,7 +373,7 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
`globe-navigation.js` retains the safe zoom-distance mapping. Mouse and single-touch drags uniformly use legacy screen deltas, 0.18 smoothing and release inertia, including movement outside the globe silhouette; ray/sphere intersection no longer gates dragging. Normal zoom retains legacy sensitivity. Once the globe exceeds the viewport, a smooth surface-distance/viewport cap limits excessive rotation at extreme zoom. Horizontal motion uses geographic yaw, vertical input changes tilt, and quaternion math prevents multi-revolution reversal. Pinch transitions and pointer capture remain intact.
Surface markers retain category icons and fixed geographic cluster membership. Numbered solid circles expand through a 320ms tween above 250% zoom; zoom never regroups members, and 500% has no aggregate badges. Vessels retain independent heading arrows without clustering. See [Earth Interactable Clustering](earth-interactable-clustering.md).
Surface markers retain category icons and fixed geographic cluster membership. Numbered solid circles expand through a 320ms tween above 250% zoom; zoom never regroups members, and 500% has no aggregate badges. Vessels retain independent heading arrows without clustering. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md).
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.
@@ -416,13 +416,13 @@ For console structure, see:
Earth's existing Roboto Condensed face is now served locally from `assets/fonts/`, with its source and license in that directory and the shared declaration in `css/fonts.css`. The remote Google Fonts stylesheet no longer blocks the page; font family and weights are preserved.
`spatial-callouts.js` provides geographic projection and shared scopes; `spatial-callout-controller.js` owns stable peripheral reading slots. `callout-dock-layout.js` treats the central observation area as a hard obstacle, retains valid placements first, then places new or affected cards. Rotation, front/rear changes, connector crossings and length do not request reflow. Only card-list changes, deliberate expand/drag actions, viewport changes and HUD-space changes do. Limited space preserves full cards and permits peripheral overlap, without automatic folding, grouping or font reduction. settleCardDrop validates the released position: valid drops stay, while out-of-bounds or central drops settle to a nearby valid position. Peripheral overlap remains allowed. Persistence records the committed destination, and hovering cannot pause a correction inside the center. Restored positions use the same validation; manual reset controls and handling are removed. Starting a drag raises that card above all regional cards and retains its priority until another card is dragged. Compute and news cards share stacking levels; configuration and evidence panels remain above them.
`spatial-callouts.js` provides geographic projection and shared scopes. `spatial-callout-controller.js` selects expanded compute and news cards independently, prioritizing dragging or manually pinned cards, then the current focus and distance from the view center. `callout-limits.js` stores independent limits under `planet.earth.callout-limits.v1`; controls offer 1–3 of each, defaulting to one. `earth:callout-limits-change` applies changes immediately. Other cards become capsules following geographic anchors; hover temporarily expands them and click changes focus. Expanded cards occupy left/right stages with news above compute, including a destination for every card when a side holds more than two; peripheral overlap is allowed when space is limited. Rear capsules dim and shrink. Manual drops remain pinned until their region moves deep behind the globe. Dimensions and reading state remain separate from business coordinates, with shared drag foreground priority across both types.
Object presentation is shared through `PresentationController` and the new object-callout anchor/layout path. A click on a BGP, compute, vessel, satellite, or cable object draws the connector before placing the adjacent card. The source anchor begins at the actual click or original region label, then follows a visible highlight geometry sample as rotation approaches occlusion. The stable `layoutReferencePoint` is derived from the actual connector exit direction, so `packDirectionalEdgeCallouts` keeps cards on the corresponding screen edge and leaves the globe's center open; a card may move inward only when that edge lane is unavailable, and it may never cover its own connector exit point. The bounded joint assignment considers up to 24 beam states and 32 candidates, avoids existing HUD rectangles, then allows at most three safe exchanges to reduce directional crossings; the previous solver remains a fallback and the work is not a full relayout every frame. A slow source-direction gate also checks whether the actual card target reverses more than 90 degrees from the source before committing a change. Manual drag and hover locks take priority. The source point itself remains a per-frame visual connection target; fixed objects track live positions while cards remain draggable. Rear-side objects retain a globe-outline direction cue, a subdued connector, and a hollow source point; these are orientation guides rather than geographic points. Country candidates are capped at 96 with a hard limit of 128 and cached, so GeoJSON is not scanned every frame. Compute cards keep their original point independent from dynamic source-point movement, and the shared card endpoint slides along the obstacle perimeter. Cable presentation prefers the true ray-hit local point, then samples highlighted geometry by `cableId`. Mobile keeps the existing mini popup and opens drawer details. A stale asynchronous presentation must never dismiss a newer selection.
`configVisible` and `cardsEnabled` are separate. X/Escape closes only configuration; disabling the switch releases connectors and restores the previous map query/layer state. Configuration and source windows bind HUD typography directly so reparenting cannot enlarge their text. The main card presents the selected compute metric as a large share percentage or an absolute value with unit, observation year, historical context, and source entry. Missing comparable data is explicit; no empty trend chart is rendered without observations. Facility records and coverage remain complete in the expanded auxiliary area, where sample counts are evidence context rather than a compute value or global coverage rate. Long lists open only when needed. Mobile preserves the same single metric meaning in its accordion. Local position/size persistence and reset-to-auto remain available. Stable DOM, dimensions, and reading state survive updates, and stale responses cannot re-enable cards.
`configVisible` and `cardsEnabled` are separate. Closing the configuration window with X or Escape preserves card visibility. The desktop title bar uses the news panel’s card icon and selected style, with `aria-pressed` reflecting `cardsEnabled`. Turning off cards through this button or the mobile switch releases the connectors and restores the previous map query and layer state. Configuration and source windows bind HUD typography directly so reparenting cannot enlarge their text. The main card presents the selected compute metric as a large share percentage or an absolute value with unit, observation year, historical context, and source entry. Missing comparable data is explicit; no empty trend chart is rendered without observations. Facility records and coverage remain complete in the expanded auxiliary area, where sample counts are evidence context rather than a compute value or global coverage rate. Long lists open only when needed. Mobile preserves the same single metric meaning in its accordion. Local position/size persistence remains available. Stable DOM, dimensions, and reading state survive updates, and stale responses cannot re-enable cards.
Globe dragging and inertia only pause card motion progress; they never replace committed destinations. Connector anchors follow geographic projection independently of card placement.
Expanded cards follow committed targets with smooth inertia, pausing during hover or keyboard reading; capsules independently follow geographic projections. Connector anchors still update each frame. `shared.calloutLineStyle` persists thin/bold preferences, synchronized from desktop/mobile controls to `body.dataset.calloutLineStyle` and consumed by shared connectors without changing business anchors.
The shared `CalloutConnector` calculates polyline length from its points instead of reading `getTotalLength()` after each SVG write, avoiding repeated synchronous layout during motion. Initial draw animation is retained, so existing cruise presentations receive the same optimization.
@@ -432,7 +432,7 @@ On mobile, compute uses a compact regional overview in the existing drawer. Rows
### Boundary surface attachment
Normal borders, coastlines, claim lines, highlights and landing points use `earth-surface.js` to share the HD sphere UV-triangle surface. Borders draw after the texture but before landing points. Highlight lines are always transparent-queue objects with depth testing, preventing through-globe display or overpainting markers. Geometry is computed and cached at construction, not reprojected per frame. See the [render-layer specification](earth-render-layer-order.md).
Normal borders, coastlines, claim lines, highlights and landing points use `earth-surface.js` to share the HD sphere UV-triangle surface. Borders draw after the texture but before landing points. Highlight lines are always transparent-queue objects with depth testing, preventing through-globe display or overpainting markers. Geometry is computed and cached at construction, not reprojected per frame. See the [render-layer specification](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md).
### News ticker edge fading and breaking state machine
@@ -443,12 +443,12 @@ The previous breaking rule was global shell promotion: if the payload contained
The current state machine keeps data, ticker visibility, panel lifecycle and selection separate:
- `getNewsBreakingState()` derives the data state from `items` and `cruise_items`, ignores expired stories, and ranks `critical > breaking > watch`. The expiry timestamp only schedules the next recomputation.
- A ticker viewport watcher runs every 120 ms and chooses the item with the greatest viewport overlap, breaking ties by distance to the viewport center. Only when that current item has a non-`none` level does the ticker switch its horizontal rules to `has-breaking-*`; it returns to neutral as soon as the current item is not breaking.
- A ticker viewport watcher runs every 120 ms and chooses the item with the greatest viewport overlap, breaking ties by distance to the viewport center. Only when that current item has a non-`none` level does the ticker switch its horizontal rules to `has-breaking-*`; a brief no-overlap gap at the duplicated-track seam retains the active state so the sweep timeline and borders do not restart.
- The panel lifecycle is `closed → opening → open → closing → closed`. Opening and closing control morphing, visibility and ticker hiding; they do not mutate the breaking data. During `opening/open`, the current card DOM stays stable while incoming payloads are deferred and applied once on close, so realtime updates cannot rebuild a hovered card.
- `selectedCruiseStoryId` is independent of breaking severity. Selection, focus, hover and close only change the selected story and reading position; close also clears the selection and filter popover.
- The expanded panel always keeps its blue glass surface. Breaking colors remain on the matching story cards, so opening the panel never turns the entire glass panel red; the ticker can still show a red signal when the currently scrolling story is breaking.
The glass-reflection pulse is implemented: when the current ticker story enters a breaking state, its `::after` emits a low-frequency, limited-amplitude full-width horizontal red/amber sweep and pulse; the matching breaking card gets a single glass reflection sweep through its own `::after`. The panel base remains blue and there is no permanently flashing full border. `prefers-reduced-motion` disables the animation and keeps a low-intensity static cue. This visual sub-state stays attached to the current breaking story and matching card, independent of whether the panel is open.
The glass-reflection effect is implemented: when the current ticker story enters a breaking state, its `::after` carries a short, feathered light cloud across the ticker glass area; the matching breaking card gets a full-card reflection sweep through its own `::after`. The panel base remains blue and the static top/bottom rules do not participate in the sweep. `prefers-reduced-motion` disables the animation and keeps a low-intensity static cue. This visual sub-state stays attached to the current breaking story and matching card, independent of whether the panel is open.
Compute cards directly reuse the shared `.hud-panel` background, border, shadow, and glass treatment. Summary, essentials, and detail areas add no separate black surface. Their headers share inset spacing with the layer panel through `.hud-panel__header--inset` / `.layer-panel-header`; primary numbers remain prominent and essential information stays visible.
@@ -467,13 +467,15 @@ Object clicks, search focus and gesture confirmation share `PresentationControll
Satellites use one `THREE.Points` batch with `PointsMaterial`: a 32×32 white disc texture of radius 0.42, `alphaTest=0.5`, and `sizeAttenuation=false`. The dark backdrop batch and simulated sphere lighting are removed. `satellite-display.js` calibrates against satellitemap screenshot pixels: point squares are 2.4px at zooms up to 150% and 8px at 500%, with a smoothstep transition. The disc fills 84% of the square, yielding visible cores of about 2px and 6–7px respectively. Blue/orange/green/purple/rose use the reference palette directly without the prototype's extra exposure. Point sizes retain the DPR-cap-2 adjustment without reducing globe resolution. Ordinary breathing defaults off but remains an opt-in setting. Existing hover/selected rings, halos, open predicted orbits and forward-flow animation remain intact.
Satellite point sprites use `depthTest=false`. The vertex shader tests the camera-to-satellite segment against the Earth sphere at `CONFIG.earthRadius * 0.998`, moving truly occluded points outside clip space while retaining complete visible sprites near the limb. Orbits, coverage and business coordinates retain their existing paths.
Real GP/TLE propagation stays in the Worker at 250ms intervals. `satellite-motion.js` caches sample radii and interpolates radius-preserving positions each frame, shared by points, picking, trail tips, selection rings and coverage. `satellite-trail-geometry.js` merges redundant ribbon segments only when the projected error remains below 0.2 pixels across supported zooms. All satellites and 10 history samples are retained; curved or near-camera trails keep their original segments. Only active buffer ranges are uploaded. Trail width is capped at 60% of the point size and tapers toward the tail, avoiding a thick fragmented rim at distant views. `satellite-footprint-geometry.js` retains the full 260×170 coverage grid, created once. The vertex shader evaluates the same surface projection, while local basis and extent uniforms update each frame instead of rebuilding tens of thousands of vertices. Starlink/Iridium footprints share blue `#3399ff`; sunlight changes opacity only, with real coverage models preserved.
`SATELLITE_CATEGORIES` owns real inclination categories and the blue/orange/green/purple/rose/gray palette. Selecting a satellite automatically switches to its legend. Legend categories directly control points, trails, labels and picking; hiding a selected category clears selection and coverage. Layers and settings have no separate filter entry. Explicit search selection re-enables its category. `satellite-labels.js` shows actual names and models (NORAD identifiers when unavailable) at the configured zoom threshold (**400%** by default, adjustable from **250% to 500%**). The slider below Close-up labels sets a persisted desktop/mobile count limit from 5 to 36 (default 36), immediately rescanning candidates on change. Actual visible counts still depend on available positions and occlusion. It checks occlusion and overlap in the central 65% of the view, scans candidates every 160ms and follows active labels each frame.
Settings → Display → Show FPS persists through `shared.showFps`, defaulting off. `frame-stats.js` displays actual frame-interval statistics beside the brand, updating once per second; its native tooltip contains mean interval, P95, main-thread CPU time, draw calls and an approximately 30-second rolling mean FPS (total frames / total duration, not an average of per-second rates). Slow foreground frames remain in the sample; visibility changes reset the sampling window. A single high-FPS sample is not a sustained-performance guarantee. Performance acceptance uses an actual canvas of at least 1920×1080, samples at least 30 seconds and targets a mean of 60 FPS while allowing occasional low frames. Compare distant and 400% views with matching viewport, complete data, layers and playback state, and verify category hiding, search selection and FPS persistence after reload.
The boundary loading tint, land fill, base and HD layers share the `earth-surface.js` geometry factory. Tint and fill share R+0.32, fixing pre-texture diamond rings without changing boundary/HD controls. Intersection and far-zoom depth margins are covered by `earth-surface.test.js`; see [render layer order](earth-render-layer-order.md).
The boundary loading tint, land fill, base and HD layers share the `earth-surface.js` geometry factory. Tint and fill share R+0.32, fixing pre-texture diamond rings without changing boundary/HD controls. Intersection and far-zoom depth margins are covered by `earth-surface.test.js`; see [render layer order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md).
Cables and landing points share HD-surface coordinates through `cable-surface.js`. `cable-batches.js` controls related halos with per-instance emphasis cleared on deselection. `interactable.js` uses `surface-marker-occlusion.js` for whole-badge visibility and picking near the limb. BGP change events retain their original icons; collectors use activity-colored broadcast badges. Sky material uses the original brightness (multiplier 1.0).
@@ -498,7 +500,7 @@ The current interactive object continues to determine the legend mode; enabling
Display settings add `shared.satelliteLabelsEnabled` (true by default), `shared.satelliteLabelMinZoom` (2.5–5, default 4) and `shared.skyBrightness` (0–2, default 1). Desktop/mobile controls, reload and reset share runtime state. Sky brightness multiplies the sky material in linear space with its existing local sRGB output, leaving Earth exposure unchanged. Compute legend counts reflect located types. Chip and stacked SVG glyphs remain mapped to `site_type`; a GPU-only dataset does not invent other types or coordinates.
All destinations and legal routes are decided before presentation changes. `callout-dock-motion.js` uses a 240–420ms tween; `callout-routing.js` only plans static obstacle avoidance. Pauses retain progress and destination. Layout and movement never change collapse state. If a full-card route is unavailable, use its final destination directly instead of temporarily folding the card. Continuous scoring, staged yielding, central fallback and committing intermediate coordinates have been removed.
Focus state and per-type limits decide expanded cards versus capsules. One controller owns left/right stage destinations, capsule following and hover expansion. Manual pinning takes priority; reading pauses floating and automatic expanded-card movement. The older `callout-dock-motion.js` / `callout-routing.js` utilities no longer decide this presentation path.
### Cluster badges and Spiderfy

View File

@@ -297,6 +297,6 @@ The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutp
Satellite hover rings use the hovered satellite inclination category color, matching the point and legend. Changing the hovered object resolves its color again; position updates preserve it.
All map cards use the root HUD DPI scale. Regional and cruise news share header icon, colors, typography and padding; the regional variant only simplifies metadata and adds independent rotation controls. Compute metrics, trend charts and title icons no longer use unscaled fixed pixels. Europe uses a compact EU monogram; other region emoji follow the same scale.
All map cards use the root HUD DPI scale. Regional and cruise news share header icon, colors, typography and padding; regional cards simplify metadata and add independent rotation controls. Expanded cards retain full content while other regions use capsules that temporarily open with the same card styling on hover. Rear capsules dim and shrink without removing business objects. Thin/bold lines share `CalloutConnector`; bold styling widens the white line, adds dark outlines and enlarges endpoints. Compute metrics, trend charts and title icons follow HUD scaling. Europe uses a compact EU monogram; other region emoji follow the same scale.
Compute markers use five canonical SVGs: compute-supercomputer.svg, compute-gpu-cluster.svg, compute-hdd-network.svg, compute-cloud.svg and compute-edge.svg. They retain the compute layer purple badge and existing selection behavior.

View File

@@ -84,6 +84,20 @@ Importance levels are fixed: `low` 0–34, `medium` 35–59, `high` 60–79, and
## Configuration and Cache
`GET /api/v1/news/earth-feed` reads saved news. The scheduler's
`earth_news_rss_refresh` job supplements RSS on startup and every 10 minutes,
checking freshness and feed coverage by region. Redis locks coordinate refreshes
across processes. Failed feeds use the source's configured cooldown while other
sources continue. Health timestamp updates alone do not broadcast content changes.
`POST /api/v1/news/refresh` accepts a region or view coordinates and returns
HTTP 202 with a `refresh_id` shared by active manual requests for that region.
`GET /api/v1/news/refresh/{refresh_id}` reports pending, running, succeeded, or
failed state. On success, the frontend reloads news and updates the panel before
showing success. Existing content stays readable during the wait. Failure
re-enables the button for retry. Saving or resetting source configuration also
schedules an immediate background check.
`GET /api/v1/earth/news-sources` returns the default or saved configuration. `PUT /api/v1/earth/news-sources` saves it, increments `cache_version`, and clears the process region cache. `POST /api/v1/earth/news-sources/reset` restores defaults. `POST /api/v1/earth/news-sources/test` tests one RSS/Atom/Aggregated source without writing news items.
## Manual News Content
@@ -160,13 +174,13 @@ The Web Earth category chips only store the current browser preference; changing
When `sources` is omitted, the service layer prefers stories that already have displayable title and summary content for the requested `locale`, supplements candidates from enabled sources, and rotates sources so one source's newest pending items cannot occupy all 12 default slots. An explicit `sources` filter remains precise and does not supplement other sources. The database query layer owns region, category, source, and ordering constraints only; it does not own locale presentation policy.
Source testing only proves that a specific RSS/Atom/XML feed can be parsed. It does not mean those items have already been written to the news table or are visible in the current region/category view. Saving or resetting news sources increments the configuration version and clears cache; if an enabled feed has no recent stored items, the next `earth-feed` request supplements from RSS so newly enabled sources such as 36Kr and Ebrun are not masked by fresh Google News rows.
Source testing only proves that a specific RSS/Atom/XML feed can be parsed. It does not prove that items are stored or visible under the current region/category filters. Saving or resetting sources increments the configuration version, clears cache and immediately schedules background refreshes for all regions. Background work supplements feeds without recent stored items; `earth-feed` requests remain read-only.
## Regional Balancing and Async Enrichment
`earth_news_items` is the current-state table for EarthFeed. When no explicit `sources` filter is present, the endpoint sorts by Breaking state, region, and publish time, then applies a regional round-robin so Asia Pacific or any other high-volume source cannot fill the entire global view and cruise queue. The default region order is Americas, Europe, Middle East / Africa, Asia Pacific, then Global; unknown regions participate after the known regions. Regional views still follow the active-region-plus-global rule and do not mix unrelated regions into the regional panel.
Both `items` and `cruise_items` are enqueued for target-location and localization enrichment, but the frontend must not wait for AI before rendering. If the requested display locale is not ready yet, Web Earth falls back to the original `title / summary` so a card does not show a "translation pending" placeholder when readable source content already exists. After translation, classification, Breaking, or target-location enrichment completes, the same `earth_news_items` row is updated and Earth receives a news reload / patch.
RSS refresh and content-write paths enqueue location and localization enrichment; `earth-feed` reads no longer create those tasks. The frontend renders existing content without waiting for AI, subject to the active locale’s content-availability filter. English mode does not fall back to Chinese source text. After translation, classification, Breaking, or target-location enrichment completes, the same `earth_news_items` row is updated and Earth receives a news reload / patch.
Target-location enrichment uses two Redis Streams queues:
@@ -236,7 +250,11 @@ flowchart LR
ManualAPI --> Store
Earth["Earth News Panel"] --> NewsAPI["/api/v1/news/earth-feed"]
NewsAPI --> Resolver["Source Resolver"]
NewsAPI --> Config
Earth --> RefreshAPI["POST /api/v1/news/refresh"]
RefreshAPI --> Scheduler["RSS refresh scheduler"]
ConfigAPI --> Scheduler
Scheduler --> Resolver["Source Resolver"]
Resolver --> Config
Resolver --> Cache["Region Feed Cache"]
Resolver --> Fetcher["RSS / Atom Fetcher"]

View File

@@ -31,9 +31,9 @@ Note: the layer control panel order and the registration / startup load order ar
| 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-4.5 | BGP collectors, event rings, and event markers | `bgp.js`, `interactable.js` | Collector markers use `BGP_COLLECTOR_RENDER_ORDER = 4.4`; event markers use `BGP_EVENT_RENDER_ORDER = 4.5`; overlays remain in BGP-owned groups | Screen-space Interactable picking; BGP collectors/events may use collision avoidance where appropriate | BGP collectors share the surface-facility band with vessels; BGP events share the compute-center band. |
| 4.3 | AIS vessel track lines | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`; `CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | Follows vessel selection, not independently picked | Recent track for the selected vessel, below vessel markers. |
| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays | `depthTest: true`; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. |
| 4.4 | AIS vessel markers | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`; business radius `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`; normal markers are bucketed `THREE.Points`, hover / locked states are single-point overlays; whole-anchor occlusion matches BGP and compute markers at the globe limb | `depthTest: false`, with visibility decided by anchor occlusion; `main.js` uses screen-space picking for front-facing markers; `cluster: false` and `avoidance: false` | Moving vessels use triangular point textures, anchored / slow vessels use dots. Dense waterways may overlap and do not participate in dynamic screen clustering, avoiding Points rebuilds during rotation. |
| 4.5 | Compute centers | `compute-centers.js`, `interactable.js` | `COMPUTE_CENTER_RENDER_ORDER` | Screen-space Interactable picking, with surface-icon collision handling | Surface facilities, below satellites. |
| 6 | Satellite dots | `satellites.js` | SGP4 altitude compressed to R+4..25; fallback R+8 | Screen-space satellite picking | Satellite dots above footprints and compute centers. |
| 6 | Satellite dots | `satellites.js` | SGP4 altitude compressed to R+4..25; fallback R+8 | `depthTest=false`; vertex shader tests camera-to-satellite segment occlusion against an R×0.998 sphere; screen-space picking | Visible sprites remain complete; truly occluded points move outside clip space instead of being partially depth-clipped near the limb. |
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw an open full-period inertial curve with forward-flow highlights; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
@@ -98,11 +98,11 @@ Base, tint, land fill and HD surface now use `createEarthSurfaceGeometry()` and
### Surface marker alignment and limb visibility
Cables and landing points share R+0.48, the `earthSurfacePoint` triangle projection and renderOrder=1. Related landing halos use an emphasis attribute in the existing batch, adding no Sprite or draw call. Rounded badges use GPU anchor ray/sphere occlusion: draw the entire billboard when its anchor is visible, otherwise clip it entirely. CPU picking uses the same test to reject rear anchors. Only `icon.badge` layers (including compute and collectors; vessels independently use heading arrows) use this path. The selected BGP event's static overlay halo uses the event icon's R+0.48 anchor, foreground order and whole-anchor front/rear visibility; the existing animated BGP event ring remains a separate effect with its original behavior.
Cables and landing points share R+0.48, the `earthSurfacePoint` triangle projection and renderOrder=1. Related landing halos use an emphasis attribute in the existing batch, adding no Sprite or draw call. Rounded badges and vessel heading arrows use GPU anchor ray/sphere occlusion: draw the entire billboard when its anchor is visible, otherwise clip it entirely. CPU picking uses the same test to reject rear anchors. The selected BGP event's static overlay halo uses the event icon's R+0.48 anchor, foreground order and whole-anchor front/rear visibility; the existing animated BGP event ring remains a separate effect with its original behavior.
The `.map-clusters` projection layer uses z-index 8 below HUD and whole-marker occlusion from real surface anchors. Ordinary points retain their Three.js layers; transitioning members render only once in the projection layer. Spider legs use widened transparent hit areas; hover highlights the matching line and child, and both clicks resolve the same business object. Wheel input over projected markers and legs continues to zoom the globe.
Co-located markers share a compact projection stack across categories; screen collisions never merge different locations. A selected child uses projection `z-index: 1000`, while the locked overlay for an unclustered marker uses the shared foreground `renderOrder >= 100`, so focus stays on top across layers; clearing focus restores stable ID order. The aggregate badge stays below a selected child during its opening tween. `icon_presentation_screen` lets detail connectors follow display offsets while markers retain their real anchors. Ownership changes immediately rebuild the corresponding GPU batch.
Regional news and compute cards share the `#earth-region-callouts` spatial root (z-index 48), with independent data and reading state. News connectors use representative regional anchors, unchanged by story rotation. One controller excludes all owned cards when measuring HUD obstacles and tracks size changes by element. Mobile news cards live in the drawer without map connectors.
Regional news and compute cards share the `#earth-region-callouts` spatial root (z-index 20), with independent data and reading state. Within that root, expanded cards use 50, dragged foreground cards 55, hover-expanded cards 60, ordinary capsules 30 and dimmed/shrunk rear capsules 8. Per-type expanded counts persist independently. Full cards occupy the globe’s flanks, while capsules follow geographic anchors; hover temporarily expands them and click changes focus. News connectors retain representative regional anchors across story rotation. One controller excludes owned cards when measuring HUD obstacles and handles size changes. Thin/bold lines share `body.dataset.calloutLineStyle`. Mobile news cards live in the drawer without map connectors.

View File

@@ -297,10 +297,15 @@ Satellite names and NORAD identifiers appear in a limited set at 400% zoom and a
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
Click a BGP, compute, vessel, satellite, or cable object on the globe to draw a connector from the object before revealing a nearby detail card. The card follows the source direction toward the nearest usable screen edge, leaving breathing room around the globe; it never covers its own connector exit point. As the globe rotates, the source moves smoothly toward a visible part of the highlight. Fully hidden objects retain an outline direction cue with a lighter line and hollow source point. Cards stay readable and remain draggable; their connection points adjust along the edge automatically. Mobile shows a compact popup first, with full details available in the drawer. Dropdown menus and tool lists retain their existing opening behavior.
Click a BGP, compute, vessel, satellite, or cable object on the globe to draw a connector from the object before revealing a nearby detail card. The card follows the source direction toward the nearest usable screen edge, leaving breathing room around the globe; it never covers its own connector exit point. As the globe rotates, the source moves smoothly toward a visible part of the highlight. Fully hidden objects retain an outline direction cue with a lighter line and hollow source point. Cards follow the connector direction smoothly and remain draggable; their connection points adjust along the edge automatically. Mobile shows a compact popup first, with full details available in the drawer. Dropdown menus and tool lists retain their existing opening behavior.
### Four regional news cards
Clicking refresh shows a refreshing state while existing news stays readable.
Success is shown after the latest results are displayed. If refresh fails, the
page explains the failure and enables the button for another attempt. Repeated
clicks during the wait do not start duplicate refreshes.
Open the news panel from the top ticker, then choose “Show regional news cards” in its header. Four cards cover the Americas, Europe, Middle East & Africa, and Asia-Pacific. They remain visible after the panel closes, and the switch is remembered. Its background and border highlight while enabled; click again to turn it off.
Each card rotates only displayable stories from its own region after its typewriter summary finishes and a seven-second reading interval elapses. Hovering, keyboard interaction, dragging or collapsing pauses that card while other regions continue. Controls provide previous/next, pause/resume and a source link. Refresh preserves the current story where possible. Empty regions stay empty instead of borrowing unrelated stories.
@@ -428,13 +433,13 @@ Administrators maintain card data in **Earth content → Compute comparison**. A
Innovation gap observations remain independent US-minus-China values. Referenced reports cannot be removed until their observations are removed or reassigned. Duplicate metric/year rows, missing sources and global shares totaling over 100% are rejected. Concurrent edits return a conflict while preserving the local draft. Restore defaults requires confirmation and restores the bundled report data. Publishing and restoring are audited. Same-origin Earth pages receive a refresh notification; other visible pages check once per minute and hidden pages check when visible again. Reports must still be reviewed manually; configuring a URL does not scrape a report.
Open the analysis icon beside Compute Centers, then enable Show region cards. Connectors draw to their target positions before the cards fade in; subsequent globe motion does not replay entrance. Closing the configuration window or pressing Escape leaves the cards enabled; the analysis icon reopens configuration, while the switch disables the cards. Anchors rotate with Earth while cards remain readable. Dragging or expanding grants reading time; after a substantial change in geographic direction, cards can move together when that materially reduces crossing or detours. Active interaction prevents movement, while manual positions remain preferences rather than permanent pins. Rear regions retain their state without a connector drawn through the globe. Existing layer, news, and media windows remain available.
Open the analysis icon beside Compute Centers, then click **Show region cards** to the left of the close button in the title bar. Connectors draw to their target positions before the cards fade in; subsequent globe motion does not replay entrance. Closing the configuration window or pressing Escape leaves the cards enabled; the analysis icon reopens the panel. Click the highlighted card icon to hide the cards. Anchors rotate with Earth while cards remain readable. Dragging or expanding grants reading time; after a substantial change in geographic direction, cards can move together when that materially reduces crossing or detours. Active interaction prevents movement, while manual positions remain preferences rather than permanent pins. Rear regions retain their state without a connector drawn through the globe. Existing layer, news, and media windows remain available.
The picker groups the canonical comparison catalog as China, US, Europe, APAC excluding China, and Other; the groups are display buckets, not statistical denominators. It offers period and metric selection. Bilateral share still uses US + China as its fixed denominator; unavailable measures stay missing for other participants. Regional anchors are label positions, never facility locations. Source buttons open report evidence and facility buttons reuse the existing detail card. Mobile uses a compact regional overview in the drawer. Each row is an accordion: tapping the same header collapses it, tapping another collapses the old row, and at most one long card is open. The expanded card keeps trends, region members, facilities, media, and sources; there is no region subpage. Settings remain a separate on-demand page.
The default national metric is global compute-device scale, covering basic, intelligent, and supercomputer capacity with observations from 2020–2023. The reviewed absolute observations are 302 for 2022 (`caict2023` evidence) and 435 for 2023 (`caict2024` evidence). Japan's global-share observations are 6%/5%/4%/4% for 2020/2021/2022/2023. Product groups APAC and OTHER do not mean the report's Japan or “other” categories, and EUROPE remains missing until its membership matches the report. Bilateral share always uses US + China as its denominator. A missing primary value may show the latest reported year (currently 2023); explicitly selecting 2026 still shows missing and does not backfill. Unconfirmed comparability does not produce year-over-year change. National report metrics remain separate from public facility samples; coverage counts remain auxiliary evidence, never computed capacity or a global coverage rate. TOP500 and Epoch may overlap, so source-record counts are not a global census. The bundled reports were reviewed on 2026-09-22; published overrides use their maintained review date and observations. An independent Epoch share mode is not implemented.
Select up to six compute participants sharing peripheral space with four regional news cards. Full cards use the same DPI-scaled 340px base width and retain business metrics, years, trends and sources; missing values remain explicit. Cards stay near screen edges while globe rotation moves only connectors. When space is limited, full cards remain visible with some peripheral overlap. On release, valid drops retain the new position. Out-of-bounds or central drops settle nearby, without returning to the pre-drag slot. Peripheral overlap is allowed. Cards have no manual reset action; positions and sizes remain persistent. News rotation and typing do not repeatedly push neighboring cards; long content scrolls internally.
Select up to six compute participants sharing peripheral space with four regional news cards. Settings → Panels independently sets the expanded compute and news card counts to 1, 2 or 3, defaulting to one each and retaining the choice. Other cards become capsules; hover temporarily opens the full content and click changes the focused region. Full cards retain metrics, years, trends and sources, with explicit missing values and internal scrolling. Expanded cards occupy the globe’s flanks while capsules follow regional projections and dim on the rear side. Dragging retains a manual position until the region moves deep behind the globe. Settings → Display → Connector Style selects thin or bold lines.
When the map cards switch is off, the overview summary still loads. Turning it on shares the map loading event; a newer request cancels the previous one. Drawer event handling follows `composedPath()` so replacing inner HTML cannot accidentally collapse the active row. The shared HUD collapsible panel animation is reused and respects reduced-motion preferences. Facility names remain in a scroll region capped at 240px; rows are left-aligned, wrap long names, and retain at least 42px of touch height.

View File

@@ -495,3 +495,45 @@ Run `PLANET_HARNESS_DOCKER_SMOKE=1 .venv/bin/python scripts/harness/test_backend
Bundled originals and their manifest live under `frontend/public/earth/assets/cards/`, with card-sized derivatives in `previews/`. After changing source assets, run `uv run --frozen --project . python scripts/prepare_card_images.py`, verify source hashes and permissions, then commit originals, manifest and generated WebP files. Preview generation follows the manifest enabled flag; permission records are retained unchanged. Backend images copy the same manifest while frontend builds distribute the files; deploy both images from the same revision.
Uploads default to the repository-root `data/earth-card-images/`; `PLANET_CARD_IMAGE_DIR` can select an absolute directory. Production containers need a persistent mount, shared across backend replicas. Back up that directory together with the PostgreSQL `earth_card_images` system setting. Database backups alone cannot restore uploaded bytes. Original and 640/1280 WebP files use content-addressed URLs under `/api/v1/earth/card-images/files/`, already covered by the API proxy. Removing configuration does not immediately delete physical files while older clients may still use them.
## Database cache and historical event maintenance
Consumed Outbox events default to one day of retention, configurable through
`PLANET_EARTH_OUTBOX_RETENTION_DAYS`. Once its maintenance index is valid, the
listener deletes at most 1,000 expired consumed rows per minute. Run initial
maintenance in a maintenance window. The first command only inspects state,
while `--apply` enables changes.
```bash
uv run --frozen python scripts/maintain_earth_outbox.py
uv run --frozen python scripts/maintain_earth_outbox.py --apply
uv run --frozen python scripts/install_bgp_prefix_geo_indexes.py
```
For a severely bloated historical Outbox, use
`scripts/maintain_earth_outbox.py --compact --apply` in a maintenance window.
It exclusively locks the table, stages pending and retained events, refills the
original table transactionally, verifies total and pending counts, and then
maintains the index. Other access blocks during the scan and refill. Reserve
temporary disk space and pause backend hot reload to prevent startup DDL from
conflicting with concurrent index installation. Check transaction outcomes and
invalid indexes before retrying a failed operation. Measure reclaimed database
space with `pg_total_relation_size`. Linux page cache has a separate lifecycle.
The WSL memory budget is configured in `%USERPROFILE%\.wslconfig`. The following
configuration was validated on a 32 GiB host. Preserve existing network settings,
restart WSL, restore services, and check `free -m`, Windows available memory,
and application load.
```ini
[wsl2]
memory=8GB
swap=4GB
[experimental]
autoMemoryReclaim=gradual
hostAddressLoopback=true
```
`hostAddressLoopback` allows Windows to reach mirrored WSL services through an
IPv4 address assigned to the host, enabling local checks of the LAN address.

View File

@@ -61,6 +61,8 @@ After selecting an object or enabling a layer, click its legend entries to filte
Open the news panel from the top ticker and enable “Show regional news cards” to rotate separate queues for the Americas, Europe, Middle East & Africa and Asia-Pacific. Each card supports independent pause, navigation, dragging and collapsing.
Refresh keeps existing news readable until the latest results are displayed, then reports success. If it fails, follow the message and retry. Settings → Panels independently expands 1–3 compute and news cards; other regions remain available as capsules that open on hover.
## 5. Recover a Lost Password
Open `/forgot-password`, enter your email, receive a code, then enter the code plus a new password. The same confirmation is shown for unknown emails (to avoid enumeration).
@@ -80,7 +82,7 @@ Open `/forgot-password`, enter your email, receive a code, then enter the code p
To maintain card values, an administrator opens **Earth content → Compute comparison**, adds report sources, enters comparable annual country observations, then chooses **Save and publish**. Omit missing countries; facility counts never replace compute shares. Bundled defaults can be restored and Earth cards automatically check published configuration.
Open the analysis icon beside Compute Centers and enable Show region cards. Closing configuration keeps the cards active; the switch controls their visibility. Resize or collapse desktop cards and inspect observed history, sources, and facility details. On mobile, use the Compute drawer tab. National indicators and facility samples are separate. “—” means missing evidence, not zero capacity.
Open the analysis icon beside Compute Centers. In the title bar, click **Show region cards** to the left of the close button. The cards stay visible when you close the panel. Click the same icon again to hide them. Resize or collapse desktop cards and inspect observed history, sources, and facility details. On mobile, use the Compute drawer tab. National indicators and facility samples are separate. “—” means missing evidence, not zero capacity.
### Inspect satellites and retry collection

View File

@@ -200,6 +200,10 @@ def parse_response(self, html):
## 七、调度机制
Earth RSS 补抓使用独立的 `earth_news_rss_refresh` 系统作业,在启动时及每
10 分钟检查区域 Feed。首页 GET 读取已保存新闻。同步数据源作业时保留该系统
作业,具体新鲜度、锁和失败冷却见[新闻源配置](/home/ray/dev/linkong/planet/docs/technical/zh/earth-news-sources.md)。
```python
# 启动时注册所有采集器到定时任务
def start_scheduler():

View File

@@ -31,8 +31,16 @@ flowchart LR
5. listener 短窗口合并同 layer 事件,失效缓存并广播 `earth_updates`。
6. 广播成功后标记 outbox consumed;失败时保留待重试。
缓存重建按 key 合并。同进程的并发请求共享构建结果或错误,冷缓存的跨进程请求等待 Redis 锁持有者完成。长构建会续租,发布和释放操作校验锁持有者。持有请求取消时,等待者可使用自己的数据库 session 接手。可用的 stale 缓存会直接返回;没有 stale 时,等待超过默认 30 秒返回 HTTP 503。Redis 协调失败时返回本次构建结果,同进程并发仍会合并。构建失败优先返回 stale,否则传播错误。
DB 变化不再默认创建 `earth_refresh` 任务,因此不会被同 source 的长采集或删除任务阻塞。`earth_refresh` 只保留给手动清缓存和非 DB 变化刷新提示。
### Outbox 写入与保留
触发器先从序列取得 `event_id`,再将含该 ID 的 payload 单次写入 `earth_data_change_events` 并发送 `NOTIFY`,避免插入后再次更新同一事件。`earth_news_items` 仅更新 `last_seen_at`、`updated_at` 时不产生 Earth 变化事件;标题等可见内容变化仍会产生事件。这两个时间戳继续记录新闻来源的健康与新鲜度。
已消费事件默认保留 1 天,可用 `PLANET_EARTH_OUTBOX_RETENTION_DAYS` 调整。listener 每 60 秒最多清理 1000 条过期已消费事件,且仅在独立维护索引有效时运行;未消费事件不进入清理条件。多实例通过 PostgreSQL advisory lock 避免同时清理。索引由[独立 Outbox 维护脚本](/home/ray/dev/linkong/planet/scripts/maintain_earth_outbox.py)以 `CREATE INDEX CONCURRENTLY` 建立,不在应用启动事务中创建。该脚本默认只读,也提供分批清理和一次性 compact 模式;具体执行步骤见[运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。
## 数据作业队列
`collection_tasks` 是统一 job ledger。worker 使用 PostgreSQL `FOR UPDATE SKIP LOCKED` 领取 `queued` 任务;同一 `source` 的写任务串行,不同 source 可并行。

View File

@@ -323,7 +323,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
```
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析,但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox,不随当前镜头视口反复请求。实时更新使用 `/ws` 的 `vessels` 全局订阅,按 MMSI 推送确认状态并原位更新;超出单帧上限时拆包,保留所有更新。首次加载、重连和删除校准仍使用快照,详见[采集器架构](backend-collectors.md)。
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析,但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox,不随当前镜头视口反复请求。实时更新使用 `/ws` 的 `vessels` 全局订阅,按 MMSI 推送确认状态并原位更新;超出单帧上限时拆包,保留所有更新。首次加载、重连和删除校准仍使用快照,详见[采集器架构](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)。
## 自定义 REST / WebSocket 映射运行时

View File

@@ -1,5 +1,13 @@
# BGP 态势上下文
## 网段归属查询
前缀归属按 OpenGeoFeed、IPtoASN、NRO 顺序查找,并校验 IPv4/IPv6 地址族及
完整起止区间包含关系。来源内仍按原始 prefix 掩码长度和记录 ID 排序。
`planet_bgp_try_inet` 和 `planet_bgp_try_masklen` 将无效历史地址转换为 NULL。
数据库初始化安装这些轻量函数,区间联合 GiST 索引由独立维护脚本部署。
索引安装及验证见[运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。
## 当前目标
BGP 模块正在从一个只展示异常的演示功能,演进为分层观测管线:

View File

@@ -70,7 +70,7 @@ React 路由入口:
- 各图层集成
- Earth 级别状态同步
Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,普通图层仍通过 `/api/v1/visualization/...` 接口重新 GET;船舶使用下文的 `vessels` 专用确认状态增量通道。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload`、`reload` 或 `delta` 策略。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉,summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,普通图层仍通过 `/api/v1/visualization/...` 接口重新 GET;船舶使用下文的 `vessels` 专用确认状态增量通道。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload`、`reload` 或 `delta` 策略。纯新闻变更不使 summary 缓存失效;同一批变更还涉及其他图层时,summary 仍随之刷新。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉,summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
### 3. 地球控制层
@@ -430,7 +430,7 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position` 或 `THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
地表标记保留分类图标,聚合使用固定地理成员及带数量的实心圆。缩放只驱动 320ms 展开/收拢补间,不重新分档聚类;超过 250% 展开,500% 无聚合圆标。船只关闭聚合,保留独立航向箭头。实现与验收见 [聚合说明](earth-interactable-clustering.md)。
地表标记保留分类图标,聚合使用固定地理成员及带数量的实心圆。缩放只驱动 320ms 展开/收拢补间,不重新分档聚类;超过 250% 展开,500% 无聚合圆标。船只关闭聚合,保留独立航向箭头。实现与验收见 [聚合说明](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-clustering.md)。
接口细节、生命周期和接入示例见:
@@ -549,13 +549,13 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
`compute-topic.js` 管理参考期、口径、参与方和来源,读取后端 `compute_catalog.py` 提供的规范名称、分组与地区标签锚点。当前目录覆盖 45 个已有坐标的参与方,未知历史指标返回缺失。参与方接口不再只接受中美;区域分组不参与分母计算,坐标不能复用为设施定位。
`spatial-callouts.js` 提供地理投影与共享分组,`spatial-callout-controller.js` 负责稳定周边阅读布局。`callout-dock-layout.js` 将中央观察区域作为不可放置卡片的硬障碍,先保留有效旧位置,再分配新增或受影响卡片。自动旋转、正背面变化、连线交叉和长短均不触发换位;仅卡片列表、用户展开/拖放、窗口与 HUD 可用空间变化时求解。空间不足时保留完整卡片,允许周边少量遮挡,不自动折叠、分组隐藏或缩小字号。拖拽松手通过 settleCardDrop 判断新落点:合法位置保持,越界或遮挡中央则调整到最近可用位置,允许周边与其他卡片轻微重叠。保存的是最终目标,不保存动画途中位置;回正运动不会因鼠标仍悬停而卡在中央。旧保存位置恢复时使用同一判断,手动归位入口及其分支已移除。开始拖动时该卡片升到所有区域卡片上方,松手后保持,直到另一张卡片被拖动;算力与新闻共享层级,配置菜单和来源面板仍在内容卡片之上。
`spatial-callouts.js` 提供地理投影与共享分组,`spatial-callout-controller.js` 为算力和新闻分别选择展开卡片,优先保留正在拖拽或手动放置的卡片,再按当前关注区域与距视图中心的距离补齐。`callout-limits.js` 在 `planet.earth.callout-limits.v1` 保存两类独立上限,界面提供 1–3 张,默认各 1 张;修改后通过 `earth:callout-limits-change` 立即重算。其余卡片使用随地理锚点移动的胶囊,悬停临时展开,点击切换关注区域。完整卡片分配到左右两侧,新闻优先在上,同侧超过两张时仍为每张分配目标位置;空间有限时允许周边重叠。背面胶囊渐淡缩小,手动拖放保留位置,区域深度进入背面时才解除固定。尺寸、阅读状态与业务坐标独立,拖动前景层级由新闻和算力共用。
对象展示统一通过 `PresentationController` 及新的 object-callout anchor/layout 路径。点击真实 BGP、算力中心、船只、卫星或海缆对象时,先绘制连接线,再把卡片放到对象旁边。源锚点从实际点击位置或原始区域标签开始;接近遮挡时,随着旋转自动平滑移向仍可见的高亮几何采样。`layoutReferencePoint` 取实际出线点的方向参考,`packDirectionalEdgeCallouts` 优先把卡片放到对应方向的屏幕边缘,给地球中间留空;边缘 lane 不可用时才内收,卡片不能盖住自己的出线点。有界联合分配最多使用 24 个 beam 状态和 32 个候选,避开已有 HUD,再最多三轮安全交换减少方向交叉;旧 solver 作为 fallback,不逐帧全量重排。slow 模式还检查实际卡片目标相对 source 反向超过 90° 后才提交变化;manual/hover 锁优先。source point 仍每帧更新为连线目标;固定对象跟踪动态位置,卡片仍可拖动。完全背面的对象保留球体轮廓方向提示、较淡连线和空心源点,这些是方向引导而非地理点。国家候选最多 96 条、硬上限 128,并使用缓存,不在每帧扫描 GeoJSON。算力卡继续让原始 point 独立于动态 source point,保持阅读稳定;共享卡片端接点沿障碍物周界滑移。海缆展示优先真实 ray-hit 的 local point,再按 `cableId` 采样高亮几何。移动端沿用 mini popup,点击后打开抽屉详情。旧异步 presentation 不能 dismiss 新的 selection。
配置窗的 `configVisible` 与卡片的 `cardsEnabled` 分离;X/Escape 只关闭配置,开关停用时才释放连线并恢复专题查询前的图层状态。配置和来源窗直接绑定 HUD 字号,避免拖到 `container` 后继承大号正文。主卡围绕当前选择的算力指标,显示大号百分比或带单位的绝对量、观测年份、历史上下文和来源入口。缺少可比数据时明确提示,不在没有观测值时绘制空趋势图。设施记录和覆盖信息仍完整保留在展开后的辅助区,样本数只是证据上下文,不能当作算力值或全球覆盖率。长列表仅在需要时展开;手机手风琴保持同一单一占比/绝对量语义。本地位置/尺寸保存与“恢复自动布局”仍可用;无变化的 DOM、尺寸、阅读状态应保留,过时响应不能重新启用卡片。
配置窗的 `configVisible` 与卡片的 `cardsEnabled` 分离;X/Escape 关闭配置窗并保留卡片状态。桌面标题栏的卡片按钮与新闻面板共用图标和选中样式,其 `aria-pressed` 跟随 `cardsEnabled`。通过该按钮或移动端开关停用卡片时,释放连线并恢复专题查询前的图层状态。配置和来源窗直接绑定 HUD 字号,避免拖到 `container` 后继承大号正文。主卡围绕当前选择的算力指标,显示大号百分比或带单位的绝对量、观测年份、历史上下文和来源入口。缺少可比数据时明确提示,不在没有观测值时绘制空趋势图。设施记录和覆盖信息仍完整保留在展开后的辅助区,样本数只是证据上下文,不能当作算力值或全球覆盖率。长列表仅在需要时展开;手机手风琴保持同一单一占比/绝对量语义。本地位置/尺寸保存仍可用;无变化的 DOM、尺寸、阅读状态应保留,过时响应不能重新启用卡片。
地球拖动与惯性状态只暂停卡片运动进度,不覆盖已提交终点。连线锚点继续跟随地球投影;卡片运动与地理出线方向独立。
展开卡片以平滑惯性追随已提交目标,悬停和键盘阅读时暂停该移动;胶囊独立跟随地理投影。连线锚点继续逐帧更新。`shared.calloutLineStyle` 保存细线/粗线偏好,桌面和移动控件同步到 `body.dataset.calloutLineStyle`,由共享连接线读取,不改变业务锚点。
共享 `CalloutConnector` 直接从折线点计算长度,不在每次写入 SVG 点后调用 `getTotalLength()`,避免多条移动连线触发反复同步布局。原有首次绘线动画保留;这一优化同时适用于原巡航展示。
@@ -565,7 +565,7 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
### 国界贴地与登陆点
普通国界、海岸、主张线、高亮及登陆点使用 `earth-surface.js` 对齐高清球面的 UV 三角形。国界与高亮在贴图之后、登陆点之前绘制,高亮开启深度测试且始终位于透明渲染队列;不再透过球体显示或盖住登陆点。几何构建时计算并缓存,不增加每帧重投影。具体顺序见[渲染图层规范](earth-render-layer-order.md)。
普通国界、海岸、主张线、高亮及登陆点使用 `earth-surface.js` 对齐高清球面的 UV 三角形。国界与高亮在贴图之后、登陆点之前绘制,高亮开启深度测试且始终位于透明渲染队列;不再透过球体显示或盖住登陆点。几何构建时计算并缓存,不增加每帧重投影。具体顺序见[渲染图层规范](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)。
### 新闻栏端部渐变与 breaking 状态机
@@ -576,12 +576,12 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
当前状态机将数据态、ticker 显示态、面板生命周期和选中态分开:
- 数据态由 `getNewsBreakingState()` 从 `items` 与 `cruise_items` 合并计算,只接受未过期项目,并按 `critical > breaking > watch` 排序;到期时间只负责安排下一次重新计算。
- ticker 的显示态由滚动视口 watcher 每 120ms 取视口内重叠最多、距离中心最近的当前项目。只有当前项目的 breaking 等级非 `none` 时,ticker 的上下细线才切换为对应的 `has-breaking-*` 颜色;当前项目离开后立即恢复中性。
- ticker 的显示态由滚动视口 watcher 每 120ms 取视口内重叠最多、距离中心最近的当前项目。只有当前项目的 breaking 等级非 `none` 时,ticker 的上下细线才切换为对应的 `has-breaking-*` 颜色;循环换项产生的短暂无重叠空档会保留 active 状态,避免扫光时间轴重启或边线闪烁。
- 面板生命周期 `panelPhase` 只有 `closed → opening → open → closing → closed`。打开/关闭只控制形变、可见性和 ticker 的隐藏,不改变 breaking 数据态;进入 `opening/open` 后保留当前卡片 DOM,后台 payload 先暂存,关闭时再一次性应用,避免 hover 卡片被实时更新重建。
- `selectedCruiseStoryId` 独立于 breaking 等级。选择、聚焦、悬停和关闭只更新选中项与阅读位置;关闭时清空选中项及筛选弹层。
- 展开面板始终使用蓝色玻璃底。breaking 只在对应新闻卡片上使用等级颜色,因此点开面板不会把整块玻璃染成红色,之后滚动到 breaking 新闻时仍可让 ticker 显示红色提示。
breaking 的玻璃反射脉冲已实现:ticker 当前项目进入 breaking 状态时,`::after` 以低频、有限幅度的全宽横向扫光带增强并闪烁;展开面板中只在对应 breaking 新闻卡片上用 `::after` 扫过一条玻璃反射带。面板底色保持蓝色,不使用持续闪烁的整圈边缘;`prefers-reduced-motion` 下停用动画并保留低强度静态提示。该视觉子状态挂在当前 breaking 项目和对应卡片上,不与面板是否打开耦合。
breaking 的玻璃反射效果已实现:ticker 当前项目进入 breaking 状态时,`::after` 用短而四周羽化的光团横穿 ticker 玻璃区域;展开面板中只在对应 breaking 新闻卡片上用 `::after` 扫过整卡反射带。面板底色保持蓝色,静态上下边线不参与扫光;`prefers-reduced-motion` 下停用动画并保留低强度静态提示。该视觉子状态挂在当前 breaking 项目和对应卡片上,不与面板是否打开耦合。
算力卡片外观直接复用共享 `.hud-panel` 的背景、边框、阴影和玻璃效果,摘要、基础信息及详情区不再叠加单独黑底。标题栏与图层面板共用 `.hud-panel__header--inset` / `.layer-panel-header` 的内边距;主数字继续突出,基础信息保持常显。
@@ -600,13 +600,15 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
卫星由单批 `THREE.Points` 与 `PointsMaterial` 绘制:32×32 白色圆盘贴图、半径 0.42、`alphaTest=0.5`、`sizeAttenuation=false`,不再绘制深色背景点或伪球面明暗。`satellite-display.js` 按 satellitemap 的截图像素校准:150% 及以下的点方框为 2.4px、500% 为 8px,实心圆盘占 84%,对应约 2px 与 6–7px 的可见点径,中间 smoothstep 过渡。蓝/橙/绿/紫/玫红直接使用参考色板,不额外叠加原型曝光。点尺寸保留 DPR 上限 2 的折算,地球渲染分辨率不变。普通呼吸默认关闭,设置仍可单独开启;悬停/选中环、光晕、开放预测轨道及流光保持现有交互。
卫星点精灵设置 `depthTest=false`,顶点着色器用相机到卫星的线段与地球球体做遮挡测试,半径为 `CONFIG.earthRadius * 0.998`。真正被球体遮挡的点移出裁剪空间,可见点则整点显示,避免近地平线被逐像素深度裁切。轨道、覆盖与业务位置仍使用原有路径。
真实 GP/TLE 仍在 Worker 每 250ms 传播。`satellite-motion.js` 缓存采样半径并逐帧半径保持插值,粒子、拾取、短尾迹末端、选中环和覆盖共享同一位置。`satellite-trail-geometry.js` 仅在所有支持缩放下的投影误差不超过 0.2 像素时合并冗余尾迹段;保留全部卫星和 10 个历史采样,曲率或近相机条件不满足时保留原分段。仅上传活跃缓冲区,尾迹宽度不超过点尺寸的 60%,尾端收细,避免远景形成粗碎线圈。`satellite-footprint-geometry.js` 保留 260×170 的完整覆盖网格,仅创建一次;同一地表投影公式在顶点着色器计算,每帧更新局部基向量和范围参数,避免移动时重建数万个顶点。Starlink/Iridium 覆盖统一蓝色 `#3399ff`,日照只调整透明度,真实覆盖模型不变。
`SATELLITE_CATEGORIES` 统一真实倾角分类及蓝/橙/绿/紫/玫红/灰色。选中卫星时自动切换到卫星图例,直接点击图例分类控制点、拖尾、标签和拾取,隐藏已选分类会清除选择与覆盖;图层和设置中不另设筛选入口。搜索明确选择隐藏类别的卫星时重新显示该类。`satellite-labels.js` 在缩放达到设置阈值(默认 **400%**,可调范围 **250%–500%**)时显示真实名称与型号(缺省为 NORAD 编号),数量上限可在近景标签开关下通过滑块设置为 5–36 个,默认 36;桌面和移动端共享持久化设置,修改后立即重新选取候选。实际显示数量仍受遮挡和可用位置限制,中央 65% 范围内做遮挡和重叠检查;候选扫描每 160ms 一次,已选标签逐帧跟随。
“设置 → 显示 → 显示 FPS”由 `shared.showFps` 持久化,默认关闭。`frame-stats.js` 在品牌右侧显示实际帧间隔统计,每秒更新;原生提示包含平均帧间隔、P95、主线程 CPU 时间、Draw calls 和约 30 秒滚动平均 FPS(总帧数 / 总时长,不平均每秒 FPS)。前台慢帧必须计入,隐藏页切换时重置窗口;不能把个别高帧样本当成稳定性能保证。性能验收至少使用 1920×1080 实际画布,连续采样至少 30 秒,以平均 60 FPS 为目标,允许偶发低帧。验证应使用同一视口、缩放、完整数据、图层和播放状态,对比远景及 400% 近景,并检查分类隐藏、搜索选中和 FPS 刷新后的持久化。
国界加载占位球、海陆填色、主球和高清层使用 `earth-surface.js` 的统一网格工厂;占位球与海陆填色共用 R+0.32,修复纹理加载前的菱形纬圈。国界和高清开关行为保持不变;几何穿插与远距深度余量由 `earth-surface.test.js` 覆盖,详见[渲染图层顺序](earth-render-layer-order.md)。
国界加载占位球、海陆填色、主球和高清层使用 `earth-surface.js` 的统一网格工厂;占位球与海陆填色共用 R+0.32,修复纹理加载前的菱形纬圈。国界和高清开关行为保持不变;几何穿插与远距深度余量由 `earth-surface.test.js` 覆盖,详见[渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)。
海缆与登陆点使用 `cable-surface.js` 共用高清地表坐标;关联光晕由 `cable-batches.js` 的逐实例 emphasis 控制,解除选择后清零。圆角徽标在 `interactable.js` 中接入 `surface-marker-occlusion.js`,以锚点决定整块显隐及拾取,避免近地平线被地球切开。BGP 变化事件保留原图标,观测站采用带活动色的广播塔圆角徽标;星空材质使用原始亮度(系数 1.0)。
@@ -631,7 +633,7 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
显示设置新增 `shared.satelliteLabelsEnabled`(默认 true)以及 `shared.satelliteLabelMinZoom`(2.5–5,默认 4)与 `shared.skyBrightness`(0–2,默认 1)。桌面与移动控件、刷新、重置共用同一运行时状态。星空倍率在线性空间作用于星空材质,沿用局部 sRGB 输出,不修改地球曝光。算力图例显示实际已定位分类数量,芯片与堆叠 SVG 继续按 `site_type` 区分;数据集中只有 GPU 时不伪造其他类型或坐标。
所有目标位置与合法路径先确定再提交。`callout-dock-motion.js` 使用 240–420ms 时间补间,`callout-routing.js` 只提供静态避障路径。暂停保留进度与终点。布局与移动不改变卡片的展开状态;没有完整卡片通路时直接使用最终落点,不临时折叠。旧的循环评分、分阶段让位、中心区域兜底和途中坐标固化路径已移除。
展开卡片与胶囊由关注状态和各自数量上限控制。左右两侧舞台位置、胶囊跟随与悬停展开共享控制器;手动固定优先,阅读时暂停漂浮及展开卡片的自动移动。旧 `callout-dock-motion.js` / `callout-routing.js` 工具不再决定这一展示路径。
### 聚合圆标与 Spiderfy

View File

@@ -316,6 +316,6 @@ BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用
卫星 hover 小圈直接使用当前卫星的倾角类别色,与粒子和图例一致;切换悬停对象时重新取色,位置更新不覆盖颜色。
所有地表卡片统一使用根 HUD DPI 比例。新闻区域卡片与巡航新闻复用标题图标、配色、字号及内边距;区域版只精简元信息字段并增加独立轮播控件。算力指标数字、趋势图及标题图标不再使用未缩放的固定像素。欧洲使用小型 EU 字标,其他区域使用受同一比例约束的代表 emoji。
所有地表卡片统一使用根 HUD DPI 比例。新闻区域卡片与巡航新闻复用标题图标、配色、字号及内边距;区域版精简元信息并增加独立轮播控件。展开卡片保留完整内容,其余区域使用胶囊,悬停以相同卡片样式临时展开;背面胶囊渐淡并缩小,不隐藏业务对象。细线/粗线引导线共用 `CalloutConnector`,粗线增加白色线宽与深色描边,端点同步放大。算力指标数字、趋势图及标题图标随 HUD 缩放。欧洲使用小型 EU 字标,其他区域使用受同一比例约束的代表 emoji。
算力图标按五类选择:超算芯片、GPU 层叠、数据中心机柜、云计算云朵、边缘节点连接。几何分别来自 compute-supercomputer.svg、compute-gpu-cluster.svg、compute-hdd-network.svg、compute-cloud.svg、compute-edge.svg,保持算力图层紫色底板与原有选中行为。

View File

@@ -84,6 +84,17 @@ Earth 态势新闻使用 `/api/v1/news/earth-feed` 输出给前端。新闻源
## 配置与缓存
`GET /api/v1/news/earth-feed` 读取已保存新闻。RSS 补抓由调度器的
`earth_news_rss_refresh` 作业在启动时及每 10 分钟执行,按区域检查新鲜度和
Feed 覆盖,并通过 Redis 锁合并多进程抓取。失败 Feed 按来源配置的冷却时间
重试,其他来源继续处理。仅更新时间戳的健康记录不会广播新闻内容变化。
`POST /api/v1/news/refresh` 接收区域或视角坐标,返回 HTTP 202 和
`refresh_id`,同区域进行中的手动刷新共享该标识。
`GET /api/v1/news/refresh/{refresh_id}` 返回等待、执行、成功或失败状态。
前端在成功后重新读取新闻并更新面板,再显示刷新成功。刷新期间保留已有内容,
失败后恢复按钮并允许重试。保存或重置新闻源配置也会立即安排后台检查。
`GET /api/v1/earth/news-sources` 返回默认或已保存配置。`PUT /api/v1/earth/news-sources` 保存配置并递增 `cache_version`,同时清理进程内 region cache。`POST /api/v1/earth/news-sources/reset` 恢复默认源。`POST /api/v1/earth/news-sources/test` 只测试单个 RSS/Atom/Aggregated 源,不写入新闻表。
## 手动新闻内容
@@ -160,13 +171,13 @@ Web 星球端的新闻类型按钮只保存当前浏览器的显示偏好;偏
未指定 `sources` 时,服务层会优先选择当前 `locale` 已有可展示标题和摘要的新闻,并从当前启用来源补齐候选后做来源轮转,避免一个来源的最新待处理条目占满默认 12 条。指定 `sources` 时保持精确来源过滤,不做跨来源补齐。数据库查询层只负责区域、类型、来源和排序条件,不包含语言展示策略。
源测试只证明当前 RSS/Atom/XML 能解析到条目,不等于这些条目已经入库展示。展示链路还会检查区域、类型过滤和数据库新鲜度。保存或重置新闻源会递增配置版本并清理缓存;如果当前启用的 Feed 子项在库里没有近期条目,下一次 `earth-feed` 请求会补抓,避免新启用的 36氪、亿邦被旧 Google News 缓存挡住。
源测试只证明当前 RSS/Atom/XML 能解析到条目,不等于这些条目已经入库展示。展示链路仍检查区域和类型过滤。保存或重置新闻源会递增配置版本、清理缓存并立即安排所有区域的后台刷新;缺少近期条目的 Feed 由后台补抓,`earth-feed` 请求保持只读。
## 区域均衡与异步精修
`earth_news_items` 是 EarthFeed 的当前状态表。接口在没有显式 `sources` 过滤时会先按 Breaking、区域、发布时间排序,再做区域轮转,避免亚太或任一高频来源把全球视图和巡航队列全部占满。默认区域顺序是美洲、欧洲、中东与非洲、亚太、全球;未知区域只在已知区域之后参与轮转。区域视图仍遵守“当前区域 + global”的规则,不会把其它区域混进区域面板。
`items` 和 `cruise_items` 都会进入目标位置与本地化精修队列,但前端不能等 AI 完成后再展示。标题或摘要没有目标语言翻译时,Web Earth 先显示原始 `title / summary`,避免卡片出现“新闻汉化中”而实际内容已经可读。后台完成翻译、分类、Breaking 或目标坐标后,会更新同一条 `earth_news_items` 并通过 Earth news reload / patch 刷新前端。
补抓和内容写入链路负责提交目标位置与本地化精修,`earth-feed` 读取不再创建这些任务。前端不能等待 AI 才绘制已有内容;展示语言仍受当前语言的可用内容过滤,英文模式不回退到中文原文。后台完成翻译、分类、Breaking 或目标坐标后,会更新同一条 `earth_news_items` 并通过 Earth news reload / patch 刷新前端。
目标位置队列使用 Redis Streams 两级队列:
@@ -236,7 +247,11 @@ flowchart LR
ManualAPI --> Store
Earth["Earth 新闻面板"] --> NewsAPI["/api/v1/news/earth-feed"]
NewsAPI --> Resolver["Source Resolver"]
NewsAPI --> Config
Earth --> RefreshAPI["POST /api/v1/news/refresh"]
RefreshAPI --> Scheduler["RSS refresh scheduler"]
ConfigAPI --> Scheduler
Scheduler --> Resolver["Source Resolver"]
Resolver --> Config
Resolver --> Cache["Region Feed Cache"]
Resolver --> Fetcher["RSS / Atom Fetcher"]

View File

@@ -32,9 +32,9 @@
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium 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`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 |
| 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay;使用锚点整块遮挡,与 BGP/算力图标在地球边缘保持同一显隐策略 | `depthTest: false`,由锚点前后显隐控制;`main.js` 使用屏幕空间 picking,只取正面 marker;`cluster: false` 且 `avoidance: false` | 航行船只用三角点纹理,停泊/低速用圆点;密集海域允许重叠,不参与动态屏幕聚类,避免旋转时重建 Points;低于算力中心 `4.5`。 |
| 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 |
| 6 | 卫星点 | `satellites.js` | SGP4 高度压缩为 R+4..25;回退高度 R+8 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 |
| 6 | 卫星点 | `satellites.js` | SGP4 高度压缩为 R+4..25;回退高度 R+8 | `depthTest=false`;顶点着色器以 R×0.998 球体测试相机到卫星的线段遮挡,屏幕空间拾取 | 可见点完整显示,真正被球体遮挡的点移出裁剪空间,避免近地平线逐像素裁切。 |
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制 完整周期开放惯性轨道与方向流光;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
@@ -100,11 +100,11 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
### 地表标记对齐与侧面显隐
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。该路径只作用于 `icon.badge` 层(含算力、观测站;船舶独立使用航向箭头)。选中 BGP 事件的静态覆盖光晕使用事件图标相同的 R+0.48 锚点、前景顺序和整块前后显隐;BGP 发散圆环仍保留原有独立动画语义。
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标和船舶航向箭头采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。选中 BGP 事件的静态覆盖光晕使用事件图标相同的 R+0.48 锚点、前景顺序和整块前后显隐;BGP 发散圆环仍保留原有独立动画语义。
聚合投影层 `.map-clusters` 的 z-index 为 8,低于 HUD;以真实地表锚点做整标遮挡。普通点仍使用原 Three.js 图层,过渡中的成员只在投影层显示一次,避免双图标。Spiderfy 引线有透明加宽命中区域,悬停联动高亮引线与子点,点击返回同一业务对象;滚轮在图标与引线上继续缩放地球。
跨图层同坐标/极近坐标标记在同一投影层内紧凑堆叠,不以屏幕碰撞合并不同地点;选中项使用投影子点 `z-index: 1000`,普通未聚合图标的锁定覆盖层使用共享的前景 `renderOrder >= 100`,因此跨图层重叠时仍由焦点对象在最上层;取消后恢复稳定 ID 顺序。聚合徽标在展开过渡期间保持低于选中子点。`icon_presentation_screen` 仅供详情引线跟随显示偏移,真实锚点仍由原 marker 持有;隐藏状态变化立即重建对应 GPU 批次。
区域新闻卡片与算力卡片共用 `#earth-region-callouts` 空间布局根节点(z-index 48),各自数据与阅读状态独立。新闻卡片按大区代表锚点引线,内容轮播不改变锚点;同一布局控制器排除自身所有卡片后测量 HUD 障碍,并统一处理卡片尺寸变化。手机新闻卡片进入抽屉,不绘制地图连线。
区域新闻卡片与算力卡片共用 `#earth-region-callouts` 空间布局根节点(z-index 20),各自数据与阅读状态独立。根节点内部的展开卡片为 50、拖拽前景为 55、悬停展开为 60,普通胶囊为 30,背面胶囊降至 8 并渐淡缩小。各类同时展开数独立保存,完整卡片在地球两侧分配位置,其余以胶囊跟随地理锚点;悬停临时展开,点击改变关注区域。新闻引线始终连接大区代表锚点,内容轮播不改变锚点。共享控制器排除自身卡片后测量 HUD 障碍并处理尺寸变化。细线/粗线样式统一来自 `body.dataset.calloutLineStyle`。手机新闻卡片进入抽屉,不绘制地图连线。

View File

@@ -296,10 +296,14 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
点击地球上的 BGP、算力中心、船只、卫星或海缆后,先从物件引出连接线,再沿同一方向把详情卡尽量放到屏幕边缘,中间给地球留出呼吸空间;卡片不能盖住自己的出线点。地球转动时,连线起点会自动平滑移向仍可见的高亮部分;整个对象转到背面后,保留球体轮廓上的方向提示,以较淡连线和空心端点区分。卡片位置保持稳定,仍可拖动,接点会随卡片沿边缘调整,无需手动编辑连线。手机上先显示精简卡片,点开后在抽屉中查看完整详情。下拉菜单与工具列表保持原有打开方式。
点击地球上的 BGP、算力中心、船只、卫星或海缆后,先从物件引出连接线,再沿同一方向把详情卡尽量放到屏幕边缘,中间给地球留出呼吸空间;卡片不能盖住自己的出线点。地球转动时,连线起点会自动平滑移向仍可见的高亮部分;整个对象转到背面后,保留球体轮廓上的方向提示,以较淡连线和空心端点区分。卡片随出线方向平滑跟随,仍可拖动,接点会沿卡片边缘调整,无需手动编辑连线。手机上先显示精简卡片,点开后在抽屉中查看完整详情。下拉菜单与工具列表保持原有打开方式。
### 四大区新闻卡片
点击新闻面板的刷新按钮后,会显示“正在刷新”,并保留已有新闻供继续阅读。
完成更新并显示最新结果后提示刷新成功;失败时说明原因,按钮恢复可用,
可以再次刷新。等待期间重复点击不会重复启动刷新。
点击顶部新闻条打开态势新闻面板,再点右上角“显示区域新闻卡片”,即可同时显示美洲、欧洲、中东与非洲、亚太四张卡片。关闭新闻面板后,区域卡片仍保留,开关会记住,启用时背景和边框高亮,再次点击关闭。
每张卡片只轮播本区已可显示的新闻,摘要以打字机效果完整显示后,停留 7 秒再换一条;悬停、键盘操作、拖拽或折叠时暂停当前卡片,其他区域继续。底部可上一条、下一条、暂停/继续,或打开原文。刷新新闻时尽量保留正在阅读的条目;本区无可用新闻会显示空态,不拿其他地区填充。
@@ -427,13 +431,13 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
“创新指数分差”独立维护美国减中国的观测,不反推单国指数。来源被观测引用时不能直接移除;重复指标年份、无效来源、占比总和超过 100% 等内容会拒绝发布。多人同时编辑时,后保存者收到冲突提示,草稿保留以便核对。“恢复默认”需要确认,会恢复随版本提供的报告数据;保存和恢复均有操作审计。同源页面收到发布通知后更新,其他正在展示的地球页每分钟检查一次,后台标签页恢复可见时也会检查。数值仍需人工核验报告,配置链接不会自动抓取报告。
点击算力中心图层行的“算力态势分析”打开配置窗,再开启“显示区域卡片”,地球周围会先绘制到目标位置的连接线,随后数据卡平滑出现;再次转动时不会重播入场。关闭配置窗或按 Escape 仅收起配置,卡片继续显示;再次点击分析按钮可重开配置,关闭显示开关才会停用卡片。区域锚点随地球旋转,卡片保持朝向读者;背面区域有状态提示,不绘制穿过地球的连线。其他图层、新闻和媒体窗口保持可用。
点击算力中心图层行的“算力态势分析”打开配置窗,再点击标题栏关闭按钮左侧的“显示区域卡片”图标。地球周围会先绘制到目标位置的连接线,随后数据卡平滑出现;再次转动时不会重播入场。关闭配置窗或按 Escape 仅收起配置,卡片继续显示;再次点击分析按钮可重开配置,点击已高亮的卡片图标可隐藏卡片。区域锚点随地球旋转,卡片保持朝向读者;背面区域有状态提示,不绘制穿过地球的连线。其他图层、新闻和媒体窗口保持可用。
选择器按规范目录分为中国、美国、欧洲、不含中国的亚太和其他五组;这些分组只用于展示,不参与统计分母,可改变参考期与指标口径。双边份额的分母仍固定为美国加中国,其他参与方没有该口径时显示缺失。区域中心只用于卡片定位,不代表设施位置。点击来源查看报告,点击设施按钮使用原有详情卡。手机通过底部抽屉的“算力”页查看紧凑地区概览:同一列表中的短卡采用手风琴,重复点击卡头收起,点击另一张先收起旧卡,最多同时展开一张长卡。展开详情保留趋势、区域成员、设施、媒体和来源,不设地区二级页面;设置仍是单独按需页。
默认主指标为全球计算设备总规模,拆分基础计算、智能计算和超级计算,观察期为 2020—2023。已核对的绝对量为 2022 年 302(证据 `caict2023`)和 2023 年 435(证据 `caict2024`)。日本全球份额为 2020/2021/2022/2023 = 6%/5%/4%/4%。产品分组 APAC、OTHER 不能直接等同报告里的日本或“其他”;EUROPE 成员集合尚未与报告对齐,仍显示缺失。双边份额分母固定为美国加中国,不受日本加入影响。主指标缺失时可显示最近有报告的年份(当前到 2023),明确选择 2026 仍显示缺失,不回填最近值。不可比性未确认时不展示同比变化。国家报告指标与公开设施样本独立,覆盖 count 只保留在辅助证据区,不能当作算力值或全球覆盖率。TOP500 与 Epoch 可能重复收录,来源记录数不代表全球设施总数。默认报告核验日期为 2026-09-22;后台发布后以维护的核验日期和实际观测为准。独立 Epoch 份额模式尚未实现。
最多选择六个算力参与方,与四大区新闻卡片共用周边空间。内容卡片按同一 340px 基准宽度随 DPI 缩放,显示业务指标、年份、趋势与来源;缺失值明确标注,不用设施数量代替算力。卡片保持在屏幕周边,地球旋转只改变连线;空间不足时允许周边少量遮挡,完整卡片不会自动收起。拖放松手后保留合适的新位置;越界或遮挡中央时就近调整,不退回拖拽前的位置。周边轻微重叠允许保留,卡片不再提供手动归位按钮,位置和尺寸仍可保存。新闻轮播和打字不会反复推挤周围卡片,长内容在卡片内滚动。
最多选择六个算力参与方,与四大区新闻卡片共用周边空间。“设置 → 面板”可分别将算力卡和新闻卡同时展开数设为 1、2 或 3,默认各 1 张,选择会保存。其余卡片显示为胶囊,悬停可临时查看完整内容,点击可切换关注区域。完整卡片保留业务指标、年份、趋势和来源,缺失值明确标注;长内容内部滚动。展开卡片沿地球两侧安排位置,胶囊随区域投影移动,背面胶囊变淡。拖放保留手动位置,区域深度转到背面后恢复自动跟随。“设置 → 显示 → 引导线样式”可选择细线或粗线。
地图卡片开关关闭时仍会加载概览摘要,打开后共享地图加载事件;新请求会取消旧请求。抽屉事件使用 `composedPath()`,即使通过 innerHTML 换节点也不会误收起当前卡片。复用共享 HUD 折叠面板动画,并尊重 reduced-motion。设施名单使用最高 240px 的内部滚动区域,名称左对齐并正常换行,每行至少保留 42px 触控高度。

View File

@@ -492,6 +492,43 @@ uv run pytest backend/tests/test_otp_service.py
## 详情卡图片存储
内置原图及清单位于 `frontend/public/earth/assets/cards/`,卡片尺寸预览位于其 `previews/` 子目录。新增或更换原图后运行 `uv run --frozen --project . python scripts/prepare_card_images.py`,检查来源哈希及授权状态,再提交原图、清单和生成的 WebP。预览生成遵循清单 enabled:明确启用的素材生成预览,授权记录保持原样。后端镜像复制同一 manifest,前端构建分发图片,因此两个镜像应使用同一版本发布。
人工上传默认写入仓库根的 `data/earth-card-images/`;可通过 `PLANET_CARD_IMAGE_DIR` 指定绝对存储目录。生产容器须将该目录挂载到持久卷,多副本须共享同一目录。备份同时包含 PostgreSQL 中 `system_settings` 的 `earth_card_images` 配置及该图片目录;仅备份数据库无法恢复上传文件。原图和 640/1280 WebP 使用内容哈希路径,经 `/api/v1/earth/card-images/files/` 提供,现有 `/api/` 代理可直接转发。移除配置不会立即删除物理文件,以免破坏仍在读取旧版本的客户端。
## 数据库缓存与历史事件维护
已消费 Outbox 事件默认保留一天,`PLANET_EARTH_OUTBOX_RETENTION_DAYS` 可设置
保留天数。监听器每分钟最多删除 1000 条过期已消费记录,并要求维护索引有效。
首次部署应在维护窗口运行以下命令。默认命令只检查,添加 `--apply` 才修改。
```bash
uv run --frozen python scripts/maintain_earth_outbox.py
uv run --frozen python scripts/maintain_earth_outbox.py --apply
uv run --frozen python scripts/install_bgp_prefix_geo_indexes.py
```
历史 Outbox 严重膨胀时,可在维护窗口使用
`scripts/maintain_earth_outbox.py --compact --apply`。该模式持排他锁,将未消费和
保留期内事件暂存,再事务性重填原表,核对总数和未消费数后提交,并继续维护
索引。扫描及回填期间其他访问会阻塞。预留临时数据空间,停止后端热重载,
避免应用启动 DDL 与并发索引安装争锁。失败时检查事务结果及无效索引后再重试。
压缩后的空间以 `pg_total_relation_size` 为准,Linux Page Cache 另行回收。
WSL 宿主机可在 `%USERPROFILE%\.wslconfig` 设置内存预算。32 GiB 宿主机上的
一组已验证配置如下,现有网络设置应一并保留。修改后须重启 WSL 并恢复服务,
随后验证 `free -m`、Windows 可用内存和真实负载。
```ini
[wsl2]
memory=8GB
swap=4GB
[experimental]
autoMemoryReclaim=gradual
hostAddressLoopback=true
```
`hostAddressLoopback` 允许 Windows 使用自身 IPv4 地址访问 mirrored 模式中的
WSL 服务,用于本机验证局域网地址入口。

View File

@@ -61,6 +61,8 @@
顶部新闻条可打开态势新闻面板;启用“显示区域新闻卡片”后,美洲、欧洲、中东与非洲、亚太分别轮播本区新闻。每张卡片可单独暂停、翻页、拖动与折叠。
刷新新闻时可继续阅读已有内容,等待“刷新成功”后查看最新结果;失败时可按提示重试。“设置 → 面板”可分别选择算力卡和新闻卡展开 1–3 张,其他区域保留为可悬停展开的胶囊。
## 5. 找回密码
忘记密码时打开 `/forgot-password`,输入邮箱并接收验证码,再输入验证码 + 新密码即可。系统对未注册的邮箱也会返回同样的提示(防止账号枚举)。
@@ -80,7 +82,7 @@
维护卡片数值:管理员打开“智能星球内容 → 算力对比”,先维护报告来源,再填写年度国家观测并“保存并发布”。只录入同年同口径数据,缺失国家不填,设施数量不会替代算力份额。可恢复内置默认值,地球卡片自动检查已发布配置。
在算力中心旁打开“算力态势分析”,开启“显示区域卡片”,选择地区并旋转地球。关闭配置窗会保留卡片,显示开关才控制启停。桌面卡片可调整大小、收起并查看真实历史图;点击来源或设施进入详情。手机使用底部“算力”页。国家指标与设施样本分开,“—”表示缺数据,不代表算力为零。
在算力中心旁打开“算力态势分析”,点击标题栏关闭按钮左侧的“显示区域卡片”图标,再选择地区并旋转地球。关闭配置窗后卡片会继续显示,再次点击该图标可隐藏卡片。桌面卡片可调整大小、收起并查看真实历史图;点击来源或设施进入详情。手机使用底部“算力”页。国家指标与设施样本分开,“—”表示缺数据,不代表算力为零。
### 查看卫星与重试采集

View File

@@ -16,12 +16,18 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.76.13`
- `dev` 当前开发分支历史推导到:`0.76.19`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.76.19` | improvement | `dev` | `v0.76.19` | 改善 Earth 卡片与移动交互、新闻后台刷新、图层缓存、BGP 前缀查询及 Outbox 维护 |
| `0.76.18` | bugfix | `dev` | `v0.76.18` | 收敛 breaking 新闻交互、卡片 hover/扫光、面板稳定性、船只锚点显隐并同步开发文档 |
| `0.76.17` | bugfix | `dev` | `v0.76.17` | 稳定 breaking active 动画状态,修复连续新闻切换时扫光重启闪烁 |
| `0.76.16` | bugfix | `dev` | `v0.76.16` | 修复 breaking class 重置导致的边框闪烁、扫光停滞与方向反转 |
| `0.76.15` | bugfix | `dev` | `v0.76.15` | ticker 扫光限制在上下边线以内,消除 breaking 边框闪烁并与卡片特效分离 |
| `0.76.14` | bugfix | `dev` | `v0.76.14` | 修复卡片扫光起点,改为从卡片左边界到右边界的内部背景扫描 |
| `0.76.13` | bugfix | `dev` | `v0.76.13` | breaking hover 以自身高亮为基准继续增强,修复红 ticker/卡片变暗 |
| `0.76.12` | bugfix | `dev` | `v0.76.12` | 修复 breaking ticker 与黄卡 hover 被白色混合漂淡,保留等级色相 |
| `0.76.11` | bugfix | `dev` | `v0.76.11` | breaking 卡片整卡扫光及 ticker hover 强度对齐共享 HUD |

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>智能星球计划</title>
</head>
<body>

View File

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

View File

@@ -27,8 +27,8 @@
--hud-title-size: calc(1.02rem * var(--hud-scale));
--hud-panel-header-title-size: calc(0.82rem * var(--hud-scale));
--hud-kicker-size: calc(0.68rem * var(--hud-scale));
--hud-surface-top: rgba(10, 23, 37, 0.92);
--hud-surface-bottom: rgba(5, 13, 24, 0.88);
--hud-surface-top: rgba(10, 23, 37, 0.94);
--hud-surface-bottom: rgba(5, 13, 24, 0.94);
--hud-surface-overlay: rgba(157, 204, 255, 0.07);
--hud-border: rgba(124, 190, 218, 0.18);
--hud-border-hover: rgba(226, 238, 250, 0.24);
@@ -53,6 +53,7 @@
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
@@ -60,6 +61,8 @@ body,
.earth-page {
width: 100%;
height: 100%;
overscroll-behavior: none;
-webkit-overscroll-behavior: none;
}
/* Ensure [hidden] always wins over component display rules */
@@ -70,6 +73,8 @@ body.earth-page {
background-color: #0a0a1a;
color: #fff;
overflow: hidden;
overscroll-behavior: none;
-webkit-overscroll-behavior: none;
}
html.is-globe-dragging,
@@ -83,6 +88,8 @@ body.earth-page.is-globe-dragging * {
position: relative;
width: 100vw;
height: 100vh;
overscroll-behavior: none;
-webkit-overscroll-behavior: none;
}
.earth-app canvas {

View File

@@ -47,12 +47,8 @@
gap: 8px 12px;
}
.compute-picker__toggle,
.compute-picker__notice {
width: 100%;
}
.compute-picker__notice {
color: var(--hud-accent-strong);
}
@@ -91,6 +87,129 @@
padding: 0;
overflow: hidden;
resize: none;
contain: layout style;
will-change: width, height, transform, box-shadow;
transition:
width 0.36s cubic-bezier(0.16, 1, 0.3, 1),
height 0.36s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Desktop compact mode for peripheral / non-spotlight compute cards */
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) {
width: min(calc(220px * var(--hud-scale)), calc(100vw - 24px));
min-width: min(calc(180px * var(--hud-scale)), calc(100vw - 24px));
min-height: auto;
height: calc(42px * var(--hud-scale)) !important;
max-height: calc(42px * var(--hud-scale)) !important;
padding: 0;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
border-color: rgba(124, 190, 218, 0.18);
transform: scale(var(--spatial-scale, 1));
transform-origin: center center;
transition:
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
body:not(.layout-mode-mobile) .compute-card.is-backside:not(.is-spotlight) {
z-index: 8 !important;
pointer-events: auto !important;
border-color: rgba(100, 160, 220, 0.18) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight):hover {
border-color: rgba(147, 202, 255, 0.45);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.28), 0 4px 14px rgba(0, 0, 0, 0.45);
transform: translateY(-1px);
}
body:not(.layout-mode-mobile) .compute-card.is-hover-expanded {
z-index: 60 !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65), 0 0 20px rgba(56, 189, 248, 0.35) !important;
transition:
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
body:not(.layout-mode-mobile) .compute-card.is-hover-expanded::before {
content: "";
position: absolute;
inset: -8px;
pointer-events: auto;
z-index: -1;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .hud-panel__header {
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
gap: calc(6px * var(--hud-scale));
height: calc(42px * var(--hud-scale));
box-sizing: border-box;
}
.compute-card .compute-card__essentials,
.compute-card .compute-card__body {
transform-origin: top left;
will-change: opacity, transform;
transition:
opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
.compute-card.hud-panel--expand-up .compute-card__essentials,
.compute-card.hud-panel--expand-up .compute-card__body {
transform-origin: bottom left;
}
.compute-card.hud-panel--expand-down .compute-card__essentials,
.compute-card.hud-panel--expand-down .compute-card__body {
transform-origin: top left;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .compute-card__essentials,
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .compute-card__body {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(-6px) scale(0.97);
overflow: hidden !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
border-top-color: transparent !important;
}
body:not(.layout-mode-mobile) .compute-card.hud-panel--expand-up:not(.is-spotlight) .compute-card__essentials,
body:not(.layout-mode-mobile) .compute-card.hud-panel--expand-up:not(.is-spotlight) .compute-card__body {
transform: translateY(6px) scale(0.97);
}
body:not(.layout-mode-mobile) .compute-card.is-spotlight .compute-card__essentials,
body:not(.layout-mode-mobile) .compute-card.is-spotlight .compute-card__body {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
transition:
opacity 0.32s cubic-bezier(0.16, 1, 0.3, 1) 0.06s,
transform 0.36s cubic-bezier(0.16, 1, 0.3, 1) 0.04s;
}
body:not(.layout-mode-mobile) .compute-card:not(.is-spotlight) .hud-panel__actions {
opacity: 0;
pointer-events: none;
width: 0;
overflow: hidden;
}
/* Spotlight mode */
body:not(.layout-mode-mobile) .compute-card.is-spotlight {
border-color: rgba(145, 195, 255, 0.5);
box-shadow:
0 0 32px rgba(56, 189, 248, 0.28),
0 18px 46px rgba(1, 7, 16, 0.45);
z-index: 50;
}
.compute-card.is-expanded {
@@ -490,36 +609,17 @@
}
.compute-connector {
z-index: 47;
z-index: 6;
}
.compute-connector polyline {
stroke: rgba(165, 205, 241, 0.8);
stroke-width: 1.3;
filter: none;
}
.compute-connector circle {
r: 3;
fill: rgba(165, 205, 241, 0.9);
filter: none;
.compute-connector.is-rear {
z-index: 4;
}
.compute-connector.is-visible.is-rear {
opacity: 0.62;
}
.compute-connector.is-rear polyline {
stroke: rgba(165, 205, 241, 0.58);
}
.compute-connector.is-rear circle:first-of-type {
fill: rgba(8, 18, 34, 0.72);
stroke: rgba(165, 205, 241, 0.88);
stroke-width: 1.4;
stroke-dasharray: 2 2;
}
.compute-evidence {
position: absolute;
z-index: 51;

File diff suppressed because it is too large Load Diff

View File

@@ -21,9 +21,10 @@
box-sizing: border-box;
}
/* ── Brand panel ──────────────────────────────────────────────── */
/* ── Brand panel: static borderless logo container without hover state ── */
.hud-panel-brand {
.hud-panel-brand,
#brand-panel {
--brand-scale: 0.88;
--brand-copy-width: 160px;
padding: calc(10px * var(--hud-scale)) calc(4px * var(--hud-scale)) calc(12px * var(--hud-scale)) 0;
@@ -32,17 +33,50 @@
justify-content: flex-start;
/* Reserve full panel height before brand images load */
min-height: calc(66px * var(--hud-scale));
background: transparent;
border: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: transparent !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
cursor: default;
isolation: isolate;
user-select: none;
transition: none !important;
}
.hud-panel-brand:hover,
.hud-panel-brand:focus,
.hud-panel-brand:active,
.hud-panel-brand.is-pressed,
#brand-panel:hover,
#brand-panel:focus,
#brand-panel:active,
#brand-panel.is-pressed {
background: transparent !important;
border: 0 !important;
border-color: transparent !important;
outline: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transform: none !important;
}
.hud-panel-brand::before,
.hud-panel-brand::after {
display: none;
.hud-panel-brand::after,
.hud-panel-brand:hover::before,
.hud-panel-brand:hover::after,
#brand-panel::before,
#brand-panel::after,
#brand-panel:hover::before,
#brand-panel:hover::after {
display: none !important;
content: none !important;
opacity: 0 !important;
border: 0 !important;
box-shadow: none !important;
background: none !important;
}
.hud-panel-brand .earth-brand {
@@ -170,7 +204,7 @@
display: flex;
flex-direction: column;
max-height: var(--hud-detail-card-max-height);
z-index: 50;
z-index: 52;
width: min(var(--hud-detail-card-width), calc(100vw - 32px));
border-radius: 0;
padding: 0;
@@ -203,6 +237,10 @@
transform: none;
}
.hud-panel-info.is-dragging {
z-index: 56;
}
.callout-connector {
position: absolute;
inset: 0;
@@ -212,22 +250,49 @@
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
z-index: 49;
z-index: 6;
}
.callout-connector.is-rear {
z-index: 4;
}
.callout-connector polyline {
fill: none;
stroke: rgba(255, 255, 255, 0.98);
stroke-width: 2.15;
stroke: rgba(165, 205, 241, 0.85);
stroke-width: 1.3;
stroke-linecap: round;
stroke-linejoin: round;
filter: none;
transition: stroke 0.2s ease, stroke-width 0.2s ease;
}
.callout-connector circle {
r: 3;
fill: rgba(165, 205, 241, 0.9);
stroke: none;
stroke-width: 0;
filter: none;
transform-box: fill-box;
transform-origin: center;
transition: fill 0.2s ease, r 0.2s ease;
}
.callout-connector.is-visible {
opacity: 1;
}
body[data-callout-line-style="bold"] .callout-connector polyline {
stroke: rgba(255, 255, 255, 0.98);
stroke-width: 2.15;
filter:
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
drop-shadow(0 0 2px rgba(6, 14, 28, 0.56))
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
}
.callout-connector circle {
body[data-callout-line-style="bold"] .callout-connector circle {
r: 4;
fill: rgba(255, 255, 255, 0.98);
stroke: rgba(7, 16, 32, 0.72);
stroke-width: 1.0;
@@ -235,21 +300,6 @@
drop-shadow(0 0 1px rgba(6, 14, 28, 0.72))
drop-shadow(0 0 2px rgba(6, 14, 28, 0.54))
drop-shadow(0 0 6px rgba(8, 20, 36, 0.1));
transform-box: fill-box;
transform-origin: center;
}
.callout-connector.is-visible {
opacity: 1;
}
.callout-connector[data-owner="selection"].is-rear {
opacity: 0.66;
}
.callout-connector[data-owner="selection"].is-rear circle:first-of-type {
fill: none;
stroke-dasharray: 2 2;
}
.callout-connector.is-animating polyline {

View File

@@ -53,21 +53,20 @@
.earth-news-ticker::after {
content: "";
position: absolute;
inset: calc(-8px * var(--hud-scale)) calc(-14px * var(--hud-scale));
inset: calc(4px * var(--hud-scale)) calc(-1 * var(--news-ticker-edge-fade));
z-index: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(
102deg,
transparent 0%,
transparent 34%,
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 43%,
transparent 42%,
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 47%,
var(--news-breaking-color) 50%,
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 57%,
transparent 66%,
color-mix(in srgb, var(--news-breaking-color) 18%, transparent) 53%,
transparent 58%,
transparent 100%
) 140% 0 / 220% 100% no-repeat;
filter: blur(calc(8px * var(--hud-scale)));
) -100% 0 / 200% 100% no-repeat;
mix-blend-mode: screen;
}
@@ -76,10 +75,8 @@
z-index: 3;
}
.earth-news-ticker.has-breaking-watch::after,
.earth-news-ticker.has-breaking-breaking::after,
.earth-news-ticker.has-breaking-critical::after {
animation: earthNewsBreakingGlassPulse 2.4s ease-in-out infinite;
.earth-news-ticker.is-breaking-active::after {
animation: earthNewsBreakingGlassSweep 1.8s linear infinite;
}
.earth-news-ticker.has-breaking-watch {
@@ -286,34 +283,29 @@
}
}
@keyframes earthNewsBreakingGlassPulse {
0%,
100% {
opacity: 0;
background-position: 140% 0;
@keyframes earthNewsBreakingGlassSweep {
from {
opacity: 0.68;
background-position: 100% 0;
}
18%,
74% {
opacity: 0.76;
}
50% {
opacity: 0.9;
background-position: -40% 0;
to {
opacity: 0.68;
background-position: -100% 0;
}
}
@keyframes earthNewsBreakingCardReflection {
0% {
opacity: 0;
transform: translateX(-125%);
background-position: 100% 0;
}
16%,
64% {
10%,
88% {
opacity: 0.58;
}
100% {
opacity: 0;
transform: translateX(125%);
background-position: -100% 0;
}
}
@@ -499,11 +491,16 @@
.news-filter-chip {
min-width: 0;
max-width: 100%;
border: 1px solid rgba(201, 225, 247, 0.12);
border: 1px solid rgba(201, 225, 247, 0.1);
border-radius: calc(14px * var(--hud-scale));
padding: calc(7px * var(--hud-scale)) calc(10px * var(--hud-scale));
color: var(--hud-text-soft);
background: rgba(255, 255, 255, 0.04);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(116, 166, 224, 0.03)),
rgba(255, 255, 255, 0.02);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 4px 10px rgba(0, 0, 0, 0.1);
font: inherit;
font-size: calc(0.74rem * var(--hud-scale));
line-height: 1.25;
@@ -511,14 +508,32 @@
overflow-wrap: anywhere;
white-space: normal;
cursor: pointer;
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
}
.news-filter-chip:hover {
color: var(--hud-text);
border-color: rgba(147, 202, 255, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(116, 166, 224, 0.06));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 6px 14px rgba(0, 0, 0, 0.12);
}
.news-filter-chip.is-active {
color: var(--hud-text);
border-color: rgba(120, 190, 255, 0.36);
color: #eaf3ff;
border-color: rgba(147, 202, 255, 0.22);
background:
linear-gradient(180deg, rgba(79, 143, 232, 0.22), rgba(64, 111, 191, 0.12));
box-shadow: inset 0 0 0 1px rgba(206, 232, 255, 0.08);
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(116, 166, 224, 0.06)),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 4px 12px rgba(2, 10, 22, 0.12);
}
.news-board {
@@ -558,7 +573,8 @@
}
.news-story-card {
--news-hover-color: rgb(214 235 255);
--news-breaking-hue: rgb(214 235 255);
--news-hover-color: var(--news-breaking-hue);
--news-hover-border-strength: 24%;
position: relative;
overflow: hidden;
@@ -605,42 +621,42 @@
}
.news-story-card--cruise {
border-color: rgba(255, 213, 128, 0.42);
--news-hover-color: rgb(127 219 255);
--news-hover-border-strength: 52%;
border-color: rgba(127, 219, 255, 0.28);
background:
linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08));
linear-gradient(180deg, rgba(127, 219, 255, 0.08), rgba(92, 151, 218, 0.04));
box-shadow:
0 0 0 1px rgba(255, 213, 128, 0.18) inset,
0 0 18px rgba(255, 184, 77, 0.12);
0 0 0 1px rgba(127, 219, 255, 0.12) inset,
0 0 18px rgba(92, 151, 218, 0.1);
}
.news-story-card--breaking-watch,
.news-story-card--breaking-breaking,
.news-story-card--breaking-critical {
--news-hover-border-strength: 68%;
border-color: var(--news-breaking-color);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--news-breaking-hue) 11%, var(--hud-surface-top)),
color-mix(in srgb, var(--news-breaking-hue) 7%, var(--hud-surface-bottom))
);
}
.news-story-card--breaking-watch {
--news-breaking-color: rgba(255, 213, 128, 0.42);
--news-hover-color: rgb(255 213 128);
border-color: var(--news-breaking-color);
background:
linear-gradient(180deg, rgba(255, 248, 220, 0.08), rgba(255, 184, 77, 0.05));
--news-breaking-hue: rgb(255 213 128);
}
.news-story-card--breaking-breaking {
--news-breaking-color: rgba(255, 151, 78, 0.5);
--news-hover-color: rgb(255 151 78);
border-color: var(--news-breaking-color);
background:
linear-gradient(180deg, rgba(255, 205, 150, 0.1), rgba(255, 135, 80, 0.07));
--news-breaking-hue: rgb(255 151 78);
}
.news-story-card--breaking-critical {
--news-breaking-color: rgba(255, 95, 95, 0.56);
--news-hover-color: rgb(255 95 95);
border-color: var(--news-breaking-color);
background:
linear-gradient(180deg, rgba(255, 155, 155, 0.11), rgba(205, 58, 58, 0.08));
--news-breaking-hue: rgb(255 95 95);
}
.news-story-card--breaking-watch::after,
@@ -656,7 +672,7 @@
transparent 32%,
var(--news-breaking-color) 49%,
transparent 66%
);
) -100% 0 / 200% 100% no-repeat;
mix-blend-mode: screen;
opacity: 0;
animation: none;
@@ -736,7 +752,13 @@
max-width: 100%;
border-radius: 999px;
padding: calc(3px * var(--hud-scale)) calc(7px * var(--hud-scale));
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(210, 230, 255, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.01) 100%),
rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 1px 0.5px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0.5px 0 rgba(0, 0, 0, 0.25);
line-height: 1.25;
overflow-wrap: anywhere;
white-space: normal;
@@ -744,8 +766,13 @@
.news-story-tag--breaking {
color: rgba(255, 232, 205, 0.96);
border: 1px solid rgba(255, 184, 96, 0.24);
background: rgba(255, 151, 78, 0.11);
border: 1px solid rgba(255, 184, 96, 0.3);
background:
linear-gradient(180deg, rgba(255, 184, 96, 0.16) 0%, rgba(255, 140, 60, 0.08) 100%),
rgba(255, 151, 78, 0.1);
box-shadow:
inset 0 1px 0.5px 0 rgba(255, 232, 205, 0.35),
inset 0 -1px 0.5px 0 rgba(0, 0, 0, 0.25);
}
.news-board-empty {
@@ -756,9 +783,7 @@
}
@media (prefers-reduced-motion: reduce) {
.earth-news-ticker.has-breaking-watch::after,
.earth-news-ticker.has-breaking-breaking::after,
.earth-news-ticker.has-breaking-critical::after,
.earth-news-ticker.is-breaking-active::after,
.news-story-card--breaking-watch:hover::after,
.news-story-card--breaking-watch:focus-visible::after,
.news-story-card--breaking-breaking:hover::after,

View File

@@ -1,5 +1,5 @@
#earth-region-callouts, #news-region-workspace {
position:absolute; inset:0; pointer-events:none; z-index:auto;
position:absolute; inset:0; pointer-events:none; z-index:20;
color:var(--hud-text); font-size:var(--hud-font-size);
}
#news-region-workspace[hidden] { display:none; }
@@ -7,15 +7,17 @@
keep text between pixels and make the displayed card lag its connector. */
.compute-card,
.news-region-card {
z-index:48;
transform:none;
will-change:auto;
contain:layout style;
/* The dark shared surface already provides contrast. Avoid repeatedly
filtering the moving WebGL/video backdrop underneath every reading card. */
backdrop-filter:none;
-webkit-backdrop-filter:none;
transition:background .22s ease, border-color .22s ease, box-shadow .22s ease;
z-index: 30;
transform: none;
will-change: auto;
contain: layout style;
background:
radial-gradient(circle at 86% 115%, rgba(145, 186, 255, 0.08), transparent 36%),
linear-gradient(180deg, rgba(145, 211, 240, 0.025), transparent 26%),
linear-gradient(180deg, rgba(10, 23, 37, 0.94), rgba(6, 14, 24, 0.97)) !important;
backdrop-filter: blur(20px) saturate(135%);
-webkit-backdrop-filter: blur(20px) saturate(135%);
transition: background .22s ease, border-color .22s ease, box-shadow .22s ease;
}
.compute-card::after,
.news-region-card::after {
@@ -25,8 +27,126 @@
position:absolute; top:0; left:0; pointer-events:auto; padding:0;
display:flex; flex-direction:column; overflow:hidden;
width:min(var(--hud-detail-card-width),calc(100vw - 32px));
height:var(--hud-detail-card-max-height);
max-width:calc(100vw - 24px); max-height:calc(100vh - 24px);
height:auto;
max-width:calc(100vw - 24px); max-height:var(--hud-detail-card-max-height);
contain:layout style;
will-change:width, height, transform, box-shadow;
transition:
width 0.36s cubic-bezier(0.16, 1, 0.3, 1),
height 0.36s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Desktop compact mode for peripheral / non-spotlight news cards */
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) {
width: min(calc(230px * var(--hud-scale)), calc(100vw - 32px));
height: calc(42px * var(--hud-scale)) !important;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
border-color: rgba(124, 190, 218, 0.18);
transform: scale(var(--spatial-scale, 1));
transform-origin: center center;
transition:
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
body:not(.layout-mode-mobile) .news-region-card.is-backside:not(.is-spotlight) {
z-index: 8 !important;
pointer-events: auto !important;
border-color: rgba(100, 160, 220, 0.18) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight):hover {
border-color: rgba(147, 202, 255, 0.45);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.28), 0 4px 14px rgba(0, 0, 0, 0.45);
transform: translateY(-1px);
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded {
z-index: 60 !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65), 0 0 20px rgba(56, 189, 248, 0.35) !important;
transition:
border-color 0.25s ease,
box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
body:not(.layout-mode-mobile) .news-region-card.is-hover-expanded::before {
content: "";
position: absolute;
inset: -8px;
pointer-events: auto;
z-index: -1;
}
.news-region-card .news-region-card__body {
transform-origin: top left;
will-change: opacity, transform;
transition:
opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
.news-region-card.hud-panel--expand-up .news-region-card__body {
transform-origin: bottom left;
}
.news-region-card.hud-panel--expand-down .news-region-card__body {
transform-origin: top left;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .news-region-card__body {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(-6px) scale(0.97);
overflow: hidden !important;
height: 0 !important;
}
body:not(.layout-mode-mobile) .news-region-card.hud-panel--expand-up:not(.is-spotlight) .news-region-card__body {
transform: translateY(6px) scale(0.97);
}
body:not(.layout-mode-mobile) .news-region-card.is-spotlight .news-region-card__body {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
transition:
opacity 0.32s cubic-bezier(0.16, 1, 0.3, 1) 0.06s,
transform 0.36s cubic-bezier(0.16, 1, 0.3, 1) 0.04s;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .info-card-header {
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
gap: calc(6px * var(--hud-scale));
height: calc(42px * var(--hud-scale));
box-sizing: border-box;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .info-card-header h3 {
font-size: calc(0.74rem * var(--hud-scale));
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body:not(.layout-mode-mobile) .news-region-card:not(.is-spotlight) .hud-panel__actions {
opacity: 0;
pointer-events: none;
width: 0;
overflow: hidden;
}
/* Spotlight mode (the camera-facing hero card) */
body:not(.layout-mode-mobile) .news-region-card.is-spotlight {
border-color: rgba(145, 195, 255, 0.5);
box-shadow:
0 0 32px rgba(56, 189, 248, 0.28),
0 18px 46px rgba(1, 7, 16, 0.5);
z-index: 50;
}
.news-region-card .info-card-header { flex-shrink:0; }
.news-region-card__body { --hud-body-collapse-gap:0px; --hud-body-max-height:calc(100vh - 24px); display:flex; flex:1 1 auto; flex-direction:column; min-height:0; overflow:hidden; }
@@ -50,7 +170,7 @@
.news-region-card .info-card-header h3 { min-width:0; }
.news-region-card__region { flex-shrink:0; font-size:var(--hud-font-size-sm); color:#ffd77a; }
.hud-panel__action[data-news-region-cards][aria-pressed="true"],
.hud-panel__action:is([data-news-region-cards], [data-action="toggle-cards"])[aria-pressed="true"],
.earth-mobile-action-btn[data-news-region-cards][aria-pressed="true"] {
background:rgba(116,180,255,.22); border:1px solid rgba(145,195,255,.8);
color:#d9edff; box-shadow:inset 0 0 0 1px rgba(145,195,255,.15),0 0 10px rgba(85,163,255,.2);
@@ -60,4 +180,4 @@
/* Both workspaces share the card stacking level; raise only the dragged card. */
.compute-card.is-callout-front,
.news-region-card.is-callout-front { z-index:49; }
.news-region-card.is-callout-front { z-index:55; }

View File

@@ -49,27 +49,6 @@
pointer-events: none;
}
.earth-toolbar-cluster::before {
content: "";
position: absolute;
left: 50%;
bottom: calc(var(--toolbar-hub-size) / 2);
width: calc(var(--toolbar-radius-x) * 2 + 24px * var(--toolbar-scale));
height: calc(var(--toolbar-radius-y) * 2 + 24px * var(--toolbar-scale));
transform: translate(-50%, 50%);
border-radius: 50%;
border: 1px dashed rgba(56, 189, 248, 0.25);
mask-image: linear-gradient(to top, transparent 40%, black 75%);
-webkit-mask-image: linear-gradient(to top, transparent 40%, black 75%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.earth-toolbar-cluster.is-expanded::before {
opacity: 1;
}
.earth-toolbar-orb {
position: absolute;
left: 50%;
@@ -225,18 +204,24 @@
.liquid-glass-surface {
--btn-scale: 1;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 20, 40, 0.72);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%),
rgba(10, 20, 40, 0.52);
backdrop-filter: blur(16px) saturate(130%);
-webkit-backdrop-filter: blur(16px) saturate(130%);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.4),
inset 0 -1px 1px 0 rgba(0, 0, 0, 0.35);
color: #94a3b8;
transform: scale(var(--btn-scale));
transition:
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
box-shadow 0.2s ease,
background 0.2s ease;
}
.liquid-glass-surface:hover,
@@ -244,13 +229,25 @@
--btn-scale: 1.15;
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.6);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.4), inset 0 1px 2px rgba(255, 255, 255, 0.4);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.16) 0%, rgba(10, 20, 40, 0.62) 100%),
rgba(10, 20, 40, 0.62);
box-shadow:
0 0 20px rgba(56, 189, 248, 0.35),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.65),
inset 0 -1px 1px 0 rgba(0, 0, 0, 0.4);
}
.liquid-glass-surface.active {
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.6);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.2);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.2) 0%, rgba(10, 20, 40, 0.68) 100%),
rgba(10, 20, 40, 0.68);
box-shadow:
0 0 20px rgba(56, 189, 248, 0.25),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 1px 0 rgba(0, 0, 0, 0.35);
}
.liquid-glass-surface:focus-visible {
@@ -259,11 +256,19 @@
}
.earth-toolbar-hub-btn.liquid-glass-surface {
background: radial-gradient(circle at 35% 25%, rgba(56, 189, 248, 0.3), rgba(8, 18, 38, 0.85) 75%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.22) 0%, rgba(10, 24, 48, 0.55) 100%),
rgba(8, 18, 38, 0.5);
background-origin: border-box;
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid rgba(56, 189, 248, 0.45);
box-shadow: 0 0 25px rgba(56, 189, 248, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.4),
0 0 20px rgba(56, 189, 248, 0.2),
inset 0 1.5px 1px 0 rgba(255, 255, 255, 0.6),
inset 0 -1.5px 1.5px 0 rgba(0, 0, 0, 0.4),
inset 0 0 0 1px rgba(56, 189, 248, 0.25);
color: #e0f2fe;
}
@@ -271,7 +276,15 @@
.earth-toolbar-hub-btn.liquid-glass-surface:focus-visible {
--btn-scale: 1.08;
border-color: #38bdf8;
box-shadow: 0 0 35px rgba(56, 189, 248, 0.45), inset 0 1px 2px rgba(255, 255, 255, 0.5);
background:
linear-gradient(180deg, rgba(56, 189, 248, 0.3) 0%, rgba(12, 28, 56, 0.65) 100%),
rgba(8, 18, 38, 0.6);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.45),
0 0 30px rgba(56, 189, 248, 0.4),
inset 0 1.5px 1px 0 rgba(255, 255, 255, 0.8),
inset 0 -1.5px 1.5px 0 rgba(0, 0, 0, 0.45),
inset 0 0 0 1px rgba(56, 189, 248, 0.4);
}
.earth-toolbar-hub-btn > .material-symbols-rounded {
@@ -372,7 +385,6 @@
@media (prefers-reduced-motion: reduce) {
.earth-toolbar-orb,
.earth-toolbar-cluster::before,
.liquid-glass-surface,
.earth-toolbar-hub-btn > .material-symbols-rounded {
transition-duration: 0.01ms;

View File

@@ -112,7 +112,21 @@
.earth-mobile-page--tv > .hud-select-trigger {
flex: 0 0 auto;
width: 100%;
height: 40px;
height: 42px;
padding: 0 14px;
border: 1px solid rgba(201, 225, 247, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
color: var(--hud-text);
font-size: 0.88rem;
-webkit-tap-highlight-color: transparent;
outline: none;
transition: border-color 0.18s ease, background 0.18s ease;
}
.earth-mobile-page--tv > .hud-select-trigger[aria-expanded="true"] {
border-color: rgba(147, 202, 255, 0.4);
background: rgba(120, 180, 255, 0.12);
}
.tv-panel-meta-wrap {

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>智能星球计划 - 现实层宇宙全息感知</title>
<script type="importmap">
{
@@ -50,8 +50,8 @@
})();
</script>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/hud.css">
<link rel="stylesheet" href="css/base.css?v=20261109_v1">
<link rel="stylesheet" href="css/hud.css?v=20261109_v1">
<link rel="stylesheet" href="css/toolbar.css">
<link rel="stylesheet" href="css/info-panel.css">
<link rel="stylesheet" href="css/legend.css">
@@ -77,7 +77,7 @@
<output id="earth-fps" class="earth-fps" aria-label="渲染帧率" hidden>— FPS</output>
<div id="container" class="earth-app">
<div id="left-column" class="earth-left-column">
<div id="brand-panel" class="hud-panel hud-panel-brand">
<div id="brand-panel" class="hud-panel-brand">
<div id="brand-root"></div>
</div>
@@ -582,35 +582,51 @@
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">layers</span>
</span>
<span class="earth-mobile-drawer-tab-label">图层</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">search</span>
</span>
<span class="earth-mobile-drawer-tab-label">搜索</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="details" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">info</span>
</span>
<span class="earth-mobile-drawer-tab-label">详情</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">travel_explore</span>
</span>
<span class="earth-mobile-drawer-tab-label">态势</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="compute" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">monitoring</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">monitoring</span>
</span>
<span class="earth-mobile-drawer-tab-label">算力</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">article</span>
</span>
<span class="earth-mobile-drawer-tab-label">新闻</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">live_tv</span>
</span>
<span class="earth-mobile-drawer-tab-label">TV</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="motion" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">sensors</span>
<span class="earth-mobile-drawer-tab-label">动捕</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
<span class="earth-mobile-drawer-tab-icon">
<span class="material-symbols-rounded" aria-hidden="true">tune</span>
</span>
<span class="earth-mobile-drawer-tab-label">设置</span>
</button>
</div>
@@ -648,7 +664,15 @@
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
<div class="earth-mobile-search-chips" role="group" aria-label="快捷分类筛选">
<button type="button" class="earth-mobile-search-chip is-active" data-search-filter="all">全部</button>
<button type="button" class="earth-mobile-search-chip" data-search-filter="satellite" data-search-keyword="卫星">🛰️ 卫星</button>
<button type="button" class="earth-mobile-search-chip" data-search-filter="cable" data-search-keyword="海缆">🌊 海缆</button>
<button type="button" class="earth-mobile-search-chip" data-search-filter="landing_point" data-search-keyword="登陆点">📍 登陆点</button>
<button type="button" class="earth-mobile-search-chip" data-search-filter="compute_center" data-search-keyword="算力">🖥️ 算力</button>
<button type="button" class="earth-mobile-search-chip" data-search-filter="bgp" data-search-keyword="BGP">⚡ BGP</button>
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词或轻点上方分类快速探索地球对象</div>
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
</div>
@@ -661,33 +685,48 @@
</div>
<div class="earth-mobile-stats-grid">
<div class="earth-mobile-stat-card">
<div class="earth-mobile-stat-header">
<span class="earth-mobile-stat-icon material-symbols-rounded" aria-hidden="true">waves</span>
<span class="earth-mobile-stat-label">海缆系统</span>
</div>
<span id="mobile-cable-count" class="earth-mobile-stat-num" data-earth-stat="cable-count">—</span>
<span class="earth-mobile-stat-label">海缆系统</span>
</div>
<div class="earth-mobile-stat-card">
<div class="earth-mobile-stat-header">
<span class="earth-mobile-stat-icon material-symbols-rounded" aria-hidden="true">location_on</span>
<span class="earth-mobile-stat-label">登陆点</span>
</div>
<span id="mobile-landing-point-count" class="earth-mobile-stat-num" data-earth-stat="landing-point-count">—</span>
<span class="earth-mobile-stat-label">登陆点</span>
</div>
<div class="earth-mobile-stat-card">
<div class="earth-mobile-stat-header">
<span class="earth-mobile-stat-icon material-symbols-rounded" aria-hidden="true">satellite_alt</span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<div class="earth-mobile-stat-card">
<div class="earth-mobile-stat-header">
<span class="earth-mobile-stat-icon material-symbols-rounded" aria-hidden="true">database</span>
<span class="earth-mobile-stat-label">算力中心</span>
</div>
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</span>
<span class="earth-mobile-stat-label">算力中心</span>
</div>
<div class="earth-mobile-stat-card">
<div class="earth-mobile-stat-header">
<span class="earth-mobile-stat-icon material-symbols-rounded" aria-hidden="true">bolt</span>
<span class="earth-mobile-stat-label">BGP 事件</span>
</div>
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count">—</span>
<span class="earth-mobile-stat-label">BGP 事件</span>
</div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card" hidden>
<div class="earth-mobile-situation-card-title">图例</div>
<div id="mobile-situation-legend-mode" class="earth-mobile-situation-card-subtitle">海缆</div>
<div id="mobile-situation-legend-list" class="earth-mobile-situation-legend-list"></div>
</div>
<div class="earth-mobile-situation-card">
<div class="earth-mobile-situation-card-title">BGP 状态</div>
<div class="earth-mobile-situation-card-title">实时态势监控</div>
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
<div id="mobile-vessel-live-summary" class="earth-mobile-situation-status" data-earth-stat="vessel-live-summary">AISStream 未连接</div>
</div>
@@ -722,12 +761,12 @@
</button>
</div>
<div class="news-filter-popover earth-mobile-news-filter-popover" data-news-filter-popover hidden></div>
<div id="mobile-news-region-cards"></div>
<div id="mobile-news-region-cards" hidden></div>
<div id="mobile-news-board-status" class="earth-mobile-news-board-status">正在准备全球态势新闻...</div>
<div id="mobile-news-board-list" class="earth-mobile-news-board-list"></div>
<div id="mobile-news-board-empty" class="earth-mobile-news-board-empty" hidden>正在准备全球态势新闻聚合源...</div>
<div class="earth-mobile-news-actions">
<button class="earth-mobile-action-btn" type="button" data-news-region-cards aria-pressed="false">区域卡片</button>
<button class="earth-mobile-action-btn" type="button" data-news-region-cards aria-pressed="false" hidden>区域卡片</button>
<button id="mobile-news-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-news-open-external" class="earth-mobile-action-btn" type="button">打开源站</button>
</div>
@@ -740,7 +779,10 @@
<span class="earth-mobile-page-kicker">TV</span>
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<button id="mobile-tv-source-select" class="tv-panel-select hud-select-trigger" type="button" aria-label="选择移动端新闻直播源" aria-haspopup="dialog" aria-expanded="false"></button>
<button id="mobile-tv-source-select" class="tv-panel-select hud-select-trigger" type="button" aria-label="选择移动端新闻直播源" aria-haspopup="dialog" aria-expanded="false">
<span class="hud-select-trigger__label">选择新闻直播频道</span>
<span class="material-symbols-rounded" aria-hidden="true">expand_more</span>
</button>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
@@ -800,9 +842,8 @@
<div class="earth-mobile-settings-tabs" role="tablist" aria-label="移动端设置分类">
<button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false" hidden>面板</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false" hidden>快捷键</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="about" aria-selected="false">关于</button>
</div>
@@ -1034,6 +1075,16 @@
<button type="button" class="earth-mobile-settings-pill is-active" data-surface-hover-info-mode="full" aria-pressed="true">完整</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">引导线样式</span>
<span class="earth-mobile-settings-subtitle">选择卡片引导线的粗细视觉风格</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择引导线样式">
<button type="button" class="earth-mobile-settings-pill is-active" data-callout-line-style="thin" aria-pressed="true">细线</button>
<button type="button" class="earth-mobile-settings-pill" data-callout-line-style="bold" aria-pressed="false">粗线</button>
</div>
</div>
<div class="earth-mobile-settings-subsection-title">新闻类型</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
@@ -1099,6 +1150,28 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">算力卡同时展开数</span>
<span class="earth-mobile-settings-subtitle">控制可同时展开的算力卡数量</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="算力卡同时展开数">
<button type="button" class="earth-settings-segmented-btn is-active" data-max-cards="compute" data-limit="1" aria-pressed="true">1 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="compute" data-limit="2" aria-pressed="false">2 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="compute" data-limit="99" aria-pressed="false">全部</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">新闻卡同时展开数</span>
<span class="earth-mobile-settings-subtitle">控制可同时展开的新闻卡数量</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="新闻卡同时展开数">
<button type="button" class="earth-settings-segmented-btn is-active" data-max-cards="news" data-limit="1" aria-pressed="true">1 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="news" data-limit="2" aria-pressed="false">2 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="news" data-limit="99" aria-pressed="false">全部</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="motion" hidden>
<div class="earth-mobile-settings-title">动捕</div>
@@ -1171,7 +1244,7 @@
<span class="earth-mobile-settings-label">星球语言</span>
<span class="earth-mobile-settings-subtitle">同步 Docs 和控制台语言偏好</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="星球语言">
<div class="earth-mobile-settings-segmented earth-mobile-settings-segmented--locale" role="group" aria-label="星球语言" style="--item-count: 2;">
<button type="button" class="earth-mobile-settings-pill is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
<button type="button" class="earth-mobile-settings-pill" data-earth-locale="en-US" aria-pressed="false">English</button>
</div>
@@ -1188,24 +1261,28 @@
<img class="earth-about-logo" src="./assets/brand/lim-logo.png" alt="浙江大学临空智能媒体研究院 Logo">
</div>
<div class="earth-about-heading">
<span class="earth-about-kicker">About</span>
<strong>智能星球计划</strong>
<span>v0.65.2</span>
<div class="earth-about-title-row">
<strong class="earth-about-title">智能星球计划</strong>
<span class="earth-about-version-badge">v0.76.18</span>
</div>
<span class="earth-about-kicker">临空智能媒体实验室 · 全球态势感知系统</span>
</div>
<p class="earth-about-copy">
面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。
</p>
<div class="earth-about-meta">
<span>出品方</span>
<strong>浙江大学临空智能媒体研究院</strong>
</div>
<div class="earth-about-meta">
<span>策划人</span>
<strong>方兴东、黄柳青</strong>
</div>
<div class="earth-about-meta">
<span>产品兼开发者</span>
<strong>钱坤、张鸽、齐鹏</strong>
<div class="earth-about-meta-list">
<div class="earth-about-meta-item">
<span class="earth-about-meta-label">出品方</span>
<strong class="earth-about-meta-value">浙江大学临空智能媒体研究院</strong>
</div>
<div class="earth-about-meta-item">
<span class="earth-about-meta-label">策划人</span>
<strong class="earth-about-meta-value">方兴东、黄柳青</strong>
</div>
<div class="earth-about-meta-item">
<span class="earth-about-meta-label">产品兼开发者</span>
<strong class="earth-about-meta-value">钱坤、张鸽、齐鹏</strong>
</div>
</div>
</div>
</div>
@@ -1213,20 +1290,67 @@
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
<div class="earth-mobile-page earth-mobile-page--details">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Details</span>
<span class="earth-mobile-page-summary">点击地球对象后查看统一详情</span>
<!-- Empty state: shown when no object is selected -->
<div id="mobile-details-empty" class="earth-mobile-details-empty">
<div class="earth-mobile-details-empty-icon" aria-hidden="true">
<span class="material-symbols-rounded">travel_explore</span>
</div>
<div class="earth-mobile-details-empty-title">未选中地球对象</div>
<p class="earth-mobile-details-empty-desc">在地球上轻触任意海缆、算力中心、BGP 事件、卫星或船舶,即可在此查看实时详细数据与态势分析。</p>
</div>
<div class="earth-mobile-detail-card">
<div class="earth-mobile-detail-header">
<span id="mobile-info-card-icon" class="earth-mobile-detail-icon">🛰️</span>
<div class="earth-mobile-detail-heading">
<div id="mobile-info-card-title" class="earth-mobile-detail-title">对象详情</div>
<div id="mobile-info-card-type" class="earth-mobile-detail-type">等待选择对象</div>
<!-- Active state: native mobile details presentation -->
<div id="mobile-details-active" class="earth-mobile-details-active" hidden>
<!-- Ambient media background layer (if imagery available) -->
<div id="mobile-details-media" class="earth-mobile-details-media" hidden aria-hidden="true">
<div class="earth-mobile-details-media-frame">
<img id="mobile-details-media-img" class="earth-mobile-details-media-img" alt="" />
<a id="mobile-details-media-credit" class="earth-mobile-details-media-credit" target="_blank" rel="noopener noreferrer" hidden>
<span class="material-symbols-rounded" aria-hidden="true">image</span>
<span id="mobile-details-media-credit-text">来源</span>
</a>
</div>
</div>
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
<!-- Hero identity header -->
<header class="earth-mobile-details-hero">
<div class="earth-mobile-details-badge-row">
<span class="earth-mobile-details-badge">
<span id="mobile-details-icon" class="earth-mobile-details-icon" aria-hidden="true">🛰️</span>
<span id="mobile-details-type-label" class="earth-mobile-details-type-label">卫星</span>
</span>
<span id="mobile-details-status-badge" class="earth-mobile-details-status-badge" hidden></span>
</div>
<h2 id="mobile-details-title" class="earth-mobile-details-title">STARLINK-34561</h2>
<div id="mobile-details-subtitle" class="earth-mobile-details-subtitle">NORAD 65,081 · 近地点 481 km</div>
</header>
<!-- Quick Metrics Grid (Primary Highlights) -->
<div id="mobile-details-metrics" class="earth-mobile-details-metrics" hidden></div>
<!-- Native Detail Group / Properties List -->
<div class="earth-mobile-details-group">
<div class="earth-mobile-details-group-header">
<span class="earth-mobile-details-group-title">详细参数</span>
<span id="mobile-details-prop-count" class="earth-mobile-details-group-count"></span>
</div>
<div id="mobile-details-props" class="earth-mobile-details-props"></div>
</div>
<!-- Native Action Buttons -->
<div class="earth-mobile-details-actions">
<button id="mobile-details-focus-btn" type="button" class="earth-mobile-action-btn earth-mobile-action-btn--primary">
<span class="material-symbols-rounded" aria-hidden="true">my_location</span>
<span>聚焦定位</span>
</button>
<button id="mobile-details-copy-btn" type="button" class="earth-mobile-action-btn earth-mobile-action-btn--ghost">
<span class="material-symbols-rounded" aria-hidden="true">content_copy</span>
<span>复制名称</span>
</button>
<a id="mobile-details-link-btn" href="#" target="_blank" rel="noopener noreferrer" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" hidden>
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
<span>查看来源</span>
</a>
</div>
</div>
</div>
@@ -1288,7 +1412,6 @@
<button type="button" class="earth-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
<button type="button" class="earth-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
<button type="button" class="earth-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
<button type="button" class="earth-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
<button type="button" class="earth-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
<button type="button" class="earth-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
<button type="button" class="earth-settings-tab" data-settings-tab="about" aria-selected="false">关于</button>
@@ -1303,7 +1426,7 @@
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转、巡航和动捕是互斥运行模式;巡航按模块轮播,动捕消费手势控制</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式" style="--item-count: 3;">
<button
type="button"
class="earth-settings-segmented-btn is-active"
@@ -1413,7 +1536,7 @@
<span class="earth-settings-item-title">巡航队列</span>
<span class="earth-settings-item-subtitle">控制巡航项按原始顺序、大区轮转或随机洗牌播放。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择巡航队列模式">
<div class="earth-settings-segmented" role="group" aria-label="选择巡航队列模式" style="--item-count: 3;">
<button type="button" class="earth-settings-segmented-btn is-active" data-cruise-queue-mode="default" aria-pressed="true">默认</button>
<button type="button" class="earth-settings-segmented-btn" data-cruise-queue-mode="region" aria-pressed="false">按大区</button>
<button type="button" class="earth-settings-segmented-btn" data-cruise-queue-mode="random" aria-pressed="false">随机</button>
@@ -1515,7 +1638,7 @@
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
</div>
<div class="earth-settings-boundary-action-row">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度" style="--item-count: 2;">
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" data-boundary-precision-build aria-pressed="false">高精</button>
</div>
@@ -1530,7 +1653,7 @@
<span class="earth-settings-item-title">卫星显示风格</span>
<span class="earth-settings-item-subtitle">选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格" style="--item-count: 2;">
<button
type="button"
class="earth-settings-segmented-btn"
@@ -1624,6 +1747,30 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">引导线样式</span>
<span class="earth-settings-item-subtitle">选择卡片引导线的粗细视觉风格。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择引导线样式" style="--item-count: 2;">
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-callout-line-style="thin"
aria-pressed="true"
>
细线
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-callout-line-style="bold"
aria-pressed="false"
>
粗线
</button>
</div>
</div>
<div class="earth-settings-subsection-title">新闻类型</div>
<div class="earth-settings-item earth-settings-item--stacked">
@@ -1691,6 +1838,30 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">算力卡同时展开数</span>
<span class="earth-settings-item-subtitle">控制画面中可同时保持完整展示的算力卡片数量,其余卡片精简为胶囊</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="算力卡同时展开数" style="--item-count: 4;">
<button type="button" class="earth-settings-segmented-btn is-active" data-max-cards="compute" data-limit="1" aria-pressed="true">1 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="compute" data-limit="2" aria-pressed="false">2 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="compute" data-limit="3" aria-pressed="false">3 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="compute" data-limit="99" aria-pressed="false">全部</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">新闻卡同时展开数</span>
<span class="earth-settings-item-subtitle">控制画面中可同时保持完整展示的新闻卡片数量,其余卡片精简为胶囊</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="新闻卡同时展开数" style="--item-count: 4;">
<button type="button" class="earth-settings-segmented-btn is-active" data-max-cards="news" data-limit="1" aria-pressed="true">1 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="news" data-limit="2" aria-pressed="false">2 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="news" data-limit="3" aria-pressed="false">3 张</button>
<button type="button" class="earth-settings-segmented-btn" data-max-cards="news" data-limit="99" aria-pressed="false">全部</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="motion" hidden>
@@ -1718,7 +1889,7 @@
<span class="earth-settings-item-title">动捕输入源</span>
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源" style="--item-count: 2;">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
@@ -1770,7 +1941,7 @@
<span class="earth-settings-item-title">星球语言</span>
<span class="earth-settings-item-subtitle">同步 Docs 和控制台语言偏好</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="星球语言">
<div class="earth-settings-segmented earth-settings-segmented--locale" role="group" aria-label="星球语言" style="--item-count: 2;">
<button type="button" class="earth-settings-segmented-btn is-active" data-earth-locale="zh-CN" aria-pressed="true">中文</button>
<button type="button" class="earth-settings-segmented-btn" data-earth-locale="en-US" aria-pressed="false">English</button>
</div>

View File

@@ -184,7 +184,10 @@ export function resolveConnectorObstacleRect(target, options = {}) {
const element = resolveRectElement(target);
if (!(element instanceof HTMLElement)) return null;
return expandRect(element.getBoundingClientRect(), padding);
if (element.hasAttribute("hidden") || element.style.display === "none") return null;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return expandRect(rect, padding);
}
export function resolveConnectorAnchor(target) {
@@ -200,8 +203,9 @@ export function resolveConnectorAnchor(target) {
const element = resolveAnchorElement(target);
if (!(element instanceof HTMLElement)) return null;
if (element.hasAttribute("hidden") || element.style.display === "none") return null;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const side = resolveElementAnchorSide(target.side);
const alignRatio = resolveElementAnchorAlignRatio(
target.alignRatio ?? target.anchorRatio ?? target.ratio,
@@ -400,6 +404,23 @@ function createAdaptiveConnectorPoints(source, target, options = {}) {
const relationRect = fallbackTargetRect || primaryObstacle;
if (!relationRect) return null;
const rectW = relationRect.width ?? (relationRect.right - relationRect.left);
const rectH = relationRect.height ?? (relationRect.bottom - relationRect.top);
if (!Number.isFinite(rectW) || !Number.isFinite(rectH) || rectW <= 0 || rectH <= 0) {
return null;
}
const vpW = window.innerWidth;
const vpH = window.innerHeight;
if (
relationRect.right < -20 ||
relationRect.left > vpW + 20 ||
relationRect.bottom < -20 ||
relationRect.top > vpH + 20
) {
return null;
}
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
@@ -506,20 +527,60 @@ function createAdaptiveConnectorPoints(source, target, options = {}) {
};
}
export function createConnectorPath(source, target, options = {}) {
const {
routingMode = "simple",
} = options;
export function createSlidingConnectorPoints(source, target, options = {}) {
const resolvedSource = resolveConnectorAnchor(source);
if (!resolvedSource) return null;
let start = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
if (!Number.isFinite(start.x) || !Number.isFinite(start.y)) return null;
if (routingMode === "adaptive") {
return createAdaptiveConnectorPoints(source, target, options);
const globe = source?.globe || options.globe;
if (source?.front === false && globe && Number.isFinite(globe.radius)) {
const dx = start.x - globe.x;
const dy = start.y - globe.y;
const dist = Math.hypot(dx, dy) || 1;
start = {
x: globe.x + (dx / dist) * globe.radius,
y: globe.y + (dy / dist) * globe.radius,
};
}
if (routingMode === "simple") {
const targetGapPx = Number.isFinite(options.targetGapPx) ? options.targetGapPx : 8;
const targetRect = resolveConnectorObstacleRect(options.targetAnchor ?? target, { padding: 0 });
if (!targetRect) return null;
const end = computeSlidingPerimeterAnchor(start, targetRect, { gap: targetGapPx });
if (!end) return null;
const normal = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }[end.side];
const length = Math.hypot(end.x - start.x, end.y - start.y);
const stub = Math.min(24, length * 0.25);
const bend = { x: end.x + normal[0] * stub, y: end.y + normal[1] * stub };
const aligned = Math.abs((end.x - start.x) * normal[1] - (end.y - start.y) * normal[0]) < 2;
const pts = aligned ? [start, end] : [start, bend, end];
const startFrom = options.startFrom || "source";
return {
points: startFrom === "target" ? pts.slice().reverse() : pts,
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
};
}
export function createConnectorPath(source, target, options = {}) {
const {
routingMode = "sliding",
} = options;
if (routingMode === "orthogonal" || routingMode === "legacy-elbow") {
return createElbowConnectorPoints(source, target, options);
}
return createElbowConnectorPoints(source, target, options);
if (routingMode === "legacy-adaptive") {
return createAdaptiveConnectorPoints(source, target, options);
}
return createSlidingConnectorPoints(source, target, options);
}
export class CalloutConnector {
@@ -564,8 +625,10 @@ export class CalloutConnector {
const polyline = createSvgElement("polyline");
const startpoint = createSvgElement("circle");
const endpoint = createSvgElement("circle");
startpoint.setAttribute("r", "4");
endpoint.setAttribute("r", "4");
const isBold = typeof document !== "undefined" && document.body?.dataset.calloutLineStyle === "bold";
const initialRadius = isBold ? "4" : "3";
startpoint.setAttribute("r", initialRadius);
endpoint.setAttribute("r", initialRadius);
connector.append(startpoint, polyline, endpoint);
container.appendChild(connector);
@@ -633,6 +696,13 @@ export class CalloutConnector {
this.lastViewBox = viewBox;
}
const isBold = typeof document !== "undefined" && document.body?.dataset.calloutLineStyle === "bold";
const currentRadius = isBold ? "4" : "3";
if (this.startpointEl?.getAttribute?.("r") !== currentRadius) {
this.startpointEl?.setAttribute?.("r", currentRadius);
this.endpointEl?.setAttribute?.("r", currentRadius);
}
const pointsText = path.points
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
.join(" ");

View File

@@ -0,0 +1,118 @@
import { expect, test } from "bun:test";
import * as THREE from "three";
import { CalloutConnector } from "./callout-connector.js";
import { setCalloutLimits } from "./callout-limits.js";
import { createCalloutController } from "./spatial-callout-controller.js";
test("saved expansion limits control actual compute and news card layout independently", () => {
class Element {
constructor(type = "") {
const classes = new Set(type ? [type] : []);
this.classList = {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle(name, active) {
if (active) classes.add(name);
else classes.delete(name);
},
};
this.style = { setProperty() {} };
this.dataset = {};
this.offsetWidth = 340;
this.offsetHeight = 120;
}
querySelector() { return null; }
addEventListener() {}
removeEventListener() {}
animate() {}
}
const storage = new Map();
const listeners = new Map();
const browserGlobals = {
HTMLElement: Element,
innerWidth: 1920,
innerHeight: 1080,
localStorage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
},
document: {
body: new Element(),
documentElement: new Element(),
querySelector: () => null,
querySelectorAll: () => [],
getElementById: () => null,
},
window: {
devicePixelRatio: 1,
matchMedia: () => ({ matches: true }),
addEventListener(name, listener) {
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
},
removeEventListener: (name, listener) => listeners.get(name)?.delete(listener),
dispatchEvent: (event) => listeners.get(event.type)?.forEach((listener) => listener(event)),
},
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
};
const originals = Object.fromEntries(Object.keys(browserGlobals).map((key) => (
[key, Object.getOwnPropertyDescriptor(globalThis, key)]
)));
const connectorMethods = {};
let controller;
try {
for (const [key, value] of Object.entries(browserGlobals)) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
for (const name of ["render", "hide", "isAnimating"]) {
connectorMethods[name] = CalloutConnector.prototype[name];
CalloutConnector.prototype[name] = () => name === "render";
}
const earth = new THREE.Object3D();
const camera = new THREE.PerspectiveCamera(45, 1920 / 1080, 1, 1000);
camera.position.set(0, 0, 300);
camera.lookAt(0, 0, 0);
earth.updateMatrixWorld();
const items = ["compute-card", "news-region-card"].flatMap((type) => (
Array.from({ length: 3 }, (_, index) => ({
key: `${type}:${index}`,
element: new Element(type),
anchor: { x: 780 + index * 20, y: 480 + index * 20, front: true, visibility: 1 },
}))
));
setCalloutLimits({ compute: 2, news: 3 });
controller = createCalloutController({
root: new Element(),
getSpatialContext: () => ({ earth, camera }),
project: (anchor) => anchor,
});
controller.set(items);
const expanded = (type) => items.filter(({ element }) => (
element.classList.contains(type) && element.classList.contains("is-spotlight")
));
controller.update(16, 1000);
expect(expanded("compute-card")).toHaveLength(2);
expect(expanded("news-region-card")).toHaveLength(3);
for (const { element } of [...expanded("compute-card"), ...expanded("news-region-card")]) {
expect(Number.isFinite(parseFloat(element.style.left))).toBe(true);
expect(Number.isFinite(parseFloat(element.style.top))).toBe(true);
}
setCalloutLimits({ compute: 1, news: 2 });
controller.update(16, 2000);
expect(expanded("compute-card")).toHaveLength(1);
expect(expanded("news-region-card")).toHaveLength(2);
} finally {
controller?.destroy();
Object.assign(CalloutConnector.prototype, connectorMethods);
for (const [key, descriptor] of Object.entries(originals)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
}
});

View File

@@ -0,0 +1,32 @@
export const CALLOUT_LIMITS_STORAGE_KEY = "planet.earth.callout-limits.v1";
export function getCalloutLimits() {
try {
const raw = typeof localStorage !== "undefined" ? localStorage.getItem(CALLOUT_LIMITS_STORAGE_KEY) : null;
if (raw) {
const parsed = JSON.parse(raw);
return {
compute: Math.max(1, parseInt(parsed.compute, 10) || 1),
news: Math.max(1, parseInt(parsed.news, 10) || 1),
};
}
} catch {}
return { compute: 1, news: 1 };
}
export function setCalloutLimits(limits) {
const current = getCalloutLimits();
const next = {
compute: limits.compute != null ? Math.max(1, parseInt(limits.compute, 10)) : current.compute,
news: limits.news != null ? Math.max(1, parseInt(limits.news, 10)) : current.news,
};
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(CALLOUT_LIMITS_STORAGE_KEY, JSON.stringify(next));
}
} catch {}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("earth:callout-limits-change", { detail: next }));
}
return next;
}

View File

@@ -0,0 +1,49 @@
import { expect, test, beforeEach, afterEach } from "bun:test";
import { getCalloutLimits, setCalloutLimits, CALLOUT_LIMITS_STORAGE_KEY } from "./callout-limits.js";
let originalStorage;
let originalWindow;
beforeEach(() => {
originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const values = new Map();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
clear: () => values.clear(),
},
});
Object.defineProperty(globalThis, "window", { configurable: true, value: undefined });
});
afterEach(() => {
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else delete globalThis.localStorage;
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else delete globalThis.window;
});
test("returns default limits when storage is empty", () => {
const limits = getCalloutLimits();
expect(limits.compute).toBe(1);
expect(limits.news).toBe(1);
});
test("saves and retrieves updated limits", () => {
setCalloutLimits({ compute: 2, news: 3 });
expect(JSON.parse(localStorage.getItem(CALLOUT_LIMITS_STORAGE_KEY))).toEqual({ compute: 2, news: 3 });
const limits = getCalloutLimits();
expect(limits.compute).toBe(2);
expect(limits.news).toBe(3);
});
test("preserves existing limit when updating only one type", () => {
setCalloutLimits({ compute: 2, news: 3 });
setCalloutLimits({ compute: 1 });
const limits = getCalloutLimits();
expect(limits.compute).toBe(1);
expect(limits.news).toBe(3);
});

View File

@@ -1,19 +1,16 @@
import { computeSlidingPerimeterAnchor } from "./callout-perimeter-anchor.js";
import { createSlidingConnectorPoints } from "./callout-connector.js";
// Geographic labels take the short route to the facing card edge. A short
// terminal segment keeps the attachment legible without a globe-wide elbow.
export function createRegionConnectorPath(source, rect) {
const end = computeSlidingPerimeterAnchor(source, {
left: rect.x, top: rect.y, right: rect.x + rect.width, bottom: rect.y + rect.height,
}, { gap: 8 });
if (!end) return null;
const normal = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }[end.side];
const length = Math.hypot(end.x - source.x, end.y - source.y);
const stub = Math.min(24, length * 0.25);
const bend = { x: end.x + normal[0] * stub, y: end.y + normal[1] * stub };
const start = { x: source.x, y: source.y };
const aligned = Math.abs((end.x - start.x) * normal[1] - (end.y - start.y) * normal[0]) < 2;
return { start, end, points: aligned ? [start, end] : [start, bend, end] };
// If the source anchor is on the backside, project it to the limb silhouette of the globe
// so the connector emerges cleanly from the horizon without piercing the planet.
export function createRegionConnectorPath(source, rect, options = {}) {
const globe = options.globe || source.globe;
return createSlidingConnectorPoints(source, rect, {
targetGapPx: 8,
...options,
globe,
});
}
export function connectorPathsCross(first, second) {

View File

@@ -583,10 +583,18 @@ export async function loadComputeCenters(_scene, earth) {
if (!query.get("year")) query.delete("year");
const url = computeTopicQuery ? `/api/v1/data-products/compute/facilities?${query}` : PATHS.computeCentersApi;
const separator = url.includes("?") ? "&" : "?";
const response = await fetch(
`${url}${separator}_=${Date.now()}`,
{ cache: "no-store", signal: loadController.signal },
);
let response;
try {
response = await fetch(
`${url}${separator}_=${Date.now()}`,
{ cache: "no-store", signal: loadController.signal },
);
} catch (error) {
if (error?.name === "AbortError") {
return { count: 0, stale: true, aborted: true };
}
throw error;
}
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}

View File

@@ -138,16 +138,18 @@ export function initComputeTopic({
const options = [...groupedCatalog(available)].map(([region, entries]) =>
`<optgroup label="${escape(region)}">${entries.map((entry) =>
`<option value="${escape(entry.code)}">${escape(nameOf(entry))}</option>`).join("")}</optgroup>`).join("");
return `<header class="hud-panel__header hud-panel-drag-handle"><div class="hud-panel__title-group"><span class="material-symbols-rounded" aria-hidden="true">memory</span><h2 class="hud-panel__title">${text("算力与资源态势", "Compute intelligence")}</h2></div><div class="hud-panel__actions">${icon("close", text("关闭配置", "Close settings"), "close")}</div></header>
return `<header class="hud-panel__header hud-panel-drag-handle">
<div class="hud-panel__title-group"><span class="material-symbols-rounded" aria-hidden="true">memory</span><h2 class="hud-panel__title">${text("算力与资源态势", "Compute intelligence")}</h2></div>
<div class="hud-panel__actions">
${icon("view_carousel", text("显示区域卡片", "Show region cards"), "toggle-cards")}
${icon("close", text("关闭配置", "Close settings"), "close")}
</div>
</header>
<div class="compute-picker__body hud-scroll-region">
<label class="earth-settings-item compute-picker__toggle" for="compute-cards-enabled">
<span class="earth-settings-copy"><span class="earth-settings-item-title">${text("显示区域卡片", "Show region cards")}</span><span class="earth-settings-item-subtitle">${text("关闭配置窗后仍跟随地球", "Keep following the globe when this window closes")}</span></span>
<span class="earth-settings-switch"><input id="compute-cards-enabled" type="checkbox" data-enabled ${cardsEnabled ? "checked" : ""}><span class="earth-settings-switch-track"></span></span>
</label>
<label>${text("添加地区", "Add region")} <select data-add aria-label="${text("添加比较地区", "Add comparison region")}"><option value="">${countries.length >= MAX_VISIBLE_REGIONS ? text("选择以替换重叠区域", "Select to replace an overlapping region") : text("选择地区", "Select region")}</option>${options}</select></label>
<div class="earth-settings-chip-group" role="group" aria-label="${text("比较地区", "Compared regions")}">${countries.map((code) => {
const entry = catalog().find((item) => item.code === code);
return `<button type="button" class="earth-settings-chip is-active" data-remove="${escape(code)}" aria-label="${text("移除", "Remove")} ${escape(entry ? nameOf(entry) : code)}">${escape(entry ? nameOf(entry) : code)} <span aria-hidden="true">×</span></button>`;
return `<button type="button" class="earth-settings-chip is-active" data-remove="${escape(code)}" aria-label="${text("移除", "Remove")} ${escape(entry ? nameOf(entry) : code)}"><span class="earth-settings-chip__label">${escape(entry ? nameOf(entry) : code)}</span><span class="earth-settings-chip__remove" aria-hidden="true">×</span></button>`;
}).join("")}</div>
<label>${text("参考期", "Period")} <select data-year aria-label="${text("参考年份", "Reference year")}"><option value="">${text("最新观测", "Latest observation")}</option>${(history?.years || []).map((value) => `<option value="${value}" ${String(value) === year ? "selected" : ""}>${value}</option>`).join("")}</select></label>
<label>${text("口径", "Measure")} <select data-mode aria-label="${text("指标口径", "Metric basis")}">${[["global_share", text("全球算力占比", "Global compute share")], ["bilateral_share", text("中美双边算力占比", "US + China compute share")], ["absolute", text("总算力(FP32)", "Total compute (FP32)")]].map(([value, label]) => `<option value="${value}" ${mode === value ? "selected" : ""}>${label}</option>`).join("")}</select></label>
@@ -525,7 +527,7 @@ export function initComputeTopic({
<label class="earth-mobile-settings-card earth-mobile-settings-card--stacked compute-mobile__field"><div class="earth-mobile-settings-copy"><span class="earth-mobile-settings-label">${text("添加地区", "Add region")}</span><span class="earth-mobile-settings-subtitle">${text("选择国家时会替换重叠的父区域", "Choosing a country replaces its overlapping parent region")}</span></div><select data-add aria-label="${text("添加比较地区", "Add comparison region")}"><option value="">${countries.length >= MAX_VISIBLE_REGIONS ? text("选择以替换重叠区域", "Select to replace an overlapping region") : text("选择地区", "Select region")}</option>${options}</select></label>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked"><div class="earth-mobile-settings-copy"><span class="earth-mobile-settings-label">${text("比较地区", "Compared regions")}</span><span class="earth-mobile-settings-subtitle">${text("至少保留一个,最多六个", "Keep at least one, up to six")}</span></div><div class="earth-mobile-settings-chip-grid">${countries.map((code) => {
const entry = catalog().find((item) => item.code === code);
return `<button type="button" class="earth-mobile-settings-chip is-active" data-remove="${escape(code)}" aria-label="${text("移除", "Remove")} ${escape(entry ? nameOf(entry) : code)}">${escape(entry ? nameOf(entry) : code)} <span aria-hidden="true">×</span></button>`;
return `<button type="button" class="earth-mobile-settings-chip is-active" data-remove="${escape(code)}" aria-label="${text("移除", "Remove")} ${escape(entry ? nameOf(entry) : code)}"><span class="earth-settings-chip__label">${escape(entry ? nameOf(entry) : code)}</span><span class="earth-settings-chip__remove" aria-hidden="true">×</span></button>`;
}).join("")}</div></div>
<label class="earth-mobile-settings-card compute-mobile__field"><div class="earth-mobile-settings-copy"><span class="earth-mobile-settings-label">${text("参考期", "Period")}</span><span class="earth-mobile-settings-subtitle">${text("历史设施按年末快照读取", "Historical facilities use year-end snapshots")}</span></div><select data-year aria-label="${text("参考年份", "Reference year")}"><option value="">${text("最新观测", "Latest observation")}</option>${(history?.years || []).map((value) => `<option value="${value}" ${String(value) === year ? "selected" : ""}>${value}</option>`).join("")}</select></label>
<label class="earth-mobile-settings-card compute-mobile__field"><div class="earth-mobile-settings-copy"><span class="earth-mobile-settings-label">${text("指标口径", "Measure")}</span><span class="earth-mobile-settings-subtitle">${text("区域缺失值不会从设施样本反推", "Missing regional values are never inferred from facility samples")}</span></div><select data-mode aria-label="${text("指标口径", "Metric basis")}">${[["global_share", text("全球算力占比", "Global compute share")], ["bilateral_share", text("中美双边算力占比", "US + China compute share")], ["absolute", text("总算力(FP32)", "Total compute (FP32)")]].map(([value, label]) => `<option value="${value}" ${mode === value ? "selected" : ""}>${label}</option>`).join("")}</select></label>
@@ -615,21 +617,24 @@ export function initComputeTopic({
if (oldBody && newBody && oldBody.innerHTML !== newBody.innerHTML) {
const scrollTop = oldBody.scrollTop;
const active = oldBody.contains(document.activeElement) ? document.activeElement : null;
const activeSelector = active?.matches("[data-enabled]") ? "[data-enabled]" :
active?.matches("[data-add]") ? "[data-add]" :
active?.matches("[data-year]") ? "[data-year]" :
active?.matches("[data-mode]") ? "[data-mode]" : null;
const activeSelector = active?.matches("[data-add]") ? "[data-add]" :
active?.matches("[data-year]") ? "[data-year]" :
active?.matches("[data-mode]") ? "[data-mode]" : null;
oldBody.innerHTML = newBody.innerHTML;
oldBody.scrollTop = scrollTop;
if (activeSelector) oldBody.querySelector(activeSelector)?.focus({ preventScroll: true });
}
picker.querySelector(".hud-panel__title").textContent =
draft.querySelector(".hud-panel__title").textContent;
const close = picker.querySelector('[data-action="close"]');
const nextClose = draft.querySelector('[data-action="close"]');
close.setAttribute("aria-label", nextClose.getAttribute("aria-label"));
close.title = nextClose.title;
for (const button of picker.querySelectorAll(".hud-panel__actions button")) {
const next = draft.querySelector(`[data-action="${button.dataset.action}"]`);
button.setAttribute("aria-label", next.getAttribute("aria-label"));
button.title = next.title;
}
}
const toggle = picker.querySelector('[data-action="toggle-cards"]');
toggle.setAttribute("aria-pressed", String(cardsEnabled));
if (cardsEnabled) toggle.title = text("隐藏区域卡片", "Hide region cards");
const evidence = history?.evidence?.[evidenceId];
if (!evidencePanel.hasChildNodes()) {
evidencePanel.innerHTML = `<header class="hud-panel__header hud-panel-drag-handle"><h3 class="hud-panel__title"></h3>${icon("close", text("关闭来源", "Close source"), "evidence-close")}</header><div class="compute-evidence__body hud-scroll-region"></div>`;
@@ -793,15 +798,22 @@ export function initComputeTopic({
returnFocus?.focus();
}
function disableCards() {
if (!cardsEnabled) return;
cardsEnabled = false;
evidenceId = null;
evidencePanel.hidden = true;
spatial.set([]);
setMapQuery(null);
onDisable?.();
function setCardsEnabled(enabled) {
if (enabled === cardsEnabled) return;
cardsEnabled = enabled;
if (!enabled) {
evidenceId = null;
evidencePanel.hidden = true;
spatial.set([]);
setMapQuery(null);
onDisable?.();
}
syncVisibility();
if (enabled) {
if (history && historyKey === selectionKey()) refreshFacilities();
else load();
}
renderPicker();
}
function openConfig() {
@@ -872,6 +884,7 @@ export function initComputeTopic({
return;
}
if (button.dataset.action === "close") closeConfig();
if (button.dataset.action === "toggle-cards") setCardsEnabled(!cardsEnabled);
if (button.dataset.action === "retry") load();
if (button.dataset.action === "evidence-close") {
evidenceId = null;
@@ -897,16 +910,7 @@ export function initComputeTopic({
function handleChange(event) {
if (event.target.matches("[data-enabled]")) {
if (event.target.checked) {
cardsEnabled = true;
syncVisibility();
if (history && historyKey === selectionKey()) refreshFacilities();
else load();
} else {
disableCards();
}
renderPicker();
renderMobile();
setCardsEnabled(event.target.checked);
}
if (event.target.matches("[data-add]")) {
const code = event.target.value;

View File

@@ -65,6 +65,13 @@ export const SURFACE_HOVER_INFO_MODES = {
export const DEFAULT_SURFACE_HOVER_INFO_MODE =
SURFACE_HOVER_INFO_MODES.FULL;
export const CALLOUT_LINE_STYLES = {
THIN: "thin",
BOLD: "bold",
};
export const DEFAULT_CALLOUT_LINE_STYLE = CALLOUT_LINE_STYLES.THIN;
const EARTH_SURFACE_FILL_ALTITUDE_OFFSET = 0.32;
export const EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48;

View File

@@ -6,6 +6,7 @@ import { getFpsEnabled, setFpsEnabled } from "./frame-stats.js";
import { bindToolbarHover } from "./toolbar-hover.js";
import { distanceForGlobeZoom, globeZoomForDistance, isGlobeWheelTarget } from "./globe-navigation.js";
import { EARTH_SETTINGS_STORAGE_KEY } from "./earth-settings-storage.js";
import { getCalloutLimits, setCalloutLimits } from "./callout-limits.js";
// controls.js - Zoom, rotate and toggle controls
import * as THREE from "three";
@@ -25,6 +26,8 @@ import {
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
SURFACE_HOVER_INFO_MODES,
CALLOUT_LINE_STYLES,
DEFAULT_CALLOUT_LINE_STYLE,
} from "./constants.js";
import {
setEarthStatValue,
@@ -266,6 +269,7 @@ let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
let lastMobileDrawerOpenTimestamp = 0;
let toolbarHubController = null;
let keyboardShortcuts = {};
let capturingShortcutActionId = null;
@@ -285,6 +289,9 @@ const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
const ALLOWED_SURFACE_HOVER_INFO_MODES = new Set(
Object.values(SURFACE_HOVER_INFO_MODES),
);
const ALLOWED_CALLOUT_LINE_STYLES = new Set(
Object.values(CALLOUT_LINE_STYLES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
@@ -924,6 +931,10 @@ function syncMobileDrawerState() {
clearInterval(mobileDrawerHintTimer);
mobileDrawerHintTimer = null;
sheet.classList.remove("is-hinting");
if (mobileDrawerOpen) {
sheet.style.transform = "";
sheet.style.transition = "";
}
}
}
}
@@ -998,6 +1009,17 @@ function applyResponsiveLayout() {
if (earthSettingsState) {
applyCurrentViewportPanelVisibility({ persist: false });
}
window.dispatchEvent(
new CustomEvent("earth:layout-mode-change", {
detail: {
previousMode: previousLayoutMode,
mode: layoutMode,
isMobile,
isCompact,
},
})
);
}
syncMobileDrawerState();
@@ -1006,6 +1028,9 @@ function applyResponsiveLayout() {
function setMobileDrawerState({ open = mobileDrawerOpen, card = mobileDrawerCard } = {}) {
mobileDrawerOpen = Boolean(open);
mobileDrawerCard = card || "layers";
if (mobileDrawerOpen) {
lastMobileDrawerOpenTimestamp = performance.now();
}
activeMobileDrawerId = mobileDrawerOpen && mobileDrawerCard === "layers"
? "layer-toggles"
: null;
@@ -1089,6 +1114,11 @@ function syncMobileLayerCards() {
button.classList.toggle("is-disabled", disabledState.disabled);
button.disabled = disabledState.disabled;
button.setAttribute("aria-checked", visible ? "true" : "false");
const switchInput = button.querySelector(".earth-mobile-settings-switch input");
if (switchInput instanceof HTMLInputElement) {
switchInput.checked = visible && !disabledState.disabled;
switchInput.disabled = disabledState.disabled;
}
if (disabledState.tooltip) {
button.title = disabledState.tooltip;
} else {
@@ -1125,7 +1155,11 @@ function renderMobileLayerCards() {
<span class="earth-mobile-layer-card-title">${definition.label}</span>
<span class="earth-mobile-layer-card-subtitle">${definition.meta || ""}</span>
</span>
<span class="earth-mobile-layer-card-status" data-mobile-layer-status>${definition.getVisible?.() ? "开启" : "关闭"}</span>
<span class="earth-mobile-settings-switch" aria-hidden="true">
<input type="checkbox" tabindex="-1" ${definition.getVisible?.() ? "checked" : ""}>
<span class="earth-mobile-settings-switch-track"></span>
</span>
<span class="earth-mobile-layer-card-status" data-mobile-layer-status hidden>${definition.getVisible?.() ? "开启" : "关闭"}</span>
</button>
`)
.join("");
@@ -1157,6 +1191,7 @@ function setupMobileDrawerShell() {
const sheet = shell?.querySelector(".earth-mobile-drawer-sheet");
bindListener(overlay, "click", () => {
if (performance.now() - lastMobileDrawerOpenTimestamp < 350) return;
setMobileDrawerState({ open: false });
});
@@ -1277,9 +1312,16 @@ function setupMobileDrawerShell() {
bindListener(window, "earth:search-result-focus", () => {
if (isMobileLayout() && mobileDrawerCard === "search") setMobileDrawerState({ open: false });
});
bindListener(window, "earth:open-mobile-drawer", (event) => {
const card = event.detail?.card;
if (isMobileLayout()) setMobileDrawerState({ open: true, card: card || "layers" });
});
bindListener(window, "earth:open-details-tab", () => {
if (isMobileLayout()) setMobileDrawerState({ open: true, card: "details" });
});
bindListener(window, "earth:close-mobile-drawer", () => {
if (isMobileLayout()) setMobileDrawerState({ open: false });
});
bindListener(window, "earth:close-compute-tab", () => {
if (isMobileLayout()) setMobileDrawerState({ open: true, card: "layers" });
});
@@ -1513,6 +1555,7 @@ function getCurrentSharedSettingsSnapshot() {
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(),
surfaceHoverInfoMode: getSurfaceHoverInfoMode(),
calloutLineStyle: getCalloutLineStyle(),
newsCategoryFilters: normalizeNewsCategoryFilters(
earthSettingsState?.shared?.newsCategoryFilters,
),
@@ -1585,6 +1628,9 @@ function cloneEarthSettings(settings) {
surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode(
settings.shared.surfaceHoverInfoMode,
),
calloutLineStyle: normalizeCalloutLineStyle(
settings.shared.calloutLineStyle,
),
newsCategoryFilters: normalizeNewsCategoryFilters(settings.shared.newsCategoryFilters),
keyboardShortcuts: normalizeKeyboardShortcuts(settings.shared.keyboardShortcuts),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
@@ -1732,6 +1778,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
(rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION
? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode)
: defaults.shared.surfaceHoverInfoMode;
const nextCalloutLineStyle = normalizeCalloutLineStyle(
sharedSettings?.calloutLineStyle ?? defaults.shared.calloutLineStyle,
);
const nextKeyboardShortcuts =
(rawSettings?.version || 0) >= KEYBOARD_SHORTCUTS_DEFAULT_VERSION
? normalizeKeyboardShortcuts(sharedSettings?.keyboardShortcuts)
@@ -1789,6 +1838,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled,
surfaceHoverInfoMode: nextSurfaceHoverInfoMode,
calloutLineStyle: nextCalloutLineStyle,
newsCategoryFilters: nextNewsCategoryFilters,
keyboardShortcuts: nextKeyboardShortcuts,
},
@@ -2055,9 +2105,15 @@ function syncSegmentedControlSliders() {
document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => {
if (!(segmented instanceof HTMLElement)) return;
const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill"));
if (buttons.length === 0) return;
const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active")));
const activeButton = buttons[activeIndex] || buttons[0];
segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length)));
segmented.style.setProperty("--active-index", String(activeIndex));
if (activeButton && activeButton.offsetWidth > 0) {
segmented.style.setProperty("--slider-left", `${activeButton.offsetLeft}px`);
segmented.style.setProperty("--slider-width", `${activeButton.offsetWidth}px`);
}
});
}
@@ -2387,6 +2443,67 @@ export function setSurfaceHoverInfoMode(
return normalizedMode;
}
function normalizeCalloutLineStyle(style) {
return ALLOWED_CALLOUT_LINE_STYLES.has(style)
? style
: DEFAULT_CALLOUT_LINE_STYLE;
}
export function applyCalloutLineStyle(style = DEFAULT_CALLOUT_LINE_STYLE) {
const normalized = normalizeCalloutLineStyle(style);
if (typeof document !== "undefined" && document.body) {
document.body.dataset.calloutLineStyle = normalized;
}
return normalized;
}
export function getCalloutLineStyle() {
return normalizeCalloutLineStyle(earthSettingsState?.shared?.calloutLineStyle);
}
export function setCalloutLineStyle(
nextStyle,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedStyle = normalizeCalloutLineStyle(nextStyle);
const previousStyle = getCalloutLineStyle();
if (normalizedStyle === previousStyle) {
syncCalloutLineStyleControls();
return normalizedStyle;
}
ensureMutableEarthSettingsState();
earthSettingsState.shared.calloutLineStyle = normalizedStyle;
applyCalloutLineStyle(normalizedStyle);
syncCalloutLineStyleControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const label = normalizedStyle === CALLOUT_LINE_STYLES.BOLD ? "粗线" : "细线";
showStatusMessage(
earthMessage("status.valueChanged", { label: "引导线样式", value: label }),
"info",
);
}
return normalizedStyle;
}
function syncCalloutLineStyleControls() {
const activeStyle = getCalloutLineStyle();
document.querySelectorAll("[data-callout-line-style]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const style = normalizeCalloutLineStyle(button.dataset.calloutLineStyle);
const active = style === activeStyle;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
syncSegmentedControlSliders();
}
function syncDefaultEarthZoomUi(nextZoom) {
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
@@ -2571,6 +2688,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
suppressStatus: true,
});
setCalloutLineStyle(settings.shared.calloutLineStyle, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -3646,6 +3767,7 @@ function openSettingsModal() {
if (sheet instanceof HTMLElement) {
animateSettingsSheet(sheet, trigger, true);
}
syncSegmentedControlSliders();
window.setTimeout(() => {
modal.classList.remove("is-opening");
}, SETTINGS_MODAL_OPEN_ANIMATION_MS);
@@ -4023,6 +4145,9 @@ function setSettingsTab(root, tabId) {
if (!(panel instanceof HTMLElement)) return;
panel.hidden = panel.dataset.settingsTabPanel !== nextTab;
});
window.requestAnimationFrame(() => {
syncSegmentedControlSliders();
});
}
function setupSettingsTabs() {
@@ -4452,6 +4577,8 @@ function setupSettingsControls() {
syncTerrainOpacityUi(getTerrainOpacity());
syncDefaultEarthZoomUi(defaultEarthZoom);
syncAutoRotationSpeedUi(autoRotationSpeed);
applyCalloutLineStyle(getCalloutLineStyle());
syncCalloutLineStyleControls();
earthMapSourceMenu?.destroy();
earthMapSourceMenu = createHUDSelectMenu({
id: "earth-map-source-menu",
@@ -4468,6 +4595,26 @@ function setupSettingsControls() {
cleanupFns.push(() => { earthMapSourceMenu?.destroy(); earthMapSourceMenu = null; });
syncEarthMapSourceControls();
const updateCardLimitButtons = (type, limit) => {
document.querySelectorAll(`[data-max-cards="${type}"]`).forEach((btn) => {
const active = parseInt(btn.dataset.limit, 10) === limit;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", String(active));
});
};
const initialCardLimits = getCalloutLimits();
updateCardLimitButtons("compute", initialCardLimits.compute);
updateCardLimitButtons("news", initialCardLimits.news);
document.querySelectorAll("[data-max-cards]").forEach((btn) => {
bindListener(btn, "click", () => {
const type = btn.dataset.maxCards;
const limit = parseInt(btn.dataset.limit, 10);
setCalloutLimits({ [type]: limit });
updateCardLimitButtons(type, limit);
});
});
document.querySelectorAll("[data-earth-map-source-retry]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
bindListener(button, "click", () => {
@@ -4638,6 +4785,14 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-callout-line-style]").forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
setCalloutLineStyle(target.dataset.calloutLineStyle);
});
});
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
if (!(toggle instanceof HTMLInputElement)) return;
bindListener(toggle, "change", () => {
@@ -5012,6 +5167,7 @@ export async function setupControls(camera, renderer, scene, earth) {
activeCamera = camera;
earthObj = earth;
applyResponsiveLayout();
setupToolbarHubCluster();
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
@@ -5019,7 +5175,6 @@ export async function setupControls(camera, renderer, scene, earth) {
await settingsApplyPromise;
syncTrailsAvailability();
setupLiquidGlassInteractions();
setupToolbarHubCluster();
setupKeyboardControls();
bindListener(window, "resize", () => {
applyResponsiveLayout();
@@ -5624,6 +5779,7 @@ function setupTerrainControls() {
});
bindListener(document, "click", (event) => {
if (performance.now() - lastMobileDrawerOpenTimestamp < 350) return;
if (
isMobileLayout() &&
mobileDrawerOpen &&
@@ -5631,13 +5787,13 @@ function setupTerrainControls() {
// Content may re-render during this click. Its original event path still
// identifies an inside click even when the target has been detached.
!event.composedPath().includes(document.getElementById("mobile-drawer-shell")) &&
!event.composedPath().includes(document.getElementById("earth-mobile-popup")) &&
!event.composedPath().some((node) => node instanceof Element &&
node.dataset.hudMenuOwner &&
document.getElementById(node.dataset.hudMenuOwner)?.closest("#mobile-drawer-shell"))
) {
setMobileDrawerState({ open: false });
}
});
bindListener(layoutBtn, "click", () => {
@@ -5766,7 +5922,7 @@ function setupLiquidGlassInteractions() {
const panelTiltY = (px - 0.5) * 6;
surface.style.setProperty("--panel-glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--panel-glow-opacity", "0.16");
surface.style.setProperty("--panel-glow-opacity", "0.24");
surface.style.setProperty("--panel-tilt-x", `${panelTiltX.toFixed(2)}deg`);
surface.style.setProperty("--panel-tilt-y", `${panelTiltY.toFixed(2)}deg`);
} else if (!isToolbarSurface) {

View File

@@ -0,0 +1,17 @@
export async function refreshEarthUpdateBatch({
layers,
strategies,
isDatabaseChange,
loadSummary,
refreshLayer,
}) {
if (isDatabaseChange && layers.some((layer) => layer !== "news")) {
await loadSummary();
}
return Promise.allSettled(
layers.map((layer) => refreshLayer(layer, {
force: isDatabaseChange,
strategy: strategies.get(layer),
})),
);
}

View File

@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { refreshEarthUpdateBatch } from "./earth-update-batch.js";
function batchFixture(layers) {
const calls = [];
return {
calls,
batch: () => refreshEarthUpdateBatch({
layers,
strategies: new Map(layers.map((layer) => [layer, "reload"])),
isDatabaseChange: true,
loadSummary: async () => { calls.push("summary"); },
refreshLayer: async (layer, options) => { calls.push({ layer, options }); },
}),
};
}
test("a news-only database update refreshes news without fetching Earth summary", async () => {
const fixture = batchFixture(["news"]);
expect(await fixture.batch()).toEqual([{ status: "fulfilled", value: undefined }]);
expect(fixture.calls).toEqual([{ layer: "news", options: { force: true, strategy: "reload" } }]);
});
test("a mixed news and vessel update refreshes summary before both layers", async () => {
const fixture = batchFixture(["news", "vessels"]);
expect(await fixture.batch()).toHaveLength(2);
expect(fixture.calls).toEqual([
"summary",
{ layer: "news", options: { force: true, strategy: "reload" } },
{ layer: "vessels", options: { force: true, strategy: "reload" } },
]);
});

View File

@@ -1,17 +1,36 @@
import { getEarthLocale } from "./i18n.js";
import { getEarthLocale, onEarthLocaleChange } from "./i18n.js";
let enabled = false;
let output = null;
const samples = [];
const frameWindows = [];
const AVERAGE_WINDOW_MS = 30000;
let previousStart = 0, reportedAt = 0;
function positionFpsOutput() {
export function positionFpsOutput() {
if (!output || !enabled) return;
const brand = document.querySelector("#brand-root .earth-brand") || document.getElementById("brand-panel");
const rect = brand?.getBoundingClientRect();
const logo =
document.querySelector?.("#brand-root .earth-brand__logo") ||
document.querySelector?.("#brand-panel .earth-brand__logo") ||
document.querySelector?.(".earth-brand__logo") ||
document.querySelector?.("#brand-root .earth-brand__title, #brand-root .earth-brand__title-text") ||
document.querySelector?.("#brand-root .earth-brand__copy") ||
document.querySelector?.("#brand-root .earth-brand") ||
document.getElementById?.("brand-panel");
if (typeof HTMLImageElement !== "undefined" && logo instanceof HTMLImageElement && !logo.complete) {
logo.addEventListener("load", positionFpsOutput, { once: true });
}
const rect = logo?.getBoundingClientRect?.();
const shown = rect && rect.width > 0 && rect.right > 0;
output.style.left = `${shown ? Math.min(rect.right + 14, window.innerWidth - 90) : 14}px`;
output.style.top = `${shown ? Math.max(12, rect.top + 2) : 14}px`;
if (!shown) {
output.style.left = "14px";
output.style.top = "14px";
return;
}
const outputRect = output.getBoundingClientRect?.();
const outputWidth = Math.ceil(outputRect?.width || output.offsetWidth || 64);
const left = Math.min(Math.max(8, Math.round(rect.left)), window.innerWidth - outputWidth - 8);
const top = Math.max(6, Math.round(rect.top));
output.style.left = `${left}px`;
output.style.top = `${top}px`;
}
export function summarizeFrames(frames) {
if (!frames.length) return null;
@@ -21,10 +40,16 @@ export function summarizeFrames(frames) {
cpu:frames.reduce((sum,frame)=>sum+frame.cpu,0)/frames.length,
p95:intervals[Math.min(intervals.length-1,Math.floor(intervals.length*.95))] };
}
if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
if (typeof document !== "undefined" && typeof document.addEventListener === "function") document.addEventListener("visibilitychange", () => {
samples.length = 0; frameWindows.length = 0; previousStart = 0; reportedAt = 0;
if (output && enabled) output.textContent = "— FPS";
});
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("resize", positionFpsOutput, { passive: true });
}
onEarthLocaleChange(() => {
positionFpsOutput();
});
export function summarizeFrameWindows(windows) {
const duration = windows.reduce((sum, window) => sum + window.duration, 0);
const count = windows.reduce((sum, window) => sum + window.count, 0);

View File

@@ -37,7 +37,7 @@ export function createHUDSelectMenu({
const menu = document.createElement("div");
menu.id = id;
menu.className = "hud-panel hud-select-menu";
menu.setAttribute("popover", "auto");
menu.setAttribute("popover", "manual");
menu.setAttribute("role", "dialog");
const list = document.createElement("div");
@@ -102,10 +102,26 @@ export function createHUDSelectMenu({
let generation = 0;
const getLabel = (key, fallback) => translateText(labels[key] || fallback);
const isOpen = () => menu.matches(":popover-open");
const isMobileMode = () =>
document.documentElement.classList.contains("layout-mode-mobile") ||
document.body.classList.contains("layout-mode-mobile") ||
window.innerWidth <= 820;
const isOpen = () => {
try {
return menu.matches(":popover-open") || menu.classList.contains("is-open");
} catch {
return menu.classList.contains("is-open");
}
};
function close({ restoreFocus = false } = {}) {
if (isOpen()) menu.hidePopover();
if (isOpen()) {
if (typeof menu.hidePopover === "function") {
try { menu.hidePopover(); } catch {}
}
menu.classList.remove("is-open");
}
triggers.forEach((trigger) => trigger.setAttribute("aria-expanded", "false"));
search?.setAttribute("aria-expanded", "false");
if (restoreFocus) activeTrigger?.focus();
@@ -118,6 +134,21 @@ export function createHUDSelectMenu({
close();
return;
}
if (isMobileMode()) {
menu.style.position = "fixed";
menu.style.width = `${rect.width}px`;
menu.style.maxWidth = `${rect.width}px`;
menu.style.left = `${rect.left}px`;
menu.style.right = "auto";
menu.style.margin = "0";
menu.style.borderRadius = "14px";
menu.style.top = `${rect.bottom + 4}px`;
menu.style.bottom = "auto";
const availableHeight = Math.max(160, window.innerHeight - rect.bottom - 24);
menu.style.maxHeight = `${Math.min(340, availableHeight)}px`;
return;
}
const viewport = window.visualViewport;
const viewportTop = viewport?.offsetTop || 0;
const viewportLeft = viewport?.offsetLeft || 0;
@@ -132,6 +163,10 @@ export function createHUDSelectMenu({
const above = rect.top - viewportTop - VIEWPORT_MARGIN - MENU_GAP;
const openBelow = below >= Math.min(MENU_MAX_HEIGHT, MENU_MIN_HEIGHT_BELOW) || below >= above;
menu.style.width = `${width}px`;
menu.style.maxWidth = "";
menu.style.right = "";
menu.style.margin = "";
menu.style.bottom = "";
menu.style.maxHeight = `${Math.max(0, Math.min(MENU_MAX_HEIGHT, openBelow ? below : above))}px`;
menu.style.left = `${left}px`;
menu.style.top = `${openBelow
@@ -306,12 +341,21 @@ export function createHUDSelectMenu({
applyPage(initialPayload);
failed = false;
renderOptions({ resetScroll: true });
menu.showPopover();
if (typeof menu.showPopover === "function") {
try {
menu.showPopover();
} catch {
// Fallback handled below
}
}
menu.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
search?.setAttribute("aria-expanded", "true");
position();
const focusTarget = search || list;
focusTarget.focus({ preventScroll: true });
if (!isMobileMode()) {
const focusTarget = search || list;
focusTarget.focus({ preventScroll: true });
}
if (last) setActive(formatted.length - 1, { scroll: true });
else setActive(activeIndex, { scroll: true });
}
@@ -398,8 +442,33 @@ export function createHUDSelectMenu({
triggers.forEach((trigger) => trigger.setAttribute("aria-expanded", "false"));
search?.setAttribute("aria-expanded", "false");
}, { signal });
window.addEventListener("resize", close, { signal });
let lastWindowWidth = window.innerWidth;
const handleWindowResize = () => {
if (isMobileMode()) {
if (Math.abs(window.innerWidth - lastWindowWidth) < 24) {
position();
return;
}
lastWindowWidth = window.innerWidth;
}
close();
};
window.addEventListener("resize", handleWindowResize, { signal });
window.visualViewport?.addEventListener("resize", position, { signal });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && isOpen()) {
event.preventDefault();
close({ restoreFocus: true });
}
}, { signal });
document.addEventListener("pointerdown", (event) => {
if (!isOpen()) return;
const target = event.target;
if (target instanceof Node && !menu.contains(target) && !activeTrigger?.contains(target)) {
close();
}
}, { signal });
document.addEventListener("scroll", (event) => {
if (isOpen() && !menu.contains(event.target)) position();
}, { capture: true, signal });

View File

@@ -141,8 +141,40 @@ const EARTH_MESSAGE_TEMPLATES = {
en: ({ open }) => (open ? "Live News panel opened" : "Live News panel closed"),
},
"status.newsRefresh": {
zh: ({ ok }) => (ok ? "态势新闻已刷新" : "态势新闻刷新失败"),
en: ({ ok }) => (ok ? "Situation news refreshed" : "Situation news refresh failed"),
zh: ({ ok }) => (ok ? "态势新闻已刷新" : "态势新闻刷新失败,请重试"),
en: ({ ok }) => (ok ? "Situation news refreshed" : "Situation news refresh failed. Please retry"),
},
"status.newsRefreshing": {
zh: "正在刷新态势新闻...",
en: "Refreshing situation news...",
},
"status.newsRefreshFailedReason": {
zh: ({ reason }) => `态势新闻刷新失败:${reason}`,
en: ({ reason }) => `Situation news refresh failed: ${reason}`,
},
"status.newsRefreshSourceUnavailable": {
zh: "新闻源暂不可用,请稍后重试",
en: "News sources are unavailable. Please retry later",
},
"status.newsRefreshTimeout": {
zh: "等待超时,请重试",
en: "The refresh timed out. Please retry",
},
"status.newsRefreshExpired": {
zh: "刷新状态已过期,请重试",
en: "The refresh status expired. Please retry",
},
"status.newsRefreshNetworkError": {
zh: "网络连接失败,请重试",
en: "Network connection failed. Please retry",
},
"status.newsRefreshProcessingError": {
zh: "后台处理出错,请重试",
en: "The refresh could not finish. Please retry",
},
"status.newsRefreshViewChanged": {
zh: "已切换区域,请在当前区域重新刷新",
en: "The region changed. Refresh the current region again",
},
"status.newsSyncFailed": {
zh: "态势新闻同步失败",
@@ -416,6 +448,13 @@ const EXACT_TRANSLATIONS = {
"放大": "Zoom In",
"缩小": "Zoom Out",
"设置": "Settings",
"引导线样式": "Connector Style",
"选择卡片引导线的粗细视觉风格。": "Choose between thin or bold style for card connectors.",
"选择卡片引导线的粗细视觉风格": "Choose between thin or bold style for card connectors",
"选择引导线样式": "Select connector style",
"移动端选择引导线样式": "Select connector style",
"细线": "Thin",
"粗线": "Bold",
"重置视角": "Reset View",
"最大化布局": "Maximize Layout",
"工具菜单": "Tool Menu",
@@ -742,6 +781,13 @@ const EXACT_TRANSLATIONS = {
"新闻直播": "Live News",
"控制 Live 新闻面板显示": "Control the Live News panel",
"控制右上角 Live 新闻面板显示": "Control the upper-right Live News panel",
"算力卡同时展开数": "Compute Cards Max Expanded",
"新闻卡同时展开数": "News Cards Max Expanded",
"控制画面中可同时保持完整展示的算力卡片数量,其余卡片精简为胶囊": "Control how many compute cards stay expanded simultaneously; other cards collapse into capsules.",
"控制画面中可同时保持完整展示的新闻卡片数量,其余卡片精简为胶囊": "Control how many news cards stay expanded simultaneously; other cards collapse into capsules.",
"1 张": "1",
"2 张": "2",
"3 张": "3",
"动捕调试模式": "Motion Debug Mode",
"显示摄像头识别到的骨架连线和匹配动作": "Show detected skeleton lines and matched gestures",
"打开骨架连线面板;未匹配为红线,匹配动作后变绿": "Open the skeleton panel; unmatched lines are red and matched gestures turn green.",
@@ -1233,6 +1279,7 @@ const PATTERN_TRANSLATIONS = [
[/^动捕: 已切换到(.+)图层$/, (match, locale) => `Motion: switched to ${translateText(match[1], locale)} layer`],
[/^动捕: 已切换到(.+)$/, (match, locale) => `Motion: switched to ${translateText(match[1], locale)}`],
[/^共找到 (\d+) 个候选位置$/, "Found $1 candidate locations"],
[/^已定位算力中心:(.+)$/, "Located compute centers: $1"],
[/^(\d+) 个算力中心没有可信坐标$/, "$1 compute centers lack trusted coordinates"],
[/^(\d+) 个待定位$/, "$1 pending location"],
[/^正在定位并采用最高置信候选 (\d+)\/(\d+)\.\.\.$/, "Locating and applying highest-confidence candidate $1/$2..."],
@@ -1321,11 +1368,16 @@ export function getEarthLocaleLabel(locale = activeLocale) {
function syncLocaleSegmentedSliders() {
document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => {
if (!(segmented instanceof HTMLElement)) return;
const buttons = Array.from(segmented.querySelectorAll("[data-earth-locale]"));
const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill, [data-earth-locale]"));
if (buttons.length === 0) return;
const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active")));
const activeButton = buttons[activeIndex] || buttons[0];
segmented.style.setProperty("--item-count", String(buttons.length));
segmented.style.setProperty("--active-index", String(activeIndex));
if (activeButton && activeButton.offsetWidth > 0) {
segmented.style.setProperty("--slider-left", `${activeButton.offsetLeft}px`);
segmented.style.setProperty("--slider-width", `${activeButton.offsetWidth}px`);
}
});
}

View File

@@ -1,5 +1,5 @@
import { COMPUTE_SITE_TYPES, isComputeSiteType } from "./compute-site-types.js";
import { cardImageIdentity, mountCardImage } from "./card-images.js";
import { cardImageIdentity, mountCardImage, createCardImagePresenter } from "./card-images.js";
import { createTypewriter, waitForTypewriter } from "./typewriter.js";
// info-card.js - Unified info card module
import { showStatusMessage } from './ui.js';
@@ -323,65 +323,302 @@ export function renderNewsCardContent(content, data, { animateSummary = true, co
else if (summaryEl) summaryEl.textContent = summary;
}
function renderMobileNewsCardContent(content, data) {
if (!(content instanceof HTMLElement)) return;
const summary = getNewsCardSummary(data);
const title = getNewsCardTitle(data);
const metaItems = [
['来源', data?.source],
['类型', data?.categoryLabel],
['区域', data?.regionLabel],
['发布时间', data?.publishedAtDisplay],
].filter(([, value]) => String(value ?? '').trim());
const metaHtml = metaItems.length
? `<div class="info-card-news-meta-grid info-card-news-meta-grid--mobile">${metaItems.map(([label, value]) => `
<div class="info-card-news-meta-item">
<span>${escapeInfoCardHtml(infoText(label))}</span>
<strong>${escapeInfoCardHtml(value)}</strong>
</div>
`).join('')}</div>`
: '';
content.innerHTML = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">${escapeInfoCardHtml(infoText('新闻信号'))}</div>
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
${metaHtml}
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">${escapeInfoCardHtml(infoText('概要'))}</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
</div>
</div>
`;
const summaryEl = content.querySelector('[data-news-summary]');
startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 });
let mobileDetailsImagePresenter = null;
function getMobileDetailsImagePresenter() {
if (!mobileDetailsImagePresenter) {
const layer = document.getElementById('mobile-details-media');
const image = document.getElementById('mobile-details-media-img');
const credit = document.getElementById('mobile-details-media-credit');
if (layer && image && credit) {
mobileDetailsImagePresenter = createCardImagePresenter({
layer,
image,
credit,
getWidth: () => window.innerWidth,
});
}
}
return mobileDetailsImagePresenter;
}
function renderMobileDetailContent(type, config, data) {
const content = document.getElementById('mobile-info-card-content');
if (!(content instanceof HTMLElement)) return;
function getMobileDetailIdentity(type, config, data) {
let title = '';
let categoryLabel = infoText(config?.title || '对象详情').replace('详情', '').trim();
let subtitle = '';
let statusBadge = '';
let statusBadgeType = 'normal'; // 'normal', 'warning', 'danger'
let url = data?.url || data?.metadata?.source_url || null;
const metrics = [];
if (type === 'satellite') {
title = data?.name || `NORAD ${data?.norad_id}`;
categoryLabel = '在轨卫星';
subtitle = `NORAD ${data?.norad_id || '—'} · ${data?.constellation || '未知星座'}`;
if (data?.constellation) statusBadge = data.constellation;
if (data?.perigee != null && data.perigee !== '-') {
metrics.push({ label: '近地点', value: `${data.perigee} km` });
}
if (data?.apogee != null && data.apogee !== '-') {
metrics.push({ label: '远地点', value: `${data.apogee} km` });
}
if (data?.inclination != null && data.inclination !== '-') {
metrics.push({ label: '轨道倾角', value: `${data.inclination}°` });
}
if (data?.period != null && data.period !== '-') {
metrics.push({ label: '运行周期', value: `${data.period} 分钟` });
}
} else if (type === 'cable') {
title = data?.name || '海底光缆';
categoryLabel = '海底光缆';
statusBadge = data?.status || '运营中';
statusBadgeType = (data?.status?.includes('退役') || data?.status?.includes('中断')) ? 'danger' : 'normal';
subtitle = `${data?.status || '运营中'}${data?.rfs ? ' · 投产于 ' + data.rfs : ''}`;
if (data?.length) {
metrics.push({ label: '总长度', value: `${data.length} km` });
}
if (data?.status) {
metrics.push({ label: '运营状态', value: data.status });
}
if (data?.rfs) {
metrics.push({ label: '投产年份', value: String(data.rfs) });
}
} else if (type === 'landing_point') {
title = data?.name || '海缆登陆点';
categoryLabel = '海缆登陆点';
subtitle = data?.country || '';
if (data?.status) statusBadge = data.status;
if (data?.cable_count != null) {
metrics.push({ label: '关联海缆', value: `${data.cable_count} 条` });
}
if (data?.country) {
metrics.push({ label: '所属国家', value: data.country });
}
} else if (isComputeSiteType(type) || type === 'supercomputer' || type === 'gpu_cluster') {
title = data?.name || '算力中心';
categoryLabel = data?.site_type_label || infoText(config?.title || '').replace('详情', '') || '算力中心';
subtitle = [data?.city, data?.country].filter(Boolean).join(', ') || data?.country || '';
if (data?.rank && data.rank !== '-') statusBadge = `TOP500 #${data.rank}`;
if (data?.capacity && data.capacity !== '-') {
metrics.push({ label: '算力规模', value: String(data.capacity) });
}
if (data?.gpu_count && data.gpu_count !== '-') {
metrics.push({ label: 'GPU 数量', value: String(data.gpu_count) });
}
if (data?.power && data.power !== '-') {
metrics.push({ label: '功率消耗', value: `${data.power} kW` });
}
} else if (type === 'vessel') {
title = data?.name || `MMSI ${data?.mmsi}`;
categoryLabel = data?.vessel_type || '船舶';
subtitle = `MMSI ${data?.mmsi || '—'}${data?.flag ? ' · ' + data.flag : ''}`;
if (data?.status && data.status !== '-') {
statusBadge = data.status;
statusBadgeType = data.status === '航行中' ? 'normal' : 'warning';
}
if (data?.speed != null && data.speed !== '-') {
metrics.push({ label: '航速', value: `${data.speed} 节` });
}
if (data?.course != null && data.course !== '-') {
metrics.push({ label: '航向', value: `${data.course}°` });
}
if (data?.status && data.status !== '-') {
metrics.push({ label: '航行状态', value: String(data.status) });
}
} else if (type === 'bgp') {
title = data?.anomaly_type || 'BGP 路由事件';
categoryLabel = 'BGP 路由事件';
subtitle = [data?.impacted_scope, data?.created_at].filter(Boolean).join(' · ');
if (data?.severity) {
statusBadge = data.severity;
statusBadgeType = (data.severity.includes('高') || data.severity.includes('危')) ? 'danger' : 'warning';
}
if (data?.origin_asn) {
metrics.push({ label: '涉及 ASN', value: `AS${data.origin_asn}` });
}
if (data?.severity) {
metrics.push({ label: '严重度', value: data.severity });
}
if (data?.prefix) {
metrics.push({ label: '路由前缀', value: data.prefix });
}
} else if (type === 'bgp_collector') {
title = data?.collector || 'BGP 观测站';
categoryLabel = 'BGP 观测站';
subtitle = data?.location || '';
if (data?.status) statusBadge = data.status;
if (data?.anomaly_count != null) {
metrics.push({ label: '当前事件', value: `${data.anomaly_count} 起` });
}
if (data?.recent_24h_observation_count != null) {
metrics.push({ label: '近24h事件', value: `${data.recent_24h_observation_count} 起` });
}
} else if (type === 'news') {
title = getNewsCardTitle(data);
categoryLabel = '新闻信号';
subtitle = [data?.source, data?.publishedAtDisplay].filter(Boolean).join(' · ');
if (data?.categoryLabel) statusBadge = data.categoryLabel;
url = data?.url || null;
} else {
title = data?.name || data?.label || infoText(config?.title || '对象详情');
categoryLabel = infoText(config?.title || '对象详情').replace('详情', '').trim();
subtitle = data?.country || data?.status || '';
if (data?.status) statusBadge = data.status;
}
return { title, categoryLabel, subtitle, statusBadge, statusBadgeType, url, metrics };
}
function bindMobileDetailActions() {
const focusBtn = document.getElementById('mobile-details-focus-btn');
if (focusBtn && !focusBtn.__bound) {
focusBtn.__bound = true;
focusBtn.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('earth:focus-selected-object'));
window.dispatchEvent(new CustomEvent('earth:close-mobile-drawer'));
});
}
const copyBtn = document.getElementById('mobile-details-copy-btn');
if (copyBtn && !copyBtn.__bound) {
copyBtn.__bound = true;
copyBtn.addEventListener('click', async () => {
const title = document.getElementById('mobile-details-title')?.textContent || '';
const subtitle = document.getElementById('mobile-details-subtitle')?.textContent || '';
const textToCopy = `${title}\n${subtitle}`.trim();
if (!textToCopy) return;
try {
await navigator.clipboard.writeText(textToCopy);
showStatusMessage(earthMessage("status.copyValue", { label: infoText("对象"), value: title }), 'success');
} catch {
showStatusMessage(earthMessage("status.copyFailed"), 'error');
}
});
}
}
function renderNativeMobileDetail(type, config, data) {
const emptyEl = document.getElementById('mobile-details-empty');
const activeEl = document.getElementById('mobile-details-active');
if (!activeEl) return;
if (!data) {
if (emptyEl) emptyEl.hidden = false;
activeEl.hidden = true;
getMobileDetailsImagePresenter()?.hide();
return;
}
if (emptyEl) emptyEl.hidden = true;
activeEl.hidden = false;
const identity = getMobileDetailIdentity(type, config, data);
const iconEl = document.getElementById('mobile-details-icon');
const typeLabelEl = document.getElementById('mobile-details-type-label');
const statusBadgeEl = document.getElementById('mobile-details-status-badge');
const titleEl = document.getElementById('mobile-details-title');
const subtitleEl = document.getElementById('mobile-details-subtitle');
const metricsEl = document.getElementById('mobile-details-metrics');
const propsEl = document.getElementById('mobile-details-props');
const propCountEl = document.getElementById('mobile-details-prop-count');
const linkBtn = document.getElementById('mobile-details-link-btn');
if (iconEl) iconEl.textContent = config?.icon || '📍';
if (typeLabelEl) typeLabelEl.textContent = identity.categoryLabel;
if (statusBadgeEl) {
if (identity.statusBadge) {
statusBadgeEl.textContent = identity.statusBadge;
statusBadgeEl.className = 'earth-mobile-details-status-badge';
if (identity.statusBadgeType === 'warning') statusBadgeEl.classList.add('earth-mobile-details-status-badge--warning');
else if (identity.statusBadgeType === 'danger') statusBadgeEl.classList.add('earth-mobile-details-status-badge--danger');
statusBadgeEl.hidden = false;
} else {
statusBadgeEl.hidden = true;
}
}
if (titleEl) titleEl.textContent = identity.title;
if (subtitleEl) {
subtitleEl.textContent = identity.subtitle;
subtitleEl.hidden = !identity.subtitle;
}
// Quick Metrics Chips
if (metricsEl) {
if (identity.metrics && identity.metrics.length > 0) {
metricsEl.innerHTML = identity.metrics.map(m => `
<div class="earth-mobile-metric-chip">
<span class="earth-mobile-metric-label">${escapeInfoCardHtml(m.label)}</span>
<span class="earth-mobile-metric-value">${escapeInfoCardHtml(m.value)}</span>
</div>
`).join('');
metricsEl.hidden = false;
} else {
metricsEl.innerHTML = '';
metricsEl.hidden = true;
}
}
// Properties / Reading View
stopTypewriterAnimation();
if (type === 'news') {
renderMobileNewsCardContent(content, data);
return;
}
if (config.className === 'compute_unresolved') {
renderComputeCenterUnresolvedContent(content, data);
return;
if (propsEl) {
if (type === 'news') {
const summary = getNewsCardSummary(data);
propsEl.innerHTML = `
<div class="earth-mobile-news-reading-view">
<div class="earth-mobile-news-reading-label">${escapeInfoCardHtml(infoText('概要'))}</div>
<div class="earth-mobile-news-reading-text" data-news-summary></div>
</div>
`;
const summaryOutput = propsEl.querySelector('[data-news-summary]');
startTypewriterAnimation(summaryOutput, summary, { stepMs: 20, startDelayMs: 60 });
if (propCountEl) propCountEl.textContent = '快讯摘要';
} else if (config?.className === 'compute_unresolved') {
renderComputeCenterUnresolvedContent(propsEl, data);
if (propCountEl) propCountEl.textContent = '待核验';
} else {
let rowsHtml = '';
let fieldCount = 0;
if (config?.fields) {
for (const field of config.fields) {
if (field.omitIfSameAs && data[field.key] === data[field.omitIfSameAs]) continue;
const rawVal = data[field.key];
if (rawVal == null || rawVal === '' || rawVal === '-') continue;
const value = formatInfoCardValue(field, rawVal);
fieldCount++;
rowsHtml += `
<div class="earth-mobile-detail-spec-row">
<span class="earth-mobile-detail-spec-label">${escapeInfoCardHtml(infoText(field.label))}</span>
<span class="earth-mobile-detail-spec-value">${value}</span>
</div>
`;
}
}
if (config?.className === 'vessel' && data?.enrichment) {
rowsHtml += renderVesselEnrichmentSection(data.enrichment);
}
propsEl.innerHTML = rowsHtml || '<div class="earth-mobile-detail-empty">暂无更多详细参数</div>';
if (propCountEl) propCountEl.textContent = `${fieldCount} 项参数`;
}
}
let html = '';
for (const field of config.fields) {
if (field.omitIfSameAs && data[field.key] === data[field.omitIfSameAs]) continue;
const value = formatInfoCardValue(field, data[field.key]);
html += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${escapeInfoCardHtml(infoText(field.label))}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
// Image Presenter
void getMobileDetailsImagePresenter()?.show(cardImageIdentity(type, data));
// Action link button
if (linkBtn) {
if (identity.url && /^https?:\/\//i.test(identity.url)) {
linkBtn.href = identity.url;
linkBtn.hidden = false;
const linkText = linkBtn.querySelector('span:last-child');
if (linkText) linkText.textContent = type === 'news' ? '阅读原文' : '查看来源';
} else {
linkBtn.hidden = true;
}
}
content.innerHTML = html;
bindMobileDetailActions();
}
function getMobileDetailRenderKey(type, data) {
@@ -407,17 +644,11 @@ function ensureMobileDetailsListener() {
window.addEventListener('earth:open-details-tab', () => {
if (!document.body.classList.contains('layout-mode-mobile')) return;
if (!pendingMobileDetailState) return;
const nextKey = getMobileDetailRenderKey(
pendingMobileDetailState.type,
pendingMobileDetailState.data,
);
if (nextKey && nextKey === renderedMobileDetailKey) return;
renderMobileDetailContent(
renderNativeMobileDetail(
pendingMobileDetailState.type,
pendingMobileDetailState.config,
pendingMobileDetailState.data,
);
renderedMobileDetailKey = nextKey;
});
}
@@ -1257,6 +1488,7 @@ function getMobilePopupTitle(type, data) {
case 'cloud_center':
case 'edge_node': return data.name || infoText(COMPUTE_SITE_TYPES[type].label);
case 'vessel': return data.name || infoText('船只');
case 'earth_interactable': return data.label || data.name || infoText('交互点');
default: return infoText('详情');
}
}
@@ -1276,6 +1508,7 @@ function getMobilePopupSubtitle(type, data) {
case 'cloud_center':
case 'edge_node': return localizeCountryName(data.country) || infoText(COMPUTE_SITE_TYPES[type].label);
case 'vessel': return data.vessel_type || infoText('AIS 船只');
case 'earth_interactable': return infoText(data.kind || data.description || '数据点');
default: return '';
}
}
@@ -1359,29 +1592,43 @@ function showMobilePopup(type, data, x, y, options = {}) {
const token = popupShowToken;
popup.dataset.dockSide = options.dockSide === 'right' ? 'right' : 'left';
popup.dataset.sticky = options.sticky === true ? 'true' : 'false';
delete popup.dataset.userPlaced;
delete popup.dataset.dragged;
delete popup.dataset.lastUserDragAt;
popup.classList.toggle('earth-mobile-popup--anchor-stable', options.anchorStable === true);
popup.removeAttribute('hidden');
popup.classList.remove('is-visible');
positionMobilePopup(popup, x, y, options);
if (options.reveal === false) {
popup.classList.remove('is-visible');
return;
}
popup.classList.remove('is-visible');
requestAnimationFrame(() => {
if (token !== popupShowToken) return;
positionMobilePopup(popup, x, y, options);
if (options.reveal === false) {
return;
}
if (token !== popupShowToken) return; // superseded
if (token !== popupShowToken || popup.hidden) return;
void popup.getBoundingClientRect();
popup.classList.add('is-visible');
});
}
function hideMobilePopup() {
export function hideMobilePopup() {
const popup = document.getElementById('earth-mobile-popup');
if (!popup) return;
popupShowToken += 1; // invalidate any pending show
const wasVisible = popup.classList.contains('is-visible');
popup.classList.remove('is-visible');
popup.classList.remove('earth-mobile-popup--anchor-stable');
delete popup.dataset.dockSide;
delete popup.dataset.sticky;
delete popup.dataset.userPlaced;
delete popup.dataset.dragged;
delete popup.dataset.lastUserDragAt;
if (!wasVisible) {
popup.setAttribute('hidden', '');
return;
}
popup.addEventListener('transitionend', () => {
if (!popup.classList.contains('is-visible')) {
popup.setAttribute('hidden', '');
@@ -1400,7 +1647,8 @@ function ensurePopupClickHandler() {
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let dragged = false;
const DRAG_THRESHOLD = 10;
let suppressClickUntil = 0;
const DRAG_THRESHOLD = 18;
const emitDragEvent = (dragging) => {
const rect = popup.getBoundingClientRect();
@@ -1433,8 +1681,11 @@ function ensurePopupClickHandler() {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
dragged = true;
e.stopPropagation();
dragged = true;
popup.dataset.userPlaced = 'true';
popup.dataset.dragged = 'true';
popup.dataset.lastUserDragAt = String(performance.now());
const margin = 8;
const left = Math.max(margin, Math.min(startLeft + dx, window.innerWidth - popup.offsetWidth - margin));
const top = Math.max(margin, Math.min(startTop + dy, window.innerHeight - popup.offsetHeight - margin));
@@ -1443,14 +1694,24 @@ function ensurePopupClickHandler() {
emitDragEvent(true);
});
const triggerOpen = (e) => {
if (e) {
e.stopPropagation();
}
if (Date.now() < suppressClickUntil || dragged) return;
suppressClickUntil = Date.now() + 350;
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
};
document.addEventListener('pointerup', (e) => {
if (e.pointerId !== dragPointerId) return;
const wasDragged = dragged;
dragPointerId = null;
dragged = false;
delete popup.dataset.dragged;
emitDragEvent(false);
if (!wasDragged) {
window.dispatchEvent(new CustomEvent('earth:open-details-tab'));
if (wasDragged) {
suppressClickUntil = Date.now() + 300;
}
});
@@ -1458,12 +1719,13 @@ function ensurePopupClickHandler() {
if (e.pointerId === dragPointerId) {
dragPointerId = null;
dragged = false;
delete popup.dataset.dragged;
emitDragEvent(false);
}
});
// Block click from bubbling to document (which would close the drawer)
popup.addEventListener('click', (e) => e.stopPropagation());
// Open details drawer on click (after pointerup/touchend completes so synthesized click does not hit overlay)
popup.addEventListener('click', triggerOpen);
}
const CARD_CONFIG = {
@@ -1707,6 +1969,9 @@ function setupInfoCardDrag(panel) {
isDragging = false;
activePointerId = null;
panel.classList.remove('is-dragging');
panel.dataset.userPlaced = 'true';
panel.dataset.dragged = 'false';
panel.dataset.lastUserDragAt = String(performance.now());
document.body.style.userSelect = '';
emitDragEvent();
};
@@ -1727,6 +1992,9 @@ function setupInfoCardDrag(panel) {
);
panel.style.left = `${nextLeft}px`;
panel.style.top = `${nextTop}px`;
panel.dataset.userPlaced = 'true';
panel.dataset.dragged = 'true';
panel.dataset.lastUserDragAt = String(performance.now());
emitDragEvent();
};
@@ -1848,25 +2116,25 @@ function positionPanel(panel, x, y, options = {}) {
panel.style.bottom = 'calc(84px + env(safe-area-inset-bottom, 0px))';
return;
}
const margin = 12;
const offset = 14;
const margin = 16;
const offset = 160;
const vpW = window.innerWidth;
const vpH = window.innerHeight;
const scale = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--hud-scale')
) || 1;
const estW = Math.min(340 * scale, vpW - 32);
const estH = Math.min(420 * scale, vpH * 0.7);
const actualW = panel.offsetWidth > 0 ? panel.offsetWidth : Math.min(340 * scale, vpW - 32);
const actualH = panel.offsetHeight > 0 ? panel.offsetHeight : Math.min(280 * scale, vpH * 0.7);
if (options.absolute === true) {
const clampedLeft = Math.min(
Math.max(margin, x),
Math.max(margin, vpW - estW - margin),
Math.max(margin, vpW - actualW - margin),
);
const clampedTop = Math.min(
Math.max(margin, y),
Math.max(margin, vpH - estH - margin),
Math.max(margin, vpH - actualH - margin),
);
panel.style.left = `${clampedLeft}px`;
panel.style.top = `${clampedTop}px`;
@@ -1876,10 +2144,10 @@ function positionPanel(panel, x, y, options = {}) {
}
let left = x + offset;
let top = y + offset;
let top = y + 60;
if (left + estW > vpW - margin) left = x - estW - offset;
if (top + estH > vpH - margin) top = Math.max(margin, vpH - estH - margin);
if (left + actualW > vpW - margin) left = Math.max(margin, x - actualW - offset);
if (top + actualH > vpH - margin) top = Math.max(margin, vpH - actualH - margin);
panel.style.left = `${Math.max(margin, left)}px`;
panel.style.top = `${Math.max(margin, top)}px`;
@@ -1898,6 +2166,9 @@ function showPanel(x, y, options = {}) {
panel.dataset.preserveOnCruiseInterrupt =
options.preserveOnCruiseInterrupt === true ? 'true' : 'false';
panel.removeAttribute('hidden');
delete panel.dataset.userPlaced;
delete panel.dataset.dragged;
delete panel.dataset.lastUserDragAt;
panel.setAttribute('aria-hidden', options.reveal === false ? 'true' : 'false');
if (x != null && y != null) positionPanel(panel, x, y, options);
if (options.reveal === false) {
@@ -1914,7 +2185,7 @@ function showPanel(x, y, options = {}) {
);
}
function hidePanel() {
export function hidePanel() {
panelShowToken += 1;
const panel = getPanel();
if (panel) {
@@ -1922,6 +2193,9 @@ function hidePanel() {
panel.classList.remove('hud-panel-info--anchor-stable');
delete panel.dataset.sticky;
delete panel.dataset.preserveOnCruiseInterrupt;
delete panel.dataset.userPlaced;
delete panel.dataset.dragged;
delete panel.dataset.lastUserDragAt;
panel.setAttribute('aria-hidden', 'true');
panel.setAttribute('hidden', '');
}
@@ -1934,7 +2208,10 @@ function hidePanel() {
export function isInfoCardSticky() {
return getPanel()?.dataset.sticky === 'true';
return (
getPanel()?.dataset.sticky === 'true' ||
document.getElementById('earth-mobile-popup')?.dataset.sticky === 'true'
);
}
export function shouldPreserveInfoCardOnCruiseInterrupt() {
@@ -1957,36 +2234,7 @@ export function showInfoCard(type, data, options = {}) {
pendingMobileDetailState = { type, config, data };
ensureMobileDetailsListener();
// Fill drawer details slot (accessible when user taps popup → opens details tab)
const icon = document.getElementById('mobile-info-card-icon');
const title = document.getElementById('mobile-info-card-title');
const typeLabel = document.getElementById('mobile-info-card-type');
const content = document.getElementById('mobile-info-card-content');
if (icon) icon.textContent = config.icon;
if (title) {
title.textContent = type === 'news'
? getNewsCardTitle(data)
: infoText(config.title);
}
if (typeLabel) {
typeLabel.textContent = type === 'news'
? infoText('新闻信号')
: type.replaceAll('_', ' ');
}
if (content && type !== 'news') {
renderMobileDetailContent(type, config, data);
renderedMobileDetailKey = null;
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
renderMobileNewsCardContent(content, data);
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
}
if (content) {
mobileCardImage ||= mountCardImage(content, document.getElementById('mobile-info-card-title')?.closest('.earth-mobile-detail-header'));
void mobileCardImage.show(cardImageIdentity(type, data));
}
renderNativeMobileDetail(type, config, data);
// Show the floating mini popup near the touch point (requires coordinates)
if (options.x != null && options.y != null) {
@@ -2025,16 +2273,11 @@ export function showInfoCard(type, data, options = {}) {
export function hideInfoCard() {
desktopCardImage?.hide();
mobileCardImage?.hide();
getMobileDetailsImagePresenter()?.hide();
stopTypewriterAnimation();
if (document.body.classList.contains('layout-mode-mobile')) {
hideMobilePopup();
document.body.classList.remove('earth-info-open');
window.dispatchEvent(
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
pendingMobileDetailState = null;
renderedMobileDetailKey = null;
return;
}
hideMobilePopup();
renderNativeMobileDetail(null, null, null);
pendingMobileDetailState = null;
renderedMobileDetailKey = null;
hidePanel();
}

View File

@@ -296,7 +296,9 @@ function registerComputeCenterStartupTask() {
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
if (error?.name !== "AbortError") {
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
}
}
if (context.isCancelled()) return;
await context.yieldFrame(16);

View File

@@ -1,5 +1,6 @@
import { bgpEventIdentity } from "./bgp-identity.js";
import { normalizeComputeSiteType } from "./compute-site-types.js";
import { refreshEarthUpdateBatch } from "./earth-update-batch.js";
import { createFrameProfile } from "./frame-profile.js";
import { createFrameStageRunner } from "./frame-stages.js";
import { initNewsRegionCards } from "./news-region-cards.js";
@@ -296,11 +297,13 @@ import { CruiseSequencer } from "./cruise-sequencer.js";
import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js";
import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
import { createMotionCruiseAdapter } from "./motion-cruise-adapter.js";
import { PresentationController } from "./presentation-controller.js";
import { PresentationController, isTargetElementVisible } from "./presentation-controller.js";
import {
getComputeCenterLocationBatchState,
showInfoCard,
hideInfoCard,
hideMobilePopup,
hidePanel,
isInfoCardSticky,
shouldPreserveInfoCardOnCruiseInterrupt,
} from "./info-card.js";
@@ -465,6 +468,7 @@ let pointerDragDistance = 0;
let dragStartPointer = { x: 0, y: 0 };
let suppressNextClick = false;
let shouldRefreshSatellitesAfterVisibilityResume = false;
let currentSelectedCandidate = null;
const EARTH_UPDATES_CHANNEL = "earth_updates";
const EARTH_UPDATES_RECONNECT_DELAY_MS = 5000;
@@ -527,6 +531,8 @@ const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column *",
"#info-panel",
"#info-panel *",
"#earth-mobile-popup",
"#earth-mobile-popup *",
"#right-toolbar-group",
"#right-toolbar-group *",
"#legend",
@@ -587,7 +593,7 @@ function syncRendererViewport() {
if (!camera || !renderer) return;
camera.aspect = getViewportAspect();
camera.updateProjectionMatrix();
const pixelRatio = window.devicePixelRatio || 1;
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
if (renderer.getPixelRatio() !== pixelRatio) renderer.setPixelRatio(pixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
}
@@ -705,6 +711,7 @@ function clearRuntimeSelection() {
lockedSatellite = null;
lockedSatelliteIndex = null;
setLockedSatelliteIndex(null);
currentSelectedCandidate = null;
}
export function clearLockedObject() {
@@ -1768,12 +1775,7 @@ function confirmInteractionCandidate(candidate, { showConfirmationFeedback = tru
}
motionFocusedCandidate = candidate;
const presentation = presentSelectedObject(candidate);
if (showConfirmationFeedback) {
presentation.then(presented => {
if (presented) window.dispatchEvent(new CustomEvent("earth:open-details-tab"));
});
}
presentSelectedObject(candidate);
if (showConfirmationFeedback) {
showSelectionStatus(getInteractionCandidateLabel(candidate));
}
@@ -2421,6 +2423,24 @@ async function focusSearchVessel(marker) {
showSelectionStatus(marker.userData?.name || marker.userData?.mmsi || "未知船只");
}
window.addEventListener("earth:focus-selected-object", () => {
if (lockedObjectType === "satellite" && lockedSatellite) {
focusSearchSatellite(lockedSatelliteIndex);
} else if (lockedObjectType === "cable" && lockedObject) {
focusSearchCable(lockedObject);
} else if ((lockedObjectType === "bgp" || lockedObjectType === "bgp_collector") && lockedObject) {
focusSearchBGPMarker(lockedObject);
} else if (lockedObjectType === "compute_center" && lockedObject) {
focusSearchComputeCenter(lockedObject);
} else if (lockedObjectType === "vessel" && lockedObject) {
focusSearchVessel(lockedObject);
} else if (lockedObjectType === "landing_point" && lockedObject) {
focusSearchLandingPoint(lockedObject);
} else if (lockedObject?.userData?.latitude != null && lockedObject?.userData?.longitude != null) {
focusSearchTarget({ lat: Number(lockedObject.userData.latitude), lon: Number(lockedObject.userData.longitude) });
}
});
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
@@ -2830,16 +2850,55 @@ function updateBGPHud(bgpResult) {
setEarthStatValue("bgp-status-summary", getBGPStatusText(bgpResult));
}
function getCurrentSelectionCandidate() {
if (currentSelectedCandidate) return currentSelectedCandidate;
if (lockedObjectType === "satellite" && lockedSatellite) {
return { type: "satellite", object: lockedSatellite, index: lockedSatelliteIndex };
}
if (lockedObjectType === "cable" && lockedObject) {
return { type: "cable", object: lockedObject };
}
if ((lockedObjectType === "bgp" || lockedObjectType === "bgp_collector") && lockedObject) {
return { type: lockedObjectType, object: lockedObject };
}
if (lockedObjectType === "compute_center" && lockedObject) {
return { type: "compute_center", object: lockedObject };
}
if (lockedObjectType === "vessel" && lockedObject) {
return { type: "vessel", object: lockedObject };
}
if (lockedObjectType === "landing_point" && lockedObject) {
return { type: "landing_point", object: lockedObject };
}
if (lockedObjectType === "earth_interactable" && lockedObject) {
return { type: "earth_interactable", object: lockedObject };
}
return null;
}
function presentSelectedObject(candidate) {
if (!candidate) return;
currentSelectedCandidate = candidate;
revealLegendObject(candidate.type, candidate.object?.userData);
const targetProvider = () => document.getElementById(
document.body.classList.contains("layout-mode-mobile") ? "earth-mobile-popup" : "info-panel",
);
const targetProvider = () => {
const isMobile = document.body.classList.contains("layout-mode-mobile");
const primaryId = isMobile ? "earth-mobile-popup" : "info-panel";
const primaryEl = document.getElementById(primaryId);
if (isTargetElementVisible(primaryEl)) {
return primaryEl;
}
const secondaryId = isMobile ? "info-panel" : "earth-mobile-popup";
const secondaryEl = document.getElementById(secondaryId);
if (isTargetElementVisible(secondaryEl)) {
return secondaryEl;
}
return primaryEl || secondaryEl;
};
const sourceProvider = createObjectCalloutAnchor(candidate, {
getEarth, getCamera: () => camera, getCablesById, getSatellitePoints, getSatellitePositions, getSatelliteData,
getObstacles: () => {
const target = targetProvider();
if (!target || target.hidden) return [];
if (!isTargetElementVisible(target)) return [];
const rect = target.getBoundingClientRect();
return [{ x: rect.x, y: rect.y, width: rect.width, height: rect.height }];
},
@@ -2847,9 +2906,10 @@ function presentSelectedObject(candidate) {
const source = sourceProvider() || getMotionCandidateScreenCoords(candidate);
const mobile = document.body.classList.contains("layout-mode-mobile");
const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const estInitialHeight = candidate?.type === "satellite" ? 270 : (candidate?.type === "cable" ? 220 : 300);
const size = mobile ? { width: Math.min(280, innerWidth - 24), height: 100 } :
{ width: Math.min(340 * scale, innerWidth - 32), height: Math.min(420 * scale, innerHeight * 0.7) };
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel)")]
{ width: Math.min(340 * scale, innerWidth - 32), height: Math.min(estInitialHeight * scale, innerHeight * 0.7) };
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card), .news-region-card.is-spotlight, .compute-card.is-spotlight")]
.filter(element => !element.hidden && getComputedStyle(element).visibility !== "hidden")
.map(element => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; })
.filter(rect => rect.width > 0 && rect.height > 0);
@@ -2864,6 +2924,14 @@ function presentSelectedObject(candidate) {
owner: "selection",
card: {
render: ({ reveal }) => {
if (reveal) {
const target = targetProvider();
const curLeft = Number.parseFloat(target?.style?.left);
const curTop = Number.parseFloat(target?.style?.top);
if (Number.isFinite(curLeft) && Number.isFinite(curTop)) {
placement = { ...placement, x: curLeft, y: curTop };
}
}
renderCard(reveal);
if (!reveal) {
const target = targetProvider();
@@ -2892,9 +2960,12 @@ function presentSelectedObject(candidate) {
options: { routingMode: "adaptive", sourceGapPx: 0, targetGapPx: 8, obstacleClearancePx: 8 },
},
lifetime: { mode: "persistent" },
onDismiss: () => {
onDismiss: (reason) => {
connector.connectorEl?.classList.remove("is-rear");
if (connector.connectorEl) delete connector.connectorEl.dataset.owner;
if (reason !== "layout_mode_change" && reason !== "replace") {
currentSelectedCandidate = null;
}
},
}).catch(error => console.warn("物件连线展示失败:", error));
}
@@ -3071,14 +3142,23 @@ function getInteractableCruiseCardPlacement() {
}
function getVisiblePresentationCardTarget() {
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;
const isMobile = document.body.classList.contains("layout-mode-mobile");
if (isMobile) {
const mobilePopup = document.getElementById("earth-mobile-popup");
if (isTargetElementVisible(mobilePopup)) {
return mobilePopup;
}
} else {
const infoPanel = document.getElementById("info-panel");
if (isTargetElementVisible(infoPanel)) {
return infoPanel;
}
}
const mobilePopup = document.getElementById("earth-mobile-popup");
if (isTargetElementVisible(mobilePopup)) return mobilePopup;
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
if (isTargetElementVisible(infoPanel)) return infoPanel;
return null;
}
function getInteractableCruiseItemId(candidate) {
@@ -4349,7 +4429,7 @@ export function init() {
let topicPreviousVisibility = null;
const regionCardRoot = document.createElement("section");
regionCardRoot.id = "earth-region-callouts";
document.body.append(regionCardRoot);
(container || document.body).append(regionCardRoot);
regionCallouts = createCalloutGroups(createSpatialCallouts({
root: regionCardRoot,
getSpatialContext: () => ({
@@ -4405,7 +4485,7 @@ export function init() {
bindListener(window, "earth:cluster-open", () => { interruptCruisePresentation(); setAutoRotate(false); clearTransientHoverState(); hideTooltip(); });
bindListener(window, "earth:spiderfy-select", ({ detail }) => {
const marker = detail?.marker;
if (marker && !marker.userData.legend_filtered) confirmInteractionCandidate({ type:marker.userData.type, object:marker, screen:detail.screen });
if (marker && !marker.userData.legend_filtered) confirmInteractionCandidate({ type:marker.userData.type, object:marker, screen:detail.screen }, { showConfirmationFeedback: false });
});
initLegend();
bindListener(window, LEGEND_FILTER_EVENT, ({ detail }) => {
@@ -4598,6 +4678,17 @@ export function applyMotionConfirm() {
function registerGlobalApi() {
window.__planetEarth = {
reloadData,
getEarth: () => getEarth(),
getScene: () => scene,
getSatellitePoints: () => getSatellitePoints(),
setSatellitesEnabled: (enabled) => setSatellitesEnabled(enabled),
selectSatellite: (idx = 0) => {
const sat = getSatelliteAt(idx);
if (sat) return confirmInteractionCandidate({ type: "satellite", index: idx, object: sat });
return false;
},
presentSelectedObject: (c) => presentSelectedObject(c),
getPresentationController: () => ensurePresentationController(),
clearSelection: () => {
presentationController?.dismiss?.("user_close");
hideInfoCard();
@@ -4883,18 +4974,16 @@ async function flushPendingEarthUpdates() {
if (layers.length === 0) return Promise.resolve();
earthUpdateFlushPromise = (async () => {
if (isDatabaseChange) {
await loadEarthStatsSummary({
const results = await refreshEarthUpdateBatch({
layers,
strategies,
isDatabaseChange,
loadSummary: () => loadEarthStatsSummary({
shouldApply: () => !destroyed,
cacheBust: true,
});
}
const results = await Promise.allSettled(
layers.map((layer) => refreshEarthLayerFromUpdate(layer, {
force: isDatabaseChange,
strategy: strategies.get(layer),
})),
);
}),
refreshLayer: refreshEarthLayerFromUpdate,
});
const failed = results.some((result) => result.status === "rejected");
if (failed) {
console.warn("部分 Earth 图层实时刷新失败:", { layers, results });
@@ -5665,6 +5754,17 @@ function setupEventListeners() {
bindListener(window, "earth:compute-center-location-batch-change", () => syncComputeCenterUnresolvedCount());
bindListener(window, "earth:motion-debug-mode-change", handleMotionDebugModeChange);
bindListener(window, MOTION_DEBUG_CLOSE_EVENT, handleMotionDebugClose);
bindListener(window, "earth:layout-mode-change", () => {
const candidate = getCurrentSelectionCandidate();
if (candidate && presentationController?.isActive("selection")) {
presentationController.dismiss("layout_mode_change");
hideMobilePopup();
hidePanel();
window.requestAnimationFrame(() => {
presentSelectedObject(candidate);
});
}
});
getCruiseModuleDefinitions().forEach((module) => {
if (!module.externalEventName) return;
bindListener(window, module.externalEventName, (event) => {
@@ -5679,6 +5779,14 @@ function setupEventListeners() {
bindListener(renderer.domElement, "click", handleClick);
if (renderer?.domElement) {
renderer.domElement.style.touchAction = "none";
renderer.domElement.addEventListener("touchmove", (e) => {
e.preventDefault();
}, { passive: false });
renderer.domElement.addEventListener("touchstart", (e) => {
if (e.touches && e.touches.length > 1) {
e.preventDefault();
}
}, { passive: false });
}
}
@@ -5716,6 +5824,9 @@ function onWindowResize() {
syncRendererViewport();
repositionCruiseConnector();
repositionMotionConnector();
if (presentationController?.isActive("selection")) {
presentationController.update({ animate: false });
}
}
function getFrontFacingCables(cableLines) {
@@ -6108,6 +6219,7 @@ function onPointerDown(event) {
clearDocumentSelection();
if (event.pointerType === "touch") {
event.preventDefault();
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
clientY: event.clientY,
@@ -6141,6 +6253,9 @@ function onPointerMove(event) {
}
if (event.pointerType === "touch") {
if (activeDragPointerId === event.pointerId || pinchGesture) {
event.preventDefault();
}
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,

View File

@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
function createMockElement(id) {
const classes = new Set();
const attributes = new Map();
return {
id,
disabled: false,
classList: {
add: (cls) => classes.add(cls),
remove: (cls) => classes.delete(cls),
toggle: (cls, force) => {
if (force === undefined) {
if (classes.has(cls)) classes.delete(cls);
else classes.add(cls);
} else if (force) {
classes.add(cls);
} else {
classes.delete(cls);
}
return classes.has(cls);
},
contains: (cls) => classes.has(cls),
},
setAttribute: (name, value) => attributes.set(name, String(value)),
getAttribute: (name) => attributes.get(name) ?? null,
removeAttribute: (name) => attributes.delete(name),
};
}
// Set up minimal DOM before importing news.js
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const elements = new Map();
globalThis.window = {
addEventListener: () => {},
removeEventListener: () => {},
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
globalThis.document = {
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
querySelectorAll: () => [],
body: {
classList: {
contains: () => false,
},
},
};
const { applyManualRefreshBusy, setManualRefreshBusy } = await import("./news.js");
describe("news refresh button animation state", () => {
let desktopBtn;
let mobileBtn;
beforeEach(() => {
desktopBtn = createMockElement("news-refresh");
mobileBtn = createMockElement("mobile-news-refresh");
elements.set("news-refresh", desktopBtn);
elements.set("mobile-news-refresh", mobileBtn);
});
afterEach(() => {
setManualRefreshBusy(false);
elements.clear();
});
test("applyManualRefreshBusy activates is-refreshing animation and aria-busy attributes", () => {
applyManualRefreshBusy(true);
expect(desktopBtn.classList.contains("is-refreshing")).toBe(true);
expect(desktopBtn.disabled).toBe(true);
expect(desktopBtn.getAttribute("aria-busy")).toBe("true");
expect(mobileBtn.classList.contains("is-refreshing")).toBe(true);
expect(mobileBtn.disabled).toBe(true);
expect(mobileBtn.getAttribute("aria-busy")).toBe("true");
applyManualRefreshBusy(false);
expect(desktopBtn.classList.contains("is-refreshing")).toBe(false);
expect(desktopBtn.disabled).toBe(false);
expect(desktopBtn.getAttribute("aria-busy")).toBe("false");
expect(mobileBtn.classList.contains("is-refreshing")).toBe(false);
expect(mobileBtn.disabled).toBe(false);
expect(mobileBtn.getAttribute("aria-busy")).toBe("false");
});
test("setManualRefreshBusy holds animation for minimum smooth spin duration", async () => {
setManualRefreshBusy(true);
expect(desktopBtn.classList.contains("is-refreshing")).toBe(true);
// Immediate completion should keep button in spinning state to avoid visual jump
setManualRefreshBusy(false);
expect(desktopBtn.classList.contains("is-refreshing")).toBe(true);
expect(desktopBtn.disabled).toBe(true);
// After minSpinMs (600ms) has elapsed, it resets
await new Promise((resolve) => setTimeout(resolve, 650));
expect(desktopBtn.classList.contains("is-refreshing")).toBe(false);
expect(desktopBtn.disabled).toBe(false);
});
});

View File

@@ -0,0 +1,96 @@
const DEFAULT_POLL_INTERVAL_MS = 1000;
const DEFAULT_WAIT_TIMEOUT_MS = 135_000;
function waitForPoll(delayMs, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort);
resolve();
}, delayMs);
const abort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal.addEventListener("abort", abort, { once: true });
});
}
export function createNewsRefreshController({
submit,
getStatus,
loadLatest,
onBusyChange,
onSuccess,
onFailure,
onCancelled = () => {},
now = Date.now,
wait = waitForPoll,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
}) {
let active = null;
function cancel(reason = "navigation") {
if (!active) return;
const run = active;
active = null;
run.controller.abort();
onBusyChange(false);
if (reason === "view_changed") onCancelled(reason);
}
function start(context) {
if (active) return active.promise;
const run = { controller: new AbortController(), promise: null, context };
active = run;
onBusyChange(true);
run.promise = (async () => {
const signal = run.controller.signal;
const deadline = now() + waitTimeoutMs;
try {
const accepted = await submit(context, signal);
if (signal.aborted || active !== run) return;
if (!accepted?.refresh_id) throw new Error("missing_refresh_id");
let status = accepted;
while (status.status === "pending" || status.status === "running") {
if (now() >= deadline) throw Object.assign(new Error("refresh_timeout"), { code: "timeout" });
await wait(pollIntervalMs, signal);
if (now() >= deadline) throw Object.assign(new Error("refresh_timeout"), { code: "timeout" });
status = await getStatus(accepted.refresh_id, signal);
if (signal.aborted || active !== run) return;
if (now() >= deadline && status.status !== "failed") {
throw Object.assign(new Error("refresh_timeout"), { code: "timeout" });
}
}
if (status.status === "failed") {
throw Object.assign(new Error(status.error?.message || "refresh_failed"), {
code: status.error?.code || "refresh_failed",
});
}
if (status.status !== "succeeded") throw new Error("invalid_refresh_status");
if (signal.aborted || active !== run) return;
await loadLatest(context, signal);
if (!signal.aborted && active === run) onSuccess();
} catch (error) {
if (!signal.aborted && active === run) onFailure(error);
} finally {
if (active === run) {
active = null;
onBusyChange(false);
}
}
})();
return run.promise;
}
return {
start,
cancel,
isBusy: () => active !== null,
activeContext: () => active?.context || null,
};
}

View File

@@ -0,0 +1,116 @@
import { expect, test } from "bun:test";
import { createNewsRefreshController } from "./news-refresh-controller.js";
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
test("news refresh stays busy until the succeeded feed is rendered", async () => {
const feed = deferred();
const events = [];
let polls = 0;
const controller = createNewsRefreshController({
submit: async () => ({ refresh_id: "one", status: "pending" }),
getStatus: async () => (++polls === 1
? { status: "running" }
: { status: "succeeded" }),
loadLatest: async () => { events.push("load-feed"); await feed.promise; },
onBusyChange: (busy) => events.push(busy ? "busy" : "ready"),
onSuccess: () => events.push("success"),
onFailure: () => events.push("failure"),
wait: async () => {},
});
const run = controller.start({ region: "global" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toEqual(["busy", "load-feed"]);
expect(controller.isBusy()).toBe(true);
feed.resolve();
await run;
expect(events).toEqual(["busy", "load-feed", "success", "ready"]);
expect(polls).toBe(2);
});
test("a failed refresh unlocks both controls and a retry can succeed", async () => {
const events = [];
let submissions = 0;
const controller = createNewsRefreshController({
submit: async () => ({ refresh_id: String(++submissions), status: "pending" }),
getStatus: async (id) => id === "1"
? { status: "failed", error: { code: "source_unavailable", message: "Sources unavailable" } }
: { status: "succeeded" },
loadLatest: async () => events.push("load-feed"),
onBusyChange: (busy) => events.push(busy ? "busy" : "ready"),
onSuccess: () => events.push("success"),
onFailure: (error) => events.push(error.code),
wait: async () => {},
});
const first = controller.start({ region: "global" });
expect(controller.start({ region: "global" })).toBe(first);
await first;
expect(events).toEqual(["busy", "source_unavailable", "ready"]);
await controller.start({ region: "global" });
expect(events).toEqual(["busy", "source_unavailable", "ready", "busy", "load-feed", "success", "ready"]);
expect(submissions).toBe(2);
});
test("navigation cancellation does not report success after a late response", async () => {
const status = deferred();
const events = [];
const controller = createNewsRefreshController({
submit: async () => ({ refresh_id: "one", status: "pending" }),
getStatus: async () => status.promise,
loadLatest: async () => events.push("load-feed"),
onBusyChange: (busy) => events.push(busy ? "busy" : "ready"),
onSuccess: () => events.push("success"),
onFailure: () => events.push("failure"),
wait: async () => {},
});
const run = controller.start({ region: "global" });
await new Promise((resolve) => setTimeout(resolve, 0));
controller.cancel();
status.resolve({ status: "succeeded" });
await run;
expect(events).toEqual(["busy", "ready"]);
});
test("a stalled background refresh times out and unlocks retry", async () => {
const events = [];
let clock = 0;
const controller = createNewsRefreshController({
submit: async () => ({ refresh_id: "slow", status: "pending" }),
getStatus: async () => ({ status: "running" }),
loadLatest: async () => events.push("load-feed"),
onBusyChange: (busy) => events.push(busy ? "busy" : "ready"),
onSuccess: () => events.push("success"),
onFailure: (error) => events.push(error.code),
now: () => clock,
wait: async (delay) => { clock += delay; },
waitTimeoutMs: 2500,
});
await controller.start({ region: "global" });
expect(events).toEqual(["busy", "timeout", "ready"]);
expect(controller.isBusy()).toBe(false);
});
test("a request failure releases busy state without loading a stale feed", async () => {
const events = [];
const controller = createNewsRefreshController({
submit: async () => { throw Object.assign(new Error("offline"), { code: "request_failed" }); },
getStatus: async () => { throw new Error("status should not be polled"); },
loadLatest: async () => events.push("load-feed"),
onBusyChange: (busy) => events.push(busy ? "busy" : "ready"),
onSuccess: () => events.push("success"),
onFailure: (error) => events.push(error.code),
});
await controller.start({ region: "global" });
expect(events).toEqual(["busy", "request_failed", "ready"]);
expect(controller.isBusy()).toBe(false);
});

View File

@@ -41,7 +41,7 @@ export function initNewsRegionCards({ rootParent, spatial }) {
card.writer.start(target,summary,{maxDurationMs:4000,
isPaused:()=>!enabled || document.hidden || card.panel.isCollapsed() || (mobile
? !root.getClientRects().length || document.getElementById("mobile-drawer-shell")?.getAttribute("aria-hidden") === "true"
: card.element.dataset.calloutPhase !== "visible"),
: card.element.dataset.calloutPhase !== "visible" || !card.element.classList.contains("is-spotlight")),
onComplete:()=>{card.typing=false;model.restart(region.key);},
});
},
@@ -102,12 +102,18 @@ export function initNewsRegionCards({ rootParent, spatial }) {
function syncLayout() {
mobile=isMobile();
const parent=mobile ? document.getElementById("mobile-news-region-cards") : rootParent;
if (mobile) {
root.hidden = true;
cards.forEach(card=>{card.writer.stop();card.typing=false;card.signature=null;});
spatial.set([]);
return;
}
const parent=rootParent;
if(parent && root.parentElement !== parent) parent.append(root);
root.hidden=!enabled;
if(!enabled) cards.forEach(card=>{card.writer.stop();card.typing=false;card.signature=null;});
root.classList.toggle("is-mobile",mobile);
spatial.set(enabled && !mobile ? NEWS_CARD_REGIONS.map(region=>({
root.classList.toggle("is-mobile",false);
spatial.set(enabled ? NEWS_CARD_REGIONS.map(region=>({
key:`news:${region.key}`,anchor:region.anchor,element:cards.get(region.key).element,
})) : []);
model.resetClocks(performance.now());

View File

@@ -1,8 +1,10 @@
import { showStatusMessage } from "./ui.js";
import { getNewsBreakingState, isBreakingActive, normalizeBreakingLevel } from "./news-breaking.js";
import { createNewsRefreshController } from "./news-refresh-controller.js";
import {
applyEarthI18n,
earthMessage,
formatEarthMessage,
getEarthLocale,
onEarthLocaleChange,
translateText,
@@ -36,6 +38,7 @@ import {
// Mobile keeps its existing drawer page.
const EARTH_NEWS_API = "/api/v1/news/earth-feed";
const EARTH_NEWS_REFRESH_API = "/api/v1/news/refresh";
const FOCUS_UPDATE_INTERVAL_MS = 4000;
const DATA_REFRESH_INTERVAL_MS = 180000;
const MIN_REGION_SWITCH_INTERVAL_MS = 2500;
@@ -53,6 +56,10 @@ let initialized = false;
let refreshPromise = null;
let refreshRequestKey = "";
let activeRefreshToken = 0;
let newsPageUnloading = false;
const pendingNewsRequests = new Set();
const pendingNewsFeedRequests = new Set();
let manualRefreshController = null;
let payload = null;
let lastFocus = null;
let lastFetchAt = 0;
@@ -299,6 +306,15 @@ function setNewsHudOpen(open, { highlightId = null } = {}) {
}
function openNewsHud(options = {}) {
if (document.documentElement.classList.contains("layout-mode-mobile") || document.body.classList.contains("layout-mode-mobile")) {
window.dispatchEvent(new CustomEvent("earth:open-mobile-drawer", { detail: { card: "news" } }));
if (options.highlightId) {
window.setTimeout(() => {
selectNewsItem(options.highlightId, { scrollIntoView: true });
}, 50);
}
return;
}
setNewsHudOpen(true, options);
}
@@ -776,10 +792,12 @@ function stopTickerBreakingWatcher() {
if (tickerBreakingTimer !== null) window.clearTimeout(tickerBreakingTimer);
tickerBreakingTimer = null;
document.getElementById("desktop-news-ticker")?.classList.remove(
"is-breaking-active",
"has-breaking-watch",
"has-breaking-breaking",
"has-breaking-critical",
);
document.getElementById("desktop-news-ticker")?.removeAttribute("data-breaking-level");
}
function updateTickerBreakingState() {
@@ -802,13 +820,23 @@ function updateTickerBreakingState() {
best = { overlap, distance, level: normalizeBreakingLevel(item.dataset.breakingLevel) };
}
}
ticker.classList.remove("has-breaking-watch", "has-breaking-breaking", "has-breaking-critical");
if (best?.level && best.level !== "none") ticker.classList.add(`has-breaking-${best.level}`);
const currentLevel = ticker.dataset.breakingLevel || "none";
const nextLevel = best
? normalizeBreakingLevel(best.level)
: currentLevel !== "none"
? currentLevel
: "none";
ticker.classList.toggle("is-breaking-active", nextLevel !== "none");
if (ticker.dataset.breakingLevel !== nextLevel) {
ticker.classList.remove("has-breaking-watch", "has-breaking-breaking", "has-breaking-critical");
if (nextLevel !== "none") ticker.classList.add(`has-breaking-${nextLevel}`);
ticker.dataset.breakingLevel = nextLevel;
}
tickerBreakingTimer = window.setTimeout(updateTickerBreakingState, 120);
}
function scheduleTickerBreakingWatcher() {
stopTickerBreakingWatcher();
if (tickerBreakingTimer !== null) return;
tickerBreakingTimer = window.setTimeout(updateTickerBreakingState, 0);
}
@@ -1108,20 +1136,184 @@ async function fetchNews(lat, lon, context = {}) {
url.searchParams.set("locale", getEarthLocale());
const controller = new AbortController();
pendingNewsRequests.add(controller);
pendingNewsFeedRequests.add(controller);
const abort = () => controller.abort();
if (context.signal?.aborted) controller.abort();
else context.signal?.addEventListener("abort", abort, { once: true });
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(url.toString(), {
cache: "no-store",
signal: controller.signal,
}).finally(() => {
try {
const response = await fetch(url.toString(), {
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`新闻源请求失败: ${response.status}`);
}
return await response.json();
} finally {
window.clearTimeout(timeoutId);
});
if (!response.ok) {
throw new Error(`新闻源请求失败: ${response.status}`);
pendingNewsRequests.delete(controller);
pendingNewsFeedRequests.delete(controller);
context.signal?.removeEventListener("abort", abort);
}
return response.json();
}
async function refreshNews(lat, lon, { silent = false, forcePanelRender = false } = {}) {
async function fetchNewsRefreshJson(url, options = {}, signal) {
const controller = new AbortController();
pendingNewsRequests.add(controller);
const abort = () => controller.abort();
if (signal?.aborted) controller.abort();
else signal?.addEventListener("abort", abort, { once: true });
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { cache: "no-store", ...options, signal: controller.signal });
if (!response.ok) {
throw Object.assign(new Error(`HTTP ${response.status}`), { code: "request_failed" });
}
return await response.json();
} finally {
window.clearTimeout(timeoutId);
pendingNewsRequests.delete(controller);
signal?.removeEventListener("abort", abort);
}
}
async function requestNewsRefresh(lat, lon, signal) {
const body = { region: inferRegion(lat, lon) };
if (typeof lat === "number") body.lat = lat;
if (typeof lon === "number") body.lon = lon;
return fetchNewsRefreshJson(EARTH_NEWS_REFRESH_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}, signal);
}
function requestNewsRefreshStatus(refreshId, signal) {
return fetchNewsRefreshJson(`${EARTH_NEWS_REFRESH_API}/${encodeURIComponent(refreshId)}`, {}, signal);
}
function setNewsBoardStatus(message) {
for (const id of ["news-board-status", "mobile-news-board-status"]) {
const status = document.getElementById(id);
if (status) status.textContent = formatEarthMessage(message);
}
}
let manualRefreshBusyStartTime = 0;
let manualRefreshResetTimer = null;
function applyManualRefreshBusy(busy) {
for (const id of ["news-refresh", "mobile-news-refresh"]) {
const button = document.getElementById(id);
if (!button) continue;
button.disabled = busy;
button.setAttribute("aria-busy", String(busy));
button.classList.toggle("is-refreshing", busy);
}
if (busy) setNewsBoardStatus(earthMessage("status.newsRefreshing"));
}
function setManualRefreshBusy(busy) {
if (manualRefreshResetTimer) {
window.clearTimeout(manualRefreshResetTimer);
manualRefreshResetTimer = null;
}
if (busy) {
manualRefreshBusyStartTime = Date.now();
applyManualRefreshBusy(true);
} else {
const elapsed = Date.now() - manualRefreshBusyStartTime;
const minSpinMs = 600;
if (elapsed < minSpinMs) {
manualRefreshResetTimer = window.setTimeout(() => {
manualRefreshResetTimer = null;
applyManualRefreshBusy(false);
}, minSpinMs - elapsed);
} else {
applyManualRefreshBusy(false);
}
}
}
function invalidatePendingNewsFeed() {
activeRefreshToken += 1;
refreshPromise = null;
refreshRequestKey = "";
for (const controller of pendingNewsFeedRequests) controller.abort();
}
function isManualRefreshRegionCurrent(context) {
return !newsPageUnloading && (lastFocus?.region || "global") === context.region;
}
function manualRefreshFailureReason(error) {
if (error?.code === "source_unavailable") {
return formatEarthMessage(earthMessage("status.newsRefreshSourceUnavailable"));
}
if (error?.code === "timeout" || error?.name === "AbortError") {
return formatEarthMessage(earthMessage("status.newsRefreshTimeout"));
}
if (error?.code === "expired") {
return formatEarthMessage(earthMessage("status.newsRefreshExpired"));
}
if (error?.code === "request_failed" || error instanceof TypeError) {
return formatEarthMessage(earthMessage("status.newsRefreshNetworkError"));
}
return formatEarthMessage(earthMessage("status.newsRefreshProcessingError"));
}
function createManualNewsRefreshController() {
return createNewsRefreshController({
submit: ({ lat, lon }, signal) => requestNewsRefresh(lat, lon, signal),
getStatus: requestNewsRefreshStatus,
loadLatest: async (context, signal) => {
if (!isManualRefreshRegionCurrent(context)) throw new Error("view_changed");
const latest = await refreshNews(context.lat, context.lon, {
silent: true,
forcePanelRender: true,
manual: true,
signal,
shouldApply: () => isManualRefreshRegionCurrent(context),
});
if (!latest || !isManualRefreshRegionCurrent(context)) throw new Error("view_changed");
},
onBusyChange: setManualRefreshBusy,
onSuccess: () => {
const message = earthMessage("status.newsRefresh", { ok: true });
setNewsBoardStatus(message);
showStatusMessage(message, "success");
},
onFailure: (error) => {
const message = earthMessage("status.newsRefreshFailedReason", {
reason: manualRefreshFailureReason(error),
});
setNewsBoardStatus(message);
showStatusMessage(message, "error");
},
onCancelled: () => {
if (manualRefreshResetTimer) {
window.clearTimeout(manualRefreshResetTimer);
manualRefreshResetTimer = null;
}
applyManualRefreshBusy(false);
setNewsBoardStatus(earthMessage("status.newsRefreshViewChanged"));
},
});
}
async function refreshNews(lat, lon, {
silent = false,
forcePanelRender = false,
manual = false,
signal,
shouldApply = () => true,
} = {}) {
if (newsPageUnloading) return null;
if (manualRefreshController?.isBusy() && !manual) return null;
if (manual) invalidatePendingNewsFeed();
const targetRegion = inferRegion(lat, lon);
const categorySignature = getNewsCategorySignature();
const sourceSignature = getNewsSourceSignatureForFetch(lat, lon);
@@ -1133,18 +1325,15 @@ async function refreshNews(lat, lon, { silent = false, forcePanelRender = false
getEarthLocale(),
].join("|");
if (refreshPromise && refreshRequestKey === requestKey) return refreshPromise;
if (!manual && refreshPromise && refreshRequestKey === requestKey) return refreshPromise;
const requestToken = ++activeRefreshToken;
refreshRequestKey = requestKey;
const { status } = getElements();
if (status) {
status.textContent = "正在同步全球态势新闻...";
}
if (!silent) setNewsBoardStatus(translateText("正在同步全球态势新闻..."));
refreshPromise = fetchNews(lat, lon, { categorySignature, sourceSignature })
refreshPromise = fetchNews(lat, lon, { categorySignature, sourceSignature, signal })
.then((nextPayload) => {
if (requestToken !== activeRefreshToken) {
if (requestToken !== activeRefreshToken || !shouldApply() || signal?.aborted) {
return nextPayload;
}
renderPayload(nextPayload, { forcePanelRender });
@@ -1160,8 +1349,12 @@ async function refreshNews(lat, lon, { silent = false, forcePanelRender = false
applyEarthI18n();
return nextPayload;
})
.catch((error) => {
if (requestToken !== activeRefreshToken) {
.catch(async (error) => {
if (error instanceof TypeError && !newsPageUnloading) {
// Navigation can reject fetch before pagehide fires; let that lifecycle event run first.
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
if (requestToken !== activeRefreshToken || newsPageUnloading) {
return null;
}
console.error("加载 Earth RSS 新闻失败:", error);
@@ -1238,6 +1431,10 @@ export function updateNewsViewFocus(coords) {
return;
}
lastFocus = nextFocus;
if (manualRefreshController?.activeContext()?.region !== undefined &&
manualRefreshController.activeContext().region !== region) {
manualRefreshController.cancel("view_changed");
}
if (shouldRefresh) {
refreshNews(coords.lat, coords.lon, { silent: true }).catch(() => {});
}
@@ -1304,6 +1501,26 @@ export function clearSelectedNewsItem() {
export function initNewsPanel() {
if (initialized) return;
initialized = true;
manualRefreshController = createManualNewsRefreshController();
window.addEventListener("pagehide", () => {
newsPageUnloading = true;
if (manualRefreshResetTimer) {
window.clearTimeout(manualRefreshResetTimer);
manualRefreshResetTimer = null;
}
applyManualRefreshBusy(false);
manualRefreshController.cancel();
activeRefreshToken += 1;
refreshPromise = null;
refreshRequestKey = "";
lastFetchAt = 0;
for (const controller of pendingNewsRequests) controller.abort();
});
window.addEventListener("pageshow", (event) => {
if (!event.persisted) return;
newsPageUnloading = false;
refreshNews(lastFocus?.lat, lastFocus?.lon, { silent: true }).catch(() => {});
});
updateNewsToggleUI(true);
renderEmptyState(translateText("正在准备全球态势新闻聚合源..."));
@@ -1385,13 +1602,15 @@ export function initNewsPanel() {
["news-refresh", "mobile-news-refresh"].forEach((id) => {
const refreshBtn = document.getElementById(id);
refreshBtn?.addEventListener("click", async () => {
try {
await refreshNews(lastFocus?.lat, lastFocus?.lon, { forcePanelRender: true });
showStatusMessage(earthMessage("status.newsRefresh", { ok: true }), "info");
} catch {
showStatusMessage(earthMessage("status.newsRefresh", { ok: false }), "error");
}
refreshBtn?.addEventListener("click", () => {
if (manualRefreshController.isBusy() || manualRefreshResetTimer) return;
const context = {
lat: lastFocus?.lat,
lon: lastFocus?.lon,
region: lastFocus?.region || "global",
};
invalidatePendingNewsFeed();
void manualRefreshController.start(context);
});
});
@@ -1402,3 +1621,5 @@ export function initNewsPanel() {
refreshNews(undefined, undefined, { silent: true }).catch(() => {});
}
export { applyManualRefreshBusy, setManualRefreshBusy };

View File

@@ -1,34 +1,225 @@
import { packDirectionalEdgeCallouts } from "./callout-packing.js";
const MARGIN = 12;
const GAP = 64;
const GAP = 140;
const clamp = (value, low, high) => Math.max(low, Math.min(Math.max(low, high), value));
// Standard Liang-Barsky segment-AABB intersection test.
// Checks if the line segment from p1 to p2 cuts through an obstacle box.
function segmentIntersectsBox(p1, p2, box) {
const minX = box.x - 2;
const maxX = box.x + box.width + 2;
const minY = box.y - 2;
const maxY = box.y + box.height + 2;
if (p1.x >= minX && p1.x <= maxX && p1.y >= minY && p1.y <= maxY) return true;
if (p2.x >= minX && p2.x <= maxX && p2.y >= minY && p2.y <= maxY) return true;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
let t0 = 0;
let t1 = 1;
const p = [-dx, dx, -dy, dy];
const q = [p1.x - minX, maxX - p1.x, p1.y - minY, maxY - p1.y];
for (let i = 0; i < 4; i++) {
if (p[i] === 0) {
if (q[i] < 0) return false;
} else {
const t = q[i] / p[i];
if (p[i] < 0) {
if (t > t1) return false;
if (t > t0) t0 = t;
} else {
if (t < t0) return false;
if (t < t1) t1 = t;
}
}
}
return t0 <= t1;
}
// Reuse geographic cards' directional edge lanes; globe motion moves the connector,
// not the readable card. User dragging remains owned by the shared info panel.
export function placeObjectCallout(source, size, viewport, obstacles = []) {
const width = Math.min(size.width, viewport.width - MARGIN * 2);
const height = Math.min(size.height, viewport.height - MARGIN * 2);
const edgePlacement = packDirectionalEdgeCallouts([
{ key: "selection", point: source, size: { width, height }, minSourceDistance: GAP },
], obstacles, viewport)?.get("selection");
if (edgePlacement) return edgePlacement;
const positions = [
const isMobile = viewport.width <= 820 || (typeof document !== "undefined" && document.body?.classList?.contains?.("layout-mode-mobile"));
const margin = isMobile ? 14 : MARGIN;
const width = Math.min(size.width, viewport.width - margin * 2);
const height = Math.min(size.height, viewport.height - margin * 2);
const globe = viewport.globe || { x: viewport.width / 2, y: viewport.height / 2, radius: 360 };
const globeX = Number.isFinite(globe.x) ? globe.x : viewport.width / 2;
const previousPosition = viewport.previousPosition || null;
if (isMobile) {
const topHudEdge = 70;
const bottomHudEdge = viewport.height - 110;
const preferAbove = source.y > viewport.height * 0.52;
const targetX = clamp(source.x - width / 2, margin, viewport.width - width - margin);
const targetY = preferAbove
? clamp(source.y - height - 36, topHudEdge, bottomHudEdge - height)
: clamp(source.y + 36, topHudEdge, bottomHudEdge - height);
return {
x: Math.round(targetX),
y: Math.round(targetY),
width,
height,
side: preferAbove ? "bottom" : "top",
};
}
// Flank determination with strong hysteresis:
// When an established flank exists, require anchor to cross by at least 35px before switching flanks.
// During active globe drag, increase threshold to 70px to completely eliminate flip-flopping near the central meridian.
const isDraggingGlobe = typeof document !== "undefined" && (
document.body?.classList?.contains("is-globe-dragging") ||
document.documentElement?.classList?.contains("is-globe-dragging")
);
const flankThreshold = isDraggingGlobe ? 70 : 35;
const prevIsRight = Number.isFinite(previousPosition?.x) ? (previousPosition.x >= globeX) : null;
let isRight;
if (prevIsRight !== null) {
isRight = prevIsRight ? (source.x >= globeX - flankThreshold) : (source.x >= globeX + flankThreshold);
} else {
isRight = source.x >= globeX - 20;
}
// Fixed top HUD avoidance (e.g. video player or brand):
const topHudEdge = obstacles
.filter(o => o.y < 120 && o.x > viewport.width * 0.6)
.reduce((max, o) => Math.max(max, o.y + o.height), 72);
// Bottom toolbar boundary (only genuine bottom bars near screen bottom):
const bottomHudEdge = obstacles
.filter(o => o.y > viewport.height - 110)
.reduce((min, o) => Math.min(min, o.y), viewport.height - 76);
// If a small globe should be avoided:
const avoidSmallGlobe = globe.radius < Math.min(viewport.width, viewport.height) * 0.32;
if (avoidSmallGlobe) {
// If previous position is already safe (does not cover globe and does not overlap obstacles), retain it!
if (previousPosition && Number.isFinite(previousPosition.x) && Number.isFinite(previousPosition.y)) {
const prevRect = { x: previousPosition.x, y: previousPosition.y, width, height };
const nearX = Math.max(prevRect.x, Math.min(globe.x, prevRect.x + width));
const nearY = Math.max(prevRect.y, Math.min(globe.y, prevRect.y + height));
const distToGlobe = Math.hypot(nearX - globe.x, nearY - globe.y);
const overlapsObstacle = obstacles.some(obs =>
Math.max(obs.x, prevRect.x) < Math.min(obs.x + obs.width, prevRect.x + width) &&
Math.max(obs.y, prevRect.y) < Math.min(obs.y + obs.height, prevRect.y + height)
);
if (distToGlobe >= globe.radius + 8 && !overlapsObstacle) {
return prevRect;
}
}
const edgePlacement = packDirectionalEdgeCallouts([
{ key: "selection", point: source, size: { width, height }, minSourceDistance: GAP, previousSide: isRight ? "right" : "left" },
], obstacles, viewport)?.get("selection");
if (edgePlacement) return edgePlacement;
}
// Generous candidate positions that ensure the card never covers or crowds the source object:
const preferredRightX = Math.min(viewport.width - width - 36, Math.max(source.x + 180, globeX + 60));
const preferredLeftX = Math.max(36, Math.min(source.x - width - 180, globeX - width - 60));
const preferredX = isRight ? preferredRightX : preferredLeftX;
const oppositeX = isRight ? preferredLeftX : preferredRightX;
// Dynamic obstacles (e.g. Spotlight News or Compute cards) on the preferred flank:
const flankObstacles = obstacles.filter(o =>
Math.max(o.x, preferredX) < Math.min(o.x + o.width, preferredX + width) &&
o.y < viewport.height - 110 && (o.y + o.height) > 60
);
const positions = [];
// 1. Natural follower position on preferred flank:
const naturalY = clamp(source.y + 70, topHudEdge + 12, bottomHudEdge - height - 12);
positions.push({ x: preferredX, y: naturalY });
// 2. Obstacle-avoidance positions on the SAME flank (docked below or above obstacles):
for (const obs of flankObstacles) {
const belowY = obs.y + obs.height + 14;
if (belowY + height <= bottomHudEdge) {
positions.push({ x: preferredX, y: belowY });
}
const aboveY = obs.y - height - 14;
if (aboveY >= topHudEdge + 12) {
positions.push({ x: preferredX, y: aboveY });
}
}
// 3. Fallback: opposite flank
const oppositeNaturalY = clamp(source.y + 70, 72, bottomHudEdge - height - 12);
positions.push({ x: oppositeX, y: oppositeNaturalY });
// 4. Horizontal / vertical offsets around source:
positions.push(
{ x: source.x + GAP, y: source.y - height / 2 },
{ x: source.x - GAP - width, y: source.y - height / 2 },
{ x: source.x - width / 2, y: source.y - GAP - height },
{ x: source.x - width / 2, y: source.y + GAP },
];
{ x: isRight ? source.x + 60 : source.x - width - 60, y: source.y + GAP },
{ x: isRight ? source.x + 60 : source.x - width - 60, y: source.y - GAP - height },
);
const overlap = (a, b) => Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) *
Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
return positions.map((position, index) => {
const rect = { x: clamp(position.x, MARGIN, viewport.width - width - MARGIN),
y: clamp(position.y, MARGIN, viewport.height - height - MARGIN), width, height };
const coversSource = source.x >= rect.x - 24 && source.x <= rect.x + width + 24 &&
source.y >= rect.y - 24 && source.y <= rect.y + height + 24;
const score = (coversSource ? viewport.width * viewport.height * 10 : 0) +
obstacles.reduce((sum, obstacle) => sum + overlap(rect, obstacle), 0) * 10 +
Math.hypot(rect.x + width / 2 - source.x, rect.y + height / 2 - source.y) + index;
const scored = positions.map((position, index) => {
const rect = {
x: clamp(position.x, MARGIN, viewport.width - width - MARGIN),
y: clamp(position.y, MARGIN, viewport.height - height - MARGIN),
width,
height,
};
// Severe penalty if card rectangle covers or comes within 100px of the source point
const clearance = 100;
const coversSource = source.x >= rect.x - clearance && source.x <= rect.x + width + clearance &&
source.y >= rect.y - clearance && source.y <= rect.y + height + clearance;
// Small globe avoidance if applicable
let globeCoverPenalty = 0;
if (avoidSmallGlobe && Number.isFinite(globe.radius)) {
const nearX = Math.max(rect.x, Math.min(globe.x, rect.x + width));
const nearY = Math.max(rect.y, Math.min(globe.y, rect.y + height));
if (Math.hypot(nearX - globe.x, nearY - globe.y) < globe.radius + 8) {
globeCoverPenalty = viewport.width * viewport.height * 20;
}
}
// Continuity penalty against previous position (prevents high-frequency position jumping):
let continuityPenalty = 0;
if (previousPosition && Number.isFinite(previousPosition.x) && Number.isFinite(previousPosition.y)) {
const dist = Math.hypot(rect.x - previousPosition.x, rect.y - previousPosition.y);
continuityPenalty = Math.min(dist * 1.5, 600);
}
const overlapArea = obstacles.reduce((sum, obstacle) => sum + overlap(rect, obstacle), 0);
const overlapPenalty = overlapArea * 25;
// Line-of-sight check: strictly avoid placements where the connector line
// would slice directly through another on-screen card obstacle:
const cardTargetPoint = {
x: clamp(source.x, rect.x, rect.x + width),
y: clamp(source.y, rect.y, rect.y + height),
};
const lineIntersectsObstacle = obstacles.some(obs =>
segmentIntersectsBox(source, cardTargetPoint, obs)
);
const lineCrossPenalty = lineIntersectsObstacle ? viewport.width * viewport.height * 40 : 0;
// Strong bonus for staying on the preferred flank:
const onPreferredFlank = (rect.x >= globeX === isRight);
const flankScore = onPreferredFlank ? -150 : 250;
const score = (coversSource ? viewport.width * viewport.height * 50 : 0) +
globeCoverPenalty +
overlapPenalty +
lineCrossPenalty +
continuityPenalty +
flankScore +
index * 30;
return { rect, score };
}).sort((a, b) => a.score - b.score)[0].rect;
}).sort((a, b) => a.score - b.score);
return scored[0].rect;
}

View File

@@ -1,4 +1,5 @@
import { createConnectorPath } from "./callout-connector.js";
import { placeObjectCallout } from "./object-callout-layout.js";
const DEFAULT_CONNECTOR_READY_TIMEOUT_MS = 1200;
import { CALLOUT_DRAW_MS } from "./callout-reveal.js";
@@ -22,16 +23,38 @@ function waitForContext(context, durationMs) {
});
}
export function isTargetElementVisible(el) {
if (!el) return false;
if (!(el instanceof HTMLElement)) {
return (Number.isFinite(el.width) && el.width > 0 && Number.isFinite(el.height) && el.height > 0) ||
(Number.isFinite(el.x) && Number.isFinite(el.y));
}
if (el.hasAttribute("hidden")) return false;
const style = window.getComputedStyle ? window.getComputedStyle(el) : el.style;
if (style && style.display === "none") return false;
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : el;
return rect.width > 0 && rect.height > 0;
}
function getDefaultCardTarget() {
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;
const isMobile = document.body.classList.contains("layout-mode-mobile");
if (isMobile) {
const mobilePopup = document.getElementById("earth-mobile-popup");
if (isTargetElementVisible(mobilePopup)) {
return mobilePopup;
}
} else {
const infoPanel = document.getElementById("info-panel");
if (isTargetElementVisible(infoPanel)) {
return infoPanel;
}
}
const mobilePopup = document.getElementById("earth-mobile-popup");
if (isTargetElementVisible(mobilePopup)) return mobilePopup;
const infoPanel = document.getElementById("info-panel");
if (infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")) {
return infoPanel;
}
if (isTargetElementVisible(infoPanel)) return infoPanel;
return null;
}
@@ -93,21 +116,45 @@ export class PresentationController {
if (!source) return null;
const target = connectorRequest.targetProvider?.() || getDefaultCardTarget();
if (!target) return null;
// If source is on backside, project it to the limb silhouette of the globe
let effectiveSource = source;
const globe = source.globe;
if (source.front === false && globe && Number.isFinite(globe.radius)) {
const dx = source.x - globe.x;
const dy = source.y - globe.y;
const dist = Math.hypot(dx, dy) || 1;
effectiveSource = {
...source,
x: globe.x + (dx / dist) * globe.radius,
y: globe.y + (dy / dist) * globe.radius,
};
}
const sourceIsRect =
Number.isFinite(source.x) &&
Number.isFinite(source.y) &&
Number.isFinite(source.width) &&
Number.isFinite(source.height);
Number.isFinite(effectiveSource.x) &&
Number.isFinite(effectiveSource.y) &&
Number.isFinite(effectiveSource.width) &&
Number.isFinite(effectiveSource.height);
const sourceAnchor = sourceIsRect
? { x: source.x + source.width * 0.5, y: source.y + source.height * 0.5 }
: source;
? { x: effectiveSource.x + effectiveSource.width * 0.5, y: effectiveSource.y + effectiveSource.height * 0.5 }
: effectiveSource;
const options = {
...(connectorRequest.options || {}),
...(sourceIsRect && !connectorRequest.options?.sourceRect ? { sourceRect: source } : {}),
...(sourceIsRect && !connectorRequest.options?.sourceRect ? { sourceRect: effectiveSource } : {}),
globe: globe || effectiveSource.globe,
};
return this.pathFactory(sourceAnchor, target, options);
}
computeIdealBalloonPosition(anchor, size, viewport, obstacles = []) {
const width = size.width || 340;
const height = size.height || 380;
const globe = anchor.globe || viewport.globe || { x: viewport.width / 2, y: viewport.height / 2, radius: 360 };
const previousPosition = Number.isFinite(size.x) && Number.isFinite(size.y) ? { x: size.x, y: size.y } : null;
return placeObjectCallout(anchor, { width, height }, { ...viewport, globe, previousPosition }, obstacles);
}
update({ animate = false } = {}) {
if (!this.active) return false;
if (!animate && this.active.phase === "preparing") return false;
@@ -115,6 +162,138 @@ export class PresentationController {
const connector = this.getConnectorInstance(request);
if (!connector || request.connector?.enabled !== true) return false;
if (!animate && connector.isAnimating?.()) return true;
// Balloon Leash & Inertia Dynamics (气球惯性动力学):
const source = request.connector?.sourceProvider?.();
const target = request.connector?.targetProvider?.() || getDefaultCardTarget();
if (!target || !isTargetElementVisible(target)) {
connector.hide?.();
return false;
}
const isTargetDragging = Boolean(
target?.dataset?.dragged === "true" ||
target?.classList?.contains("is-dragging")
);
const isTargetUserPlaced = Boolean(
target?.dataset?.userPlaced === "true"
);
if (target instanceof HTMLElement && isTargetDragging) {
delete target.dataset.dragSyncedHemisphere;
}
if (source && target instanceof HTMLElement && !isTargetDragging) {
const globe = source.globe;
let effectiveAnchor = { x: source.x, y: source.y, globe };
if (source.front === false && globe && Number.isFinite(globe.radius)) {
const dx = source.x - globe.x;
const dy = source.y - globe.y;
const dist = Math.hypot(dx, dy) || 1;
effectiveAnchor = {
x: globe.x + (dx / dist) * globe.radius,
y: globe.y + (dy / dist) * globe.radius,
globe,
};
}
const rect = target.getBoundingClientRect();
let shouldStayPinned = isTargetUserPlaced;
if (isTargetUserPlaced) {
// User explicitly placed this card: respect user intent permanently!
// Only release user pin if anchor rotates into deep backside occlusion where line-of-sight is lost
const isDeepRear = source.front === false && (source.visibility ?? -1) < -0.35;
if (isDeepRear) {
target.dataset.userPlaced = "false";
delete target.dataset.dragSyncedHemisphere;
shouldStayPinned = false;
this._balloonVx = 0;
this._balloonVy = 0;
}
}
if (!shouldStayPinned) {
const isInteracting = (typeof document !== "undefined" && (
document.body?.classList?.contains("is-globe-dragging") ||
document.documentElement?.classList?.contains("is-globe-dragging")
)) || Boolean(request?.context?.isDragging);
const curLeft = parseFloat(target.style.left) || rect.x;
const curTop = parseFloat(target.style.top) || rect.y;
// Check if we are currently settled and resting
const lastAnchor = this.active?.lastSettledAnchor;
const anchorDist = lastAnchor
? Math.hypot(effectiveAnchor.x - lastAnchor.x, effectiveAnchor.y - lastAnchor.y)
: Infinity;
// If not interacting and settled with small anchor movement (< 35px):
// HARD SLEEP / ZERO JITTER: Keep card completely frozen in place!
const canStaySettled = !isInteracting &&
this.active?.state === "settled" &&
anchorDist < 35 &&
this.active?.settledSlot != null;
if (canStaySettled) {
target.style.left = `${this.active.settledSlot.x}px`;
target.style.top = `${this.active.settledSlot.y}px`;
this._balloonVx = 0;
this._balloonVy = 0;
} else {
const viewport = { width: window.innerWidth, height: window.innerHeight, globe };
const obstacles = [...document.querySelectorAll(".hud-panel:not(#info-panel):not(.compute-card):not(.news-region-card), .news-region-card.is-spotlight, .compute-card.is-spotlight")]
.filter(element => !element.hidden && getComputedStyle(element).visibility !== "hidden")
.map(element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })
.filter(r => r.width > 0 && r.height > 0);
const ideal = this.computeIdealBalloonPosition(effectiveAnchor, rect, viewport, obstacles);
// When first appearing (preparing/drawing connector), snap immediately to final target
// so the connector line draws directly to the final card position without any pullback!
if (animate || this.active?.phase !== "visible") {
target.style.left = `${ideal.x}px`;
target.style.top = `${ideal.y}px`;
target.style.right = "auto";
target.style.bottom = "auto";
this._balloonVx = 0;
this._balloonVy = 0;
if (this.active) {
this.active.settledSlot = { x: ideal.x, y: ideal.y };
this.active.lastSettledAnchor = { x: effectiveAnchor.x, y: effectiveAnchor.y };
this.active.state = "settled";
}
} else {
const dx = ideal.x - curLeft;
const dy = ideal.y - curTop;
const dist = Math.hypot(dx, dy);
if (dist <= 2.5) {
// Reached target slot! Settle and sleep!
target.style.left = `${ideal.x}px`;
target.style.top = `${ideal.y}px`;
this._balloonVx = 0;
this._balloonVy = 0;
if (this.active) {
this.active.settledSlot = { x: ideal.x, y: ideal.y };
this.active.lastSettledAnchor = { x: effectiveAnchor.x, y: effectiveAnchor.y };
this.active.state = "settled";
}
} else {
if (this.active) this.active.state = "seeking";
const followFactor = isInteracting ? 0.35 : 0.22;
const maxStep = isInteracting ? 70 : 35;
const stepX = Math.sign(dx) * Math.min(Math.abs(dx) * followFactor, maxStep);
const stepY = Math.sign(dy) * Math.min(Math.abs(dy) * followFactor, maxStep);
target.style.left = `${Math.round((curLeft + stepX) * 10) / 10}px`;
target.style.top = `${Math.round((curTop + stepY) * 10) / 10}px`;
this._balloonVx = stepX;
this._balloonVy = stepY;
}
}
}
}
}
const path = this.getConnectorPath(request);
if (!path) {
connector.hide?.();

View File

@@ -3,7 +3,7 @@ import * as THREE from "three";
import { satelliteCategory, satellitePixelSize, satelliteLabel } from "./satellite-display.js";
import { createSatelliteLabels, normalizeSatelliteLabelCount, normalizeSatelliteLabelMinZoom } from "./satellite-labels.js";
import { distanceForGlobeZoom } from "./globe-navigation.js";
import { summarizeFrames, summarizeFrameWindows } from "./frame-stats.js";
import { positionFpsOutput, setFpsEnabled, summarizeFrames, summarizeFrameWindows } from "./frame-stats.js";
test("real inclination categories preserve boundaries and unknowns", () => {
for (const [angle,key] of [[0,"equatorial"],[29.9,"equatorial"],[30,"low"],[60,"medium"],[90,"high"],[120,"retrograde"],[180,"retrograde"],[181,"other"],[null,"other"]]) {
@@ -65,6 +65,37 @@ test("multi-second FPS weights frame count by elapsed time instead of averaging
expect(summarizeFrameWindows([{ duration: 1000, count: 100 }, { duration: 2000, count: 60 }])).toEqual({ fps: 160000 / 3000, seconds: 3 });
});
test("FPS badge anchors to the top-left corner of the brand earth logo", () => {
const oldDocument = globalThis.document;
const oldWindow = globalThis.window;
const fpsEl = {
style: {},
hidden: true,
textContent: "",
getBoundingClientRect: () => ({ width: 61, height: 24 }),
};
const logoEl = {
getBoundingClientRect: () => ({ left: 20, top: 30, right: 132, bottom: 142, width: 112, height: 112 }),
};
globalThis.window = { innerWidth: 1920 };
globalThis.document = {
getElementById: (id) => (id === "earth-fps" ? fpsEl : null),
querySelector: (sel) => (sel.includes("earth-brand__logo") ? logoEl : null),
querySelectorAll: () => [],
};
try {
setFpsEnabled(true);
positionFpsOutput();
expect(fpsEl.hidden).toBe(false);
expect(fpsEl.style.left).toBe("20px");
expect(fpsEl.style.top).toBe("30px");
} finally {
setFpsEnabled(false);
globalThis.document = oldDocument;
globalThis.window = oldWindow;
}
});
test("reference palette keeps real inclination ownership", () => {
expect(satelliteCategory({ inclination: 53 }).color).toBe("#ff801a");
expect(satelliteCategory({ inclination: 10 }).color).toBe("#3399ff");

View File

@@ -233,15 +233,20 @@ function createSatellitePointMaterial() {
const material = new THREE.PointsMaterial({
map: satellitePointTexture, size: 2.2, alphaTest: 0.5,
sizeAttenuation: false, vertexColors: true, transparent: true,
opacity: 1, depthTest: true, depthWrite: false,
opacity: 1, depthTest: false, depthWrite: false,
});
const uniforms = { breathingPhase: { value: 0 }, breathingEnabled: { value: 0 } };
const uniforms = {
breathingPhase: { value: 0 },
breathingEnabled: { value: 0 },
uEarthRadius: { value: CONFIG.earthRadius * 0.998 },
};
material.userData.satelliteUniforms = uniforms;
material.onBeforeCompile = shader => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = `attribute float alpha;
attribute vec3 twinklePhase; attribute vec2 twinkleShape;
uniform float breathingPhase; uniform float breathingEnabled;
uniform float uEarthRadius;
varying float vSatelliteAlpha;\n` + shader.vertexShader.replace("#include <begin_vertex>", `
#include <begin_vertex>
vSatelliteAlpha = alpha;
@@ -254,13 +259,27 @@ function createSatellitePointMaterial() {
}
`).replace("#include <project_vertex>", `
#include <project_vertex>
vec4 earthCenterView = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
vec3 P = mvPosition.xyz;
vec3 E = earthCenterView.xyz;
float vSq = dot(P, P);
if (vSq > 0.0001) {
float t = dot(E, P) / vSq;
if (t > 0.0 && t < 1.0) {
vec3 closestPoint = t * P;
float distSq = dot(E - closestPoint, E - closestPoint);
if (distSq < uEarthRadius * uEarthRadius) {
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
}
}
}
if (alpha <= 0.0) gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
`);
shader.fragmentShader = "varying float vSatelliteAlpha;\n" + shader.fragmentShader.replace(
"#include <alphatest_fragment>", "#include <alphatest_fragment>\ndiffuseColor.a *= vSatelliteAlpha;",
);
};
material.customProgramCacheKey = () => "satellite-hard-disc-alpha-v1";
material.customProgramCacheKey = () => "satellite-hard-disc-alpha-v2";
return material;
}

View File

@@ -229,6 +229,23 @@ export function initSearchPanel({ resolveResults, onSelectResult } = {}) {
closeSearchPanel();
});
document.addEventListener("keydown", handleKeydown);
const filterChips = document.querySelectorAll("[data-search-filter]");
filterChips.forEach((chip) => {
chip.addEventListener("click", () => {
filterChips.forEach((c) => c.classList.toggle("is-active", c === chip));
const { input } = getElements();
if (!input) return;
const filter = chip.dataset.searchFilter || "all";
if (filter === "all") {
input.value = "";
} else {
const keyword = chip.dataset.searchKeyword || chip.textContent.replace(/^[^\u4e00-\u9fa5a-zA-Z0-9]+/, "").trim();
input.value = keyword;
}
runSearch().catch((err) => console.warn("Chip search failed:", err));
});
});
}
export function openSearchPanel() {

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import * as THREE from "three";
import { CalloutConnector, createConnectorPath } from "./callout-connector.js";
import { renderSpatialCalloutConnector } from "./spatial-callout-controller.js";
import { calculateOrbitalTarget, renderSpatialCalloutConnector } from "./spatial-callout-controller.js";
import { projectGeographicAnchor } from "./spatial-callouts.js";
test("geographic anchor preserves its real projection and front/rear depth while rotating", () => {
@@ -93,7 +93,7 @@ for (const animate of [false, true]) {
connector.polylineEl = { setAttribute() {}, style: {},
getTotalLength() { throw new Error("moving connectors must not force SVG layout"); },
};
connector.startpointEl = connector.endpointEl = { setAttribute() {} };
connector.startpointEl = connector.endpointEl = { setAttribute() {}, getAttribute() { return "3"; } };
try {
expect(connector.render({ points: [{ x: 0, y: 0 }, { x: 3, y: 4 }, { x: 3, y: 16 }],
start: { x: 0, y: 0 }, end: { x: 3, y: 16 } }, { animate })).toBe(true);
@@ -106,3 +106,91 @@ for (const animate of [false, true]) {
}
});
}
test("compact card in lower hemisphere expands downward when space below is sufficient", () => {
const globe = { x: 700, y: 540, radius: 360 };
const viewport = { width: 1920, height: 1080 };
const classes = new Set();
const element = {
classList: {
toggle(name, val) { if (val) classes.add(name); else classes.delete(name); },
remove(name) { classes.delete(name); },
},
};
// Card anchor at y = 640 (lower hemisphere, but with ample room below)
const state = {
point: { x: 540, y: 640, front: true },
sourcePoint: { x: 540, y: 640, front: true },
width: 220,
height: 42,
element,
hoverExpanded: false,
};
const collapsed = calculateOrbitalTarget(state, globe, viewport, []);
expect(collapsed.y).toBe(625);
// Now hover-expanded:
state.hoverExpanded = true;
state.width = 340;
state.height = 340;
const expanded = calculateOrbitalTarget(state, globe, viewport, []);
// Must expand downwards, keeping card's top anchored directly to capsuleY (625)
expect(expanded.y).toBe(625);
expect(classes.has("hud-panel--expand-down")).toBe(true);
expect(classes.has("hud-panel--expand-up")).toBe(false);
});
test("compact card expands upward only when near the bottom edge", () => {
const globe = { x: 700, y: 540, radius: 360 };
const viewport = { width: 1920, height: 1080 };
const classes = new Set();
const element = {
classList: {
toggle(name, val) { if (val) classes.add(name); else classes.delete(name); },
remove(name) { classes.delete(name); },
},
};
// Card anchor at y = 960 (extreme bottom edge near toolbar)
const state = {
point: { x: 540, y: 960, front: true },
sourcePoint: { x: 540, y: 960, front: true },
width: 220,
height: 42,
element,
hoverExpanded: true,
};
const expanded = calculateOrbitalTarget(state, globe, viewport, []);
expect(classes.has("hud-panel--expand-up")).toBe(true);
expect(classes.has("hud-panel--expand-down")).toBe(false);
expect(expanded.y).toBeLessThan(900);
});
test("hover-expanded card registers only its base capsule footprint so neighbors are never displaced", () => {
const globe = { x: 700, y: 540, radius: 360 };
const viewport = { width: 1920, height: 1080 };
const createMock = (key, pt, hover = false) => ({
key,
point: { ...pt, front: true },
sourcePoint: { ...pt, front: true },
width: hover ? 340 : 220,
height: hover ? 340 : 42,
hoverExpanded: hover,
element: { classList: { toggle() {}, remove() {} } },
});
// 1. Calculate normal positions for Europe and Middle East
const normalOccupied = [];
const normalEurope = calculateOrbitalTarget(createMock("eu", { x: 700, y: 220 }), globe, viewport, normalOccupied);
const normalMiddleEast = calculateOrbitalTarget(createMock("me", { x: 680, y: 340 }), globe, viewport, normalOccupied);
// 2. Hover Europe: Middle East must NOT be displaced or pushed upward
const hoverOccupied = [];
const hoverEurope = calculateOrbitalTarget(createMock("eu", { x: 700, y: 220 }, true), globe, viewport, hoverOccupied);
const middleEastWithEuropeHovered = calculateOrbitalTarget(createMock("me", { x: 680, y: 340 }), globe, viewport, hoverOccupied);
expect(hoverEurope.width).toBe(340);
expect(hoverEurope.height).toBe(340);
// Middle East must retain the exact same position as in normal state!
expect(middleEastWithEuropeHovered.x).toBe(normalMiddleEast.x);
expect(middleEastWithEuropeHovered.y).toBe(normalMiddleEast.y);
});

View File

@@ -1,5 +1,5 @@
export const ZOOM_MENU_CLOSE_MS = 220;
const ROOT_LEAVE_DELAY_MS = 200;
const ROOT_LEAVE_DELAY_MS = 360;
export function createToolbarHoverState({ onChange, schedule = setTimeout, cancel = clearTimeout }) {
let expanded = false, pinned = false, zoomOpen = false, zoomClosing = false;
@@ -135,16 +135,21 @@ export function bindToolbarHover({ cluster, hub, zoomGroup, zoomTrigger, zoomMen
};
listen(hub, "pointerenter", event => { if (event.pointerType !== "touch") state.enterHub(); });
listen(hub, "focus", () => state.enterHub());
listen(hub, "pointerdown", event => { event.stopPropagation(); });
listen(hub, "click", event => { event.preventDefault(); event.stopPropagation(); state.togglePinned(); });
listen(zoomTrigger, "pointerenter", event => { if (event.pointerType !== "touch") state.openZoom(); });
listen(zoomTrigger, "click", event => { event.preventDefault(); event.stopPropagation(); state.openZoom(); });
listen(document, "pointermove", event => {
if (event.pointerType === "touch" || !state.isOpen()) return;
if (event.pointerType === "touch") return;
if (!state.isOpen()) {
if (hub.contains(event.target)) state.enterHub();
return;
}
keyboardFocus = false;
const point = { x: event.clientX, y: event.clientY };
state.move({
inRoot: contains(rootBounds(), point, 12 * toolbarScale()),
inZoom: contains(zoomHoverBounds(zoomTrigger.getBoundingClientRect(), zoomMenu.getBoundingClientRect()), point, 6 * toolbarScale()),
inRoot: contains(rootBounds(), point, 28 * toolbarScale()),
inZoom: contains(zoomHoverBounds(zoomTrigger.getBoundingClientRect(), zoomMenu.getBoundingClientRect()), point, 8 * toolbarScale()),
onZoomTrigger: zoomTrigger.contains(event.target),
});
});

View File

@@ -1028,6 +1028,7 @@ export function initTVPanel() {
renderSource(findSourceById(currentSourceId));
},
});
renderSourceOptions();
[metaToggle, document.getElementById("tv-meta-toggle")]
.filter((element, index, array) => element && array.indexOf(element) === index)

View File

@@ -21,6 +21,7 @@ let statusBusy = false;
let loadingActive = false;
let loadingLockedWidth = 0;
let pendingLoadingMessage = "";
const statusMessages = new WeakMap();
// Selection describes the result, independently of mouse/touch/motion input.
export function showSelectionStatus(name) {
@@ -63,6 +64,10 @@ export function setEarthStatValue(statKey, value) {
onEarthLocaleChange(() => {
document.querySelectorAll("[data-earth-stat-source-value]").forEach(syncEarthStatElement);
for (const id of ["status-message", "error-message"]) {
const element = getElement(id);
if (element && statusMessages.has(element)) syncStatusMessageLocale(element);
}
});
function setElementDisplay(element, visible, displayValue = "block") {
@@ -96,6 +101,15 @@ function updateLoadingWidthLock(statusEl) {
statusEl.style.minWidth = `${loadingLockedWidth}px`;
}
function syncStatusMessageLocale(statusEl) {
const text = statusEl.querySelector(".earth-status-text");
if (!text) return;
const formatted = formatEarthMessage(statusMessages.get(statusEl));
text.textContent = formatted;
statusEl.dataset.i18nOriginalTitle = formatted;
statusEl.title = formatted;
}
function buildStatusContent(statusEl, message, type) {
statusEl.innerHTML = "";
@@ -112,10 +126,10 @@ function buildStatusContent(statusEl, message, type) {
const text = document.createElement("span");
text.className = "earth-status-text";
text.textContent = formatEarthMessage(message);
statusEl.appendChild(indicator);
statusEl.appendChild(text);
statusMessages.set(statusEl, message);
syncStatusMessageLocale(statusEl);
}
function getStatusSidePlacement(statusEl) {
@@ -125,6 +139,9 @@ function getStatusSidePlacement(statusEl) {
const brand = document.getElementById("brand-panel");
if (!(brand instanceof HTMLElement)) return false;
statusEl.style.left = "";
statusEl.style.maxWidth = "";
const brandRect = brand.getBoundingClientRect();
const statusWidth = Math.ceil(statusEl.getBoundingClientRect().width || statusEl.scrollWidth || 0);
if (!statusWidth || !brandRect.width) return false;
@@ -133,10 +150,39 @@ function getStatusSidePlacement(statusEl) {
const hudScale = Number.parseFloat(rootStyle.getPropertyValue("--hud-scale")) || 1;
const requiredGap = Math.max(10, Math.round(12 * hudScale));
const left = Math.round(brandRect.right + requiredGap);
const availableWidth = window.innerWidth - left - requiredGap;
const maxToastWidth = Math.max(280, Math.round(440 * hudScale));
const fullAvailableWidth = Math.max(
160,
Math.min(maxToastWidth, window.innerWidth - left - requiredGap),
);
const ticker = document.getElementById("desktop-news-ticker");
const tickerVisible =
ticker instanceof HTMLElement &&
!ticker.classList.contains("is-hidden") &&
ticker.offsetParent !== null;
const tickerRect = tickerVisible ? ticker.getBoundingClientRect() : null;
if (tickerRect && tickerRect.width > 0) {
const sideAvailableWidth = Math.min(
maxToastWidth,
tickerRect.left - left - requiredGap,
);
const shouldStack = tickerRect.left - left - requiredGap < statusWidth;
return {
shouldStack,
left,
maxWidth: Math.max(
160,
Math.floor(shouldStack ? fullAvailableWidth : sideAvailableWidth),
),
};
}
return {
shouldStack: false,
left,
maxWidth: Math.max(160, Math.floor(availableWidth)),
maxWidth: Math.max(160, Math.floor(fullAvailableWidth)),
};
}
@@ -149,12 +195,17 @@ function syncStatusPlacement(statusEl) {
return;
}
const placement = getStatusSidePlacement(statusEl);
statusEl.classList.remove(STATUS_TICKER_STACK_CLASS);
if (!placement) {
statusEl.classList.remove(STATUS_TICKER_STACK_CLASS);
statusEl.style.left = "";
statusEl.style.maxWidth = "";
return;
}
const isErrorMessage = statusEl.classList.contains("earth-error-message");
statusEl.classList.toggle(
STATUS_TICKER_STACK_CLASS,
!isErrorMessage && Boolean(placement.shouldStack),
);
statusEl.style.left = `${placement.left}px`;
statusEl.style.maxWidth = `${placement.maxWidth}px`;
}
@@ -180,6 +231,7 @@ function hideStatusElement(statusEl, onHidden) {
statusEl.className = STATUS_BASE_CLASS;
statusEl.style.left = "";
statusEl.style.maxWidth = "";
statusEl.title = "";
statusEl.innerHTML = "";
}
statusHideTimeoutId = null;

View File

@@ -221,6 +221,9 @@ const vesselIconLayer = createInteractableLayer({
const courseBin = getCourseBin(marker);
return `${anchored ? "anchored" : "moving"}:${courseBin}`;
},
// Keep the complete heading arrow/dot visible when its surface anchor is
// still on the front-facing limb, matching BGP and compute markers.
surfaceAnchorOcclusion: true,
getUserData: (item) => ({
...item,
vessel_kind: item.type,

View File

@@ -10,6 +10,8 @@ body,
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
-webkit-overscroll-behavior: none;
}
body {

Some files were not shown because too many files have changed in this diff Show More