Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8742b5d3c | ||
|
|
28a7132eec | ||
|
|
dd41588527 | ||
|
|
45604a1ebb | ||
|
|
06c122bade | ||
|
|
96e9b15fda | ||
|
|
a8713e58fd | ||
|
|
05377bf5c6 |
@@ -3,6 +3,12 @@
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!VERSION
|
||||
!frontend/
|
||||
!frontend/public/
|
||||
!frontend/public/earth/
|
||||
!frontend/public/earth/assets/
|
||||
!frontend/public/earth/assets/cards/
|
||||
!frontend/public/earth/assets/cards/manifest.json
|
||||
!backend/
|
||||
!backend/**
|
||||
!config/
|
||||
|
||||
@@ -33,6 +33,7 @@ COPY scripts/build_earth_boundary_china_pov_geojson.py /app/scripts/build_earth_
|
||||
COPY scripts/build_earth_boundary_pmtiles.py /app/scripts/build_earth_boundary_pmtiles.py
|
||||
COPY scripts/earth_boundary_policy.py /app/scripts/earth_boundary_policy.py
|
||||
COPY resources/earth/boundary-pov-geometry.china-v1.json /app/resources/earth/boundary-pov-geometry.china-v1.json
|
||||
COPY frontend/public/earth/assets/cards/manifest.json /app/frontend/public/earth/assets/cards/manifest.json
|
||||
COPY VERSION /app/VERSION
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.api.v1 import card_images
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import (
|
||||
ai,
|
||||
@@ -58,3 +59,5 @@ api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
api_router.include_router(interactables.router, prefix="/interactables", tags=["interactables"])
|
||||
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])
|
||||
|
||||
api_router.include_router(card_images.router, prefix="/earth/card-images", tags=["card-images"])
|
||||
|
||||
108
backend/app/api/v1/card_images.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Card-image configuration, upload, catalog selection and public resolution."""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.core.enums import UserRole
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.card_images import (
|
||||
CardImageConfiguration,
|
||||
CardImagePreview,
|
||||
CardImagePublish,
|
||||
EntityType,
|
||||
MatchScope,
|
||||
)
|
||||
from app.services.card_image_configuration import (
|
||||
publish_configuration,
|
||||
read_configuration,
|
||||
resolve_image,
|
||||
)
|
||||
from app.services.card_image_files import MAX_UPLOAD_BYTES, store_image, uploaded_path
|
||||
from app.services.card_image_targets import get_target, search_targets
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def image_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value}:
|
||||
raise HTTPException(403, "Administrator access required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/configuration")
|
||||
async def get_configuration(
|
||||
response: Response, _actor: User = Depends(image_admin), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await read_configuration(db)
|
||||
|
||||
|
||||
@router.put("/configuration")
|
||||
async def save_configuration(
|
||||
payload: CardImagePublish,
|
||||
actor: User = Depends(image_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await publish_configuration(db, payload.revision, payload.config, actor)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_image(file: UploadFile = File(...), _actor: User = Depends(image_admin)):
|
||||
content = await file.read(MAX_UPLOAD_BYTES + 1)
|
||||
await file.close()
|
||||
return await run_in_threadpool(store_image, content, (file.filename or "Image")[:200])
|
||||
|
||||
|
||||
@router.get("/files/{filename}")
|
||||
async def image_file(filename: str):
|
||||
path = uploaded_path(filename)
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, "Image not found")
|
||||
return FileResponse(
|
||||
path,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/targets")
|
||||
async def targets(
|
||||
entity_type: EntityType,
|
||||
scope: MatchScope = "object",
|
||||
q: str = Query(default="", max_length=100),
|
||||
limit: int = Query(default=25, ge=1, le=50),
|
||||
_actor: User = Depends(image_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {"items": await search_targets(db, entity_type, scope, q.strip(), limit)}
|
||||
|
||||
|
||||
@router.get("/resolve")
|
||||
async def resolve(
|
||||
response: Response,
|
||||
entity_type: EntityType,
|
||||
entity_id: str = Query(min_length=1, max_length=300),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
envelope = await read_configuration(db)
|
||||
config = CardImageConfiguration.model_validate(envelope["config"])
|
||||
context = await get_target(db, entity_type, entity_id)
|
||||
result = resolve_image(config, context)
|
||||
return {**result, "revision": envelope["revision"], "entity": context}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview(
|
||||
payload: CardImagePreview,
|
||||
_actor: User = Depends(image_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
context = await get_target(db, payload.entity_type, payload.entity_id)
|
||||
return {**resolve_image(payload.config, context), "entity": context}
|
||||
@@ -3,6 +3,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from app.core.compute_types import COMPUTE_SITE_TYPES, compute_record_filter, compute_site_type_expression
|
||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||
from app.core.earth_geography_policy import display_country
|
||||
from app.core.enums import UserRole
|
||||
@@ -35,7 +36,6 @@ from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
SOURCES = ("top500", "epoch_ai_gpu")
|
||||
|
||||
|
||||
def selected_countries(countries: str) -> list[str]:
|
||||
@@ -121,20 +121,24 @@ async def sample_query(db: AsyncSession, countries: list[str], year: int | None)
|
||||
.label("rank"),
|
||||
)
|
||||
.where(
|
||||
DataSnapshot.source.in_(SOURCES),
|
||||
DataSnapshot.source.in_(select(CollectedData.source).where(compute_record_filter(CollectedData)).distinct()),
|
||||
DataSnapshot.status == "success",
|
||||
DataSnapshot.completed_at < cutoff,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
snapshots = select(ranked.c.id).where(ranked.c.rank == 1)
|
||||
infrastructure = compute_site_type_expression(CollectedData).in_(COMPUTE_SITE_TYPES[2:])
|
||||
# Old collector rows have ambiguous units/identity and must not become
|
||||
# national evidence merely because a new UI can display them.
|
||||
query = select(CollectedData).where(
|
||||
compute_record_filter(CollectedData),
|
||||
CollectedData.deleted_at.is_(None),
|
||||
CollectedData.snapshot_id.in_(snapshots),
|
||||
CollectedData.is_valid == 1,
|
||||
CollectedData.extra_data["compute_contract"].as_string() == "v1",
|
||||
or_(CollectedData.extra_data["compute_contract"].as_string() == "v1", infrastructure),
|
||||
or_(
|
||||
infrastructure,
|
||||
CollectedData.source == "top500",
|
||||
and_(
|
||||
CollectedData.extra_data["status"].as_string() == "Existing",
|
||||
|
||||
@@ -52,7 +52,7 @@ PRODUCT_DEFINITIONS: dict[str, dict] = {
|
||||
"name": "算力",
|
||||
"sources": ["top500", "epoch_ai_gpu"],
|
||||
"primary_stat_key": "compute_center_count",
|
||||
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count"],
|
||||
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count", "data_center_count", "cloud_center_count", "edge_node_count"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.compute_types import COMPUTE_SITE_TYPES, COMPUTE_SOURCES, compute_site_type, compute_record_filter, compute_site_type_expression
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.enums import BGPStatus
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
from app.core.satellite_presentation import satellite_constellation_group as _normalize_satellite_constellation_group
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
@@ -337,22 +339,6 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _normalize_satellite_constellation_group(
|
||||
raw_group: Any,
|
||||
name: Optional[str],
|
||||
) -> Optional[str]:
|
||||
normalized_group = str(raw_group or "").strip().lower()
|
||||
if normalized_group:
|
||||
return normalized_group
|
||||
|
||||
normalized_name = str(name or "").strip().upper()
|
||||
if normalized_name.startswith("STARLINK"):
|
||||
return "starlink"
|
||||
if normalized_name.startswith("IRIDIUM"):
|
||||
return "iridium-next"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_satellite_footprint_policy(constellation_group: Optional[str]) -> str:
|
||||
if constellation_group == "starlink":
|
||||
@@ -505,16 +491,19 @@ async def _count_current_or_latest_task_data(
|
||||
async def _load_current_collected_data_by_sources(
|
||||
db: AsyncSession,
|
||||
sources: List[str],
|
||||
*, include_compute_types: bool = False,
|
||||
) -> Dict[str, List[CollectedData]]:
|
||||
if not sources:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source.in_(sources))
|
||||
.where(compute_record_filter(CollectedData) if include_compute_types else CollectedData.source.in_(sources))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.order_by(CollectedData.source.asc(), CollectedData.id.desc())
|
||||
)
|
||||
if include_compute_types:
|
||||
stmt = stmt.where(CollectedData.deleted_at.is_(None))
|
||||
result = await db.execute(stmt)
|
||||
|
||||
grouped_records: Dict[str, List[CollectedData]] = {source: [] for source in sources}
|
||||
@@ -682,11 +671,7 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
withheld_count += 1
|
||||
continue
|
||||
result = resolve_compute_center_location_full(record, metadata)
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
site_type = compute_site_type(record)
|
||||
|
||||
if not result.is_resolved:
|
||||
diagnostic = result.diagnostic or ResolutionDiagnostic(
|
||||
@@ -1964,9 +1949,10 @@ async def _build_compute_centers_geojson(
|
||||
records_by_source = await _load_current_collected_data_by_sources(
|
||||
db,
|
||||
["top500", "epoch_ai_gpu"],
|
||||
include_compute_types=True,
|
||||
)
|
||||
records = _filter_known_records(
|
||||
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
|
||||
[record for group in records_by_source.values() for record in group],
|
||||
)
|
||||
if limit is not None:
|
||||
records = records[:limit]
|
||||
@@ -1981,6 +1967,7 @@ async def _build_compute_centers_geojson(
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
"by_type": dict.fromkeys(COMPUTE_SITE_TYPES, 0),
|
||||
"unresolved": 0,
|
||||
},
|
||||
}
|
||||
@@ -2023,6 +2010,7 @@ async def _build_compute_centers_geojson(
|
||||
"count": len(sanitized_features),
|
||||
"stats": {
|
||||
"total": len(sanitized_features),
|
||||
"by_type": {kind: sum(feature["properties"]["site_type"] == kind for feature in sanitized_features) for kind in COMPUTE_SITE_TYPES},
|
||||
"supercomputers": sum(
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
@@ -2339,7 +2327,8 @@ async def _load_compute_center_record(db: AsyncSession, source_id: str) -> Colle
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source_id == source_id)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.where(compute_record_filter(CollectedData))
|
||||
.where(CollectedData.deleted_at.is_(None))
|
||||
.order_by(CollectedData.is_current.desc(), CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -2738,6 +2727,25 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db), resp
|
||||
)
|
||||
|
||||
|
||||
async def _compute_facility_counts(db: AsyncSession) -> dict[str, int]:
|
||||
kind = compute_site_type_expression(CollectedData)
|
||||
rows = (await db.execute(select(CollectedData.source, kind, func.count(CollectedData.id))
|
||||
.where(compute_record_filter(CollectedData), CollectedData.is_current.is_(True),
|
||||
CollectedData.deleted_at.is_(None))
|
||||
.group_by(CollectedData.source, kind))).all()
|
||||
counts = dict.fromkeys(COMPUTE_SITE_TYPES, 0)
|
||||
present_sources = set()
|
||||
for source, site_type, count in rows:
|
||||
present_sources.add(source)
|
||||
counts[site_type] += int(count or 0)
|
||||
# Retain the existing last-task fallback for the two collected catalogs.
|
||||
for source in COMPUTE_SOURCES:
|
||||
if source not in present_sources:
|
||||
key = "supercomputer" if source == "top500" else "gpu_cluster"
|
||||
counts[key] += await _count_current_or_latest_task_data(db, source)
|
||||
return counts
|
||||
|
||||
|
||||
async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
|
||||
cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables")
|
||||
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
|
||||
@@ -2746,9 +2754,10 @@ async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
|
||||
"celestrak_tle",
|
||||
exclude_unknown_name=True,
|
||||
)
|
||||
supercomputer_count = await _count_current_or_latest_task_data(db, "top500")
|
||||
gpu_cluster_count = await _count_current_or_latest_task_data(db, "epoch_ai_gpu")
|
||||
compute_center_count = supercomputer_count + gpu_cluster_count
|
||||
compute_counts = await _compute_facility_counts(db)
|
||||
supercomputer_count = compute_counts["supercomputer"]
|
||||
gpu_cluster_count = compute_counts["gpu_cluster"]
|
||||
compute_center_count = sum(compute_counts.values())
|
||||
|
||||
active_incident_result = await db.execute(
|
||||
select(func.count(BGPIncident.id)).where(BGPIncident.status == BGPStatus.ACTIVE.value),
|
||||
@@ -2803,6 +2812,9 @@ async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
|
||||
"aisstream_lag_seconds": aisstream_health.lag_seconds if aisstream_health else None,
|
||||
"supercomputer_count": supercomputer_count,
|
||||
"gpu_cluster_count": gpu_cluster_count,
|
||||
"data_center_count": compute_counts["data_center"],
|
||||
"cloud_center_count": compute_counts["cloud_center"],
|
||||
"edge_node_count": compute_counts["edge_node"],
|
||||
"bgp_event_count": active_incident_count or active_anomaly_count,
|
||||
"bgp_incident_count": active_incident_count,
|
||||
"bgp_anomaly_count": active_anomaly_count,
|
||||
|
||||
35
backend/app/core/compute_types.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Compute facility classification shared by the map and image resolver."""
|
||||
|
||||
from sqlalchemy import case, or_
|
||||
|
||||
COMPUTE_SITE_TYPES = ("supercomputer", "gpu_cluster", "data_center", "cloud_center", "edge_node")
|
||||
COMPUTE_SOURCES = ("top500", "epoch_ai_gpu")
|
||||
|
||||
|
||||
def compute_site_type(record) -> str:
|
||||
metadata = record.extra_data or {}
|
||||
for value in (metadata.get("site_type"), record.data_type):
|
||||
if value in COMPUTE_SITE_TYPES:
|
||||
return value
|
||||
return {"top500": "supercomputer", "epoch_ai_gpu": "gpu_cluster"}.get(
|
||||
record.source, "data_center"
|
||||
)
|
||||
|
||||
|
||||
def compute_site_type_expression(record):
|
||||
declared = record.extra_data["site_type"].as_string()
|
||||
return case(
|
||||
(declared.in_(COMPUTE_SITE_TYPES), declared),
|
||||
(record.data_type.in_(COMPUTE_SITE_TYPES), record.data_type),
|
||||
(record.source == "top500", "supercomputer"),
|
||||
(record.source == "epoch_ai_gpu", "gpu_cluster"),
|
||||
else_="data_center",
|
||||
)
|
||||
|
||||
|
||||
def compute_record_filter(record):
|
||||
return or_(
|
||||
record.source.in_(COMPUTE_SOURCES),
|
||||
record.data_type.in_(COMPUTE_SITE_TYPES),
|
||||
record.extra_data["site_type"].as_string().in_(COMPUTE_SITE_TYPES),
|
||||
)
|
||||
13
backend/app/core/satellite_presentation.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Shared source-group normalization; never infer hardware generations."""
|
||||
|
||||
|
||||
def satellite_constellation_group(raw_group: object, name: str | None) -> str | None:
|
||||
group = str(raw_group or "").strip().lower()
|
||||
if group:
|
||||
return group
|
||||
normalized_name = str(name or "").strip().upper()
|
||||
if normalized_name.startswith("STARLINK"):
|
||||
return "starlink"
|
||||
if normalized_name.startswith("IRIDIUM"):
|
||||
return "iridium-next"
|
||||
return None
|
||||
128
backend/app/schemas/card_images.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Typed, validated configuration for optional object-card images."""
|
||||
|
||||
from typing import Literal, Self
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
EntityType = Literal["satellite", "compute_center", "cable", "landing_point", "vessel", "bgp", "bgp_collector", "earth_interactable"]
|
||||
MatchScope = Literal["object", "model", "group", "category"]
|
||||
|
||||
|
||||
def safe_url(value: str, *, local: bool = False) -> str:
|
||||
if not value:
|
||||
return value
|
||||
if any(ord(char) < 32 for char in value) or "\\" in value:
|
||||
raise ValueError("Invalid URL")
|
||||
parts = urlsplit(value)
|
||||
if local and value.startswith(("/earth/assets/cards/", "/api/v1/earth/card-images/files/")):
|
||||
if (
|
||||
".." not in parts.path
|
||||
and "%" not in parts.path
|
||||
and not parts.netloc
|
||||
and not parts.query
|
||||
and not parts.fragment
|
||||
):
|
||||
return value
|
||||
if (
|
||||
parts.scheme in {"http", "https"}
|
||||
and parts.hostname
|
||||
and not parts.username
|
||||
and not parts.password
|
||||
):
|
||||
return value
|
||||
raise ValueError("Use an HTTP(S) URL without credentials or a managed image path")
|
||||
|
||||
|
||||
class CardImageAsset(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
variants: dict[str, str] = Field(default_factory=dict, max_length=4)
|
||||
width: int = Field(default=0, ge=0, le=20000)
|
||||
height: int = Field(default=0, ge=0, le=20000)
|
||||
credit: str = Field(default="", max_length=500)
|
||||
source_url: str = Field(default="", max_length=2000)
|
||||
license: str = Field(default="", max_length=300)
|
||||
license_url: str = Field(default="", max_length=2000)
|
||||
kind: Literal["photo", "model_photo", "render", "animation_still"] = "photo"
|
||||
rights_status: Literal["available", "permission_pending"] = "available"
|
||||
enabled: bool = True
|
||||
focus_x: float = Field(default=50, ge=0, le=100, allow_inf_nan=False)
|
||||
focus_y: float = Field(default=50, ge=0, le=100, allow_inf_nan=False)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_image_url(cls, value: str) -> str:
|
||||
return safe_url(value, local=True)
|
||||
|
||||
@field_validator("source_url", "license_url")
|
||||
@classmethod
|
||||
def validate_source_url(cls, value: str) -> str:
|
||||
return safe_url(value)
|
||||
|
||||
@field_validator("variants")
|
||||
@classmethod
|
||||
def validate_variants(cls, value: dict[str, str]) -> dict[str, str]:
|
||||
for width, url in value.items():
|
||||
if width not in {"640", "1280"}:
|
||||
raise ValueError("Unsupported image variant")
|
||||
safe_url(url, local=True)
|
||||
return value
|
||||
|
||||
|
||||
class CardImageRule(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
entity_type: EntityType
|
||||
scope: MatchScope
|
||||
value: str = Field(min_length=1, max_length=300)
|
||||
label: str = Field(default="", max_length=500)
|
||||
asset_id: str | None = Field(default=None, max_length=100)
|
||||
suppress: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action(self) -> Self:
|
||||
if self.scope != "object":
|
||||
self.value = self.value.casefold()
|
||||
if self.suppress and (self.scope != "object" or self.asset_id):
|
||||
raise ValueError("Only an object rule may suppress its image, without an asset")
|
||||
if not self.suppress and not self.asset_id:
|
||||
raise ValueError("Choose an image or suppress this object")
|
||||
return self
|
||||
|
||||
def target(self) -> tuple[str, str, str]:
|
||||
return self.entity_type, self.scope, self.value
|
||||
|
||||
|
||||
class CardImageConfiguration(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
assets: list[CardImageAsset] = Field(default_factory=list, max_length=1000)
|
||||
rules: list[CardImageRule] = Field(default_factory=list, max_length=3000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_relations(self) -> Self:
|
||||
assets = {asset.id for asset in self.assets}
|
||||
if len(assets) != len(self.assets) or len({rule.id for rule in self.rules}) != len(
|
||||
self.rules
|
||||
):
|
||||
raise ValueError("Image and rule IDs must be unique")
|
||||
targets = [rule.target() for rule in self.rules]
|
||||
if len(set(targets)) != len(targets):
|
||||
raise ValueError("Each target may have only one rule")
|
||||
if any(rule.asset_id and rule.asset_id not in assets for rule in self.rules):
|
||||
raise ValueError("Remove image associations before deleting the image")
|
||||
return self
|
||||
|
||||
|
||||
class CardImagePublish(BaseModel):
|
||||
revision: str = Field(max_length=200)
|
||||
config: CardImageConfiguration
|
||||
|
||||
|
||||
class CardImagePreview(BaseModel):
|
||||
entity_type: EntityType
|
||||
entity_id: str = Field(min_length=1, max_length=300)
|
||||
config: CardImageConfiguration
|
||||
174
backend/app/services/card_image_configuration.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Revision-checked image settings; new bundled assets never replace user edits."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.request_context import get_request_id
|
||||
from app.models.system_log import AuditLog
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User
|
||||
from app.schemas.card_images import CardImageConfiguration
|
||||
from app.services.card_image_defaults import (
|
||||
bundled_configuration,
|
||||
previous_bundled_assets,
|
||||
previous_bundled_rules,
|
||||
)
|
||||
|
||||
CATEGORY = "earth_card_images"
|
||||
|
||||
|
||||
def effective_envelope(payload: dict | None) -> dict:
|
||||
defaults, version = bundled_configuration()
|
||||
config = (
|
||||
CardImageConfiguration.model_validate(payload["config"])
|
||||
if payload
|
||||
else defaults.model_copy(deep=True)
|
||||
)
|
||||
if payload:
|
||||
previous = previous_bundled_assets()
|
||||
latest = {asset.id: asset for asset in defaults.assets}
|
||||
for index, asset in enumerate(config.assets):
|
||||
old = next((item for item in previous.get(asset.id, []) if item.url == asset.url), None)
|
||||
# Replace the old bundled source while preserving fields the user
|
||||
# changed (crop, title, enabled state, etc.). Custom URLs are untouched.
|
||||
if old is not None and asset.url == old.url and asset.id in latest:
|
||||
overrides = {
|
||||
key: value
|
||||
for key, value in asset.model_dump().items()
|
||||
if value != getattr(old, key)
|
||||
}
|
||||
config.assets[index] = latest[asset.id].model_copy(update=overrides, deep=True)
|
||||
known_assets = set(payload.get("known_assets", []))
|
||||
asset_ids = {asset.id for asset in config.assets}
|
||||
config.assets.extend(
|
||||
asset.model_copy(deep=True)
|
||||
for asset in defaults.assets
|
||||
if asset.id not in known_assets and asset.id not in asset_ids
|
||||
)
|
||||
prior_rules = previous_bundled_rules()
|
||||
latest_rules = {rule.id: rule for rule in defaults.rules}
|
||||
for index, rule in enumerate(config.rules):
|
||||
old_rule = prior_rules.get(rule.id)
|
||||
if (
|
||||
old_rule
|
||||
and rule.id in latest_rules
|
||||
and rule.target() == old_rule.target()
|
||||
and rule.asset_id == old_rule.asset_id
|
||||
):
|
||||
overrides = {
|
||||
key: value
|
||||
for key, value in rule.model_dump().items()
|
||||
if value != getattr(old_rule, key)
|
||||
}
|
||||
config.rules[index] = latest_rules[rule.id].model_copy(update=overrides, deep=True)
|
||||
known_rules = set(payload.get("known_rules", []))
|
||||
rule_ids = {rule.id for rule in config.rules}
|
||||
targets = {rule.target() for rule in config.rules}
|
||||
available = {asset.id for asset in config.assets}
|
||||
config.rules.extend(
|
||||
rule.model_copy(deep=True)
|
||||
for rule in defaults.rules
|
||||
if rule.id not in known_rules
|
||||
and rule.id not in rule_ids
|
||||
and rule.target() not in targets
|
||||
and rule.asset_id in available
|
||||
)
|
||||
return {
|
||||
"config": config.model_dump(mode="json"),
|
||||
"revision": f"{version}:{payload['revision'] if payload else 'default'}",
|
||||
"is_default": payload is None,
|
||||
}
|
||||
|
||||
|
||||
async def read_configuration(db: AsyncSession) -> dict:
|
||||
record = (
|
||||
await db.execute(select(SystemSetting).where(SystemSetting.category == CATEGORY))
|
||||
).scalar_one_or_none()
|
||||
return effective_envelope(record.payload if record else None)
|
||||
|
||||
|
||||
async def publish_configuration(
|
||||
db: AsyncSession, revision: str, config: CardImageConfiguration, actor: User
|
||||
) -> dict:
|
||||
defaults, _ = bundled_configuration()
|
||||
pending = {asset.id for asset in defaults.assets if asset.rights_status == "permission_pending"}
|
||||
if any(
|
||||
asset.id in pending and asset.rights_status != "permission_pending"
|
||||
for asset in config.assets
|
||||
):
|
||||
raise HTTPException(422, "Bundled image permission is still unverified")
|
||||
initial = {
|
||||
"config": defaults.model_dump(mode="json"),
|
||||
"revision": "default",
|
||||
"known_assets": [],
|
||||
"known_rules": [],
|
||||
}
|
||||
await db.execute(
|
||||
insert(SystemSetting)
|
||||
.values(category=CATEGORY, payload=initial)
|
||||
.on_conflict_do_nothing(index_elements=[SystemSetting.category])
|
||||
)
|
||||
record = (
|
||||
await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == CATEGORY).with_for_update()
|
||||
)
|
||||
).scalar_one()
|
||||
if effective_envelope(record.payload)["revision"] != revision:
|
||||
await db.rollback()
|
||||
raise HTTPException(409, "Configuration changed. Reload before saving your edits.")
|
||||
before = record.payload
|
||||
record.payload = {
|
||||
"config": config.model_dump(mode="json"),
|
||||
"revision": uuid4().hex,
|
||||
"known_assets": [asset.id for asset in defaults.assets],
|
||||
"known_rules": [rule.id for rule in defaults.rules],
|
||||
}
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_id=actor.id,
|
||||
actor_name=actor.username,
|
||||
action="card_images.update",
|
||||
target_type="system_setting",
|
||||
target_id=CATEGORY,
|
||||
result="success",
|
||||
request_id=get_request_id(),
|
||||
details={"before": before, "after": record.payload},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return effective_envelope(record.payload)
|
||||
|
||||
|
||||
def resolve_image(config: CardImageConfiguration, context: dict[str, str]) -> dict:
|
||||
assets = {asset.id: asset for asset in config.assets}
|
||||
for scope in ("object", "model", "group", "category"):
|
||||
value = context.get("entity_id" if scope == "object" else scope)
|
||||
values = [value] if value else []
|
||||
if scope == "category" and value != "*":
|
||||
values.append("*")
|
||||
for value in values:
|
||||
value = value if scope == "object" else value.casefold()
|
||||
rule = next(
|
||||
(
|
||||
rule
|
||||
for rule in config.rules
|
||||
if rule.enabled
|
||||
and rule.entity_type == context["entity_type"]
|
||||
and rule.scope == scope
|
||||
and rule.value == value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not rule:
|
||||
continue
|
||||
match = {"scope": scope, "value": value, "rule_id": rule.id}
|
||||
if rule.suppress:
|
||||
return {"image": None, "match": match, "suppressed": True}
|
||||
asset = assets.get(rule.asset_id)
|
||||
if asset and asset.enabled:
|
||||
return {"image": asset.model_dump(mode="json"), "match": match, "suppressed": False}
|
||||
return {"image": None, "match": None, "suppressed": False}
|
||||
108
backend/app/services/card_image_defaults.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Read immutable bundled assets without inserting machine-specific database rows."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.schemas.card_images import CardImageAsset, CardImageConfiguration, CardImageRule
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
MANIFEST_PATH = REPO_ROOT / "frontend/public/earth/assets/cards/manifest.json"
|
||||
|
||||
|
||||
def _asset_from_manifest(item: dict) -> CardImageAsset:
|
||||
pending = item.get("redistribution_status") == "permission_not_verified"
|
||||
return CardImageAsset(
|
||||
id=item["id"],
|
||||
title=item["label"],
|
||||
url=item["url"],
|
||||
width=item["width"],
|
||||
height=item["height"],
|
||||
credit=item["credit"],
|
||||
source_url=item.get("source_page") or "",
|
||||
license=item["license"],
|
||||
license_url=item.get("license_url") or "",
|
||||
kind=item["kind"],
|
||||
rights_status="permission_pending" if pending else "available",
|
||||
enabled=item.get("enabled", not pending),
|
||||
focus_x=item.get("focus_x", 50),
|
||||
focus_y=item.get("focus_y", 50),
|
||||
variants=item.get("variants", {}),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def previous_bundled_assets() -> dict[str, list[CardImageAsset]]:
|
||||
manifest = json.loads(MANIFEST_PATH.read_bytes())
|
||||
return {
|
||||
key: [CardImageAsset.model_validate(item) for item in value]
|
||||
for key, value in manifest.get("previous_defaults", {}).items()
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def previous_bundled_rules() -> dict[str, CardImageRule]:
|
||||
manifest = json.loads(MANIFEST_PATH.read_bytes())
|
||||
return {
|
||||
key: CardImageRule.model_validate(value)
|
||||
for key, value in manifest.get("previous_rules", {}).items()
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def bundled_configuration() -> tuple[CardImageConfiguration, str]:
|
||||
content = MANIFEST_PATH.read_bytes()
|
||||
manifest = json.loads(content)
|
||||
assets, rules = [], []
|
||||
for item in manifest["assets"]:
|
||||
asset = _asset_from_manifest(item)
|
||||
assets.append(asset)
|
||||
if not asset.enabled:
|
||||
continue
|
||||
hints = item.get("binding_hints", {})
|
||||
for index, target in enumerate(hints.get("defaults", [])):
|
||||
rules.append(
|
||||
CardImageRule(
|
||||
id=target.get("id", f"builtin-{asset.id}-default-{index}"),
|
||||
entity_type=target["entity_type"],
|
||||
scope=target["scope"],
|
||||
value=target["value"],
|
||||
label=asset.title,
|
||||
asset_id=asset.id,
|
||||
)
|
||||
)
|
||||
for index, entity_id in enumerate(hints.get("entity_ids", [])):
|
||||
rules.append(
|
||||
CardImageRule(
|
||||
id=f"builtin-{asset.id}-entity-{index}",
|
||||
entity_type=item["entity_type"],
|
||||
scope="object",
|
||||
value=entity_id,
|
||||
label=asset.title,
|
||||
asset_id=asset.id,
|
||||
)
|
||||
)
|
||||
for norad_id in hints.get("norad_ids", []):
|
||||
rules.append(
|
||||
CardImageRule(
|
||||
id=f"builtin-{asset.id}-{norad_id}",
|
||||
entity_type="satellite",
|
||||
scope="object",
|
||||
value=str(norad_id),
|
||||
label=f"{asset.title} · NORAD {norad_id}",
|
||||
asset_id=asset.id,
|
||||
)
|
||||
)
|
||||
if group := hints.get("constellation_group"):
|
||||
rules.append(
|
||||
CardImageRule(
|
||||
id=f"builtin-{asset.id}-group",
|
||||
entity_type="satellite",
|
||||
scope="group",
|
||||
value=group,
|
||||
label=asset.title,
|
||||
asset_id=asset.id,
|
||||
)
|
||||
)
|
||||
return CardImageConfiguration(assets=assets, rules=rules), hashlib.sha256(content).hexdigest()
|
||||
95
backend/app/services/card_image_files.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Validated raster uploads and content-addressed, bounded card-size derivatives."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
import warnings
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from app.schemas.card_images import CardImageAsset
|
||||
from app.services.card_image_defaults import REPO_ROOT
|
||||
|
||||
ASSET_DIR = Path(
|
||||
os.environ.get("PLANET_CARD_IMAGE_DIR", str(REPO_ROOT / "data/earth-card-images"))
|
||||
).resolve()
|
||||
MAX_UPLOAD_BYTES = 12 * 1024 * 1024
|
||||
MAX_PIXELS = 32_000_000
|
||||
FILE_PATTERN = re.compile(r"^[a-f0-9]{64}-(?:original\.(?:jpg|png|webp)|(?:640|1280)\.webp)$")
|
||||
FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||
|
||||
|
||||
def uploaded_path(filename: str) -> Path:
|
||||
if not FILE_PATTERN.fullmatch(filename):
|
||||
raise HTTPException(404, "Image not found")
|
||||
return ASSET_DIR / filename
|
||||
|
||||
|
||||
def store_image(content: bytes, title: str) -> CardImageAsset:
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "Image must be 12 MiB or smaller")
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
image = Image.open(io.BytesIO(content))
|
||||
extension = FORMATS.get(image.format)
|
||||
if (
|
||||
not extension
|
||||
or image.width * image.height > MAX_PIXELS
|
||||
or getattr(image, "is_animated", False)
|
||||
):
|
||||
raise ValueError("Unsupported or oversized raster")
|
||||
image.load()
|
||||
image = ImageOps.exif_transpose(image).convert(
|
||||
"RGBA" if "A" in image.getbands() or "transparency" in image.info else "RGB"
|
||||
)
|
||||
except (
|
||||
UnidentifiedImageError,
|
||||
OSError,
|
||||
ValueError,
|
||||
Image.DecompressionBombError,
|
||||
Image.DecompressionBombWarning,
|
||||
) as error:
|
||||
raise HTTPException(
|
||||
422, "Use a valid, still JPEG, PNG or WebP image below 32 megapixels"
|
||||
) from error
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
original = f"{digest}-original.{extension}"
|
||||
original_path = uploaded_path(original)
|
||||
if not original_path.exists():
|
||||
temporary = original_path.with_suffix(f".{uuid4().hex}.tmp")
|
||||
temporary.write_bytes(content)
|
||||
temporary.replace(original_path)
|
||||
variants = {}
|
||||
for size, content in render_variants(image).items():
|
||||
filename = f"{digest}-{size}.webp"
|
||||
path = uploaded_path(filename)
|
||||
if not path.exists():
|
||||
temporary = path.with_suffix(f".{uuid4().hex}.tmp")
|
||||
temporary.write_bytes(content)
|
||||
temporary.replace(path)
|
||||
variants[size] = f"/api/v1/earth/card-images/files/{filename}"
|
||||
return CardImageAsset(
|
||||
id=f"upload-{digest[:24]}",
|
||||
title=title or "Image",
|
||||
url=f"/api/v1/earth/card-images/files/{original}",
|
||||
variants=variants,
|
||||
width=image.width,
|
||||
height=image.height,
|
||||
)
|
||||
|
||||
|
||||
def render_variants(image: Image.Image) -> dict[str, bytes]:
|
||||
result = {}
|
||||
for size in (640, 1280):
|
||||
resized = image.copy()
|
||||
resized.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
resized.save(buffer, format="WEBP", quality=93, method=4)
|
||||
result[str(size)] = buffer.getvalue()
|
||||
return result
|
||||
180
backend/app/services/card_image_targets.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Bounded catalog queries and type defaults for every Earth object card."""
|
||||
|
||||
from sqlalchemy import String, case, cast, func, literal, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.compute_types import (
|
||||
COMPUTE_SITE_TYPES,
|
||||
compute_site_type_expression,
|
||||
compute_record_filter,
|
||||
)
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.earth_interactable import EarthInteractable
|
||||
from app.models.vessel import VesselCurrentState
|
||||
from app.schemas.card_images import EntityType, MatchScope
|
||||
|
||||
SOURCES = {
|
||||
"satellite": ("celestrak_tle", "spacetrack_tle"),
|
||||
"compute_center": ("top500", "epoch_ai_gpu"),
|
||||
"cable": ("arcgis_cables",),
|
||||
"landing_point": ("arcgis_landing_points",),
|
||||
}
|
||||
# Named constellation families, not inferred hardware models or generations.
|
||||
SATELLITE_GROUP_PATTERNS = (
|
||||
("STARLINK%", "starlink"),
|
||||
("IRIDIUM%", "iridium-next"),
|
||||
("BEIDOU-3%", "beidou-3"),
|
||||
("BEIDOU%", "beidou"),
|
||||
("QIANFAN%", "qianfan"),
|
||||
("ONEWEB%", "oneweb"),
|
||||
("KUIPER%", "kuiper"),
|
||||
)
|
||||
|
||||
|
||||
def collected_query(entity_type: EntityType):
|
||||
record, metadata = CollectedData, CollectedData.extra_data
|
||||
model = func.coalesce(
|
||||
metadata["satellite_model" if entity_type == "satellite" else "model"].as_string(), ""
|
||||
)
|
||||
category = (
|
||||
compute_site_type_expression(record)
|
||||
if entity_type == "compute_center"
|
||||
else literal(entity_type)
|
||||
)
|
||||
if entity_type == "satellite":
|
||||
group = func.coalesce(
|
||||
func.nullif(func.lower(metadata["constellation_group"].as_string()), ""),
|
||||
case(
|
||||
*[
|
||||
(func.upper(record.name).like(pattern), value)
|
||||
for pattern, value in SATELLITE_GROUP_PATTERNS
|
||||
],
|
||||
else_="",
|
||||
),
|
||||
)
|
||||
entity_id = metadata["norad_cat_id"].as_string()
|
||||
else:
|
||||
group = func.coalesce(
|
||||
metadata["operator"].as_string(), metadata["organization"].as_string(), ""
|
||||
)
|
||||
entity_id = record.source + ":" + record.source_id
|
||||
return select(
|
||||
record.name.label("name"),
|
||||
cast(entity_id, String).label("entity_id"),
|
||||
model.label("model"),
|
||||
group.label("group"),
|
||||
category.label("category"),
|
||||
).where(
|
||||
(
|
||||
compute_record_filter(record)
|
||||
if entity_type == "compute_center"
|
||||
else record.source.in_(SOURCES[entity_type])
|
||||
),
|
||||
record.is_current.is_(True),
|
||||
record.deleted_at.is_(None),
|
||||
metadata["anonymous"].as_boolean().is_not(True),
|
||||
)
|
||||
|
||||
|
||||
def target_query(entity_type: EntityType):
|
||||
if entity_type in SOURCES:
|
||||
return collected_query(entity_type)
|
||||
if entity_type == "vessel":
|
||||
model = VesselCurrentState
|
||||
identity, name, group, category = model.mmsi, model.name, model.flag, model.vessel_type_name
|
||||
conditions = []
|
||||
elif entity_type == "bgp":
|
||||
model = BGPIncident
|
||||
identity, name, group, category = (
|
||||
model.incident_key,
|
||||
model.title,
|
||||
model.source,
|
||||
model.incident_type,
|
||||
)
|
||||
conditions = []
|
||||
elif entity_type == "bgp_collector":
|
||||
model = BGPCollectorLocation
|
||||
identity, name, group, category = (
|
||||
model.collector_id,
|
||||
model.collector_id,
|
||||
model.operator,
|
||||
literal("bgp_collector"),
|
||||
)
|
||||
conditions = []
|
||||
else:
|
||||
model = EarthInteractable
|
||||
identity, name, group, category = model.id, model.label, model.layer, model.kind
|
||||
conditions = [model.is_deleted.is_(False)]
|
||||
return select(
|
||||
name.label("name"),
|
||||
cast(identity, String).label("entity_id"),
|
||||
literal("").label("model"),
|
||||
func.coalesce(group, "").label("group"),
|
||||
func.coalesce(category, entity_type).label("category"),
|
||||
).where(*conditions)
|
||||
|
||||
|
||||
def category_context(entity_type: EntityType) -> dict[str, str]:
|
||||
return {
|
||||
"entity_type": entity_type,
|
||||
"entity_id": "",
|
||||
"name": "",
|
||||
"model": "",
|
||||
"group": "",
|
||||
"category": entity_type,
|
||||
}
|
||||
|
||||
|
||||
async def get_target(db: AsyncSession, entity_type: EntityType, entity_id: str) -> dict[str, str]:
|
||||
if entity_type == "compute_center" and entity_id.startswith("__category__:"):
|
||||
category = entity_id.partition(":")[2]
|
||||
if category in COMPUTE_SITE_TYPES:
|
||||
return {**category_context(entity_type), "category": category}
|
||||
if entity_id == "__category__":
|
||||
return category_context(entity_type)
|
||||
if entity_type in SOURCES and entity_type != "satellite":
|
||||
source, separator, source_id = entity_id.partition(":")
|
||||
if not separator or not source or not source_id:
|
||||
return category_context(entity_type)
|
||||
# Match the indexed identity columns before projecting JSON metadata.
|
||||
statement = collected_query(entity_type).where(
|
||||
CollectedData.source == source, CollectedData.source_id == source_id
|
||||
)
|
||||
else:
|
||||
catalog = target_query(entity_type).subquery()
|
||||
statement = select(catalog).where(catalog.c.entity_id == entity_id)
|
||||
row = (await db.execute(statement.limit(1))).mappings().first()
|
||||
if not row:
|
||||
# Old map snapshots can outlive a catalog refresh. They may use the type
|
||||
# illustration, but must not claim an unverified exact-object identity.
|
||||
return category_context(entity_type)
|
||||
context = {key: str(value or "") for key, value in row.items()}
|
||||
return {**context, "entity_type": entity_type}
|
||||
|
||||
|
||||
async def search_targets(
|
||||
db: AsyncSession, entity_type: EntityType, scope: MatchScope, query: str, limit: int
|
||||
) -> list[dict[str, str]]:
|
||||
catalog = target_query(entity_type).subquery()
|
||||
value = catalog.c["entity_id" if scope == "object" else scope]
|
||||
label = catalog.c.name if scope == "object" else value
|
||||
statement = select(value.label("value"), label.label("label")).where(
|
||||
value.is_not(None), value != ""
|
||||
)
|
||||
if query:
|
||||
escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
statement = statement.where(
|
||||
or_(value.ilike(f"%{escaped}%", escape="\\"), label.ilike(f"%{escaped}%", escape="\\"))
|
||||
)
|
||||
rows = (await db.execute(statement.distinct().order_by(label, value).limit(limit))).mappings()
|
||||
items = [{"value": row["value"], "label": row["label"] or row["value"]} for row in rows]
|
||||
if scope == "category" and not query:
|
||||
known = {item["value"] for item in items}
|
||||
if entity_type == "compute_center":
|
||||
items.extend(
|
||||
{"value": kind, "label": kind} for kind in COMPUTE_SITE_TYPES if kind not in known
|
||||
)
|
||||
items.insert(0, {"value": "*", "label": "*"})
|
||||
return items
|
||||
@@ -33,6 +33,7 @@ import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.compute_types import compute_record_filter
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
@@ -930,7 +931,7 @@ async def seed_compute_center_locations_from_source_coords(
|
||||
"""Seed stored compute-center locations only from real source coordinates."""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.where(compute_record_filter(CollectedData))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
333
backend/tests/test_card_images.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import io
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.v1.card_images import router
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.schemas.card_images import CardImageAsset, CardImageConfiguration, CardImageRule
|
||||
from app.services.card_image_configuration import (
|
||||
effective_envelope,
|
||||
publish_configuration,
|
||||
resolve_image,
|
||||
)
|
||||
from app.services.card_image_defaults import bundled_configuration
|
||||
from app.services import card_image_files
|
||||
|
||||
|
||||
def configuration():
|
||||
assets = [
|
||||
CardImageAsset(id=key, title=key, url=f"/earth/assets/cards/{key}.jpg")
|
||||
for key in ["exact", "model", "group", "category"]
|
||||
]
|
||||
rules = [
|
||||
CardImageRule(id=key, entity_type="satellite", scope=scope, value=value, asset_id=key)
|
||||
for key, scope, value in [
|
||||
("exact", "object", "123"),
|
||||
("model", "model", "m1"),
|
||||
("group", "group", "starlink"),
|
||||
("category", "category", "satellite"),
|
||||
]
|
||||
]
|
||||
return CardImageConfiguration(assets=assets, rules=rules)
|
||||
|
||||
|
||||
def test_precedence_disabled_fallback_and_explicit_no_image():
|
||||
config = configuration()
|
||||
context = dict(
|
||||
entity_type="satellite", entity_id="123", model="M1", group="starlink", category="satellite"
|
||||
)
|
||||
for expected in ["exact", "model", "group", "category"]:
|
||||
assert resolve_image(config, context)["image"]["id"] == expected
|
||||
next(asset for asset in config.assets if asset.id == expected).enabled = False
|
||||
assert resolve_image(config, context)["image"] is None
|
||||
config = configuration()
|
||||
config.rules[0].asset_id = None
|
||||
config.rules[0].suppress = True
|
||||
result = resolve_image(config, context)
|
||||
assert result["suppressed"] and result["image"] is None
|
||||
|
||||
|
||||
def test_bundled_series_images_retain_truthful_source_metadata():
|
||||
config, _ = bundled_configuration()
|
||||
result = resolve_image(config, dict(entity_type="satellite", entity_id="123", group="starlink"))
|
||||
assert result["image"]["id"] == "starlink-solar-array"
|
||||
pending = next(asset for asset in config.assets if asset.id == "qianfan-animation-still")
|
||||
assert pending.rights_status == "permission_pending"
|
||||
assert (
|
||||
resolve_image(config, dict(entity_type="satellite", group="qianfan"))["image"]["id"]
|
||||
== pending.id
|
||||
)
|
||||
assert pending.enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"javascript:alert(1)",
|
||||
"//evil.example/image.jpg",
|
||||
"/earth/assets/cards/../secret",
|
||||
"https://user:pass@example.org/a.jpg",
|
||||
],
|
||||
)
|
||||
def test_unsafe_urls_rejected(url):
|
||||
with pytest.raises(ValidationError):
|
||||
CardImageAsset(id="test", title="test", url=url)
|
||||
|
||||
|
||||
def test_duplicate_targets_and_dangling_assets_rejected():
|
||||
data = configuration().model_dump()
|
||||
duplicate = {**data["rules"][0], "id": "another"}
|
||||
data["rules"].append(duplicate)
|
||||
with pytest.raises(ValidationError):
|
||||
CardImageConfiguration.model_validate(data)
|
||||
data = configuration().model_dump()
|
||||
data["assets"] = []
|
||||
with pytest.raises(ValidationError):
|
||||
CardImageConfiguration.model_validate(data)
|
||||
|
||||
|
||||
def test_new_defaults_do_not_undo_saved_removals_or_manual_edits():
|
||||
defaults, _ = bundled_configuration()
|
||||
payload = {
|
||||
"revision": "custom",
|
||||
"config": {"assets": [], "rules": []},
|
||||
"known_assets": [asset.id for asset in defaults.assets],
|
||||
"known_rules": [rule.id for rule in defaults.rules],
|
||||
}
|
||||
assert effective_envelope(payload)["config"] == {"assets": [], "rules": []}
|
||||
payload["known_assets"] = []
|
||||
assert len(effective_envelope(payload)["config"]["assets"]) == len(defaults.assets)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format", ["JPEG", "PNG", "WEBP"])
|
||||
def test_upload_verifies_raster_and_creates_reusable_variants(tmp_path, monkeypatch, format):
|
||||
monkeypatch.setattr(card_image_files, "ASSET_DIR", tmp_path)
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (1600, 900), "blue").save(output, format)
|
||||
asset = card_image_files.store_image(output.getvalue(), "Facility")
|
||||
assert (asset.width, asset.height) == (1600, 900)
|
||||
assert len(list(tmp_path.iterdir())) == 3
|
||||
for size, url in asset.variants.items():
|
||||
with Image.open(tmp_path / url.rsplit("/", 1)[1]) as image:
|
||||
assert max(image.size) == int(size)
|
||||
assert card_image_files.store_image(output.getvalue(), "Again").id == asset.id
|
||||
with pytest.raises(HTTPException):
|
||||
card_image_files.store_image(b"<svg></svg>", "bad")
|
||||
with pytest.raises(HTTPException):
|
||||
card_image_files.uploaded_path("../private")
|
||||
|
||||
|
||||
def fake_db(payload):
|
||||
db = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
record = SimpleNamespace(payload=payload)
|
||||
result = MagicMock()
|
||||
result.scalar_one.return_value = record
|
||||
result.scalar_one_or_none.return_value = record
|
||||
db.execute.return_value = result
|
||||
return db, record
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_revision_and_audit():
|
||||
defaults, _ = bundled_configuration()
|
||||
payload = {
|
||||
"revision": "default",
|
||||
"config": defaults.model_dump(mode="json"),
|
||||
"known_assets": [],
|
||||
"known_rules": [],
|
||||
}
|
||||
db, record = fake_db(payload)
|
||||
revision = effective_envelope(payload)["revision"]
|
||||
result = await publish_configuration(
|
||||
db, revision, defaults, SimpleNamespace(id=1, username="admin")
|
||||
)
|
||||
assert result["revision"] != revision
|
||||
assert db.add.call_args.args[0].action == "card_images.update"
|
||||
before = deepcopy(record.payload)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await publish_configuration(db, revision, defaults, SimpleNamespace(id=1, username="admin"))
|
||||
assert error.value.status_code == 409 and record.payload == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_requires_admin_and_validates_http_payload():
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
defaults, _ = bundled_configuration()
|
||||
db, _ = fake_db(
|
||||
{
|
||||
"revision": "default",
|
||||
"config": defaults.model_dump(mode="json"),
|
||||
"known_assets": [],
|
||||
"known_rules": [],
|
||||
}
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
assert (await client.get("/configuration")).status_code == 401
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(role="viewer")
|
||||
assert (await client.get("/configuration")).status_code == 403
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
role="admin", id=1, username="admin"
|
||||
)
|
||||
assert (await client.get("/configuration")).status_code == 200
|
||||
assert (
|
||||
await client.put("/configuration", json={"revision": "x", "config": {"rules": [{}]}})
|
||||
).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multipart_upload_and_public_file_delivery(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(card_image_files, "ASSET_DIR", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
role="admin", id=1, username="admin"
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (800, 500), "blue").save(buffer, "JPEG")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/upload", files={"file": ("facility.jpg", buffer.getvalue(), "image/jpeg")}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
asset = response.json()
|
||||
filename = asset["variants"]["640"].rsplit("/", 1)[1]
|
||||
image = await client.get(f"/files/{filename}")
|
||||
assert image.status_code == 200 and image.headers["content-type"] == "image/webp"
|
||||
assert "immutable" in image.headers["cache-control"]
|
||||
assert (
|
||||
await client.post("/upload", files={"file": ("bad.jpg", b"not an image", "image/jpeg")})
|
||||
).status_code == 422
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(role="viewer")
|
||||
assert (
|
||||
await client.post(
|
||||
"/upload", files={"file": ("facility.jpg", buffer.getvalue(), "image/jpeg")}
|
||||
)
|
||||
).status_code == 403
|
||||
|
||||
|
||||
def test_all_object_types_have_a_default_without_overriding_exact_suppression():
|
||||
from typing import get_args
|
||||
from app.schemas.card_images import EntityType
|
||||
|
||||
config, _ = bundled_configuration()
|
||||
for entity_type in get_args(EntityType):
|
||||
result = resolve_image(config, dict(entity_type=entity_type, category="unlisted"))
|
||||
assert result["image"] and result["match"]["value"] == "*"
|
||||
config = config.model_copy(deep=True)
|
||||
config.rules.append(
|
||||
CardImageRule(id="off", entity_type="satellite", scope="object", value="123", suppress=True)
|
||||
)
|
||||
assert resolve_image(config, dict(entity_type="satellite", entity_id="123"))["suppressed"]
|
||||
|
||||
|
||||
def test_upgrade_replaces_old_bundled_source_but_preserves_custom_assets():
|
||||
from app.services.card_image_defaults import previous_bundled_assets
|
||||
|
||||
old = previous_bundled_assets()["starlink-solar-array"][0]
|
||||
payload = {
|
||||
"revision": "old",
|
||||
"config": {"assets": [old.model_dump()], "rules": []},
|
||||
"known_assets": [old.id],
|
||||
"known_rules": [],
|
||||
}
|
||||
|
||||
def starlink():
|
||||
return next(a for a in effective_envelope(payload)["config"]["assets"] if a["id"] == old.id)
|
||||
|
||||
assert starlink()["url"].endswith("starlink-orbit.webp")
|
||||
payload["config"]["assets"][0]["focus_x"] = 20
|
||||
assert starlink()["focus_x"] == 20
|
||||
assert starlink()["url"].endswith("starlink-orbit.webp")
|
||||
payload["config"]["assets"][0]["enabled"] = False
|
||||
assert starlink()["enabled"] is False
|
||||
payload["config"]["assets"][0]["url"] = "https://example.org/custom.jpg"
|
||||
assert starlink()["url"] == "https://example.org/custom.jpg"
|
||||
|
||||
|
||||
def test_compute_categories_and_bgp_get_separate_user_images():
|
||||
config, _ = bundled_configuration()
|
||||
for kind, filename in [
|
||||
("supercomputer", "supercomputer.png"),
|
||||
("gpu_cluster", "gpu-cluster.jpg"),
|
||||
("data_center", "data-center.png"),
|
||||
("cloud_center", "cloud-center.png"),
|
||||
("edge_node", "edge-node.png"),
|
||||
]:
|
||||
result = resolve_image(config, dict(entity_type="compute_center", category=kind))
|
||||
assert result["image"]["url"].endswith(filename)
|
||||
for kind in ("bgp", "bgp_collector"):
|
||||
result = resolve_image(config, dict(entity_type=kind, category=kind))
|
||||
assert result["image"]["url"].endswith(
|
||||
"bgp-event.jpg" if kind == "bgp" else "bgp-collector.png"
|
||||
)
|
||||
|
||||
|
||||
def test_saved_collector_default_migrates_without_changing_user_bindings():
|
||||
from app.services.card_image_defaults import previous_bundled_rules, previous_bundled_assets
|
||||
|
||||
old_rule = previous_bundled_rules()["builtin-network-switches-default-1"]
|
||||
old_asset = previous_bundled_assets()["network-switches"][0]
|
||||
payload = {
|
||||
"revision": "old",
|
||||
"config": {"assets": [old_asset.model_dump()], "rules": [old_rule.model_dump()]},
|
||||
"known_assets": [old_asset.id],
|
||||
"known_rules": [old_rule.id],
|
||||
}
|
||||
result = CardImageConfiguration.model_validate(effective_envelope(payload)["config"])
|
||||
assert resolve_image(result, dict(entity_type="bgp_collector", category="bgp_collector"))[
|
||||
"image"
|
||||
]["url"].endswith("bgp-collector.png")
|
||||
payload["config"]["rules"][0]["enabled"] = False
|
||||
result = effective_envelope(payload)["config"]
|
||||
assert not next(rule for rule in result["rules"] if rule["id"] == old_rule.id)["enabled"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_lookup_filters_indexed_identity_and_keeps_actual_category():
|
||||
from app.services.card_image_targets import get_target
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
db = AsyncMock()
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.first.return_value = {
|
||||
"entity_id": "epoch_ai_gpu:site",
|
||||
"name": "Site",
|
||||
"category": "cloud_center",
|
||||
"group": "Operator",
|
||||
"model": "Model",
|
||||
}
|
||||
db.execute.return_value = result
|
||||
context = await get_target(db, "compute_center", "epoch_ai_gpu:site")
|
||||
query = db.execute.call_args.args[0]
|
||||
sql = str(query.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||
assert "collected_data.source = 'epoch_ai_gpu'" in sql
|
||||
assert "collected_data.source_id = 'site'" in sql
|
||||
assert context["category"] == "cloud_center" and context["model"] == "Model"
|
||||
|
||||
|
||||
def test_old_gpu_preview_urls_are_replaced_with_latest_variants():
|
||||
from app.services.card_image_defaults import previous_bundled_assets
|
||||
|
||||
old = previous_bundled_assets()["compute-gpu-cluster"][0]
|
||||
payload = {
|
||||
"revision": "old",
|
||||
"config": {"assets": [old.model_dump()], "rules": []},
|
||||
"known_assets": [old.id],
|
||||
"known_rules": [],
|
||||
}
|
||||
result = effective_envelope(payload)["config"]
|
||||
upgraded = next(a for a in result["assets"] if a["id"] == old.id)
|
||||
latest = next(a for a in bundled_configuration()[0].assets if a.id == old.id)
|
||||
assert upgraded["variants"] == latest.variants
|
||||
assert upgraded["url"] == latest.url
|
||||
@@ -741,6 +741,7 @@ def test_collect_location_candidates_failure_returns_attempted_queries(monkeypat
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_skips_llm_when_candidates_exist(monkeypatch):
|
||||
monkeypatch.setattr(visualization_api, "_compute_center_location_web_search_capability", AsyncMock(return_value={"enabled": True}))
|
||||
candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
@@ -784,6 +785,7 @@ async def test_collect_compute_center_location_skips_llm_when_candidates_exist(m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
monkeypatch.setattr(visualization_api, "_compute_center_location_web_search_capability", AsyncMock(return_value={"enabled": True}))
|
||||
llm_candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
@@ -851,7 +853,11 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||
async def test_compute_centers_geojson_endpoint_returns_stats(monkeypatch):
|
||||
async def build_without_shared_cache(**kwargs):
|
||||
return await kwargs["builder"]()
|
||||
|
||||
monkeypatch.setattr(visualization_api, "get_or_build_layer_payload", build_without_shared_cache)
|
||||
records = [
|
||||
_build_record(
|
||||
record_id=1,
|
||||
@@ -920,6 +926,11 @@ async def test_compute_centers_geojson_endpoint_returns_stats():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
async def build_without_shared_cache(**kwargs):
|
||||
return await kwargs["builder"]()
|
||||
|
||||
# Exercise the fixture DB, never the running development server's Redis payload.
|
||||
monkeypatch.setattr(visualization_api, "get_or_build_layer_payload", build_without_shared_cache)
|
||||
records = [
|
||||
_build_record(
|
||||
record_id=1,
|
||||
@@ -1013,6 +1024,8 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
class _FakeSession:
|
||||
async def execute(self, query):
|
||||
query_text = str(query).lower()
|
||||
if "group by collected_data.source" in query_text:
|
||||
return _ScalarResult(rows=[("top500", "supercomputer", 1), ("epoch_ai_gpu", "gpu_cluster", 1)])
|
||||
if "bgp_incidents" in query_text:
|
||||
return _ScalarResult(scalar_value=2)
|
||||
if "bgp_anomalies" in query_text:
|
||||
@@ -1083,6 +1096,7 @@ async def test_empty_cable_layers_return_empty_feature_collections():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_candidates_for_known_record(monkeypatch):
|
||||
monkeypatch.setattr(visualization_api, "_compute_center_location_web_search_capability", AsyncMock(return_value={"enabled": True}))
|
||||
def _fake_ror(query):
|
||||
assert query == "Oak Ridge National Laboratory"
|
||||
return {
|
||||
@@ -1170,6 +1184,7 @@ async def test_collect_location_endpoint_returns_candidates_for_known_record(mon
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_failure_reason(monkeypatch):
|
||||
monkeypatch.setattr(visualization_api, "_compute_center_location_web_search_capability", AsyncMock(return_value={"enabled": True}))
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
|
||||
@@ -1380,3 +1395,19 @@ def test_repository_has_no_forbidden_precision_tokens():
|
||||
assert "FORBIDDEN_PRECISIONS" in text, (
|
||||
f"{token} appears in {target} outside the FORBIDDEN_PRECISIONS guard"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["data_center", "cloud_center", "edge_node"])
|
||||
def test_infrastructure_type_is_preserved_in_geojson_and_image_classification(kind):
|
||||
from app.core.compute_types import compute_site_type
|
||||
record = _build_record(record_id=900, source="facility_catalog", data_type=kind,
|
||||
name="Verified facility", country="Japan", city="Tokyo", latitude=35.68, longitude=139.76,
|
||||
metadata={"location_precision": "site"})
|
||||
result = convert_compute_centers_to_geojson([record])
|
||||
assert compute_site_type(record) == kind
|
||||
assert result["features"][0]["properties"]["site_type"] == kind
|
||||
record.source = "epoch_ai_gpu"
|
||||
record.data_type = "gpu_cluster"
|
||||
record.extra_data = {**record.extra_data, "site_type": kind, "location": "Tokyo, Japan"}
|
||||
assert compute_site_type(record) == kind
|
||||
assert convert_compute_centers_to_geojson([record])["features"][0]["properties"]["site_type"] == kind
|
||||
|
||||
@@ -8,6 +8,77 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.76.4] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修正 breaking 新闻状态显示:ticker 仅在当前滚动到 breaking 新闻时变红,展开面板始终保持蓝色。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 选中、焦点、关闭动画与 breaking 数据状态彻底分离,关闭新闻栏不再留下橙色边线。
|
||||
- breaking 新闻按过期时间重新计算,单条新闻卡片继续保留自身 breaking 红色样式。
|
||||
- 增加 ticker breaking 状态单测和浏览器关闭/重开验证。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.3] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修复新闻栏关闭后残留橙色边线的问题,明确 breaking、展开面板和新闻选择的状态边界。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- ticker 保持中性,breaking 等级只作用于展开新闻面板整体背景和阴影。
|
||||
- 关闭新闻面板时清理选中故事和过期焦点状态,breaking 按新闻过期时间重新计算。
|
||||
- 增加 breaking 状态单测,保持新闻滚动、选择和面板动画行为不变。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.2] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 优化光缆密集区的卫星点拾取,并将点状交互物件优先级泛化到其他密集图层。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 屏幕命中先统一比较卫星、BGP、算力和其他点状对象的距离,海缆线作为兜底候选,保护穿插在密集线缆中的小面积对象。
|
||||
- hover 与 click 共用同一候选排序,避免鼠标提示和最终选中对象不一致。
|
||||
- 补充密集区域拾取回归测试,清理中间态代码并同步渲染层说明。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.1] — 2026-10-01
|
||||
|
||||
Released: 2026-10-01
|
||||
|
||||
### Highlights
|
||||
- 修复选中 BGP 事件静态光晕在地表边缘被切断或陷入地表的问题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 抽取通用整体锚点遮挡能力,BGP 事件保持原有图标样式并复用圆角图标的前后显隐规则。
|
||||
- 选中事件光晕与事件图标共享地表锚点、前景层级和前后显隐路径,清理中间态代码与资源。
|
||||
- 补充中英文渲染层说明、回归测试和浏览器验证记录。
|
||||
|
||||
---
|
||||
|
||||
## [0.76.0] — 2026-09-30
|
||||
|
||||
Released: 2026-09-30
|
||||
|
||||
### Highlights
|
||||
- 新增详情卡默认配图与后台配置,缺图时保持完整数据卡片,并清理已替换的旧资源。
|
||||
- 算力中心补齐超算、GPU 集群、数据中心、云计算中心和边缘计算节点类型及配图。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 统一地表物件身份、拾取优先级、连线目标、聚合展示和选中置顶;跨图层重叠时焦点对象保持在前景。
|
||||
- 详情卡保留全量显示,统一高度、字段顺序、拖拽落位和新闻展开交互;图片解析改用按对象索引的快速路径。
|
||||
- 合并卫星、轨迹、海缆和聚合渲染的重复计算,保留完整数据与现有交互;同步中英文 Earth、后台与运维文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.75.4] — 2026-09-29
|
||||
|
||||
Released: 2026-09-29
|
||||
|
||||
@@ -253,7 +253,7 @@ All material, layer, satellite, BGP, cable, terrain, celestial, and other style
|
||||
|
||||
## Render hot paths
|
||||
|
||||
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object.
|
||||
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object. In dense cable areas, screen-distance picking compares satellites, BGP, compute and other point objects before using cable lines as the fallback; hover and click share the same ordering so small objects threaded through a line bundle remain selectable.
|
||||
|
||||
Satellite breathing runs in vertex shaders. `satellite-position-worker.js` moves full SGP4 position and initial trail calculations off the main thread; the main thread applies snapshots to interaction coordinates and render buffers. `satellite-propagation.js` supplies the same mathematics to the Worker, predicted orbits, and synchronous fallback. Reload, clear, and altitude changes must terminate prior work so stale snapshots cannot resurrect cleared layers.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps projection-based clustering, and `none` disables it. Vessels explicitly disable clustering and avoidance so incremental updates do not rebuild cluster topology. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
|
||||
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).
|
||||
|
||||
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` delegates layout to `spatial-callout-controller.js` and reuses `CalloutConnector` with stable region IDs. Layout is event-driven and retains valid old rectangles while work is pending; the projected source line follows the globe continuously. The layout reference is the direction of the actual source exit point, so a card stays near the object side instead of being pulled from a top-left geographic point to an opposite corner. Neighboring same-sector placements, shorter connectors, and fewer crossings are preferred; the solver may exchange free positions for at most three rounds rather than relayout everything each frame. In fullscreen Earth, nearby foreground positions are allowed so cards of normal size do not cross the whole globe merely to avoid it. In slow mode, sustained direction drift or connector crossings must persist for 1.8 seconds before proposing a joint reflow. Automatic proposals are at least 8 seconds apart and need an improvement of at least 18% plus a minimum absolute gain. Manual/inertial input retains the 72ms/12px response budget. Expansion and manual placement grant 12 seconds of reading time; hover, keyboard focus and dragging continue to lock a card, while residual pointer focus does not lock it forever. Manual placement is a preference: after its hold, automatic repair requires at least 45 degrees of source direction change. Restored positions are not permanent pins. Rear connectors retain the globe-outline cue without changing card identity. Local position/size persistence and reset-to-auto are part of the controller. A `ResizeObserver` handles resizing and collapse; configuration and source windows retain shared drag handles.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
Motion presentation uses the shared `main.js` input state. Sustained drift or crossings trigger a joint assignment, rather than each card competing independently for leftover slots. `callout-region-path.js` provides the short connection and terminal stub used both for rendering and intersection scoring. Candidate paths are precomputed and layout states are cloned only after pruning. Existing cards remain visible during planning; queued reflows survive frame throttling.
|
||||
Globe dragging and inertia only pause card motion progress; they never replace committed destinations. Connector anchors follow geographic projection independently of card placement.
|
||||
|
||||
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.
|
||||
|
||||
@@ -436,11 +436,10 @@ Normal borders, coastlines, claim lines, highlights and landing points use `eart
|
||||
|
||||
### News ticker edge fading
|
||||
|
||||
A separate pseudo-element paints the ticker background and horizontal rules, fading to full transparency outside the text bounds. The shell has no rectangular backdrop filter, box shadow, or text mask. The region label sizes to its content and the scrolling text follows directly without fading. Alert states change only the gradient rule color so they cannot reintroduce rectangular shadow edges.
|
||||
A separate pseudo-element paints the ticker background and horizontal rules, fading to full transparency outside the text bounds. The shell has no rectangular backdrop filter, box shadow, or text mask. The region label sizes to its content and the scrolling text follows directly without fading. The ticker stays neutral; breaking levels style the expanded news panel as a whole. Selection, focus and close animation do not mutate the breaking data state, which is recomputed from item expiry timestamps.
|
||||
|
||||
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.
|
||||
|
||||
Saved placement restoration migrates legacy central positions: only old central coordinate locks without the current position marker return to automatic layout, retaining dimensions. Existing edge positions are preserved and versioned. New drags save `positionVersion=2` and continue to honor deliberate placement. The existing storage key is retained instead of clearing every personalized position.
|
||||
|
||||
### First frame and spatial card entrance
|
||||
|
||||
@@ -458,7 +457,7 @@ Satellites use one `THREE.Points` batch with `PointsMaterial`: a 32×32 white di
|
||||
|
||||
Real GP/TLE propagation stays in the Worker at 250ms intervals. `satellite-motion.js` caches sample radii and interpolates radius-preserving positions each frame, shared by points, picking, trail tips, selection rings and coverage. `satellite-trail-geometry.js` merges redundant ribbon segments only when the projected error remains below 0.2 pixels across supported zooms. All satellites and 10 history samples are retained; curved or near-camera trails keep their original segments. Only active buffer ranges are uploaded. Trail width is capped at 60% of the point size and tapers toward the tail, avoiding a thick fragmented rim at distant views. `satellite-footprint-geometry.js` retains the full 260×170 coverage grid, created once. The vertex shader evaluates the same surface projection, while local basis and extent uniforms update each frame instead of rebuilding tens of thousands of vertices. Starlink/Iridium footprints share blue `#3399ff`; sunlight changes opacity only, with real coverage models preserved.
|
||||
|
||||
`SATELLITE_CATEGORIES` owns real inclination categories and the blue/orange/green/purple/rose/gray palette. Selecting a satellite automatically switches to its legend. Legend categories directly control points, trails, labels and picking; hiding a selected category clears selection and coverage. Layers and settings have no separate filter entry. Explicit search selection re-enables its category. `satellite-labels.js` shows actual names and models (NORAD identifiers when unavailable) only at **400% zoom or above**. It caps labels at 36, checks occlusion and overlap in the central 65% of the view, scans candidates every 160ms and follows active labels each frame.
|
||||
`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.
|
||||
|
||||
@@ -480,3 +479,104 @@ The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutp
|
||||
The current interactive object continues to determine the legend mode; enabling a layer with its existing switch also selects its legend, without adding a filter entry. Cables filter by route name, compute facilities by type, vessels by type and anchored state, and BGP by severity and collector activity. Coverage and impacted-region entries control their respective effects. The geographic legend separately controls lines, land fill and ocean fill without changing the shared land mask data. The obsolete BGP connections/hubs entry is removed.
|
||||
|
||||
`legend-filters.js` owns independent per-mode session filters. `legend.js` renders shared desktop/mobile toggle buttons with `aria-pressed`, preserving keyboard focus when refreshed. `interactable.js` filters draw batches, cluster candidates and picking while retaining complete source data, counts and object identities. Live vessel type changes, data reload and layer toggles respect filters. Hiding a selected item clears its card and overlays; explicit search selection reveals its category. Vessel track requests are versioned so late responses cannot resurrect a hidden track.
|
||||
|
||||
### Stable cards and display preferences
|
||||
|
||||
`callout-rest.js` retains a 450ms interaction-settle gate and subtle floating capped at 1.5px. Hover, keyboard reading, dragging and reduced-motion pause the float. Decorative offsets never enter persistence or placement solving, and the central boundary remains protected.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
### Cluster badges and Spiderfy
|
||||
|
||||
Ordinary surface markers retain GPU batches. Each aggregate uses the same projected children in `cluster-presentation.js` through collapse, expansion and the final resting state. A 26px solid numbered circle represents the group, with an amber ring for mixed categories. Membership changes only with data or filters, never through intermediate groups while zooming.
|
||||
|
||||
`cluster-motion.js` drives a 320ms cubic tween after visible icons are prepared and decoded, with encoded textures cached. Children interpolate from the group center to their real projections; `marker-stack.js` owns final display offsets. Animation and resting endpoints retain the same DOM nodes, with continuous reversal.
|
||||
|
||||
The UI's `globeZoomForDistance()` expands groups above 250%, with return hysteresis and no aggregate badges at 500%. Only co-located or extremely close geographic anchors (about 0.00015 degrees) share a compact cross-layer stack. Screen overlap and coarse city buckets never merge different locations. Each icon retains its own real projection as its baseline, with the entire stack bounded to 24px per axis and small groups exposing approximately 8–12px edges. Stable IDs define resting order; selecting a rear item brings it forward, with projected children at `z-index: 1000` and unclustered locked overlays at shared `renderOrder >= 100`, so focus stays above overlapping layers; clearing focus restores its slot. Ordinary expansion ends exactly at real projected positions; coordinates and marker positions remain unchanged. Data refresh removes retired member instances and synchronizes GPU ownership. Satellites and AIS vessels stay separate.
|
||||
|
||||
### Regional news cards
|
||||
|
||||
`news-region-cards.js` reuses `renderNewsCardContent` from `info-card.js` and the cruise adapter's news presentation mapping. Cruise and regional cards share the news template, 📰 icon and gold header. Regional metadata is reduced to source, category and time. Each card owns an independent cancellable `typewriter.js` instance, while an invisible sizing layer reserves the full summary height. Summaries are not truncated. A fixed content area with internal scrolling keeps new headlines and typing from changing placement. Automatic stowing pauses typing and rotation; expanding resumes, and the seven-second reading interval begins after typing completes.
|
||||
|
||||
`news-region-model.js` owns four isolated queues and remaining reading time, using `CRUISE_CONFIG.dwellMs` (seven seconds). It supports per-card pause, navigation, hidden-page resume and current-ID preservation across refreshes. Existing `getCruiseNewsItems()` data updates through `earth:news-payload-updated`, retaining source/category/content-readiness filtering. No new collection requests or global/other-region backfill are introduced.
|
||||
|
||||
News and compute submit scopes through `createCalloutGroups` to one `createSpatialCallouts` controller, sharing destination planning, drag locks, size changes and obstacle handling. Resize state is looked up by element identity; news layout keys use `news:<region>` to avoid compute-key collisions. Existing regional connector styling and routing remain unchanged, as do normal object-detail connectors.
|
||||
|
||||
The opt-in switch persists under `planet.earth.news-region-cards.v1` and lives in the existing news panel. Mobile moves the same nodes into the news drawer and removes their map connectors. Destruction releases cards, animations, observers and subscriptions. Verification covers region isolation, pause/resume, current-story retention, scope removal, simultaneous 1080p layout, mobile and returning to desktop.
|
||||
|
||||
The news cruise adapter completes `presentItem` only after typing finishes, so `CruiseSequencer` dwell never cuts off long text. The wait checks sequence ownership every frame and exits on manual interruption. Regional-news toggles reflect `aria-pressed` with a highlighted background and border. Compute headers retain country flags and use small representative emoji for regional groups.
|
||||
|
||||
### Card DPI and frame-stage boundaries
|
||||
|
||||
Compute, regional news and normal/cruise detail cards share `--hud-detail-card-width` and the same `info-panel.css` typography, metadata and summary rules. Compute cards, charts, icons, spacing and controls use root `--hud-scale`; manually resized inline pixel dimensions are normalized to a scale expression. SVG viewBox units scale with the chart viewport rather than receiving a second multiplier. Europe uses a small EU monogram with the title's font metrics, avoiding Windows flag-emoji fallback alignment.
|
||||
|
||||
The configuration menu retains its independent 340px width and original height limit rather than inheriting detail-card geometry scaling. Compute headers keep the shared HUD specification; larger primary values, charts and content spacing support the common width. Regional motion is owned by the JS tween: CSS position transitions and forced transform compositing are removed. Final left/top values align to device pixels, including decorative float, and connectors use that same displayed position. Regional cards use peripheral stowing when full content does not fit.
|
||||
|
||||
Global selections are heterogeneous: satellites are GeoJSON objects, cables are lines, and only surface interaction markers carry `userData.icon_layer_id`. `markerPresentationId` generates stack identity only for that marker contract, returning null for other selections instead of dereferencing missing fields during the frame loop.
|
||||
|
||||
`frame-stages.js` isolates independent update stages so a failed card, label or layer cannot skip other stages and scene rendering. It reports the stage/error, retries at one-second intervals or immediately after selection changes, and resumes normally on success. Fixing the cause and observing no runtime errors in normal scenarios remain acceptance requirements; isolation does not replace the fix.
|
||||
|
||||
All object-selection confirmations use `showSelectionStatus(name)` and “Selected: name”, including direct clicks, cluster children, search and motion confirmation. `confirmInteractionCandidate` and its feedback option are input-neutral. The cable layer updates selection/legend state while the interaction entry emits one notification. Obsolete lock/location/motion-confirmation message keys are removed; actual motion-mode changes and gesture guidance retain their mode label.
|
||||
|
||||
### Detail-card information cleanup
|
||||
|
||||
Normal details retain identity, business properties, timestamps and useful reliability context. Satellite cards omit the three rendering diagnostics (coverage capability, current presentation and coverage model) without changing coverage controls. News details omit feed identity, source type and fetch channel, retaining publisher, category, region, publication time and summary. Facility cards omit geocoding provenance, confidence scores, reasoning, matched names and verification timestamps while retaining location precision and verification status. Identical GPU model/platform values appear once. Internal source status is omitted; compute precision/measurement and original-source access remain.
|
||||
|
||||
Collector cards omit the redundant activity summary while retaining observation counts, event times and coverage. BGP event confidence remains relevant to alert reliability. Cables omit a single-coordinate row and custom points omit internal record IDs. Empty vessel enrichment no longer displays a caching message or enrichment confidence scores; identity, navigation, available profile data and source attribution remain. Desktop and mobile share field definitions. Underlying data and location-correction operations remain intact.
|
||||
|
||||
### Performance with multiple cards and the full satellite set
|
||||
|
||||
Regional news and compute cards retain their shared dark surfaces, borders, shadows and text while avoiding per-card backdrop filtering of the changing WebGL/video scene and SVG edge distortion. `contain: layout style` limits layout propagation. Pixel-aligned positioning, card count, content, typing and float timing remain intact.
|
||||
|
||||
Each `CalloutConnector` SVG covers its path plus node/shadow padding rather than the entire viewport; path coordinates remain in screen space. Geographic anchors cache unchanged local coordinates, invalidate on coordinate changes, and still update world projection and front/rear visibility each frame. Obstacle tests compare rectangle coordinates directly without allocating bounds for every candidate pair. HUD obstacle measurement precedes that frame's card-style writes.
|
||||
|
||||
Satellite interpolation copies exact endpoint samples and retains radius-preserving interpolation between them. Trail compaction only adds an early rejection for high-altitude trails that already fail the existing error bound. Repeated identical hover/selection indices avoid full alpha-buffer rewrites. Static cable and landing-point local matrices are cached; landing-point scale changes explicitly rebuild their matrix while parent-world transforms propagate normally. No satellites, trail samples or rendering pixels are removed.
|
||||
|
||||
For development diagnostics, append `?profileFrames=1` to the Earth URL. `data-earth-frame-profile` reports per-stage CPU mean, maximum and count every second; profiling is disabled normally. `data-earth-gpu-renderer` identifies the GPU. Performance evidence must include actual canvas pixel dimensions, fully visible card count, satellite visibility, zoom, video state and 30-second average FPS. Pending cards or smaller viewports do not establish a 1080p full-scene result. CPU stage durations are not GPU timings.
|
||||
|
||||
## Detail-card images
|
||||
|
||||
`card-images.js` integrates optional imagery into the existing `showInfoCard` path used by selection and cruise, with one presenter implementation for desktop and mobile. It requests `/api/v1/earth/card-images/resolve` on selection using NORAD IDs or source-qualified object IDs. The server derives models, groups and categories from current catalog data. A bounded 100-identity, 30-second cache avoids repeated lookups; a same-origin storage revision event refreshes open cards after an admin save. No image work runs in the animation loop.
|
||||
|
||||
The shared `card-images.css` places imagery behind the rightmost 68% of the existing body. Horizontal and bottom gradients intersect using standard/WebKit mask composition. The image uses 0.85 opacity with `saturate(1.2) contrast(1.1)`; its containing layer fades to 1, without an additional dark scrim or group opacity reduction. Text, headers, DPI sizing, scrolling, dragging and connectors retain their own layout. Decode precedes the fade; reduced motion skips animation. Missing/broken images produce a data-only card. Selection tokens guard both resolution and decoding, so late results cannot replace newer selections or reopen a closed card. The header source link exposes attribution and license information.
|
||||
|
||||
`card_image_configuration.py` stores assets and assignments in a separate system setting, using revision checks, row locks and audit transactions. Collected records are never edited. Resolution prefers object, model, group and category in that order; explicit per-object suppression stops fallback. Disabled assets cannot resolve; permission metadata remains truthful and separate from the enabled flag. Schemas reject duplicate targets and dangling references. Built-in assets come from the same manifest copied into the backend container, without seeding the database. Known built-in IDs preserve edits/removals while merging newly shipped entries; previous_defaults replaces old bundled URLs while preserving fields changed from the prior default and all custom image URLs.
|
||||
|
||||
Pillow validates still JPEG/PNG/WebP uploads and generates 640/1280-bound WebP derivatives, preserving original files. Content-addressed files are written atomically and served through a read-only public endpoint; management, upload, catalog search and preview require administrators. See the operations runbook for storage/backup. The Frontier photo is facility-specific; Starlink remains a series illustration. Qianfan series imagery is enabled as requested, while its permission metadata remains unverified.
|
||||
|
||||
Category `*` covers satellites, compute facilities, cables, landing stations, vessels, BGP incidents, BGP collectors and custom points, after specific category rules. Stable keys are MMSI, incident_key, collector_id and the custom-point ID. Missing identities in old snapshots allow only a type illustration. Rebind a collected object if its upstream source_id changes.
|
||||
|
||||
|
||||
### Five compute facility types and default imagery
|
||||
|
||||
Supported types are `supercomputer`, `gpu_cluster`, `data_center`, `cloud_center` and `edge_node`. `compute-site-types.js` centralizes frontend titles, labels, counts and fallback; backend `compute_types.py` shares classification between GeoJSON and image lookup. An explicit supported metadata.site_type wins, followed by data_type and known source defaults. Unrecognized facilities use a generic data-center presentation instead of becoming GPU clusters. No facilities are fabricated or existing records reclassified by name.
|
||||
|
||||
Each type supports legend filtering, marker icons, desktop/mobile details and image identity. New infrastructure cards use identity, operator, capacity, power and location fields without GPU counts or TOP500 ranks. The map accepts known sources or records declaring a supported compute type. Regional facilities retain snapshot/country constraints; infrastructure records do not require a GPU measurement contract and do not imply a global compute share. Built-in collectors remain TOP500 and Epoch AI; adding types does not create records.
|
||||
|
||||
Seven user-supplied images provide defaults for the five facility types, BGP collectors and BGP events. All five categories are available in image management before data exists. Exact assignments retain priority. Manifest previous_defaults / previous_rules migrate old bundled sources and unchanged collector assignments while preserving custom crop, enabled state and specific bindings.
|
||||
|
||||
|
||||
### Frame-work reductions with unchanged presentation
|
||||
|
||||
Cluster presentation reuses each parent's world matrix and local camera position within one update, refreshing on every frame. Fully collapsed members without child DOM still participate in hidden-state reconciliation, but skip invisible endpoint projections. Unchanged count and ARIA text no longer cause repeated DOM writes.
|
||||
|
||||
Static point batches refresh on data, selection, filtering, distance scaling or explicit invalidation; selected overlays continue pulsing every frame. Parsed colors cache immutable strings/numbers only, never mutable caller-owned Color objects. Satellite dot/halo overlays update only the required ancestor transform rather than traversing all Earth descendants twice.
|
||||
|
||||
Trail sampling combines the radius bound and initial error scan, preserving the existing threshold and subdivision indices. No satellites, trajectory samples, card content or resolution are removed. Deterministic comparison covered 10,000 histories with identical indices. Local Bun microbenchmarks (not whole-page FPS): 480 cluster members took about 1.19→0.90 ms collapsed and 4.47→2.86 ms expanded; scanning 16,621 trails took about 11.50→8.94 ms. End-to-end FPS requires a separate 30-second foreground comparison at identical resolution and scene/card/video settings.
|
||||
|
||||
Regional news collapse buttons explicitly invoke the shared HUD controller. Their bodies use hud-panel__body--collapsible and aria-expanded follows the actual state. Only user clicks collapse cards; layout and performance code never hide their contents automatically.
|
||||
|
||||
Floating object details and regional news share `--hud-detail-card-max-height`: 390px times HUD scale, capped at viewport height minus 24px. A min-height:0 flex chain keeps the header fixed and scrolls only the image-backed body. Short cards keep their natural height. BGP incidents prioritize type/severity/status/impact/time/summary; collectors prioritize identity/status/recent activity; facilities prioritize name/capacity/hardware, with location verification and provenance later. Desktop and mobile field order share CARD_CONFIG.
|
||||
|
||||
|
||||
### Detail identity and connector consistency
|
||||
|
||||
BGP collector indexes use collector names. Shared marker indexes omit empty IDs and refuse cross-refresh replacement for ambiguous duplicates; a clicked marker still in the current layer retains its own reference. Stack presentation shares icon_item_id with the index. BGP incidents prefer incident_key, while raw anomalies use source plus ID to prevent numeric collisions across feeds.
|
||||
|
||||
Satellite detail connectors identify the selected NORAD and relocate its index when the catalog array changes. Selection highlights rebind to that NORAD after refresh; a removed target clears selection. Uninitialized zero positions are not valid anchors. Cable anchors remain confined to the selected cable geometry, landing points use the clicked marker, and regional news/compute cards retain their regional anchors.
|
||||
|
||||
|
||||
When compute image configuration has no model/group rules, resolution uses a bounded fast path: the stable source prefix supplies the supercomputer/GPU category, then object and category rules are checked without scanning the collected catalog. Adding model or group rules automatically uses the full catalog resolver. Frontend identities, collected data and other object types are unchanged.
|
||||
|
||||
@@ -1,84 +1,35 @@
|
||||
# Intelligent Planet Interactable Clustering
|
||||
# Earth interactable clustering
|
||||
|
||||
Earth interactable icons are managed by `createInteractableLayer`. Rendering still uses Three.js `Points`, and picking, hover, locked state, tooltips, and cruise focus still depend on marker `userData`; the clustering strategy only decides which markers are represented by a cluster dot.
|
||||
`interactable.js` owns fixed geographic membership, `cluster-motion.js` owns timed tweening, and `cluster-presentation.js` owns circular badges, projected children, picking and Spiderfy. Independent markers remain GPU Points. Each clustered child uses the same DOM icon throughout its animation and at the final position.
|
||||
|
||||
## Strategies
|
||||
## Membership and zoom
|
||||
|
||||
`cluster.strategy` supports three modes:
|
||||
Spherical buckets use real `icon_base_position` anchors. Defaults are distance 15 in Earth units, at least two members and at most 14 per group. Data, filter and visibility changes invalidate membership; rotation and zoom do not. Badges stay at the spherical centroid without screen avoidance.
|
||||
|
||||
- `stable-spherical`: stable spherical clustering. It clusters by local 3D positions on the globe and caches topology by zoom band. Rotation and small zoom changes within the same band only update projection and size. Use it for semi-static layers such as BGP, compute centers, and Earth interactables.
|
||||
- `dynamic-screen`: dynamic screen-space clustering. This keeps the previous projection-based behavior and evaluates visible relationships per frame. Use it for high-frequency realtime layers such as vessels, or as a fallback.
|
||||
- `none`: no clustering. Every marker is shown independently. Use it for low-count layers or precision-first views.
|
||||
Use `cluster: { enabled: true }` or `cluster: false`. Options are `distance`, `minCount`, `maxMarkersPerDot`, `disableAboveZoom` (2.5) and `expansionHysteresis` (0.08). Zoom uses the UI's `globeZoomForDistance()` mapping: expand above 250%, collapse below 230%, and show no aggregate badges at 500%. Satellites and AIS vessels remain separate.
|
||||
|
||||
`cluster: false` is equivalent to `strategy: "none"`. If a layer enables clustering without declaring a strategy, it keeps the compatible `dynamic-screen` behavior.
|
||||
## Motion and Spiderfy
|
||||
|
||||
## Stable Spherical
|
||||
Both directions use a 320ms easeInOutCubic tween, complete after wheel input stops, and reverse continuously. Children interpolate directly from the centroid to their real local positions and retain the same DOM nodes at the endpoint. Because this chord can temporarily enter the sphere, occlusion uses a radial surface anchor while projection retains the interpolated position; intermediate frames remain visible.
|
||||
|
||||
`stable-spherical` moves cluster identity from screen distance to globe distance:
|
||||
Only co-located or extremely close geographic anchors (about 0.00015 degrees) share a compact cross-layer stack. Screen overlap and coarse city buckets never merge different locations. Each icon retains its own real projection as its baseline, with the entire stack bounded to 24px per axis and small groups exposing approximately 8–12px edges. Stable IDs define resting order; selecting a rear item brings it forward, and clearing focus restores its slot. Ordinary expansion ends exactly at real projected positions; coordinates and marker positions remain unchanged.
|
||||
|
||||
- Each marker uses `icon_base_position` as its geographic anchor.
|
||||
- By default, zoom levels above `2.5` force clustering off and show every marker as its original icon.
|
||||
- The current zoom maps to a discrete band; rotation and small zoom changes inside the same band do not recompute topology.
|
||||
- Clusters are recomputed only when the band, data revision, visibility, or filter state changes.
|
||||
- A cluster centroid is computed from member 3D positions and normalized back to the globe shell, so the cluster dot stays rigidly aligned to its geographic center.
|
||||
- Cluster dots do not participate in 2D avoidance, so screen-space repulsion cannot push them away from their real geographic projection.
|
||||
- Band changes use a small hysteresis margin so zooming at a boundary does not repeatedly bounce between two bands.
|
||||
- Newly created marker and cluster dots run a short scale + opacity ease. This is only a rendering transition; it does not change marker coordinates, picking objects, or locked state.
|
||||
## Interaction and BGP
|
||||
|
||||
The stable strategy uses spherical bucket/hash neighbor lookup and must not use an all-pairs loop. Most frames only pay projection and material-size cost; topology cost is paid only on band or data changes.
|
||||
Clicking a badge expands it and pauses rotation. Legs have 14px transparent hit areas; hover highlights the matching leg and child, and either click selects the same business object. The canvas container accepts wheel input over the canvas and projected markers while excluding HUD scrolling.
|
||||
|
||||
## Configuration
|
||||
Projected textures bake the original category tint. BGP events retain severity colors. Badges containing BGP and expanded BGP event children use an expanding/fading halo based on `BGP_CONFIG.ring`; replaced GPU effects are hidden to avoid duplicate rings at old anchors. Reduced-motion preference disables the pulse.
|
||||
|
||||
```js
|
||||
const computeCenterIconLayer = createInteractableLayer({
|
||||
id: "computeCenters",
|
||||
// ...
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
minCount: 2,
|
||||
maxMarkersPerDot: 14,
|
||||
transitionMs: 220,
|
||||
bandHysteresis: 0.08,
|
||||
disableAboveZoom: 2.5,
|
||||
bands: [
|
||||
{ key: "far", maxZoom: 1.7, distance: 15 },
|
||||
{ key: "mid", maxZoom: 2.6, distance: 8 },
|
||||
{ key: "near", maxZoom: 3.5, distance: 4 },
|
||||
{ key: "detail", maxZoom: Infinity, distance: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
## Removed paths
|
||||
|
||||
Realtime layers can stay dynamic:
|
||||
The low-zoom compact-dot desktop/mobile controls, persistence field, textures, rendering/picking branches and translations are removed. Dynamic screen clustering, discrete zoom bands, GPU aggregate dots and the old creation-time fade/scale transition are also removed. There is no legacy strategy fallback.
|
||||
|
||||
```js
|
||||
const vesselIconLayer = createInteractableLayer({
|
||||
id: "vessels",
|
||||
// ...
|
||||
cluster: {
|
||||
strategy: "dynamic-screen",
|
||||
enabled: true,
|
||||
maxMarkersPerDot: 10,
|
||||
},
|
||||
});
|
||||
```
|
||||
## Regression coverage
|
||||
|
||||
## Tuning
|
||||
Tests cover 320ms completion, reversal continuity, visible intermediate frames, retained endpoint nodes, 500% expansion, immutable source coordinates, filtering cleanup, wheel target routing, linked leg hover/click and projected BGP category colors: `cluster-motion.test.js`, `cluster-presentation.test.js`, `globe-navigation.test.js`, `marker-badges.test.js`.
|
||||
|
||||
- `distance` is the 3D globe-distance threshold and uses the same unit as `CONFIG.earthRadius`. Larger values cluster more aggressively.
|
||||
- The farthest band usually uses a larger `distance`; the nearest detail band usually uses `0` to split clusters into original icons.
|
||||
- `bandHysteresis` controls band-boundary stickiness. Too little can flicker near thresholds; too much can make band changes feel late.
|
||||
- `transitionMs` controls cluster split/merge easing. Keep it around 160-260ms; longer durations can make realtime layers feel sluggish.
|
||||
- `disableAboveZoom` controls the precision-view threshold. It defaults to `2.5`; above that zoom, no cluster dots are generated. Set it to `false` to disable the hard threshold.
|
||||
- High-frequency realtime layers should prefer `dynamic-screen` so frequent data changes do not trigger stable topology recomputation.
|
||||
- If a layer behaves poorly, switch it back to `dynamic-screen` or use `cluster: false`.
|
||||
## Refresh and first-frame preparation
|
||||
|
||||
## Acceptance Checks
|
||||
A reused cluster ID does not imply reused Object3D instances: stale children are reconciled against current instances. Ownership changes immediately rebuild the GPU batch, and topology invalidation clears its old signature.
|
||||
|
||||
- Rotating the globe inside one zoom band should not cause clusters to flicker or regroup.
|
||||
- Cluster dots should stay aligned with the globe-surface centroid and should not be pushed by avoidance.
|
||||
- Zooming into the detail band should restore the layer's original icon textures and click behavior.
|
||||
- Zoom levels above 250% should not show cluster dots.
|
||||
- Realtime layers such as vessels should reflect incoming updates immediately.
|
||||
Expansion prepares visible icons and waits for image decoding before starting its 320ms clock. Encoded URLs are reused per texture, and invisible rear members are not instantiated. Regression tests cover repeated refresh/zoom, preparation slower than the tween duration, selection/restoration and connector anchors.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- Surface: `EARTH_MATERIAL_CONFIG.surface` owns exposure=0.9, landSaturation=0.9, oceanAbsorption=0.36, oceanDeepColor=[0.004,0.012,0.032], night retention and Fresnel parameters. `atmosphere` owns the single shell's radius, color and strength. Base and terrain specular highlights are disabled.
|
||||
- Cables blend a cool normal tint with their category color, consistently with the legend. Selected pulses follow cumulative route length with 6 repetitions and speed 4.5 in the existing batch draw. Drawing no longer writes depth; original Line pick objects, cable IDs and state logic remain intact.
|
||||
- Landing points use the 128×128 monochrome `marker-landing-point.svg` micro-core instead of a glossy procedural ball; Sprite picking and billboard size logic remain intact.
|
||||
- Compute markers use canonical SVG inner glyphs over shared gradient plates, pale rims, top highlights and shadows. GPU/AI uses purple stacked layers, supercomputers purple chips, and infrastructure teal server racks. Estimated badges, optional compact dots and picking remain intact.
|
||||
- Compute markers use canonical SVG inner glyphs over shared gradient plates, pale rims, top highlights and shadows. GPU/AI uses purple stacked layers, supercomputers purple chips, and infrastructure teal server racks. Estimated badges and picking remain intact.
|
||||
- Satellites share vivid real-inclination category colors with the legend and predicted orbits. Normal markers are prototype hard discs without outlines or sphere shading; breathing defaults off. Coverage stays blue and sunlight only affects opacity.
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel
|
||||
| Vessel | Cargo blue, tanker orange, passenger teal, fishing amber, military indigo, other silver | Heading arrow; anchored state retained |
|
||||
| Custom marker | Place green, note yellow, alert red, default blue | Pin, note sheet, warning triangle |
|
||||
|
||||
BGP change events retain irregular flat symbols as the exception; collectors use rounded badges; other finite surface objects share cached badge textures without a new DOM node or draw object per marker. Vessels retain heading buckets and vertex colors rather than multiplying buckets by vessel type. Satellites use distinct category colors and hard discs without an outline; orbit and footprint calculations stay intact. Optional low-zoom dots remain available, default off; both rendering and pick sizing honor the switch.
|
||||
BGP change events retain irregular flat symbols as the exception; collectors use rounded badges; other finite surface objects share cached badge textures without a new DOM node or draw object per marker. Vessels retain heading buckets and vertex colors rather than multiplying buckets by vessel type. Satellites use distinct category colors and hard discs without an outline; orbit and footprint calculations stay intact. The low-zoom dot control, persisted field and rendering/picking branches are removed.
|
||||
|
||||
|
||||
### Stable satellite palette
|
||||
@@ -294,3 +294,9 @@ Normal and related landing points retain the same orange core and yellow rim. Re
|
||||
### Sky color output
|
||||
|
||||
The star map is decoded as sRGB. `celestial.js` uses shared `preserveTextureOutput()` to encode only the sky material back to display sRGB. The Three.js r128 renderer retains its existing global linear output so Earth, satellites and other layers remain unchanged. `skyBrightness=1.0` retains source brightness; missing output encoding displays decoded dark values directly and cannot be corrected by raising the multiplier alone. Validate with the same map, camera and orientation, comparing rendered pixels and the 1.0/1.5 response instead of checking asset loading only.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -98,4 +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; flat BGP change-event depth testing stays unchanged.
|
||||
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.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -395,7 +395,7 @@ Current boundary:
|
||||
- `TV Livestream` owns the Earth media-panel source configuration.
|
||||
- `Branding` owns Earth HUD brand assets. `Logo URL` and `Title Image URL` use inline upload controls inside the fields; the upload buttons keep the primary `TactileButton` style, and dropping an image onto the matching field shows a low-saturation drag reaction instead of the old global asset-picker toolbar.
|
||||
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
|
||||
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
|
||||
- `Base Map`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
|
||||
|
||||
Do not add Earth experience resources or collection-lifecycle tabs back into `/settings`; collection belongs to `/collection-management`, and Earth display resources belong to `/earth-content`.
|
||||
|
||||
@@ -454,3 +454,11 @@ Therefore:
|
||||
For Earth-related structure, see:
|
||||
|
||||
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
||||
|
||||
## Detail-card image workspace
|
||||
|
||||
`CardImageSettings.tsx` replaces the `layer_resources` placeholder in Earth Content, retaining the shared page frame, section navigation, controls and confirmation dialogs. Its persistent component preserves drafts across section changes. List and editor own their scrolling with a constrained height chain; narrow layouts stack these regions. `CardImagePreview.tsx` reuses the actual Earth mask stylesheet.
|
||||
|
||||
The image library supports upload/URL entry, attribution, licensing, enabled state and crop focus. Referenced assets cannot be deleted until assignments are removed. Assignments use database-backed object/model/group/category choices and support edit, disable, removal and explicit per-object suppression. Draft preview calls the same resolver used after publication. Only Save publishes; conflicts retain drafts, and the editing fieldset is disabled during save/upload. Uploading alone does not publish an asset.
|
||||
|
||||
Under `/earth/card-images`, configuration GET/PUT, upload POST, targets GET and preview POST require administrators. Resolve and content-addressed files are public read-only routes. Configuration writes use revision checks and audit transactions.
|
||||
|
||||
@@ -204,7 +204,7 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
|
||||
- **TV Livestream**: manages sources shown in the Earth media panel.
|
||||
- **News Content**: browses news grouped by RSS source and manual group. RSS items remain read-only; manual groups support create, JSON import, edit, delete, and reprocess.
|
||||
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
|
||||
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
- **Base Map**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
|
||||
The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference.
|
||||
|
||||
@@ -299,6 +299,14 @@ Search finds cables, landing points, satellites, compute centers, BGP events, BG
|
||||
|
||||
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.
|
||||
|
||||
### Four regional news cards
|
||||
|
||||
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.
|
||||
|
||||
Cards reuse the cruise-news gold header and 📰 icon, preserve full summaries and simplify source metadata, with the compute cards' dragging, collapsing and automatic placement. News and compute cards share one placement plan. Connectors identify representative regional anchors, not precise event locations. On mobile, enable “Region cards” from the news page and read them in the drawer.
|
||||
|
||||
### Choosing a News Live Stream
|
||||
|
||||
Open the live tab in the media panel and click the current channel to open the search menu. Search covers the complete catalog. The list loads 50 channels at a time and appends another page when you scroll to the bottom. A fixed footer shows the loaded and matching channel counts; a failed request can be retried below the list. The default source is Al Jazeera Mubasher, using its HLS playback URL. Other channels remain subject to availability of their playback service.
|
||||
@@ -327,7 +335,7 @@ The batch continues within the current Earth page when you close the candidate p
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
|
||||
News categories use the same chip selector as Cruise Modules. They only filter the news panel and news cruise items in the current browser; they do not affect layers, TV, data points, basemap, boundaries, collector jobs, or admin news-source configuration.
|
||||
|
||||
@@ -426,16 +434,38 @@ The picker groups the canonical comparison catalog as China, US, Europe, APAC ex
|
||||
|
||||
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.
|
||||
|
||||
This prototype shows up to six content cards. The main card centers on the selected compute metric: a large share percentage or absolute value with its unit, observation year, historical context, and source entry. Missing comparable data is shown as “No comparable data yet”; a trend is omitted when there are no observations rather than rendered as an empty chart. Facility records and coverage metadata remain complete but move to the expanded auxiliary area; sample counts are evidence context, never a compute value or global coverage rate. Region previews may show member counts, and long lists open only when needed. Desktop compact cards are about 268px wide with content-driven height; expanded cards support user resizing up to 480px. Mobile keeps the same single metric meaning in the one-card accordion rather than promoting facility counts. Local card position and size are saved, with a reset-to-auto action. The content layout leaves room for later image and video integration, but those media data are not connected yet. When outside space is insufficient, cards can travel through the globe's foreground instead of teleporting or shrinking to labels.
|
||||
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.
|
||||
|
||||
When the map cards switch is off, the overview summary still loads. Turning it on shares the map loading event; a newer request cancels the previous one. Drawer event handling follows `composedPath()` so replacing inner HTML cannot accidentally collapse the active row. The shared HUD collapsible panel animation is reused and respects reduced-motion preferences. Facility names remain in a scroll region capped at 240px; rows are left-aligned, wrap long names, and retain at least 42px of touch height.
|
||||
|
||||
## Surface and object appearance
|
||||
|
||||
The real surface retains all four map choices. Day/night shading follows the sun, with uniform illumination when disabled. Selected cables show traveling pulses and landing points use amber micro-markers. Facilities show chip symbols up close while the compact-dot preference remains available. Satellites retain self-glow/ground-coverage modes, trails and their existing orbital motion. BGP severity, vessel heading, related highlights, detail cards and mobile drawers retain their established interactions.
|
||||
The real surface retains all four map choices. Day/night shading follows the sun, with uniform illumination when disabled. Selected cables show traveling pulses and landing points use amber micro-markers. Facilities retain category symbols, with numbered solid circles for aggregates. Satellites retain self-glow/ground-coverage modes, trails and their existing orbital motion. BGP severity, vessel heading, related highlights, detail cards and mobile drawers retain their established interactions.
|
||||
|
||||
### Satellite orbits and collection attempts
|
||||
|
||||
Selecting a satellite shows an open predicted arc with highlights moving in the travel direction. Satellites, trails and footprints remain synchronized. Coverage stays blue across day/night; particle and orbit category colors match the legend. Dragging continues outside the globe silhouette, with reduced sensitivity at extreme zoom. The TV panel has square corners like other cards.
|
||||
|
||||
Retriggering a datasource creates a new task while older failures remain in the failed group. Task Status uses a checklist icon, distinct from Refresh. CelesTrak can reuse valid recent download cache; each new collection attempt still gets its own task and updates collected data.
|
||||
|
||||
|
||||
Display → Satellites → Close-up labels defaults on; turning it off hides satellite map names while retaining detail cards. The zoom-threshold slider below the switch ranges from 250% to 500% (default 400%); labels appear at or above the selected zoom and hide below it. The count slider sets a saved limit of 5–36 labels (default 36); occlusion or limited space may result in fewer visible labels. Sky brightness ranges from 0–200%, defaulting to 100%. Both choices persist. Compute region cards wait for drag/globe motion and new-card placement to settle before necessary avoidance. Otherwise they float subtly around stable positions and pause during interaction. Compute legend category counts describe currently located markers.
|
||||
|
||||
|
||||
Surface aggregates appear as solid numbered circles. Click to expand members; only co-located or extremely close geographic markers form compact stacks at their location, with exposed clickable edges. Ordinary markers stay at their real projected positions. Selecting a rear icon brings it forward and opens its details; clearing focus restores its original position. Ordinary groups split above 250%; no aggregate badge remains at 500%, and co-located markers expand directly.
|
||||
|
||||
Object detail cards show identity, business properties and update times without rendering diagnostics, collection channels or coordinate-matching internals. Facility location precision and verification status, BGP alert confidence, and news publisher, publication time and summary remain visible.
|
||||
|
||||
Detail popups share the news-card height cap. Headers remain fixed while long bodies scroll; key event information and device specifications precede location verification, provenance and related objects.
|
||||
|
||||
### Detail-card images
|
||||
|
||||
Open Earth Content → Layer resources → Detail card images. Upload a still JPEG, PNG or WebP, or enter an image URL. Set its title, source, credit, license and crop focus; the preview uses the actual feathered card background.
|
||||
|
||||
In Assignments, select satellites, compute facilities, cables, landing stations, vessels, BGP incidents, BGP collectors and custom points, then an object/model/group/category. Search selects current catalog objects and stable identifiers; missing model data does not produce invented choices. Apply an assignment to the draft, then Save to publish. Object rules take precedence over model, group and category rules. “No image for this object” suppresses all fallback images for that object.
|
||||
|
||||
Verify an object to see its draft match before publishing. Disabled assignments allow more general rules to match. Remove assignments before deleting an image. Conflicting saves preserve your draft.
|
||||
|
||||
Earth fades available imagery behind the existing detail data. Missing or failed images leave a complete data-only card. The header image icon links to the source and exposes attribution/license in its tooltip. Every map object type has a bundled default: exact and series images take precedence over explicitly labeled type illustrations. Starlink, BeiDou-3 and Qianfan are assigned; Qianfan retains its unverified permission metadata. Fresh installs need no setup. Upgrades merge missing defaults after services restart while preserving custom assignments and explicit suppression.
|
||||
|
||||
Compute legends and details support supercomputers, GPU clusters, data centers, cloud computing centers and edge nodes. Manage their defaults under Compute facilities → Category in image management. Types without collected records show a zero count. BGP collectors and incidents have separate default imagery.
|
||||
|
||||
@@ -489,3 +489,9 @@ The old `frontend/public/earth/assets/8k_earth_daymap.jpg` path remains but now
|
||||
High-precision boundary outputs live in a Git-ignored generated directory. Pulling does not deliver them automatically. Outputs lacking the required new policy metadata are rejected in favor of the shipped policy fallback; regenerate and publish the static artifacts through the existing boundary workflow to retain high precision. This fallback does not reduce basemap resolution.
|
||||
|
||||
Run `PLANET_HARNESS_DOCKER_SMOKE=1 .venv/bin/python scripts/harness/test_backend_runtime_assets.py` to exercise the actual Docker context and production COPY rules, verify the shared policy/geometry and builder imports, and exclude environment files. This probe does not start the full application stack.
|
||||
|
||||
## Detail-card image storage
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,6 +59,8 @@ Once in, verify:
|
||||
|
||||
After selecting an object or enabling a layer, click its legend entries to filter directly. Display settings can enable FPS; satellite names appear at zoom levels of 400% or above.
|
||||
|
||||
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.
|
||||
|
||||
## 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).
|
||||
@@ -83,3 +85,7 @@ Open the analysis icon beside Compute Centers and enable Show region cards. Clos
|
||||
### Inspect satellites and retry collection
|
||||
|
||||
Enable satellites in Earth and select a particle to inspect its open predicted orbit, travel-direction highlights and supported footprint. Dragging continues beyond the globe silhouette. A failed datasource can be triggered again: the new task is separate, and older failures remain in the current queue’s failed group.
|
||||
|
||||
Regional cards remain around the globe while connectors follow rotation. Full cards remain visible when space is limited. Valid drops retain their new location; central or out-of-bounds drops settle nearby automatically. Detail cards retain business information and essential reliability context.
|
||||
|
||||
Manage detail-card imagery under Earth Content → Layer resources: upload an image, assign an object or group, preview and save. Bundled exact, series and type illustrations cover all map object types. Fresh installs need no import; pull updates and restart both services to merge missing defaults. Custom assignments and explicit suppression remain intact.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- [智能星球卫星覆盖策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-satellite-footprint-policy.md):卫星 footprint 的显示边界和策略
|
||||
- [BGP 态势上下文](/home/ray/dev/linkong/planet/docs/technical/zh/earth-bgp-context.md):BGP 在智能星球中的渲染、聚合和观测站实现
|
||||
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):`Interactable` 的接口、生命周期和接入示例
|
||||
- [智能星球可交互图标聚类策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-clustering.md):可插拔 cluster strategy、稳定球面聚类和动态屏幕聚类的适用边界
|
||||
- [智能星球可交互图标聚类策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-clustering.md):固定地理聚合、时间补间、Spiderfy 放射展开及交互验收
|
||||
- [智能星球工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索、设置、新闻、图层浮层的关闭矩阵
|
||||
- [智能星球新闻源配置](/home/ray/dev/linkong/planet/docs/technical/zh/earth-news-sources.md):默认新闻源、Feed 子项、源属性标签、内容类型、重要度规则和配置接口
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
|
||||
- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目;Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。
|
||||
- `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。
|
||||
- `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。
|
||||
- `底图资源`、`图层资源`、`3D 模型`、`新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。
|
||||
- `底图资源`、`3D 模型`、`新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。
|
||||
|
||||
TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签,这样控制台看到的内置、直播加载中、播放源、地区、语言等信息与 Earth 中实际显示保持一致。
|
||||
|
||||
@@ -233,7 +233,7 @@ TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签
|
||||
|
||||
## 渲染热路径
|
||||
|
||||
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。
|
||||
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。密集线缆区的拾取先在屏幕距离上比较卫星、BGP、算力和其他点状对象,海缆线作为没有更近点状对象时的兜底;hover 与 click 共用该候选顺序,保护穿插在线缆中的小面积物件。
|
||||
|
||||
卫星的逐点呼吸在 GPU 中计算。全量 SGP4 位置和初始轨迹计算通过 `satellite-position-worker.js` 离开主线程;主线程只接收位置快照、更新共享交互坐标及绘制缓冲。轨道数学由 `satellite-propagation.js` 同时供 Worker、预测轨道和同步降级路径使用,避免多套公式漂移。数据重载、清空和高度切换必须同步终止旧计算,不能让旧快照恢复已清空的图层。
|
||||
|
||||
@@ -410,7 +410,7 @@ AISStream 与 BarentsWatch 共用 `/ws` 的 `vessels` 增量通道。Earth 使
|
||||
|
||||
登陆点是当前明确保留的例外:它曾接入 `Interactable`,但 pin 类 SVG 在地球边缘会被 `THREE.Points` 的深度测试裁切成碎片;关闭 depthTest 又会破坏背面遮挡语义。因此登陆点退回 `cables.js` 内的专用 `THREE.Sprite` 路径,并改为 canvas 生成的黄色扁平球纹理。它的 `altitudeOffset` 和 `renderOrder` 与海缆线一致,避免漂在海缆之上;Sprite 本体关闭 `depthTest` 保持球完整,背面可见性由 `isFacingCamera()` 的球体遮挡判断控制。
|
||||
|
||||
图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加未确认位置的 `?` badge。算力中心后端在启动链路只渲染源数据自带坐标或 `compute_center_locations` 维表坐标;手动候选采集会调用 ROR 和 Nominatim/OpenStreetMap,并在 GeoJSON 或候选响应中返回位置精度、置信度、来源说明和核验时间;前端详情卡展示这些字段。
|
||||
图标资源可以继续用 canvas draw,也可以放到 `frontend/public/earth/assets/icons/` 后由 `Interactable` 预加载。asset 路径不会在每帧读取;图层加载阶段通过 `preloadAssets()` 只加载一次 SVG / 图片,之后按 `icon source + state + bucket + color` 生成 `CanvasTexture` 并复用。当前算力中心已经从 `assets/icons/compute-supercomputer.svg`、`assets/icons/compute-gpu-cluster.svg` 和备用 `assets/icons/compute-hdd-network.svg` 读取图标,再在 canvas 上叠加未确认位置的 `?` badge。算力中心后端在启动链路只渲染源数据自带坐标或 `compute_center_locations` 维表坐标;手动候选采集会调用 ROR 和 Nominatim/OpenStreetMap,并在 GeoJSON 或候选响应中返回位置精度、置信度、来源说明和核验时间;前端详情卡保留位置精度和核验状态;定位来源、置信分、匹配过程等仍保留在数据与位置校准流程中,不再逐项占据常规详情卡。
|
||||
|
||||
算力中心图层行左上角的通知气泡显示 GeoJSON `unresolved` 数量。这个数字表示“完全没有可信坐标、不能渲染到地球上”的记录,不等同于地图上带 `?` 的已定位待确认点。点击气泡会在图层面板右侧打开固定信息卡,信息卡内容区内部滚动,不随鼠标 hover 消失。列表中的单条 `采集` 只展示候选;顶部 `一键采用` 会按当前列表顺序逐条采集、保存最高置信候选,成功一条就移除一条、重新编号,并通过 `earth:compute-center-unresolved-count-change` 同步气泡数量。批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。
|
||||
|
||||
@@ -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 层实现,而不是把对象沿地表切平面挪开。
|
||||
|
||||
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶,BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类;`none` 关闭聚类。船只显式关闭聚类与避让,保证增量更新时不重建聚类拓扑。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让,避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。
|
||||
地表标记保留分类图标,聚合使用固定地理成员及带数量的实心圆。缩放只驱动 320ms 展开/收拢补间,不重新分档聚类;超过 250% 展开,500% 无聚合圆标。船只关闭聚合,保留独立航向箭头。实现与验收见 [聚合说明](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`,按稳定地区 ID 复用 `CalloutConnector` 管理卡片。布局采用事件驱动;新计算尚未完成时保留有效旧矩形,投影 source 连线持续跟随地球。布局参考点改为实际出线方向,因此卡片保持在对象同侧,不会被左上地理点牵到右下角。solver 优先同扇区的邻近位置、较短连线和较少交叉;最多交换三轮空位改善 assignment,不在每帧全量重排。全屏地球允许附近前景位置,正常大小尽量留在球外,不为了完全避开球体而跨越整盘面。慢速模式下,方向明显偏移或连线持续交叉 1.8 秒后才能提议整体重排,两次自动提议至少间隔 8 秒;只有整体代价降低至少 18% 且超过最低收益门槛才采用。手动/惯性仍在 72ms/12px 预算内响应。展开与手动摆放获得 12 秒阅读缓冲;鼠标悬停、键盘焦点及拖动继续锁定,普通点击留下的焦点不永久锁卡。手动位置保持为偏好,缓冲结束且地球出线方向变化至少 45° 后才允许自动调整,旧保存位置不作为永久锁定。背面保留球体轮廓方向提示且不改变卡片身份。本地位置/尺寸保存与“恢复自动布局”由控制器处理。`ResizeObserver` 响应卡片调整尺寸和收起;选择器与来源窗复用共享拖动句柄。
|
||||
`spatial-callouts.js` 提供地理投影与共享分组,`spatial-callout-controller.js` 负责稳定周边阅读布局。`callout-dock-layout.js` 将中央观察区域作为不可放置卡片的硬障碍,先保留有效旧位置,再分配新增或受影响卡片。自动旋转、正背面变化、连线交叉和长短均不触发换位;仅卡片列表、用户展开/拖放、窗口与 HUD 可用空间变化时求解。空间不足时保留完整卡片,允许周边少量遮挡,不自动折叠、分组隐藏或缩小字号。拖拽松手通过 settleCardDrop 判断新落点:合法位置保持,越界或遮挡中央则调整到最近可用位置,允许周边与其他卡片轻微重叠。保存的是最终目标,不保存动画途中位置;回正运动不会因鼠标仍悬停而卡在中央。旧保存位置恢复时使用同一判断,手动归位入口及其分支已移除。开始拖动时该卡片升到所有区域卡片上方,松手后保持,直到另一张卡片被拖动;算力与新闻共享层级,配置菜单和来源面板仍在内容卡片之上。
|
||||
|
||||
对象展示统一通过 `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、尺寸、阅读状态应保留,过时响应不能重新启用卡片。
|
||||
|
||||
动捕/地球运动模式由 `main.js` 的 `isDragging`、`activeTouchPoints`、`hasActiveGlobeInertia` 接入。方向错位或交叉触发的是一组卡片联合优化,而非逐卡抢占剩余空位。`callout-region-path.js` 提供短直连加末端短折线;实际绘制、交叉评分及候选搜索共用路径几何。候选路径只计算一次,裁剪候选后才复制布局状态,避免重排期间不必要的分配。新布局计算期间保留现有卡片;队列不会因帧间隔而丢失重排请求。
|
||||
地球拖动与惯性状态只暂停卡片运动进度,不覆盖已提交终点。连线锚点继续跟随地球投影;卡片运动与地理出线方向独立。
|
||||
|
||||
共享 `CalloutConnector` 直接从折线点计算长度,不在每次写入 SVG 点后调用 `getTotalLength()`,避免多条移动连线触发反复同步布局。原有首次绘线动画保留;这一优化同时适用于原巡航展示。
|
||||
|
||||
@@ -569,11 +569,10 @@ Earth 原有 Roboto Condensed 字体已随 `assets/fonts/` 本地分发,许可
|
||||
|
||||
### 新闻栏端部渐变
|
||||
|
||||
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。警报状态只改变渐变细线的颜色,避免重新引入矩形阴影边界。
|
||||
新闻栏的底色与上下细线由独立伪元素绘制,两端在正文范围之外渐变到完全透明;外框不使用矩形 backdrop-filter、box-shadow 或文字遮罩。地区标签按内容占宽,正文紧接标签,滚动文字不做渐隐。ticker 始终保持中性,breaking 等级只作用于展开的新闻面板整体背景/阴影;选中、焦点和关闭动画不改变 breaking 数据状态,过期后由时间戳重新计算并清除面板状态。
|
||||
|
||||
算力卡片外观直接复用共享 `.hud-panel` 的背景、边框、阴影和玻璃效果,摘要、基础信息及详情区不再叠加单独黑底。标题栏与图层面板共用 `.hud-panel__header--inset` / `.layer-panel-header` 的内边距;主数字继续突出,基础信息保持常显。
|
||||
|
||||
保存位置恢复时会迁移旧版居中拖放坐标:只解除位于屏幕核心区域、且没有新版位置标记的旧坐标锁,保留宽高;旧版边缘位置保持不动并更新标记。新拖放保存 `positionVersion=2`,以后仍尊重用户的新位置。迁移沿用原存储键,避免因全量清空布局而丢失所有个性化位置。
|
||||
|
||||
### 首帧与空间卡片入场
|
||||
|
||||
@@ -591,7 +590,7 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
|
||||
|
||||
真实 GP/TLE 仍在 Worker 每 250ms 传播。`satellite-motion.js` 缓存采样半径并逐帧半径保持插值,粒子、拾取、短尾迹末端、选中环和覆盖共享同一位置。`satellite-trail-geometry.js` 仅在所有支持缩放下的投影误差不超过 0.2 像素时合并冗余尾迹段;保留全部卫星和 10 个历史采样,曲率或近相机条件不满足时保留原分段。仅上传活跃缓冲区,尾迹宽度不超过点尺寸的 60%,尾端收细,避免远景形成粗碎线圈。`satellite-footprint-geometry.js` 保留 260×170 的完整覆盖网格,仅创建一次;同一地表投影公式在顶点着色器计算,每帧更新局部基向量和范围参数,避免移动时重建数万个顶点。Starlink/Iridium 覆盖统一蓝色 `#3399ff`,日照只调整透明度,真实覆盖模型不变。
|
||||
|
||||
`SATELLITE_CATEGORIES` 统一真实倾角分类及蓝/橙/绿/紫/玫红/灰色。选中卫星时自动切换到卫星图例,直接点击图例分类控制点、拖尾、标签和拾取,隐藏已选分类会清除选择与覆盖;图层和设置中不另设筛选入口。搜索明确选择隐藏类别的卫星时重新显示该类。`satellite-labels.js` 仅在缩放达到 **400%** 时显示真实名称与型号(缺省为 NORAD 编号),最多 36 个,中央 65% 范围内做遮挡和重叠检查;候选扫描每 160ms 一次,已选标签逐帧跟随。
|
||||
`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 刷新后的持久化。
|
||||
|
||||
@@ -613,3 +612,104 @@ Earth 由 `earth-runtime-plugin.mjs` 将本地模块合并为带内容哈希的
|
||||
当前交互物件继续决定图例类型;通过现有开关启用图层也会切换到对应图例,不增加筛选入口。海缆按线路名称、算力按类型、船只按船型及停泊状态、BGP 按事件级别和观测站活跃度筛选;覆盖范围和影响区域各自控制对应效果。国界图例分别控制线条、陆地填色与海洋填色,海陆 mask 数据本身不变。已不存在的 BGP“事件连线 / 枢纽”行被移除。
|
||||
|
||||
`legend-filters.js` 保存各类独立的页面内筛选状态;`legend.js` 统一绘制带 `aria-pressed` 的图例按钮,桌面和移动端共享同一状态,刷新图例保留键盘焦点。`interactable.js` 在绘制批次、聚类候选及拾取前应用筛选,保留完整原始数据、统计总量和对象身份。实时船只类型变化、数据重载及图层开关不会绕过筛选;隐藏选中项清除卡片和覆盖,显式搜索选中隐藏项则重新显示相应类别。船只轨迹请求使用版本检查,避免隐藏后迟到响应恢复旧轨迹。
|
||||
|
||||
### 稳定卡片与显示偏好
|
||||
|
||||
`callout-rest.js` 保留 450ms 的动作稳定门槛与最多 1.5px 的轻微漂浮。鼠标悬停、键盘阅读、拖拽与 reduced-motion 暂停漂浮;装饰偏移不参与持久化和布局求解,中央边界始终受保护。
|
||||
|
||||
显示设置新增 `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` 只提供静态避障路径。暂停保留进度与终点。布局与移动不改变卡片的展开状态;没有完整卡片通路时直接使用最终落点,不临时折叠。旧的循环评分、分阶段让位、中心区域兜底和途中坐标固化路径已移除。
|
||||
|
||||
|
||||
### 聚合圆标与 Spiderfy
|
||||
|
||||
普通地表标记继续由 `interactable.js` 批量绘制;聚合组使用 `cluster-presentation.js` 的同一组投影节点贯穿合并、展开和展开后停留。圆标为 26px 实心渐变圆,显示真实成员数,混合类别带琥珀外圈。分组只随数据或筛选更新,缩放过程中不反复拆成中间子簇。
|
||||
|
||||
`cluster-motion.js` 用固定 320ms 三次缓动控制进度。可见图标准备和解码完成后才开始计时,纹理编码按缓存复用;子点从组中心补间到真实标记投影,最终显示偏移由 `marker-stack.js` 统一计算。动画与终点保持同一个 DOM 节点,反向切换从当前进度续接。
|
||||
|
||||
聚合判定使用界面的 `globeZoomForDistance()`;超过 250% 展开,缩回阈值有滞后,因此 500% 不显示聚合圆标。只有同坐标或极近地理锚点(约 0.00015°)跨图层紧凑堆叠;屏幕重叠和城市粗网格不会把不同地点合并。每个图标以自己的真实投影为基准,整叠在每个轴向最多错开 24px,小组露出约 8–12px 点击边缘。稳定 ID 决定初始顺序,选中后排对象时移到前端;聚合子点使用 `z-index: 1000`,未聚合图标使用共享的锁定覆盖层 `renderOrder >= 100`,跨图层重叠时仍由焦点对象在最上面,取消焦点后恢复原顺序。普通点展开终点严格等于真实投影,堆叠不改变经纬度或 marker 位置。数据刷新按实际成员实例清除旧节点,并同步重绘 GPU 隐藏批次;卫星与 AIS 船只不接入。
|
||||
|
||||
### 区域新闻卡片
|
||||
|
||||
`news-region-cards.js` 复用 `info-card.js` 的 `renderNewsCardContent` 与 `news-cruise-adapter.js` 的新闻展示映射。巡航与区域卡片共用新闻内容模板和 📰 图标、金色标题栏;区域卡片精简为来源/类型/时间三项元信息。`typewriter.js` 为每张卡片提供独立、可取消的打字机实例,先用隐藏排版层预留完整摘要高度,避免打字时卡片持续变高。摘要不截断;新闻卡片预留固定内容高度,长内容内部滚动,换标题和打字不改变落位。手动折叠时暂停打字及轮播;展开后继续,完整打完后再开始 7 秒阅读计时。
|
||||
|
||||
`news-region-model.js` 维护四个互相隔离的队列与剩余阅读时间,使用 `CRUISE_CONFIG.dwellMs`(7 秒),支持单卡暂停、手动翻页、页面隐藏恢复和数据刷新保留当前 ID。数据来自已有 `getCruiseNewsItems()`,随 `earth:news-payload-updated` 更新,沿用来源、类别和可展示内容过滤;不新增采集请求,不将 global 或其他区域补入空队列。
|
||||
|
||||
新闻与算力通过 `createCalloutGroups` 向同一个 `createSpatialCallouts` 提交卡片,统一求解目标位置、拖拽锁定、尺寸变化和遮挡。尺寸观察以元素身份查找状态,不依赖地区字符串;新闻布局键为 `news:<region>`,避免与算力地区冲突。仍使用既有区域卡片连线,不改变普通点击详情的连线方式。
|
||||
|
||||
启用开关保存在 `planet.earth.news-region-cards.v1`,默认关闭,入口位于现有新闻面板。手机将相同节点放入新闻抽屉并退出地图连线布局。销毁时释放卡片、动画、尺寸/布局观察与事件订阅。验证覆盖队列隔离、暂停与继续、刷新保留条目、共享布局增删、1080p 同时显示、移动布局和切回桌面。
|
||||
|
||||
新闻巡航适配器在正文打字完成后才结束 `presentItem`,因此 `CruiseSequencer` 的停留计时不会截断长文。等待每帧检查当前序列,手动切换或退出可立即中断。新闻区域开关使用 `aria-pressed` 同步高亮背景与边框;算力区域标题保留国旗,区域组合使用小尺寸代表 emoji。
|
||||
|
||||
### 卡片 DPI 与渲染帧边界
|
||||
|
||||
算力卡片、新闻区域卡片与普通/巡航详情卡共用 `--hud-detail-card-width`,标题、正文、元信息及摘要直接使用同一套 `info-panel.css` 规则。算力卡片、趋势图、图标、间距和控件尺寸均随根节点 `--hud-scale` 缩放;手动调整大小生成的内联像素尺寸会转换为相同比例的表达式。SVG 图表内部的 viewBox 单位随图表整体缩放,避免重复乘倍率。欧洲标题使用与正文同字体基线的小型 EU 字标,避免 Windows 将旗帜 emoji 展示为基线不同的字母。
|
||||
|
||||
配置菜单保留独立的 340px 宽度和原有高度上限,不套用详情卡的宽高缩放。算力卡片标题保持共享 HUD 规格,内容区用更大的主数值、趋势图和说明间距支撑统一宽度。区域卡片的移动由 JS 时间补间独占,取消通用 HUD 的 CSS 位移过渡和强制 transform 合成层;最终 left/top 对齐设备像素,漂浮继续保留,连线使用同一显示位置。空间不足允许周边少量遮挡,卡片内容保持显示。
|
||||
|
||||
全局选中对象是异构数据:卫星为 GeoJSON 对象,海缆是线对象,只有地表交互标记带 `userData.icon_layer_id`。`markerPresentationId` 仅为带该标识的对象生成堆叠身份,其余返回空,防止选中卫星时在帧内访问不存在的字段。
|
||||
|
||||
`frame-stages.js` 为各独立更新阶段建立异常边界;某个卡片、标签或图层更新失败不会截断其他阶段和主场景绘制。失败会记录阶段和异常,按一秒间隔重试,切换选中对象时允许立即重试;成功后恢复正常更新。根因修复和正常场景无运行异常仍是验收要求,保护机制不替代修复。
|
||||
|
||||
物件选中结果统一由 `showSelectionStatus(name)` 生成“已选择:名称”。普通点击、聚合子点、搜索定位和动捕确认共用该入口;`confirmInteractionCandidate` 的名称和反馈参数不再暗示仅适用于动捕。海缆图层只更新选中和图例状态,由交互入口通知一次,避免重复提示。已删除旧的锁定/定位/动捕确认消息键;真正的动捕模式切换和手势引导仍可标注动捕。
|
||||
|
||||
### 详情卡信息精简
|
||||
|
||||
常规详情保留对象身份、业务属性、时间与必要的数据可信度提示。卫星卡删除覆盖能力、当前显示和覆盖模型三项渲染说明,覆盖显示设置仍然有效;新闻详情删除 RSS 来源、源类型及抓取通道,保留媒体、分类、区域、发布时间和摘要。算力设施删除定位过程的来源、置信分、解析依据、匹配名称和核验时间,保留位置精度与核验状态;相同的 GPU 型号与芯片/平台只显示一次。来源状态等内部元信息不再展示,实际算力的精度/测量口径和原始来源仍保留。
|
||||
|
||||
BGP 观测站移除重复的日常活跃度汇总,保留观测计数、事件时间、覆盖范围;BGP 事件置信度仍用于判断告警可靠性。海缆不再显示没有单一含义的经纬度行,自定义点不再显示内部记录 ID。船舶资料为空时不显示“资料缓存中”,也不展示补全置信分;船舶身份、动态、资料及来源保持可用。桌面和移动详情共用字段配置,底层数据与定位操作不受影响。
|
||||
|
||||
### 多卡片与全量卫星的性能路径
|
||||
|
||||
区域新闻与算力卡片保留共享深色表面、边框、阴影和文字,取消每张卡片对动态 WebGL/视频背景的实时 backdrop 滤镜及边缘 SVG 扭曲滤镜,并用 `contain: layout style` 限定布局影响。卡片仍按设备像素定位,未改动数量、内容、打字机或漂浮时序。
|
||||
|
||||
`CalloutConnector` 的 SVG 视口只覆盖自身路径及节点/阴影留白,路径仍使用原始屏幕坐标;不再为每条短连线保留整屏 SVG 面积。地理锚点缓存不变的本地坐标,坐标变更会失效,世界变换和正背面判断逐帧更新。遮挡检查直接比较矩形,避免每个候选点与每个障碍物组合重复分配对象;HUD 障碍测量集中在该帧写入卡片样式前。
|
||||
|
||||
卫星插值在端点直接复制采样数据,中间帧继续使用半径保持插值;尾迹仅提前拒绝原有误差界限下必不合格的高轨弦合并。重复选中/悬停索引不重写全量透明度缓冲。海缆与登陆点的固定本地矩阵不再逐帧重建,登陆点缩放变化显式更新矩阵,地球父级的世界矩阵仍正常传播。上述优化不减少卫星、轨迹样本或渲染分辨率。
|
||||
|
||||
开发诊断可在 Earth URL 加 `?profileFrames=1`:`data-earth-frame-profile` 每秒输出各更新阶段的 CPU 均值、最大值和次数,默认关闭。GPU 名称见 `data-earth-gpu-renderer`。性能验收同时记录实际 canvas 像素尺寸、可见完整卡片数、卫星图层状态、缩放、直播状态和 30 秒平均 FPS;卡片未完成落位或仅调整到较小视口的读数不属于 1080p 全场景成绩。CPU 阶段计时不等于 GPU 耗时。
|
||||
|
||||
## 详情卡配图
|
||||
|
||||
`card-images.js` 在普通详情与巡航复用的 `showInfoCard` 路径中管理可选配图;桌面与移动详情共用 presenter。选择物件时按稳定标识请求 `/api/v1/earth/card-images/resolve`,卫星使用 NORAD,设施使用 `source:source_id`,海缆和登陆站使用各自来源 ID。数据适配只传身份,型号、分组及分类由后端当前记录解析,不在点击处理器中分散匹配。响应缓存最多 100 个身份、30 秒;同源后台保存通过 storage 版本事件使缓存失效并刷新当前卡片。请求不进入动画帧循环。
|
||||
|
||||
`card-images.css` 同时用于后台预览和真实详情正文。独立绝对定位图片层覆盖右侧 68%,横向与自底向上的渐变通过 `mask-composite: intersect` 相交;WebKit 使用 `source-in`。图片自身不透明度为 0.85,使用 `saturate(1.2) contrast(1.1)`;图片层淡入终点为 1,不再叠加暗色遮罩或整体降透明度。内容在上层,标题保持原样,蒙版不作用于文字。原有 DPI、滚动、拖拽及连线不受图片尺寸影响;图片解码后淡入,reduced-motion 下直接显示。加载失败、无图、关闭或切到其他对象都会隐藏旧图片;异步请求与解码均检查选择代次,避免串图。标题中的图片来源链接提供署名和授权信息。
|
||||
|
||||
后端 `card_image_configuration.py` 用独立的 `system_settings.earth_card_images` 保存资产与关联,沿用配置版本检查、行锁和审计事务,不改采集记录、不建并行采集状态。匹配顺序是物件、型号、分组、分类;专属“无图”阻断回退,停用资产不能被解析;授权状态作为真实来源记录保留,与启用开关分开。重复目标和悬空关联在 schema 层拒绝。默认值来自 `assets/cards/manifest.json`,无需数据库种子;保存时记录已知内置 ID,升级合并新内置项,按清单 previous_defaults 替换旧内置图片地址,同时保留用户修改的属性,保留已编辑或删除的旧项。容器构建同步复制同一清单,避免两份默认配置漂移。
|
||||
|
||||
上传经 Pillow 校验 JPEG/PNG/WebP、尺寸及解码结果,原图保留,另生成 640/1280 边界的 WebP,正文按显示宽度与设备像素比选用。文件按内容哈希命名并原子写入,经公共只读 files 接口分发;管理、上传、目标搜索和预览需管理员权限。文件存储与备份见运维手册。内置 Frontier 实景只绑定该设施;Starlink 为系列示意图,不假定 V2/V3。千帆按用户要求启用系列配图,来源授权仍标记待确认。
|
||||
|
||||
默认分类 `*` 覆盖卫星、算力设施、海缆、登陆站、船舶、BGP 事件、BGP 观测站和自定义点,在具体分类之后匹配。船舶使用 MMSI,BGP 使用 incident_key,观测站使用 collector_id,自定义点使用既有 ID;旧快照缺少身份时仅允许类型通用图,不冒充专属匹配。采集来源的 source_id 若因上游改名发生变化,需要重新关联。
|
||||
|
||||
|
||||
### 五类算力设施与默认配图
|
||||
|
||||
算力类型为 `supercomputer`(超算中心)、`gpu_cluster`(GPU 集群)、`data_center`(数据中心)、`cloud_center`(云计算中心)和 `edge_node`(边缘计算节点)。前端 `compute-site-types.js` 统一标题、类型标签、计数与缺省类型;后端 `compute_types.py` 为 GeoJSON 与图片查询共用分类规则:有效的 metadata.site_type 优先,其次 data_type,最后使用已知采集源的类型。未识别设施按通用数据中心展示,不强制改成 GPU 集群。类型扩展不添加虚构设施或改变已采集记录的分类。
|
||||
|
||||
五类均接入图例独立筛选、地图图标、详情卡、移动详情及图片身份。新类型详情复用设施身份、运营方、算力、功耗和位置字段;不沿用 GPU 数量或 TOP500 排名。主地图接受现有采集来源或明确声明算力类型的当前记录;地区设施查询保留快照时间和国家条件,三类基础设施不要求具有 GPU 算力计量合同,也不据此推算全球算力份额。当前内置采集器仍为 TOP500 / Epoch AI,新增类型不会自动产生记录。
|
||||
|
||||
七张用户指定配图分别作为五类算力设施、BGP 观测站、BGP 事件的默认图。图库分类选项包含五类,无需等待某类已有数据。专属图片优先于类型图;旧内置图片及未自定义的 BGP 观测站关联按 manifest 的 previous_defaults / previous_rules 更新,手工修改的裁切、启用状态与专属绑定保留。
|
||||
|
||||
|
||||
### 保持显示行为的帧计算优化
|
||||
|
||||
聚合展示在一次 update 内按父节点复用世界矩阵和相机局部坐标;跨帧强制刷新缓存,因此旋转、缩放、切换视角和筛选不会使用旧投影。完全收起且没有子 DOM 的聚合成员仍执行隐藏状态维护,但不计算看不见的展开坐标。计数和无变化的 ARIA 文案不重复写入 DOM。
|
||||
|
||||
静态点批次仅在数据、选择、筛选、距离缩放或显式刷新时更新;选中物件的呼吸效果继续逐帧更新覆盖层。颜色缓存只接受不可变的字符串/数字,调用方可变 Color 对象不会被缓存。卫星圆点/光晕只更新其需要的地球父变换,避免各自遍历整棵地球节点。
|
||||
|
||||
轨迹采样合并半径界限和第一轮误差扫描,使用原来的阈值与递归分割结果;未减少卫星数量、轨迹点、分辨率或任何卡片内容。确定性对比检查了 10,000 组历史样本,输出索引一致。局部 Bun 微基准(不是整页 FPS):480 个聚合成员收起约 1.19→0.90 ms,展开约 4.47→2.86 ms;16,621 条轨迹扫描约 11.50→8.94 ms。端到端 FPS 仍需同分辨率、同卡片/卫星/视频状态、前台连续 30 秒单独比较。
|
||||
|
||||
区域新闻卡的折叠按钮显式调用共享 HUD 控制器,正文复用 hud-panel__body--collapsible;aria-expanded 与真实折叠状态同步。仅用户点击触发折叠,布局和性能逻辑不自动隐藏卡片内容。
|
||||
|
||||
物件详情浮窗与区域新闻卡共享 `--hud-detail-card-max-height`:390px 基准乘 HUD 缩放,并受视口减 24px 限制。标题固定,配图正文容器与内容组成 min-height:0 的 flex 链,仅正文滚动;内容少的卡片自然保持较短高度。BGP 事件优先显示事件/严重度/状态/影响区域/时间/摘要;观测站优先身份、状态及近期活跃;算力设施优先名称、算力和硬件,定位核验及来源后置。字段顺序来自共享 CARD_CONFIG,移动详情同步使用同一顺序。
|
||||
|
||||
|
||||
### 详情卡身份与连线一致性
|
||||
|
||||
BGP 观测站以 collector 编号建立标记索引。共享索引不登记空 ID,重复 ID 不参与跨刷新替换;仍在当前图层中的点击对象直接保留自身引用。堆叠展示使用同一个 icon_item_id,避免详情、连线和选中置顶分别采用不同身份。BGP 聚合事件优先使用 incident_key,原始异常使用来源加 ID,防止不同来源的数字编号碰撞。
|
||||
|
||||
卫星详情连线以 NORAD 识别卫星,并在数据数组替换时重新定位下标;选择高亮也在目录刷新后按同一 NORAD 重绑,目标消失则清除选择。轨道位置尚未就绪时不把零坐标当作连线锚点。海缆仍沿被选中海缆自己的几何取锚点,登陆点使用被点击标记,区域新闻/算力卡继续使用其区域锚点。
|
||||
|
||||
|
||||
算力详情配图解析在没有型号/分组规则时使用快速路径:由稳定 source 前缀确定超算或 GPU 分类,先匹配对象规则,再匹配分类默认图,跳过完整采集目录查询。只要管理员配置了型号或分组规则,就自动回退到完整目录解析;这不会改变前端身份、采集数据或其他物件类型。
|
||||
|
||||
@@ -1,84 +1,41 @@
|
||||
# 智能星球可交互图标聚类策略
|
||||
# 智能星球可交互图标聚合
|
||||
|
||||
智能星球的可交互图标由 `createInteractableLayer` 统一管理。图标仍然使用 Three.js `Points` 渲染,拾取、hover、locked、tooltip 和巡航焦点继续依赖 marker 的 `userData`;聚类策略只决定“哪些 marker 被合成一个 cluster dot”。
|
||||
`interactable.js` 负责固定地理成员,`cluster-motion.js` 负责时间补间,`cluster-presentation.js` 负责圆形聚合、展开图标、拾取与 Spiderfy。普通独立标记继续使用 GPU Points;聚合成员从动画开始到终点由同一 DOM 图标显示,不再切回另一套位置。
|
||||
|
||||
## 策略
|
||||
## 成员与缩放
|
||||
|
||||
`cluster.strategy` 支持三种模式:
|
||||
聚合使用真实 `icon_base_position` 和球面分桶,默认距离 15(与地球半径同单位)、至少 2 个成员、每组至多 14 个。数据、筛选、显隐变化使缓存失效;旋转和缩放不改变成员。圆标锚在成员的球面质心,不参与屏幕避让。
|
||||
|
||||
- `stable-spherical`:稳定球面聚类。按地球局部 3D 坐标聚合,并用 zoom band 缓存拓扑;旋转和同档缩放只更新投影和尺寸,不重算谁和谁聚在一起。适合 BGP、超算/GPU 中心和 Earth interactable 这类半静态图层。
|
||||
- `dynamic-screen`:动态屏幕聚类。保留原来的屏幕空间聚类逻辑,每帧按可见投影关系判断。适合船舶等高频实时图层,也可作为稳定策略的回退。
|
||||
- `none`:不聚类。所有 marker 独立显示,适合低数量或需要精确展示的图层。
|
||||
`cluster: { enabled: true }` 启用,`cluster: false` 关闭。可调项为 `distance`、`minCount`、`maxMarkersPerDot`、`disableAboveZoom`(默认 2.5)和 `expansionHysteresis`(默认 0.08)。界面缩放统一通过 `globeZoomForDistance()` 换算,超过 250% 自动展开,低于 230% 收拢;500% 不显示聚合圆标。卫星和 AIS 船只不接入本机制。
|
||||
|
||||
`cluster: false` 等价于 `strategy: "none"`。未显式声明 `strategy` 时,保持兼容行为:开启聚类的旧图层继续走 `dynamic-screen`。
|
||||
## 动画与同坐标展开
|
||||
|
||||
## Stable Spherical
|
||||
展开和收拢统一使用 320ms easeInOutCubic;停止滚轮后仍按时间完成,反向输入从当前进度续接。成员直接从质心插值到真实局部坐标,终点 DOM 保留,因此不存在动画终点与实际图标之间的切换。插值弦线可能暂入球体,显隐使用同方向球壳锚点判断,投影仍使用原插值位置,避免中途被误裁掉。
|
||||
|
||||
`stable-spherical` 的核心是把聚类身份从屏幕距离迁到球面距离:
|
||||
只有同坐标或极近地理锚点(约 0.00015°)跨图层紧凑堆叠;屏幕重叠和城市粗网格不会把不同地点合并。每个图标以自己的真实投影为基准,整叠在每个轴向最多错开 24px,小组露出约 8–12px 点击边缘。稳定 ID 决定初始顺序,选中后排对象时移到前端,取消焦点后恢复原顺序。普通点展开终点严格等于真实投影,堆叠不改变经纬度或 marker 位置。
|
||||
|
||||
- 每个 marker 使用 `icon_base_position` 作为真实地理锚点。
|
||||
- 默认 zoom 超过 `2.5` 时强制关闭聚类,所有 marker 展开为原图标。
|
||||
- 当前 zoom 只映射到离散 band;同一 band 内旋转地球或细微缩放不会重算拓扑。
|
||||
- 跨 band、数据变更、图层显隐变化时才重新计算 cluster。
|
||||
- cluster 质心由成员 3D 坐标平均后 normalize 回球壳半径,因此 cluster dot 刚性贴在地理质心投影上。
|
||||
- cluster dot 不参与 2D 避让,避免被屏幕排斥推离真实地理位置。
|
||||
- band 切换带有少量 hysteresis,避免缩放停在临界点时在两个 band 之间来回跳。
|
||||
- 新生成的 marker / cluster dot 会执行短 scale + opacity ease;这只是渲染过渡,不改变 marker 的真实经纬度、拾取对象或 locked 状态。
|
||||
## 交互与 BGP
|
||||
|
||||
稳定策略使用球面 bucket/hash 邻域查询,禁止用全量双循环。这样大多数帧只承担投影与材质尺寸更新,聚类成本只在 band 或数据版本变化时支付。
|
||||
点击圆标展开并暂停自转。支线有 14px 透明命中区域,悬停同时强调对应支线与子点;点击任一处选择同一业务对象。画布容器统一处理画布和投影层的滚轮,HUD 的滚动不触发地球缩放。
|
||||
|
||||
## 配置示例
|
||||
投影图标生成纹理时烘入原分类色。BGP 事件沿用告警颜色;包含 BGP 的聚合圆标及展开的事件图标使用扩散淡出光环,周期与大小来自 `BGP_CONFIG.ring`。GPU 中被投影替代的事件光环隐藏,避免在旧锚点重复绘制。减少动态效果偏好关闭脉冲动画。
|
||||
|
||||
```js
|
||||
const computeCenterIconLayer = createInteractableLayer({
|
||||
id: "computeCenters",
|
||||
// ...
|
||||
avoidance: SURFACE_AVOIDANCE_PROFILES.city,
|
||||
cluster: {
|
||||
strategy: "stable-spherical",
|
||||
minCount: 2,
|
||||
maxMarkersPerDot: 14,
|
||||
transitionMs: 220,
|
||||
bandHysteresis: 0.08,
|
||||
disableAboveZoom: 2.5,
|
||||
bands: [
|
||||
{ key: "far", maxZoom: 1.7, distance: 15 },
|
||||
{ key: "mid", maxZoom: 2.6, distance: 8 },
|
||||
{ key: "near", maxZoom: 3.5, distance: 4 },
|
||||
{ key: "detail", maxZoom: Infinity, distance: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
## 已删除的旧实现
|
||||
|
||||
实时层可以保留动态策略:
|
||||
低缩放圆点的桌面/移动端设置、存储字段、纹理、绘制/拾取分支和翻译均已删除。动态屏幕聚合、离散缩放分档、GPU 聚合圆点与旧的创建时缩放淡入路径也已删除,不提供旧策略回退。
|
||||
|
||||
```js
|
||||
const vesselIconLayer = createInteractableLayer({
|
||||
id: "vessels",
|
||||
// ...
|
||||
cluster: {
|
||||
strategy: "dynamic-screen",
|
||||
enabled: true,
|
||||
maxMarkersPerDot: 10,
|
||||
},
|
||||
});
|
||||
```
|
||||
## 回归验证
|
||||
|
||||
## 调参原则
|
||||
- 320ms 到达真实终点、反向连续、中间帧可见、终点保持同一节点。
|
||||
- 500% 无聚合圆标,同坐标成员可分别选中,源坐标保持不变。
|
||||
- 图层筛选/移除清理失效节点与 GPU 隐藏状态。
|
||||
- 图标和支线上滚轮可缩放,HUD 滚动除外。
|
||||
- 支线 hover 只联动对应子点;投影 BGP 纹理保留分类色。
|
||||
|
||||
- `distance` 是球面 3D 距离阈值,单位与 `CONFIG.earthRadius` 一致。值越大,越容易聚合。
|
||||
- 最远 band 用较大的 `distance` 降低视觉密度;最近 band 通常设为 `0`,让图标完全解散。
|
||||
- `bandHysteresis` 控制 band 边界滞回。值太小容易临界闪烁,值太大会让切换略显迟钝。
|
||||
- `transitionMs` 控制聚散过渡时间。建议保持在 160-260ms,过长会让实时层显得拖泥带水。
|
||||
- `disableAboveZoom` 控制精细查看阈值。默认 `2.5`,超过后不再生成 cluster;设为 `false` 可关闭这个硬阈值。
|
||||
- 高实时性图层优先用 `dynamic-screen`,避免数据频繁变更时触发稳定策略的拓扑重算。
|
||||
- 若某图层出现异常,可临时切回 `dynamic-screen` 或 `cluster: false`。
|
||||
对应测试:`cluster-motion.test.js`、`cluster-presentation.test.js`、`globe-navigation.test.js`、`marker-badges.test.js`。
|
||||
|
||||
## 验收重点
|
||||
## 刷新与首帧准备
|
||||
|
||||
- 同一 zoom band 内旋转地球,cluster 不应闪烁或重新聚散。
|
||||
- cluster dot 应跟随地球表面质心,不被避让逻辑推开。
|
||||
- 放大到 detail band 后,应恢复该图层原本的图标纹理和点击行为。
|
||||
- zoom 超过 250% 后不应再显示 cluster dot。
|
||||
- vessel 等实时层更新后,图标和 cluster 应立即反映最新数据。
|
||||
数据刷新即使沿用聚合 ID,也必须按当前 Object3D 实例清理旧子节点。显示所有权变更立即重绘对应 GPU 批次;聚合拓扑失效同时废弃旧签名,避免旧圆标与独立图标并存。
|
||||
|
||||
展开前先准备可见图标并等待图片解码,再开始 320ms 计时。同一纹理复用编码结果,不为背面的不可见成员创建图标,避免纹理准备耗时吞掉动画。回归覆盖重复刷新/缩放、首次纹理准备超过动画时长、选中前置/取消复位和卡片引线。
|
||||
|
||||
@@ -284,7 +284,7 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。
|
||||
| 船舶 | 货轮蓝、油轮橙、客船青绿、渔船琥珀、军舰靛紫、其他银灰 | 独立航向箭头,无徽标底板;停泊为圆点,悬停/选中发光 |
|
||||
| 自定义标记 | 地点绿、备注黄、告警红、默认蓝 | 地点针、备注页、告警三角 |
|
||||
|
||||
BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用圆角徽标;其他有限地表物件使用缓存的徽标纹理,不为每个图标新增 DOM 或独立绘制对象。船舶继续按航向分桶,以顶点色区分船型,不将船型与航向相乘扩张批次数。卫星使用参考的鲜明类别色与无衬边硬圆盘;轨道和覆盖计算不变。“低缩放圆点”开关仍可开启,新默认关闭;渲染与拾取尺寸共同尊重开关,不再关闭后仍强制用圆点。
|
||||
BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用圆角徽标;其他有限地表物件使用缓存的徽标纹理,不为每个图标新增 DOM 或独立绘制对象。船舶继续按航向分桶,以顶点色区分船型,不将船型与航向相乘扩张批次数。卫星使用参考的鲜明类别色与无衬边硬圆盘;轨道和覆盖计算不变。低缩放圆点的设置、持久化字段和绘制/拾取分支已删除。
|
||||
|
||||
|
||||
### 卫星统一色板
|
||||
@@ -313,3 +313,9 @@ BGP 变化事件保持不规则扁平符号作为唯一例外,观测站使用
|
||||
### 星空颜色输出
|
||||
|
||||
星空贴图按 sRGB 解码;`celestial.js` 通过共享 `preserveTextureOutput()` 仅为星空材质补齐屏幕 sRGB 输出。Three.js r128 的全局 renderer 仍保持既有线性输出,避免改变地球、卫星及其他图层。`skyBrightness=1.0` 表示原图亮度;缺少输出编码时,解码后的暗部会再次以线性数值显示,不能靠提高倍率修正。验证应固定星图、相机与视角,对比渲染像素和 1.0/1.5 两档响应,不能只检查加载成功。
|
||||
|
||||
卫星 hover 小圈直接使用当前卫星的倾角类别色,与粒子和图例一致;切换悬停对象时重新取色,位置更新不覆盖颜色。
|
||||
|
||||
所有地表卡片统一使用根 HUD DPI 比例。新闻区域卡片与巡航新闻复用标题图标、配色、字号及内边距;区域版只精简元信息字段并增加独立轮播控件。算力指标数字、趋势图及标题图标不再使用未缩放的固定像素。欧洲使用小型 EU 字标,其他区域使用受同一比例约束的代表 emoji。
|
||||
|
||||
算力图标按五类选择:超算芯片、GPU 层叠、数据中心机柜、云计算云朵、边缘节点连接。几何分别来自 compute-supercomputer.svg、compute-gpu-cluster.svg、compute-hdd-network.svg、compute-cloud.svg、compute-edge.svg,保持算力图层紫色底板与原有选中行为。
|
||||
|
||||
@@ -100,4 +100,11 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
|
||||
|
||||
### 地表标记对齐与侧面显隐
|
||||
|
||||
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。该路径只作用于 `icon.badge` 层(含算力、观测站;船舶独立使用航向箭头),不改变 BGP 变化事件的原深度测试。
|
||||
海缆和登陆点现在统一使用 R+0.48 及 `earthSurfacePoint` 三角表面投影,并保持 renderOrder=1。登陆点关联光晕进入原实例批次的 emphasis 属性,不增加独立 Sprite 或 draw call。侧面的完整圆角徽标采用 GPU 锚点射线/球体遮挡:锚点可见则整个 billboard 绘制,锚点被地球挡住则整块裁除。CPU 拾取使用同样的射线判断,避免点到背面图标。该路径只作用于 `icon.badge` 层(含算力、观测站;船舶独立使用航向箭头)。选中 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 障碍,并统一处理卡片尺寸变化。手机新闻卡片进入抽屉,不绘制地图连线。
|
||||
|
||||
@@ -426,7 +426,7 @@ AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=
|
||||
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
|
||||
- `品牌标识` 管理 Earth HUD 的品牌资源。`Logo 地址` 与 `标题图地址` 使用字段内上传控件,上传按钮保持 `TactileButton` 的 primary 样式;图片拖到对应字段时显示低饱和拖拽反应,避免回到旧的全局“选择资产/上传”工具栏。
|
||||
- `国界精度` 管理 Earth 静态国界资产:provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
|
||||
- `地球底图`、`图层资源`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
|
||||
- `地球底图`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
|
||||
|
||||
系统级 `/settings` 不应再新增 Earth 体验资源或采集生命周期 tab;采集相关入口属于 `/collection-management`,Earth 展示资源属于 `/earth-content`。
|
||||
|
||||
@@ -458,3 +458,11 @@ AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=
|
||||
详细经验见:
|
||||
|
||||
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
|
||||
|
||||
## 图层资源中的详情卡配图
|
||||
|
||||
`CardImageSettings.tsx` 替换智能星球内容页 `layer_resources` 的占位区,沿用 `PageFrame`、分区导航、Panel、Input、Select、Switch 和确认弹窗。组件常驻并按 active 隐藏,切换分区保留草稿;图片库和规则各有内部滚动,父级保持 `min-height:0`,窄屏改为上下工作区。`CardImagePreview.tsx` 引用真实详情卡的蒙版 CSS,不维护第二套羽化参数。
|
||||
|
||||
图片库支持上传或地址录入、来源/授权、启停、焦点预览;关联中的资产须先解除规则才能删除。规则编辑从后端当前目录搜索物件或选择已有型号/分组/分类,支持停用、改绑、删除和专属无图。预览接口接收草稿,复用发布后的同一解析函数,返回命中范围。只有顶栏保存才发布,409 时保留草稿;上传返回的文件仍需保存资产配置。保存期间禁用编辑区,防止返回结果覆盖保存期间的新输入。
|
||||
|
||||
接口前缀 `/earth/card-images`:管理员 `GET/PUT configuration`、`POST upload`、`GET targets`、`POST preview`;公共 `GET resolve`、`GET files/{filename}`。配图配置读写保持版本和审计,文件路径不接受任意目录。
|
||||
|
||||
@@ -203,7 +203,7 @@ Base URL 输入框尾端的插头图标会触发轻量连接测试:检查代
|
||||
- **电视直播**:维护智能星球媒体面板里的直播源。
|
||||
- **新闻内容**:按 RSS 来源和手动新闻组查看新闻。RSS 新闻保持只读;手动新闻组可以新增、批量导入 JSON、编辑、删除和重新处理。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
- **地球底图**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
智能星球页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
|
||||
@@ -298,6 +298,14 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
点击地球上的 BGP、算力中心、船只、卫星或海缆后,先从物件引出连接线,再沿同一方向把详情卡尽量放到屏幕边缘,中间给地球留出呼吸空间;卡片不能盖住自己的出线点。地球转动时,连线起点会自动平滑移向仍可见的高亮部分;整个对象转到背面后,保留球体轮廓上的方向提示,以较淡连线和空心端点区分。卡片位置保持稳定,仍可拖动,接点会随卡片沿边缘调整,无需手动编辑连线。手机上先显示精简卡片,点开后在抽屉中查看完整详情。下拉菜单与工具列表保持原有打开方式。
|
||||
|
||||
### 四大区新闻卡片
|
||||
|
||||
点击顶部新闻条打开态势新闻面板,再点右上角“显示区域新闻卡片”,即可同时显示美洲、欧洲、中东与非洲、亚太四张卡片。关闭新闻面板后,区域卡片仍保留,开关会记住,启用时背景和边框高亮,再次点击关闭。
|
||||
|
||||
每张卡片只轮播本区已可显示的新闻,摘要以打字机效果完整显示后,停留 7 秒再换一条;悬停、键盘操作、拖拽或折叠时暂停当前卡片,其他区域继续。底部可上一条、下一条、暂停/继续,或打开原文。刷新新闻时尽量保留正在阅读的条目;本区无可用新闻会显示空态,不拿其他地区填充。
|
||||
|
||||
卡片复用巡航新闻的金色标题栏与 📰 图标,保留完整摘要并精简来源字段,可像算力区域卡片一样拖拽、折叠,松手后自动判断落点。新闻和算力卡片同时开启时统一安排位置。引线连接大区的代表锚点,不表示每篇报道的精确事发地。手机上从新闻页的“区域卡片”开启,在抽屉内依次阅读四大区。
|
||||
|
||||
### 新闻直播源选择
|
||||
|
||||
打开媒体面板的直播页,点击当前频道可展开搜索菜单。搜索会检索完整频道库;列表每次加载 50 个频道,滚动到底部自动追加。底部固定显示已加载数量和搜索结果总数,加载失败时可在列表下方重试。默认新闻直播源为 Al Jazeera Mubasher(半岛电视台),使用 HLS 播放地址;其他频道仍取决于各自的播放服务是否可用。
|
||||
@@ -326,7 +334,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
### 设置
|
||||
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
|
||||
新闻类型使用与巡航模块一致的标签选择器,只筛选当前浏览器里的新闻面板和新闻巡航条目,不影响图层、TV、数据点、底图、边界、采集任务或后台新闻源配置。
|
||||
|
||||
@@ -425,16 +433,38 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
默认主指标为全球计算设备总规模,拆分基础计算、智能计算和超级计算,观察期为 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 份额模式尚未实现。
|
||||
|
||||
当前原型最多同时显示六张内容卡。主卡围绕当前选择的算力指标:大号百分比或带单位的绝对量、观测年份、历史上下文和来源入口。缺少可比数据时明确显示“暂无可比数据”;没有观测值时不绘制空趋势图。设施记录和覆盖信息仍完整保留,但移到展开后的辅助区;样本数只是证据上下文,不能当作算力值或全球覆盖率。区域预览可以显示成员数,长列表仅在需要时展开。桌面卡片约宽 268 像素,高度随内容自适应;展开卡片支持用户调整至最高 480 像素。手机手风琴单卡也保持同一占比/绝对量语义,不把设施条数提升为主数字。位置和尺寸保存于本地,并可恢复自动布局。拖动或展开后先留出阅读时间;地区随地球明显转到另一侧或上下方后,卡片才会在能减少连线交叉、绕行的情况下成组平滑换位。鼠标正在卡片上操作时不移动,手动位置不会被立即抢回,也不会永久阻止必要调整。图片、视频等内容可以沿用内容区布局,当前尚未接入这些媒体数据。空间不足时卡片可以经过球体前景移动,不再靠瞬移避让或缩成小标签。
|
||||
最多选择六个算力参与方,与四大区新闻卡片共用周边空间。内容卡片按同一 340px 基准宽度随 DPI 缩放,显示业务指标、年份、趋势与来源;缺失值明确标注,不用设施数量代替算力。卡片保持在屏幕周边,地球旋转只改变连线;空间不足时允许周边少量遮挡,完整卡片不会自动收起。拖放松手后保留合适的新位置;越界或遮挡中央时就近调整,不退回拖拽前的位置。周边轻微重叠允许保留,卡片不再提供手动归位按钮,位置和尺寸仍可保存。新闻轮播和打字不会反复推挤周围卡片,长内容在卡片内滚动。
|
||||
|
||||
地图卡片开关关闭时仍会加载概览摘要,打开后共享地图加载事件;新请求会取消旧请求。抽屉事件使用 `composedPath()`,即使通过 innerHTML 换节点也不会误收起当前卡片。复用共享 HUD 折叠面板动画,并尊重 reduced-motion。设施名单使用最高 240px 的内部滚动区域,名称左对齐并正常换行,每行至少保留 42px 触控高度。
|
||||
|
||||
## 地表与物件视觉
|
||||
|
||||
真实地表保留原有四种底图选择;日夜模式按太阳方向控制明暗,关闭后均匀照亮。海缆选中时沿线显示流动脉冲,登陆点使用琥珀微标;设施在近距离显示芯片图标,低缩放圆点设置继续有效。卫星仍可切换自身发光/真实地表覆盖,显示轨迹并沿既有轨道运动。BGP 告警、船舶方向、关联高亮、详情卡与移动端抽屉继续使用原有操作。
|
||||
真实地表保留原有四种底图选择;日夜模式按太阳方向控制明暗,关闭后均匀照亮。海缆选中时沿线显示流动脉冲,登陆点使用琥珀微标;设施保留分类图标,聚合以带数量的实心圆显示。卫星仍可切换自身发光/真实地表覆盖,显示轨迹并沿既有轨道运动。BGP 告警、船舶方向、关联高亮、详情卡与移动端抽屉继续使用原有操作。
|
||||
|
||||
### 卫星轨道与采集任务
|
||||
|
||||
选中卫星后,预测轨道以开放弧线显示,线上流光表示运行方向;卫星、轨迹与覆盖投影继续同步移动。覆盖区昼夜保持蓝色,粒子和轨道按图例保持固定类别色。拖动从地球移到轮廓外仍可继续旋转,大倍率下灵敏度会减小。电视窗口使用与其他卡片一致的方角。
|
||||
|
||||
再次触发同一数据源会创建新任务,旧失败项留在失败分组。“任务状态”使用清单图标,与刷新操作区分。CelesTrak 在短时间内可复用有效下载缓存;重新采集仍会生成独立任务并更新采集数据。
|
||||
|
||||
|
||||
显示设置中的“卫星 → 近景标签”默认开启,关闭后隐藏地图上的卫星名称,详情卡仍可使用。开关下的“近景标签显示阈值”滑块可设为 250%–500%,默认 400%;达到该缩放比例时显示标签,低于阈值时隐藏。数量滑块可设为 5–36 个,默认 36;这是显示上限,遮挡或空间不足时实际数量可能更少,设置会保存。“星空亮度”滑块范围为 0–200%,默认 100%,两项会记住选择。算力区域卡片在拖拽、地球运动或新卡片落位稳定后才进行必要避让;平时只在原位附近轻微漂浮,鼠标操作时暂停,减少阅读时频繁换位。算力图例里的分类数量只统计当前已定位点。
|
||||
|
||||
|
||||
地表点位聚合后显示带数量的实心圆,点击可展开成员;只有同坐标或极近坐标点位在原位置附近紧凑堆叠,普通点始终回到各自真实位置;堆叠露出可点击边缘,点击后排图标会将它置前并打开详情,取消焦点后恢复原位。放大超过 250% 后普通聚合自动拆开,500% 不再保留聚合圆标,同坐标点直接展开。
|
||||
|
||||
详情卡按物件展示身份、业务参数和更新时间,隐藏覆盖渲染、采集通道、坐标匹配过程等内部信息。算力设施仍显示位置精度与核验状态,BGP 事件仍显示告警置信度;新闻保留媒体来源、发布时间和摘要。
|
||||
|
||||
详情浮窗与新闻卡共用高度上限,标题固定、长正文内部滚动;关键事件信息和设备参数优先,位置核验、来源及关联信息后置。
|
||||
|
||||
### 详情卡配图
|
||||
|
||||
在“智能星球内容 → 图层资源 → 详情卡配图”维护图片。图片库中可上传 JPEG、PNG、WebP 或填写图片地址,设置名称、来源、署名、授权和裁切焦点;预览显示实际的右侧羽化效果。
|
||||
|
||||
切到“适用规则”,选择卫星、算力设施、海缆、登陆站、船舶、BGP 事件、BGP 观测站和自定义点,再选择指定物件、型号、分组或分类。搜索使用实际已采集对象及编号;没有型号数据时不会编造选项。选好图片后保存关联到草稿,再点顶栏“保存”发布。指定物件的图片优先于型号、分组、分类;“此物件不使用配图”可关闭它的所有回退图片。
|
||||
|
||||
“验证物件配图”可在发布前检查某个对象会用哪张图、命中了哪条规则。停用关联后可继续匹配更通用的规则;删除图片前须先解除其关联。多人同时修改时,保存冲突会保留当前草稿。
|
||||
|
||||
地球详情卡在有图时淡入正文背景,缺图或图片加载失败时继续显示完整数据。标题中的图片图标可查看来源,悬停显示署名和授权。全部地图物件类型均有内置默认图片:优先使用专属图和系列图,否则使用明确标注的类型通用示意。Starlink、北斗三号、千帆等已关联;千帆仍保留授权待确认的来源记录。新安装无需配置,旧安装更新并重启服务后自动补入缺少的默认项;自定义绑定和明确无图规则保留。
|
||||
|
||||
算力图例和详情支持超算中心、GPU 集群、数据中心、云计算中心、边缘计算节点五类;配图管理中选择“算力设施 → 分类”即可分别维护默认图。没有已采集记录的类型显示数量 0。BGP 观测站与 BGP 事件使用独立默认图。
|
||||
|
||||
@@ -489,3 +489,9 @@ uv run pytest backend/tests/test_otp_service.py
|
||||
高精国界产物位于被 Git 忽略的生成目录,不会随拉取自动交付。旧产物缺少新规则元数据时会被拒绝,页面使用已交付的政策基础国界;需保持高精效果时,应按既有国界构建流程重新生成并发布对应静态产物。底图分辨率不受这一回退影响。
|
||||
|
||||
容器资源回归使用 `PLANET_HARNESS_DOCKER_SMOKE=1 .venv/bin/python scripts/harness/test_backend_runtime_assets.py`。它通过真实 Docker 构建上下文验证生产 COPY 规则、共享规则/几何及构建脚本,同时检查环境文件未打包;不启动整套服务。
|
||||
|
||||
## 详情卡图片存储
|
||||
|
||||
内置原图及清单位于 `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/` 代理可直接转发。移除配置不会立即删除物理文件,以免破坏仍在读取旧版本的客户端。
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
|
||||
点击物件或开启图层后,可直接点击对应图例项筛选;无需在图层中寻找额外入口。设置 → 显示可开启 FPS,卫星名称在缩放达到 400% 后显示。
|
||||
|
||||
顶部新闻条可打开态势新闻面板;启用“显示区域新闻卡片”后,美洲、欧洲、中东与非洲、亚太分别轮播本区新闻。每张卡片可单独暂停、翻页、拖动与折叠。
|
||||
|
||||
## 5. 找回密码
|
||||
|
||||
忘记密码时打开 `/forgot-password`,输入邮箱并接收验证码,再输入验证码 + 新密码即可。系统对未注册的邮箱也会返回同样的提示(防止账号枚举)。
|
||||
@@ -83,3 +85,7 @@
|
||||
### 查看卫星与重试采集
|
||||
|
||||
在 Earth 启用卫星并选中一个粒子,可查看开放预测轨道、方向流光及受支持的覆盖投影。拖动移出球体仍会继续旋转。数据源采集失败后可重新触发;新任务独立显示,旧失败记录保留在当前任务队列的失败分组。
|
||||
|
||||
区域卡片保持在地球周边,旋转时主要变化是连线。空间不足时保留完整内容,允许少量遮挡;合法落点会保留;落在中央或越界时会自动就近调整。详情卡只保留业务信息和必要的可信度提示。
|
||||
|
||||
详情卡配图可在“智能星球内容 → 图层资源”管理:上传图片,选择适用物件或分组,预览后保存。内置专属图、系列图和类型通用示意覆盖全部地图物件;新安装无需导入,旧项目更新后重启前后端即可补齐默认配图。用户自定义关联和明确禁用的设置保留。
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.75.4`
|
||||
- `dev` 当前开发分支历史推导到:`0.76.4`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.76.4` | bugfix | `dev` | `v0.76.4` | 修正 breaking 新闻 ticker 与面板状态边界,当前滚动到 breaking 项时才显示红色 |
|
||||
| `0.76.3` | bugfix | `dev` | `v0.76.3` | 修复新闻栏关闭后残留 breaking 橙色边线,分离 ticker、面板、选择和过期状态 |
|
||||
| `0.76.2` | bugfix | `dev` | `v0.76.2` | 优化光缆密集区卫星点拾取,统一点状对象与线缆候选排序并补齐回归验证 |
|
||||
| `0.76.1` | bugfix | `dev` | `v0.76.1` | 修复选中 BGP 事件静态光晕地表遮挡,统一事件锚点显隐与前景偏移,清理中间态逻辑并补齐验证 |
|
||||
| `0.76.0` | feature | `dev` | `v0.76.0` | 详情卡默认配图与后台配置、五类算力中心、统一地表拾取与选中置顶、卡片交互修复及 Earth 性能优化 |
|
||||
| `0.75.4` | improvement | `dev` | `v0.75.4` | 图例统一筛选、卫星 Points 点径与性能优化、FPS、菜单 DPI/hover、星空 sRGB、地表对齐及新闻编码修复,清理废弃代码文档 |
|
||||
| `0.75.3` | improvement | `dev` | `v0.75.3` | 地球海陆材质与可读夜侧、连续拖拽、现代徽标、卫星平滑开放轨道及流光,修复 CelesTrak 缓存与任务历史并清理废弃实现 |
|
||||
| `0.75.2` | bugfix | `dev` | `v0.75.2` | Windows 启动器优先 Terminal 与 PowerShell 7,修复管理员入口分流,补齐兼容回退与提权测试 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.75.4",
|
||||
"version": "0.76.4",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
185
frontend/public/earth/assets/cards/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 内置详情卡配图 / Bundled detail-card images
|
||||
|
||||
图片与默认匹配规则一起随仓库分发,无需外网下载、后台上传或手动导入数据库。新安装直接读取清单,已有配置自动补入新默认项;旧内置图片地址随版本替换,用户修改的裁切、名称等属性保留,用户编辑、删除和明确无图规则保留。
|
||||
|
||||
覆盖全部地图物件类型。专属图 → 型号图 → 分组图 → 具体分类 → 类型通用图;通用图明确标为示意,不代表当前设施或船舶的实际照片。千帆按用户要求作为系列默认配图启用,但开放再分发许可仍未确认,清单保留真实状态。
|
||||
|
||||
Bundled files and bindings work after checkout without external downloads, uploads or database imports. Existing settings merge new defaults and upgrade old bundled sources while preserving edited fields; custom edits, removals and suppression survive. Type illustrations are not photographs of the selected object. Qianfan is enabled as requested; its open redistribution permission remains unverified.
|
||||
|
||||
## 素材与来源 / Assets and credits
|
||||
|
||||
### Starlink 太阳翼展开示意图
|
||||
|
||||
- 文件 / File: [starlink-orbit.webp](starlink-orbit.webp) · 768 × 768
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"constellation_group": "starlink", "scope": "series_representative", "example_norad_ids": [100779]}`
|
||||
- 说明 / Note: 用户指定的 Starlink 系列默认配图;不是单颗卫星实拍或已核实的硬件型号。
|
||||
|
||||
### GPS Block IIIA 在轨示意图
|
||||
|
||||
- 文件 / File: [gps-block-iiia-orbit.jpg](gps-block-iiia-orbit.jpg) · 3150 × 3150
|
||||
- 来源 / Source: [U.S. Air Force](https://commons.wikimedia.org/wiki/File:GPS_Block_IIIA.jpg)
|
||||
- 许可 / License: Public domain (U.S. federal government work) — https://commons.wikimedia.org/wiki/File:GPS_Block_IIIA.jpg#Licensing
|
||||
- 默认关联 / Defaults: `{"norad_ids": [43873, 44506, 45854, 46826, 48859, 55268], "scope": "model_representative"}`
|
||||
- 说明 / Note: 只关联已核实的 GPS IIIA 卫星,不按 NAVSTAR 前缀应用到全部 GPS 世代。
|
||||
|
||||
### Landsat 8 在轨示意图
|
||||
|
||||
- 文件 / File: [landsat-8-orbit.jpg](landsat-8-orbit.jpg) · 2000 × 1126
|
||||
- 来源 / Source: [NASA/Goddard Space Flight Center Conceptual Image Lab](https://svs.gsfc.nasa.gov/10812/)
|
||||
- 许可 / License: Public domain (NASA SVS) — https://svs.gsfc.nasa.gov/help/
|
||||
- 默认关联 / Defaults: `{"norad_ids": [39084], "scope": "spacecraft_illustration"}`
|
||||
- 说明 / Note:
|
||||
|
||||
### Landsat 9 在轨示意图
|
||||
|
||||
- 文件 / File: [landsat-9-orbit.jpg](landsat-9-orbit.jpg) · 3840 × 2160
|
||||
- 来源 / Source: [NASA's Goddard Space Flight Center/Conceptual Image Lab](https://svs.gsfc.nasa.gov/13259/)
|
||||
- 许可 / License: Public domain (NASA SVS) — https://svs.gsfc.nasa.gov/help/
|
||||
- 默认关联 / Defaults: `{"norad_ids": [49260], "scope": "spacecraft_illustration"}`
|
||||
- 说明 / Note:
|
||||
|
||||
### NOAA-21 / JPSS-2 在轨示意图
|
||||
|
||||
- 文件 / File: [noaa-21-jpss-2-orbit.jpg](noaa-21-jpss-2-orbit.jpg) · 971 × 648
|
||||
- 来源 / Source: [NOAA/NESDIS; artist Lamont W. Harvey](https://www.nesdis.noaa.gov/imagery/media-stills-animation/jpss/mission-satellite/jpss-beauty-renders)
|
||||
- 许可 / License: NOAA public-domain content policy; retain credit — https://oceanservice.noaa.gov/about/faq.html
|
||||
- 默认关联 / Defaults: `{"norad_ids": [54234], "scope": "spacecraft_illustration"}`
|
||||
- 说明 / Note: JPSS-2 对应 NOAA-21;不要关联到 NOAA-20 / JPSS-1。
|
||||
|
||||
### 北斗三号展览模型照片
|
||||
|
||||
- 文件 / File: [beidou-3-mockup.jpg](beidou-3-mockup.jpg) · 2680 × 2011
|
||||
- 来源 / Source: [AKAMGO yalms; crop published by Shujianyang](https://commons.wikimedia.org/wiki/File:Beidou-3_Satellite_Mockup.jpg)
|
||||
- 许可 / License: CC-BY-SA-4.0 — https://creativecommons.org/licenses/by-sa/4.0/
|
||||
- 默认关联 / Defaults: `{"series": "beidou-3", "scope": "series_representative", "example_norad_ids": [44204, 44337, 44709, 43683, 45344, 45807, 56564], "constellation_group": "beidou-3"}`
|
||||
- 说明 / Note: 展览模型照片,原始照片主题为 IGSO/GEO。只作北斗三号系列代表图,不把模型当成在轨实拍,不用于声称 MEO 或某颗卫星的精确外形。
|
||||
|
||||
### Galileo IOV 卫星模型照片
|
||||
|
||||
- 文件 / File: [galileo-iov-model.jpg](galileo-iov-model.jpg) · 1536 × 1075
|
||||
- 来源 / Source: [Science Museum Group; model by Paragon Creative, York (2014)](https://commons.wikimedia.org/wiki/File:Galileo_satellite_model.jpg)
|
||||
- 许可 / License: CC-BY-4.0 — https://creativecommons.org/licenses/by/4.0/
|
||||
- 默认关联 / Defaults: `{"norad_ids": [37846, 37847, 38857], "scope": "model_representative"}`
|
||||
- 说明 / Note: IOV 型模型,不应用到 Galileo FOC 或第二代卫星。只列当前采集到的三个 IOV 编号。
|
||||
|
||||
### 千帆卫星动画示意图
|
||||
|
||||
- 文件 / File: [qianfan-animation-still.jpg](qianfan-animation-still.jpg) · 860 × 540
|
||||
- 来源 / Source: [CCTV, as credited by Raumfahrtkalender](https://www.raumfahrtkalender.de/uebersichten/nutzlasten/nutzlast/25140)
|
||||
- 许可 / License: Not established; no open redistribution license found for this still
|
||||
- 默认关联 / Defaults: `{"series": "qianfan", "scope": "series_representative", "example_norad_ids": [61556], "constellation_group": "qianfan"}`
|
||||
- 说明 / Note: CCTV 千帆动画静帧,按用户要求用于系列默认配图;非单颗卫星实拍,开放再分发授权仍未确认。
|
||||
|
||||
### Frontier 超级计算机实景
|
||||
|
||||
- 文件 / File: [frontier-supercomputer.jpg](frontier-supercomputer.jpg) · 3000 × 2000
|
||||
- 来源 / Source: [Oak Ridge National Laboratory](https://commons.wikimedia.org/wiki/File:Frontier_supercomputer_(10).jpg)
|
||||
- 许可 / License: CC-BY-2.0 — https://creativecommons.org/licenses/by/2.0/
|
||||
- 默认关联 / Defaults: `{"entity_ids": ["epoch_ai_gpu:epoch_oak_ridge_nl_frontier"], "scope": "specific_facility"}`
|
||||
- 说明 / Note: Frontier 实景,仅关联 Oak Ridge NL Frontier,不作为其他机房的通用实景。
|
||||
|
||||
### 通用卫星示意图(Landsat 9)
|
||||
|
||||
- 文件 / File: [landsat-9-orbit.jpg](landsat-9-orbit.jpg) · 3840 × 2160
|
||||
- 来源 / Source: [NASA's Goddard Space Flight Center/Conceptual Image Lab](https://svs.gsfc.nasa.gov/13259/)
|
||||
- 许可 / License: Public domain (NASA SVS) — https://svs.gsfc.nasa.gov/help/
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "satellite", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 类型通用示意,不代表当前选中卫星的实际外观。
|
||||
|
||||
### 数据中心通用示意图
|
||||
|
||||
- 文件 / File: [data-center.png](data-center.png) · 1700 × 1133
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "compute_center", "scope": "category", "value": "*"}, {"entity_type": "compute_center", "scope": "category", "value": "data_center"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### BGP 事件网络设备示意图
|
||||
|
||||
- 文件 / File: [bgp-event.jpg](bgp-event.jpg) · 600 × 401
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "bgp", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### 海缆登陆站通用示意(ACE Duynefontein)
|
||||
|
||||
- 文件 / File: [cable-landing-station.jpg](cable-landing-station.jpg) · 1600 × 777
|
||||
- 来源 / Source: [John.combey](https://commons.wikimedia.org/wiki/File:ACE%27s_Duynefontein_landing_station.jpg)
|
||||
- 许可 / License: CC-BY-SA-4.0 — https://creativecommons.org/licenses/by-sa/4.0/
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "landing_point", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 类型通用示意,不代表当前物件的实际现场或外观。
|
||||
|
||||
### 海底光缆通用示意图
|
||||
|
||||
- 文件 / File: [subsea-cable.png](subsea-cable.png) · 800 × 500
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "cable", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 用户指定的海底光缆通用示意图,不代表当前海缆的实际外观。
|
||||
|
||||
### 船舶通用示意
|
||||
|
||||
- 文件 / File: [vessel-at-sea.jpg](vessel-at-sea.jpg) · 2000 × 1600
|
||||
- 来源 / Source: [U.S. Navy / Navy Media Content Services](https://www.dvidshub.net/image/99922/container-ship-sea)
|
||||
- 许可 / License: Public domain — https://www.dvidshub.net/image/99922/container-ship-sea
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "vessel", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 类型通用示意,不代表当前物件的实际现场或外观。
|
||||
|
||||
### 地理点位通用示意
|
||||
|
||||
- 文件 / File: [earth-location.jpg](earth-location.jpg) · 1280 × 640
|
||||
- 来源 / Source: [NASA GSFC / Reto Stöckli, Robert Simmon; composition by Meow](https://commons.wikimedia.org/wiki/File:Blue_Marble_2002.jpg)
|
||||
- 许可 / License: Public domain (NASA) — https://commons.wikimedia.org/wiki/File:Blue_Marble_2002.jpg#Licensing
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "earth_interactable", "scope": "category", "value": "*"}]}`
|
||||
- 说明 / Note: 类型通用示意,不代表当前物件的实际现场或外观。
|
||||
|
||||
### 超算中心通用示意图
|
||||
|
||||
- 文件 / File: [supercomputer.png](supercomputer.png) · 1024 × 717
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "compute_center", "scope": "category", "value": "supercomputer"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### GPU 集群通用示意图
|
||||
|
||||
- 文件 / File: [gpu-cluster.jpg](gpu-cluster.jpg) · 936 × 528
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "compute_center", "scope": "category", "value": "gpu_cluster"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### 云计算中心通用示意图
|
||||
|
||||
- 文件 / File: [cloud-center.png](cloud-center.png) · 680 × 400
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "compute_center", "scope": "category", "value": "cloud_center"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### 边缘计算节点通用示意图
|
||||
|
||||
- 文件 / File: [edge-node.png](edge-node.png) · 614 × 345
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "compute_center", "scope": "category", "value": "edge_node"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
### BGP 观测站通用示意图
|
||||
|
||||
- 文件 / File: [bgp-collector.png](bgp-collector.png) · 326 × 235
|
||||
- 来源 / Source: 用户提供 / User supplied
|
||||
- 许可 / License: 未提供授权信息
|
||||
- 默认关联 / Defaults: `{"defaults": [{"entity_type": "bgp_collector", "scope": "category", "value": "*", "id": "builtin-network-switches-default-1"}]}`
|
||||
- 说明 / Note: 用户指定的类型通用示意图,不作为当前物件的实景证明。
|
||||
|
||||
## 预览与维护 / Derivatives
|
||||
|
||||
原始素材保留下载字节;previews/ 按最长边 640 / 1280 像素等比缩小,不放大、不裁切,编码为 WebP 并保留透明通道。来源许可和署名继续适用于衍生文件;BY-SA 素材的预览继续遵守同一许可。manifest.json 保存原图和预览的尺寸、哈希、来源及修改说明。
|
||||
|
||||
Original downloaded raster bytes are retained. WebP derivatives fit 640 / 1280 pixels without upscaling or cropping. Credits and licenses, including ShareAlike terms, continue to apply. The manifest records checksums and modification descriptions.
|
||||
|
||||
重新生成 / Regenerate: `uv run --frozen --project . python scripts/prepare_card_images.py`。前后端须发布同一版本;后端只打包清单,前端分发图片。
|
||||
BIN
frontend/public/earth/assets/cards/beidou-3-mockup.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
frontend/public/earth/assets/cards/bgp-collector.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/earth/assets/cards/bgp-event.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/earth/assets/cards/cable-landing-station.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
frontend/public/earth/assets/cards/cloud-center.png
Normal file
|
After Width: | Height: | Size: 673 KiB |
BIN
frontend/public/earth/assets/cards/data-center.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
frontend/public/earth/assets/cards/earth-location.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
frontend/public/earth/assets/cards/edge-node.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
frontend/public/earth/assets/cards/frontier-supercomputer.jpg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
frontend/public/earth/assets/cards/galileo-iov-model.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
frontend/public/earth/assets/cards/gps-block-iiia-orbit.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
frontend/public/earth/assets/cards/gpu-cluster.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/earth/assets/cards/landsat-8-orbit.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
frontend/public/earth/assets/cards/landsat-9-orbit.jpg
Normal file
|
After Width: | Height: | Size: 648 KiB |
1181
frontend/public/earth/assets/cards/manifest.json
Normal file
BIN
frontend/public/earth/assets/cards/noaa-21-jpss-2-orbit.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/public/earth/assets/cards/qianfan-animation-still.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
frontend/public/earth/assets/cards/starlink-orbit.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
frontend/public/earth/assets/cards/subsea-cable.png
Normal file
|
After Width: | Height: | Size: 791 KiB |