From 8955c58d19b2d3e53667070eb554e44f92539a60 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Mon, 11 May 2026 10:59:41 +0800 Subject: [PATCH 01/19] release: bump version to 0.51.1 --- VERSION | 2 +- docs/CHANGELOG.md | 13 ++++++++++ docs/version-history.md | 3 ++- frontend/package.json | 2 +- .../fonts/material-symbols-rounded-500.ttf | Bin 0 -> 1198388 bytes frontend/public/earth/css/fonts.css | 24 ++++++++++++++++++ frontend/public/earth/index.html | 2 +- frontend/public/earth/js/bgp.js | 2 +- frontend/public/earth/js/compute-centers.js | 6 ++--- frontend/public/earth/js/constants.js | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 frontend/public/earth/assets/fonts/material-symbols-rounded-500.ttf create mode 100644 frontend/public/earth/css/fonts.css diff --git a/VERSION b/VERSION index c5d4cee3..1e0c609c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.51.0 +0.51.1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 76f0066a..d9f5b3b0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,19 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.51.1] — 2026-05-11 + +Released: 2026-05-11 + +### Highlights +- 修复 Earth 静态资源引用方式,图标和国家边界数据改为模块相对 URL,避免部署路径变化时资源加载失败。 +- 将 Material Symbols Rounded 字体切换为本地资源,减少 Earth 页面首屏对外部字体服务的依赖。 + +### Fixed +- 修复 BGP 广播图标、算力中心图标和国家边界 GeoJSON 在非固定 `/earth` 路径下可能失效的问题。 + +--- + ## [0.51.0] — 2026-05-11 Released: 2026-05-11 diff --git a/docs/version-history.md b/docs/version-history.md index 624119f9..90609d3a 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.51.0` +- `dev` 当前开发分支历史推导到:`0.51.1` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 | | `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 | | `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ | | `0.49.0` | feature | `dev` | `pending` | 新增位置解析 Pipeline、BGP/算力中心地理定位、Docs Gatekeeper、Earth 新闻栏与 Mobile 国家高亮 | diff --git a/frontend/package.json b/frontend/package.json index 758f9299..417af8a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.51.0", + "version": "0.51.1", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/fonts/material-symbols-rounded-500.ttf b/frontend/public/earth/assets/fonts/material-symbols-rounded-500.ttf new file mode 100644 index 0000000000000000000000000000000000000000..83b70f74f83583651e5fd21994ac8d28544fe0ce GIT binary patch literal 1198388 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%0APJ}0L1VV==TuaZ2_>{|1mNJ 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
Li~VhIsWFHHVavW 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

Z|L=)YsK-y7A3Zdu)W$TR0iLMXv1;{_SQI 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 {J>Yj!1F7NmL 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 *qXnY>NA;DxpfyP-woIMr(E!v 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

BbxzwMuw$FOV1~^m)%PkM6K)mPhQE5`2*|o} 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 sWWjQt&(AlO?UxySoPC4zTc!=B>TGTU%g8nsUa5 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

This is a test email from Planet SMTP settings.

", + config=config, + ) + except EmailError as exc: + return {"success": False, "message": str(exc), "code": exc.code} + return {"success": True, "message": "Test email sent"} + + @router.get("/tv") async def get_tv_settings( current_user: User = Depends(get_current_user), @@ -1033,10 +1245,11 @@ async def connect_ai_provider_integration( ) ) current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) + current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {}) await save_setting_payload( db, "external_integrations", - {"ai_provider": draft_ai_payload, "web_search": current_web_search}, + {"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr}, ) return { "success": True, @@ -1110,6 +1323,21 @@ async def reveal_web_search_secrets( } +@router.get("/integrations/ocr/secrets") +async def reveal_ocr_secrets( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + current_payload = await get_setting_payload(db, "external_integrations") + ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {}) + api_key, api_key_source = _resolve_ocr_api_key(ocr_payload) + return { + "provider": ocr_payload["provider"], + "api_key": api_key, + "api_key_source": api_key_source, + } + + @router.post("/integrations/web-search/connect") async def connect_web_search_integration( payload: WebSearchIntegrationUpdate, diff --git a/backend/app/api/v1/vessels.py b/backend/app/api/v1/vessels.py new file mode 100644 index 00000000..091ca283 --- /dev/null +++ b/backend/app/api/v1/vessels.py @@ -0,0 +1,39 @@ +"""Bounded vessel snapshot APIs for viewport-first consumers.""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response +from app.db.session import get_db +from app.services.vessel_ais_aggregation import MAX_SNAPSHOT_LIMIT + +router = APIRouter() + + +@router.get("/snapshot") +async def get_vessel_snapshot( + bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"), + zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"), + type: Optional[str] = Query( + None, + description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other", + ), + limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT), + since_minutes: int = Query(60, ge=1, le=1440), + db: AsyncSession = Depends(get_db), +): + if not bbox: + raise HTTPException(status_code=400, detail="bbox is required") + parsed_bbox = _parse_bbox(bbox) + if parsed_bbox is None: + raise HTTPException(status_code=400, detail="bbox is required") + return await build_vessel_snapshot_response( + db, + bbox=parsed_bbox, + zoom=zoom, + type_filter=type, + limit=limit, + since_minutes=since_minutes, + ) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 79a0a669..2fa6e65d 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -51,8 +51,10 @@ from app.services.vessel_ais_aggregation import ( get_aggregated_vessel, get_aggregated_vessel_track, get_aggregated_vessels, + get_aggregated_vessels_snapshot, get_vessel_conflict_records, get_vessel_raw_observations, + MAX_SNAPSHOT_LIMIT, ) from app.core.logging import get_logger @@ -960,6 +962,52 @@ def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]: } +def _safe_vessel_limit(value: int | None, *, default: int = 1000) -> int: + if value is None or value <= 0: + return default + return min(value, MAX_SNAPSHOT_LIMIT) + + +async def build_vessel_snapshot_response( + db: AsyncSession, + *, + bbox: tuple[float, float, float, float] | None, + zoom: int | None, + type_filter: str | None, + limit: int | None, + since_minutes: int = 60, +) -> dict[str, Any]: + requested_types = _requested_vessel_types(type_filter) + safe_limit = _safe_vessel_limit(limit) + safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440) + observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes) + features, diagnostics = await _load_raw_vessel_snapshot_features( + db, + bbox=bbox, + limit=safe_limit, + observed_since=observed_since, + ) + features = _filter_vessel_features( + features, + bbox=bbox, + requested_types=requested_types, + )[:safe_limit] + return { + "type": "FeatureCollection", + "features": features, + "count": len(features), + "stats": _build_vessel_stats(features), + "diagnostics": { + **diagnostics, + "filtered_count": len(features), + "bbox_applied": bbox is not None, + "zoom": zoom, + "limit": safe_limit, + "since_minutes": safe_since_minutes, + }, + } + + def convert_bgp_anomalies_to_geojson( records: List[BGPAnomaly], geography_hints: Optional[Dict[str, Dict[str, Any]]] = None, @@ -2042,66 +2090,58 @@ async def get_vessels_geojson( ), db: AsyncSession = Depends(get_db), ): - """Return latest vessel positions as GeoJSON points.""" - parsed_bbox = _parse_bbox(bbox) - requested_types = _requested_vessel_types(type) - merged_features, diagnostics = await _load_merged_vessel_features(db) - features = _filter_vessel_features( - merged_features, - bbox=parsed_bbox, - requested_types=requested_types, + """Legacy vessel endpoint removed in favor of /api/v1/vessels/snapshot.""" + raise HTTPException( + status_code=410, + detail=( + "Legacy vessel GeoJSON endpoint has been removed. " + "Use /api/v1/vessels/snapshot with bbox, zoom, and limit." + ), ) - if limit and limit > 0: - features = features[:limit] - return { - "type": "FeatureCollection", - "features": features, - "count": len(features), - "stats": _build_vessel_stats(features), - "diagnostics": { - **diagnostics, - "filtered_count": len(features), - }, - } -async def _load_merged_vessel_features(db: AsyncSession) -> tuple[list[dict[str, Any]], dict[str, Any]]: - aggregated_vessels = await get_aggregated_vessels(db) +async def _load_raw_vessel_snapshot_features( + db: AsyncSession, + *, + bbox: tuple[float, float, float, float] | None, + limit: int, + observed_since: datetime, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + if bbox is None: + aggregated_vessels = await get_aggregated_vessels( + db, + limit=limit, + observed_since=observed_since, + ) + else: + aggregated_vessels = await get_aggregated_vessels_snapshot( + db, + bbox=bbox, + limit=limit, + observed_since=observed_since, + ) raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels) - - latest_times = ( - select( - VesselPosition.mmsi.label("mmsi"), - func.max(VesselPosition.received_at).label("received_at"), - ) - .group_by(VesselPosition.mmsi) - .subquery() - ) - stmt = ( - select(VesselPosition, VesselStatic) - .join( - latest_times, - (VesselPosition.mmsi == latest_times.c.mmsi) - & (VesselPosition.received_at == latest_times.c.received_at), - ) - .outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi) - .order_by(VesselPosition.received_at.desc()) - ) - - result = await db.execute(stmt) - rows = list(result.all()) - legacy_geojson = convert_vessels_to_geojson(rows) - merged_features, diagnostics = _merge_vessel_features( - raw_geojson.get("features", []), - legacy_geojson.get("features", []), - ) - return merged_features, { - **diagnostics, - "raw_feature_count": len(raw_geojson.get("features", [])), - "legacy_feature_count": len(legacy_geojson.get("features", [])), + features = raw_geojson.get("features", []) + return features, { + "raw_feature_count": len(features), + "raw_unique_mmsi": len( + { + key + for key in (_feature_mmsi_key(feature) for feature in features) + if key is not None + } + ), + "legacy_feature_count": 0, + "legacy_backfilled_mmsi": 0, + "final_unique_mmsi": len( + { + key + for key in (_feature_mmsi_key(feature) for feature in features) + if key is not None + } + ), } - @router.get("/vessels/custom-supplements") async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)): """Group custom vessel_ais sources by their declared merge target for diagnostics.""" diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index b4cbc903..d6a8bf94 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -1,7 +1,6 @@ """WebSocket API endpoints""" import asyncio -import json from datetime import UTC, datetime from typing import Optional @@ -95,14 +94,44 @@ async def websocket_endpoint( } ) elif data.get("type") == "subscribe": - channels = data.get("data", {}).get("channels", []) + payload_data = data.get("data", {}) + if not isinstance(payload_data, dict): + payload_data = {} + channels = payload_data.get("channels", []) + if isinstance(channels, str): + channels = [channels] + elif not isinstance(channels, list): + channels = [] + channel = payload_data.get("channel") + if channel and channel not in channels: + channels = [*channels, channel] if is_anonymous: channels = [channel for channel in channels if channel in supported_channels] + vessel_subscription = None + if "vessels" in channels and "bbox" in payload_data: + try: + vessel_subscription = manager.subscribe_vessels(websocket, payload_data) + except ValueError as exc: + await websocket.send_json( + { + "type": "subscription_error", + "data": {"channel": "vessels", "detail": str(exc)}, + } + ) + continue + channels = [channel for channel in channels if channel != "vessels"] manager.subscribe(websocket, channels) await websocket.send_json( { "type": "subscription_confirmed", - "data": {"action": "subscribe", "channels": channels}, + "data": { + "action": "subscribe", + "channels": [ + *channels, + *(["vessels"] if vessel_subscription else []), + ], + "vessels": vessel_subscription, + }, } ) elif data.get("type") == "unsubscribe": diff --git a/backend/app/core/websocket/broadcaster.py b/backend/app/core/websocket/broadcaster.py index 563c23de..4e295d5c 100644 --- a/backend/app/core/websocket/broadcaster.py +++ b/backend/app/core/websocket/broadcaster.py @@ -2,7 +2,7 @@ import asyncio from datetime import UTC, datetime -from typing import Dict, Any, Optional +from typing import Dict, Any from app.core.time import to_iso8601_utc from app.core.websocket.manager import manager @@ -15,6 +15,8 @@ class DataBroadcaster: def __init__(self): self.running = False self.tasks: Dict[str, asyncio.Task] = {} + self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {} + self._vessel_flush_interval = 1.0 async def get_dashboard_stats(self) -> Dict[str, Any]: """Get dashboard statistics""" @@ -68,6 +70,9 @@ class DataBroadcaster: async def broadcast_custom(self, channel: str, data: Dict[str, Any]): """Broadcast custom data to a specific channel""" + if channel == "vessels": + self.enqueue_vessel_update(data) + return await manager.broadcast( { "type": "data_frame", @@ -78,6 +83,58 @@ class DataBroadcaster: channel=channel, ) + def enqueue_vessel_update(self, data: Dict[str, Any]): + vessels = data.get("vessels") if isinstance(data, dict) else None + if not isinstance(vessels, list): + return + source = data.get("source") + action = data.get("action") or "upsert" + created = data.get("created") + for vessel in vessels: + if not isinstance(vessel, dict): + continue + mmsi = vessel.get("mmsi") + if mmsi in (None, ""): + continue + self._pending_vessel_updates[str(mmsi)] = { + **vessel, + "_source": source, + "_action": action, + "_created": created, + } + + async def flush_vessel_updates(self): + if not self._pending_vessel_updates: + return + pending = self._pending_vessel_updates + self._pending_vessel_updates = {} + vessels = [] + for item in pending.values(): + vessel = dict(item) + source = vessel.pop("_source", None) + action = vessel.pop("_action", "upsert") + created = vessel.pop("_created", None) + vessel["source"] = source + vessel["action"] = action + vessel["created"] = created + vessels.append(vessel) + await manager.broadcast_vessels( + { + "action": "upsert", + "source": "mixed", + "created": None, + "vessels": vessels, + } + ) + + async def broadcast_vessels_periodically(self): + while self.running: + try: + await self.flush_vessel_updates() + except Exception: + pass + await asyncio.sleep(self._vessel_flush_interval) + async def broadcast_datasource_task_update(self, data: Dict[str, Any]): """Broadcast datasource task progress updates to connected clients.""" await manager.broadcast( @@ -95,6 +152,7 @@ class DataBroadcaster: if not self.running: self.running = True self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5)) + self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically()) def stop(self): """Stop all broadcasters""" @@ -102,6 +160,7 @@ class DataBroadcaster: for task in self.tasks.values(): task.cancel() self.tasks.clear() + self._pending_vessel_updates.clear() broadcaster = DataBroadcaster() diff --git a/backend/app/core/websocket/manager.py b/backend/app/core/websocket/manager.py index 57bad1ad..7d884f32 100644 --- a/backend/app/core/websocket/manager.py +++ b/backend/app/core/websocket/manager.py @@ -1,11 +1,16 @@ """WebSocket Connection Manager""" -from typing import Dict, Set, Optional +from datetime import UTC, datetime +from typing import Any, Dict, Set, Optional from fastapi import WebSocket import redis.asyncio as redis from app.core.config import settings +MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000 +MAX_VESSEL_WS_MESSAGE_ITEMS = 1000 +MAX_VESSEL_BBOX_AREA = 2500.0 + class ConnectionManager: """Manages WebSocket connections""" @@ -14,6 +19,7 @@ class ConnectionManager: self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections self.channel_subscriptions: Dict[str, Set[WebSocket]] = {} self.websocket_channels: Dict[WebSocket, Set[str]] = {} + self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {} self.redis_client: Optional[redis.Redis] = None async def connect(self, websocket: WebSocket, user_id: str): @@ -72,6 +78,50 @@ class ConnectionManager: channels = list(self.websocket_channels.get(websocket, set())) if channels: self.unsubscribe(websocket, channels) + self.vessel_subscriptions.pop(websocket, None) + + def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]: + subscription = self._normalize_vessel_subscription(config) + self.channel_subscriptions.setdefault("vessels", set()).add(websocket) + self.websocket_channels.setdefault(websocket, set()).add("vessels") + self.vessel_subscriptions[websocket] = subscription + return subscription + + def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]: + bbox = config.get("bbox") + if not isinstance(bbox, (list, tuple)) or len(bbox) != 4: + raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]") + try: + lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox] + except (TypeError, ValueError) as exc: + raise ValueError("bbox values must be numbers") from exc + if lat_min > lat_max: + lat_min, lat_max = lat_max, lat_min + if lon_min > lon_max: + lon_min, lon_max = lon_max, lon_min + if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180): + raise ValueError("bbox longitude values must be between -180 and 180") + if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90): + raise ValueError("bbox latitude values must be between -90 and 90") + if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA: + raise ValueError("bbox is too large; zoom in or request a smaller viewport") + + zoom = int(config.get("zoom") or 1) + if zoom < 1 or zoom > 20: + raise ValueError("zoom must be between 1 and 20") + limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT) + vessel_types = { + str(item).strip().lower() + for item in str(config.get("type") or "").split(",") + if str(item).strip() + } + return { + "bbox": (lon_min, lat_min, lon_max, lat_max), + "zoom": zoom, + "limit": limit, + "type": vessel_types, + "last_sent_at": None, + } async def send_personal_message(self, message: dict, user_id: str): if user_id in self.active_connections: @@ -92,6 +142,58 @@ class ConnectionManager: except Exception: self.unsubscribe_all(connection) + async def broadcast_vessels(self, data: dict[str, Any]): + vessels = data.get("vessels") if isinstance(data, dict) else None + if not isinstance(vessels, list) or not vessels: + return + + for connection, subscription in list(self.vessel_subscriptions.items()): + matched = [ + vessel + for vessel in vessels + if self._vessel_matches_subscription(vessel, subscription) + ][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)] + if not matched: + continue + subscription["last_sent_at"] = datetime.now(UTC) + message = { + "type": "data_frame", + "channel": "vessels", + "timestamp": subscription["last_sent_at"].isoformat(), + "payload": { + **data, + "vessels": matched, + "subscription": { + "bbox": list(subscription["bbox"]), + "zoom": subscription["zoom"], + "limit": subscription["limit"], + }, + }, + } + try: + await connection.send_json(message) + except Exception: + self.unsubscribe_all(connection) + + def _vessel_matches_subscription( + self, + vessel: dict[str, Any], + subscription: dict[str, Any], + ) -> bool: + try: + lon = float(vessel.get("lon")) + lat = float(vessel.get("lat")) + except (TypeError, ValueError): + return False + lon_min, lat_min, lon_max, lat_max = subscription["bbox"] + if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max): + return False + requested_types = subscription.get("type") or set() + if not requested_types: + return True + type_name = str(vessel.get("vessel_type_name") or "").lower() + return any(requested_type in type_name for requested_type in requested_types) + async def close_all(self): for user_id in self.active_connections: for connection in self.active_connections[user_id]: @@ -99,6 +201,7 @@ class ConnectionManager: self.active_connections.clear() self.channel_subscriptions.clear() self.websocket_channels.clear() + self.vessel_subscriptions.clear() manager = ConnectionManager() diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 8fa9b3b4..6e9286bd 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -107,6 +107,7 @@ async def ensure_default_admin_user(session: AsyncSession): password_hash=get_password_hash(default_user["password"]), role=default_user["role"], is_active=True, + email_verified=True, ) ) await session.commit() @@ -148,14 +149,31 @@ async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + users_email_verified_existed = ( + await conn.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'email_verified' + """ + ) + ) + ).fetchone() is not None await conn.execute( text( """ ALTER TABLE users - ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb + ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS pending_email VARCHAR(255) """ ) ) + if not users_email_verified_existed: + await conn.execute( + text("UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE") + ) await conn.execute( text( """ @@ -216,6 +234,26 @@ async def init_db(): """ ) ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_desc + ON ais_raw_observations (target_schema, observed_at DESC) + """ + ) + ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_ais_raw_payload_lon_lat + ON ais_raw_observations ( + ((normalized_payload->>'lon')::double precision), + ((normalized_payload->>'lat')::double precision) + ) + WHERE target_schema = 'vessel_ais' + """ + ) + ) await conn.execute( text( """ diff --git a/backend/app/models/user.py b/backend/app/models/user.py index b16f4c49..e06407b7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -14,6 +14,8 @@ class User(Base): role = Column(String(20), default="viewer") gatekeeper_groups = Column(JSON, default=list) is_active = Column(Boolean, default=True) + email_verified = Column(Boolean, default=False, nullable=False) + pending_email = Column(String(255), nullable=True) last_login_at = Column(DateTime(timezone=True)) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column( diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9148d561..eaa0c272 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -39,7 +39,34 @@ class UserResponse(UserBase): role: str gatekeeper_groups: list[str] = Field(default_factory=list) is_active: bool + email_verified: bool = False created_at: datetime class Config: from_attributes = True + + +class UserRegister(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + password: str = Field(..., min_length=8, max_length=128) + + +class VerifyEmailRequest(BaseModel): + email: EmailStr + code: str = Field(..., min_length=6, max_length=6) + + +class ResendCodeRequest(BaseModel): + email: EmailStr + purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$") + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + email: EmailStr + code: str = Field(..., min_length=6, max_length=6) + new_password: str = Field(..., min_length=8, max_length=128) diff --git a/backend/app/services/collectors/aisstream.py b/backend/app/services/collectors/aisstream.py index 3bd24362..eb9956f1 100644 --- a/backend/app/services/collectors/aisstream.py +++ b/backend/app/services/collectors/aisstream.py @@ -138,7 +138,7 @@ class AISStreamCollector(BaseCollector): try: import websockets - except ImportError as exc: + except ImportError: return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"} start_time = datetime.now(UTC) diff --git a/backend/app/services/docs_gatekeeper.py b/backend/app/services/docs_gatekeeper.py index 807e5878..7fb69293 100644 --- a/backend/app/services/docs_gatekeeper.py +++ b/backend/app/services/docs_gatekeeper.py @@ -53,6 +53,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = ( DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"), DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"), DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"), + DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"), DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"), DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"), ) diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 00000000..6db4fe32 --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,123 @@ +"""SMTP-backed email sender. + +Generic primitive used by registration/verification today, reusable for alert +digests and other notifications later. Configuration lives in the `smtp` row of +`system_settings` and is loaded once per send (small surface, no caching layer +yet to keep behavior obvious after settings changes). +""" + +from __future__ import annotations + +from email.message import EmailMessage +from typing import Literal, Optional + +import aiosmtplib +from sqlalchemy.ext.asyncio import AsyncSession + +OtpPurpose = Literal["register", "verify_email", "reset_password"] + + +class EmailError(Exception): + code: str = "EMAIL_ERROR" + + +class EmailNotConfiguredError(EmailError): + code = "EMAIL_PROVIDER_NOT_CONFIGURED" + + +class EmailSendError(EmailError): + code = "EMAIL_SEND_FAILED" + + +async def _load_smtp_config(db: AsyncSession) -> dict: + from app.api.v1.settings import get_setting_payload # local import avoids cycle + + payload = await get_setting_payload(db, "smtp") + if not payload.get("host") or not payload.get("from_address"): + raise EmailNotConfiguredError("SMTP host/from_address not set") + return payload + + +async def send_email( + db: AsyncSession, + *, + to: str, + subject: str, + text_body: str, + html_body: Optional[str] = None, + config: Optional[dict] = None, +) -> None: + cfg = config or await _load_smtp_config(db) + + message = EmailMessage() + from_name = (cfg.get("from_name") or "").strip() + from_address = cfg["from_address"] + message["From"] = f"{from_name} <{from_address}>" if from_name else from_address + message["To"] = to + message["Subject"] = subject + message.set_content(text_body) + if html_body: + message.add_alternative(html_body, subtype="html") + + use_tls = bool(cfg.get("use_tls", True)) + use_starttls = bool(cfg.get("use_starttls", False)) + port = int(cfg.get("port") or (465 if use_tls else 587)) + + try: + await aiosmtplib.send( + message, + hostname=cfg["host"], + port=port, + username=cfg.get("username") or None, + password=cfg.get("password") or None, + use_tls=use_tls and not use_starttls, + start_tls=use_starttls, + timeout=int(cfg.get("timeout_seconds") or 20), + ) + except aiosmtplib.SMTPException as exc: + raise EmailSendError(str(exc)) from exc + except OSError as exc: + raise EmailSendError(str(exc)) from exc + + +_SUBJECTS: dict[OtpPurpose, str] = { + "register": "Confirm your Planet account", + "verify_email": "Verify your Planet email", + "reset_password": "Reset your Planet password", +} + +_HEADLINES: dict[OtpPurpose, str] = { + "register": "Welcome to Planet — confirm your email to activate your account.", + "verify_email": "Confirm your new email address to keep your Planet account active.", + "reset_password": "Use this code to set a new password for your Planet account.", +} + + +async def send_verification_email( + db: AsyncSession, + *, + to: str, + code: str, + purpose: OtpPurpose, + config: Optional[dict] = None, +) -> None: + subject = _SUBJECTS[purpose] + headline = _HEADLINES[purpose] + text_body = ( + f"{headline}\n\n" + f"Your verification code: {code}\n" + "This code expires in 10 minutes. If you did not request it, ignore this email.\n" + ) + html_body = ( + f"

{headline}

" + f"

{code}

" + "

This code expires in 10 minutes. If you did not request it, ignore this email.

" + ) + await send_email( + db, + to=to, + subject=subject, + text_body=text_body, + html_body=html_body, + config=config, + ) diff --git a/backend/app/services/otp.py b/backend/app/services/otp.py new file mode 100644 index 00000000..4d9cafcf --- /dev/null +++ b/backend/app/services/otp.py @@ -0,0 +1,100 @@ +"""One-time verification codes backed by Redis. + +Reusable primitive for register/verify-email/reset-password (and any future 2FA or +phone-number verification). Codes are bcrypt-hashed before storage so a Redis dump +does not leak active codes. +""" + +from __future__ import annotations + +import json +import secrets +from typing import Literal + +import bcrypt + +from app.core.security import redis_client + +OtpPurpose = Literal["register", "verify_email", "reset_password"] + +CODE_TTL_SECONDS = 600 # 10 minutes +RESEND_COOLDOWN_SECONDS = 60 +MAX_ATTEMPTS = 5 +CODE_LENGTH = 6 + + +class OtpError(Exception): + code: str = "OTP_ERROR" + + +class OtpResendRateLimited(OtpError): + code = "OTP_RESEND_RATE_LIMITED" + + def __init__(self, retry_after_seconds: int) -> None: + super().__init__(f"Resend allowed in {retry_after_seconds}s") + self.retry_after_seconds = retry_after_seconds + + +class OtpInvalid(OtpError): + code = "OTP_INVALID" + + +class OtpExpired(OtpError): + code = "OTP_EXPIRED" + + +class OtpAttemptsExceeded(OtpError): + code = "OTP_ATTEMPTS_EXCEEDED" + + +def _code_key(email: str, purpose: OtpPurpose) -> str: + return f"otp:{purpose}:{email.lower()}" + + +def _rate_key(email: str, purpose: OtpPurpose) -> str: + return f"otp_rate:{purpose}:{email.lower()}" + + +def _generate_code() -> str: + # secrets.randbelow gives uniform 0..10**CODE_LENGTH-1 without modulo bias + return f"{secrets.randbelow(10 ** CODE_LENGTH):0{CODE_LENGTH}d}" + + +def check_resend_allowed(email: str, purpose: OtpPurpose) -> None: + ttl = redis_client.ttl(_rate_key(email, purpose)) + if ttl and ttl > 0: + raise OtpResendRateLimited(ttl) + + +def issue_code(email: str, purpose: OtpPurpose) -> str: + """Generate a new code, persist its hash, and start the resend cooldown. + + Caller is responsible for delivering the returned plaintext (e.g. via email). + Any pre-existing code for the same (purpose, email) is overwritten. + """ + check_resend_allowed(email, purpose) + code = _generate_code() + hashed = bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode() + payload = json.dumps({"hash": hashed, "attempts": 0}) + redis_client.set(_code_key(email, purpose), payload, ex=CODE_TTL_SECONDS) + redis_client.set(_rate_key(email, purpose), "1", ex=RESEND_COOLDOWN_SECONDS) + return code + + +def verify_code(email: str, purpose: OtpPurpose, code: str) -> None: + """Validate and consume a code. Raises subclasses of OtpError on failure.""" + key = _code_key(email, purpose) + raw = redis_client.get(key) + if raw is None: + raise OtpExpired("Code expired or never issued") + record = json.loads(raw) + attempts = int(record.get("attempts", 0)) + if attempts >= MAX_ATTEMPTS: + redis_client.delete(key) + raise OtpAttemptsExceeded("Too many invalid attempts") + if not bcrypt.checkpw(code.encode(), record["hash"].encode()): + record["attempts"] = attempts + 1 + ttl = redis_client.ttl(key) + redis_client.set(key, json.dumps(record), ex=max(ttl, 1)) + raise OtpInvalid("Incorrect code") + redis_client.delete(key) diff --git a/backend/app/services/vessel_ais_aggregation.py b/backend/app/services/vessel_ais_aggregation.py index c8d45904..bb6f2c60 100644 --- a/backend/app/services/vessel_ais_aggregation.py +++ b/backend/app/services/vessel_ais_aggregation.py @@ -6,6 +6,7 @@ import json from typing import Any, Iterable from sqlalchemy import select +from sqlalchemy import Float from sqlalchemy.ext.asyncio import AsyncSession from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth @@ -17,6 +18,10 @@ from app.services.vessel_types import normalize_vessel_type_name VESSEL_AIS_SCHEMA = "vessel_ais" DEFAULT_AGGREGATION_WINDOW_HOURS = 24 +DEFAULT_SNAPSHOT_WINDOW_MINUTES = 60 +MAX_SNAPSHOT_LIMIT = 5000 +MAX_SNAPSHOT_CANDIDATE_MULTIPLIER = 20 +MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS = 100_000 BARENTSWATCH_DELIVERY_MODE = "polling" BARENTSWATCH_TRANSPORT = "http" AISSTREAM_DELIVERY_MODE = "realtime_stream" @@ -564,6 +569,46 @@ async def get_aggregated_vessels( return vessels +async def get_aggregated_vessels_snapshot( + db: AsyncSession, + *, + bbox: tuple[float, float, float, float], + limit: int = 1000, + observed_since: datetime | None = None, +) -> list[dict[str, Any]]: + """Return a bounded viewport snapshot without loading the global AIS window.""" + + observed_since = observed_since or ( + datetime.now(UTC) - timedelta(minutes=DEFAULT_SNAPSHOT_WINDOW_MINUTES) + ) + safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT) + candidate_limit = min( + max(safe_limit * MAX_SNAPSHOT_CANDIDATE_MULTIPLIER, safe_limit), + MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS, + ) + lon_min, lat_min, lon_max, lat_max = bbox + payload_lon = AISRawObservation.normalized_payload["lon"].as_string().cast(Float) + payload_lat = AISRawObservation.normalized_payload["lat"].as_string().cast(Float) + + stmt = ( + select(AISRawObservation) + .where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA) + .where(AISRawObservation.observed_at >= observed_since) + .where(payload_lon >= lon_min) + .where(payload_lon <= lon_max) + .where(payload_lat >= lat_min) + .where(payload_lat <= lat_max) + .order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc()) + .limit(candidate_limit) + ) + + result = await db.execute(stmt) + if not hasattr(result, "scalars"): + return [] + vessels = await aggregate_vessel_observations(db, result.scalars().all()) + return vessels[:safe_limit] + + async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None: observations = await get_vessel_raw_observations(db, mmsi, limit=1000) vessels = await aggregate_vessel_observations(db, observations) diff --git a/backend/tests/test_datasources_batch.py b/backend/tests/test_datasources_batch.py new file mode 100644 index 00000000..5f1ecf0f --- /dev/null +++ b/backend/tests/test_datasources_batch.py @@ -0,0 +1,86 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from app.api.v1 import datasources as datasources_api +from app.models.datasource import DataSource + + +def make_datasource( + datasource_id: int, + source: str, + *, + name: str | None = None, + module: str = "L4", + is_active: bool = True, + last_status: str | None = None, + last_run_at: datetime | None = None, + frequency_minutes: int = 60, +) -> DataSource: + return DataSource( + id=datasource_id, + name=name or source, + source=source, + module=module, + priority="P1", + frequency_minutes=frequency_minutes, + collector_class=source, + is_active=is_active, + last_status=last_status, + last_run_at=last_run_at, + ) + + +def test_datasource_product_key_groups_domain_specific_sources(): + assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels" + assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables" + assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites" + assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp" + + +def test_filter_datasources_by_product_status_and_collected_state(): + vessels = make_datasource(1, "aisstream_vessels", last_status="success") + cables = make_datasource(2, "telegeography_cables", last_status="failed") + filtered = datasources_api._filter_datasources_in_memory( + [vessels, cables], + running_tasks={}, + record_counts={"aisstream_vessels": 12, "telegeography_cables": 0}, + product="vessels", + run_status="success", + collected=True, + ) + + assert filtered == [vessels] + + +@pytest.mark.asyncio +async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch): + now = datetime.now(timezone.utc) + disabled = make_datasource(1, "aisstream_vessels", is_active=False) + not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120) + due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2)) + triggered_sources: list[str] = [] + + async def fake_running_tasks(_db, _ids): + return {} + + async def fake_latest_task_ids(_db, _ids): + return {} + + monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks) + monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids) + monkeypatch.setattr( + datasources_api, + "run_collector_now", + lambda source: triggered_sources.append(source) or True, + ) + + result = await datasources_api._trigger_datasource_batch( + object(), + [disabled, not_due, due], + force=False, + ) + + assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"] + assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"} + assert triggered_sources == ["ris_live_bgp"] diff --git a/backend/tests/test_layers.py b/backend/tests/test_layers.py new file mode 100644 index 00000000..ab8d9417 --- /dev/null +++ b/backend/tests/test_layers.py @@ -0,0 +1,44 @@ +from fastapi import HTTPException + +from app.api.v1 import layers + + +def test_layer_guard_requires_bbox(): + try: + layers._parse_layer_bbox("") + except HTTPException as exc: + assert exc.status_code == 400 + else: + raise AssertionError("Expected missing bbox to fail") + + +def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit(): + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [121.0, 31.0]}, + "properties": {"id": "inside"}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [10.0, 10.0]}, + "properties": {"id": "outside"}, + }, + ], + } + + result = layers._guard_geojson_layer( + geojson, + bbox=(120.0, 30.0, 122.0, 32.0), + zoom=2, + limit=6000, + ) + + assert result["returned_count"] == 1 + assert result["visible_count"] == 1 + assert result["features"][0]["properties"]["id"] == "inside" + assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT + assert result["diagnostics"]["limit_clamped"] is True + assert result["diagnostics"]["degraded"] is True diff --git a/backend/tests/test_otp_service.py b/backend/tests/test_otp_service.py new file mode 100644 index 00000000..e856eba7 --- /dev/null +++ b/backend/tests/test_otp_service.py @@ -0,0 +1,106 @@ +"""Unit tests for app.services.otp using an in-memory Redis fake.""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest + +from app.services import otp + + +class FakeRedis: + """Minimal subset of redis-py used by services.otp.""" + + def __init__(self) -> None: + self._store: dict[str, tuple[Any, float | None]] = {} + + def _expired(self, key: str) -> bool: + item = self._store.get(key) + if item is None: + return True + _, expires = item + if expires is not None and expires <= time.time(): + self._store.pop(key, None) + return True + return False + + def set(self, key: str, value: Any, ex: int | None = None) -> None: + expires = time.time() + ex if ex else None + self._store[key] = (value, expires) + + def get(self, key: str) -> Any: + if self._expired(key): + return None + return self._store[key][0] + + def ttl(self, key: str) -> int: + if self._expired(key): + return -2 + _, expires = self._store[key] + if expires is None: + return -1 + return max(int(expires - time.time()), 0) + + def delete(self, key: str) -> None: + self._store.pop(key, None) + + +@pytest.fixture +def fake_redis(monkeypatch): + fake = FakeRedis() + monkeypatch.setattr(otp, "redis_client", fake) + return fake + + +def test_issue_code_returns_six_digits(fake_redis): + code = otp.issue_code("alice@example.com", "register") + assert len(code) == 6 + assert code.isdigit() + + +def test_verify_code_succeeds_and_consumes(fake_redis): + code = otp.issue_code("alice@example.com", "register") + otp.verify_code("alice@example.com", "register", code) + with pytest.raises(otp.OtpExpired): + otp.verify_code("alice@example.com", "register", code) + + +def test_verify_code_rejects_wrong_code(fake_redis): + otp.issue_code("alice@example.com", "register") + with pytest.raises(otp.OtpInvalid): + otp.verify_code("alice@example.com", "register", "000000") + + +def test_verify_code_locks_after_max_attempts(fake_redis): + code = otp.issue_code("alice@example.com", "register") + for _ in range(otp.MAX_ATTEMPTS): + with pytest.raises(otp.OtpInvalid): + otp.verify_code("alice@example.com", "register", "000000") + # After max attempts the next call should raise OtpAttemptsExceeded and clear the code. + with pytest.raises(otp.OtpAttemptsExceeded): + otp.verify_code("alice@example.com", "register", code) + with pytest.raises(otp.OtpExpired): + otp.verify_code("alice@example.com", "register", code) + + +def test_issue_code_enforces_resend_cooldown(fake_redis): + otp.issue_code("alice@example.com", "register") + with pytest.raises(otp.OtpResendRateLimited) as excinfo: + otp.issue_code("alice@example.com", "register") + assert excinfo.value.retry_after_seconds > 0 + + +def test_issue_code_emails_are_case_insensitive(fake_redis): + code = otp.issue_code("Alice@Example.com", "register") + otp.verify_code("alice@example.com", "register", code) + + +def test_purposes_are_isolated(fake_redis): + register_code = otp.issue_code("alice@example.com", "register") + reset_code = otp.issue_code("alice@example.com", "reset_password") + assert register_code != reset_code + otp.verify_code("alice@example.com", "register", register_code) + # Reset code should still be valid after consuming the register code. + otp.verify_code("alice@example.com", "reset_password", reset_code) diff --git a/backend/tests/test_settings_ai_provider.py b/backend/tests/test_settings_ai_provider.py index dc9042c8..96808a0f 100644 --- a/backend/tests/test_settings_ai_provider.py +++ b/backend/tests/test_settings_ai_provider.py @@ -5,9 +5,12 @@ import pytest from app.api.v1 import settings as settings_api from app.api.v1.settings import ( AIProviderIntegrationUpdate, + OCRIntegrationUpdate, _build_ai_provider_payload, + _build_ocr_payload, _mask_secret, _normalize_ai_provider_payload, + _normalize_ocr_payload, _resolve_provider_api_key, get_runtime_ai_provider_config, ) @@ -130,6 +133,43 @@ def test_build_payload_keeps_saved_key_when_preview_submitted(): assert payload["providers"]["openai"]["api_key"] == "sk-old-secret" +def test_normalize_ocr_payload_adds_defaults(): + payload = _normalize_ocr_payload({}) + + assert payload["enabled"] is False + assert payload["provider"] == "paddleocr" + assert payload["languages"] == ["zh", "en"] + assert payload["output_format"] == "markdown" + + +def test_build_ocr_payload_keeps_saved_key_when_preview_submitted(): + current = { + "ocr": { + "enabled": True, + "provider": "custom", + "base_url": "http://localhost:8020", + "api_key": "ocr-old-secret", + } + } + update = OCRIntegrationUpdate( + enabled=True, + provider="custom", + base_url="http://localhost:8020", + api_key="**************", + model="ocr-model", + languages=["zh", "en"], + timeout_seconds=45, + max_file_size_mb=50, + output_format="json", + ) + + payload = _build_ocr_payload(current, update) + + assert payload["api_key"] == "ocr-old-secret" + assert payload["model"] == "ocr-model" + assert payload["output_format"] == "json" + + @pytest.mark.asyncio async def test_runtime_config_uses_default_provider_specific_key(monkeypatch): record = SimpleNamespace( diff --git a/backend/tests/test_settings_smtp.py b/backend/tests/test_settings_smtp.py new file mode 100644 index 00000000..2da9001a --- /dev/null +++ b/backend/tests/test_settings_smtp.py @@ -0,0 +1,86 @@ +"""Unit tests for SMTP settings helpers in app.api.v1.settings.""" + +from app.api.v1.settings import ( + SMTPSettingsUpdate, + _build_smtp_payload, + _serialize_smtp_payload, +) + + +def test_serialize_masks_password_and_reports_configured(): + serialized = _serialize_smtp_payload( + { + "host": "smtp.example.com", + "port": 587, + "username": "noreply@example.com", + "password": "super-secret", + "from_address": "noreply@example.com", + "from_name": "Planet", + "use_tls": False, + "use_starttls": True, + "timeout_seconds": 20, + } + ) + assert serialized["configured"] is True + assert serialized["password"]["configured"] is True + assert "secret" not in serialized["password"]["preview"] + + +def test_serialize_marks_unconfigured_when_host_missing(): + serialized = _serialize_smtp_payload( + { + "host": "", + "port": 587, + "from_address": "", + } + ) + assert serialized["configured"] is False + assert serialized["password"]["configured"] is False + + +def test_build_payload_preserves_password_when_placeholder_submitted(): + current = { + "host": "smtp.example.com", + "port": 587, + "username": "noreply@example.com", + "password": "super-secret", + "from_address": "noreply@example.com", + "from_name": "Planet", + "use_tls": False, + "use_starttls": True, + "timeout_seconds": 20, + } + preview = _serialize_smtp_payload(current)["password"]["preview"] + update = SMTPSettingsUpdate( + host="smtp.example.com", + port=587, + username="noreply@example.com", + password=preview, + from_address="noreply@example.com", + ) + merged = _build_smtp_payload(current, update) + assert merged["password"] == "super-secret" + + +def test_build_payload_replaces_password_when_new_value_submitted(): + current = {"password": "old", "host": "", "port": 587, "from_address": ""} + update = SMTPSettingsUpdate( + host="smtp.example.com", + port=587, + password="new-secret", + from_address="noreply@example.com", + ) + merged = _build_smtp_payload(current, update) + assert merged["password"] == "new-secret" + + +def test_build_payload_clears_password_when_requested(): + current = {"password": "old"} + update = SMTPSettingsUpdate( + host="smtp.example.com", + port=587, + from_address="noreply@example.com", + clear_password=True, + ) + merged = _build_smtp_payload(current, update) + assert merged["password"] == "" diff --git a/backend/tests/test_vessels.py b/backend/tests/test_vessels.py index 6f37efac..d5d09907 100644 --- a/backend/tests/test_vessels.py +++ b/backend/tests/test_vessels.py @@ -523,22 +523,115 @@ def test_convert_vessels_to_geojson_dedupes_mmsi_rows(): @pytest.mark.asyncio -async def test_vessels_geojson_endpoint_filters_type_and_bbox(): +async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch): now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) - rows = [ - ( - VesselPosition(mmsi=1, lat=59.9, lon=10.7, received_at=now), - VesselStatic(mmsi=1, name="Cargo Ship", vessel_type=70, vessel_type_name="Cargo"), + monkeypatch.setattr( + visualization, + "get_aggregated_vessels_snapshot", + AsyncMock( + return_value=[ + { + "mmsi": 1, + "lat": 59.9, + "lon": 10.7, + "received_at": now, + "name": "Cargo Ship", + "vessel_type": 70, + "vessel_type_name": "Cargo", + }, + { + "mmsi": 2, + "lat": 60.3, + "lon": 5.3, + "received_at": now - timedelta(minutes=1), + "name": "Passenger Ship", + "vessel_type": 60, + "vessel_type_name": "Passenger", + }, + ] ), - ( - VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now - timedelta(minutes=1)), - VesselStatic(mmsi=2, name="Passenger Ship", vessel_type=60, vessel_type_name="Passenger"), - ), - ] + ) + + async def override_get_db(): + yield object() + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/vessels/snapshot", + params={"bbox": "0,50,20,70", "zoom": 12, "type": "cargo", "limit": 1000}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + assert data["features"][0]["properties"]["name"] == "Cargo Ship" + assert data["stats"]["by_type"]["Cargo"] == 1 + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_legacy_vessels_geojson_endpoint_is_gone(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/visualization/geo/vessels") + + assert response.status_code == 410 + assert "/api/v1/vessels/snapshot" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_vessel_snapshot_requires_bbox(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/vessels/snapshot", params={"zoom": 12}) + + assert response.status_code == 400 + assert response.json()["detail"] == "bbox is required" + + +@pytest.mark.asyncio +async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): + now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) + captured = {} + + async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since): + captured["bbox"] = bbox + captured["limit"] = limit + captured["observed_since"] = observed_since + return [ + { + "mmsi": 1, + "lat": 59.9, + "lon": 10.7, + "received_at": now, + "name": "Cargo Ship", + "vessel_type": 70, + "vessel_type_name": "Cargo", + }, + { + "mmsi": 2, + "lat": 60.3, + "lon": 5.3, + "received_at": now, + "name": "Passenger Ship", + "vessel_type": 60, + "vessel_type_name": "Passenger", + }, + ] + + monkeypatch.setattr( + visualization, + "get_aggregated_vessels_snapshot", + fake_get_aggregated_vessels_snapshot, + ) class _Result: def all(self): - return rows + return [] class _FakeSession: async def execute(self, _query): @@ -552,75 +645,39 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox(): try: async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get( - "/api/v1/visualization/geo/vessels", - params={"bbox": "0,50,20,70", "type": "cargo", "limit": 0}, + "/api/v1/vessels/snapshot", + params={ + "bbox": "10,59,11,60", + "zoom": 12, + "type": "cargo", + "limit": 5000, + "since_minutes": 30, + }, ) assert response.status_code == 200 data = response.json() assert data["count"] == 1 assert data["features"][0]["properties"]["name"] == "Cargo Ship" - assert data["stats"]["by_type"]["Cargo"] == 1 + assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0) + assert captured["limit"] == 5000 + assert data["diagnostics"]["bbox_applied"] is True + assert data["diagnostics"]["legacy_feature_count"] == 0 + assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0 finally: app.dependency_overrides.clear() @pytest.mark.asyncio -async def test_vessels_geojson_merges_raw_and_legacy_sources(monkeypatch): - now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) - monkeypatch.setattr( - visualization, - "get_aggregated_vessels", - AsyncMock( - return_value=[ - { - "mmsi": 1, - "lat": 59.9, - "lon": 10.7, - "received_at": now, - "name": "AISSTREAM SHIP", - "vessel_type_name": "Cargo", - "source_summary": {"aisstream_vessels": {"message_types": ["PositionReport"]}}, - } - ] - ), - ) - rows = [ - ( - VesselPosition(mmsi=1, lat=60.0, lon=10.8, received_at=now), - VesselStatic(mmsi=1, name="LEGACY DUP", vessel_type_name="Cargo"), - ), - ( - VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now), - VesselStatic(mmsi=2, name="BARENTSWATCH ONLY", vessel_type_name="Passenger"), - ), - ] - - class _Result: - def all(self): - return rows - - class _FakeSession: - async def execute(self, _query): - return _Result() - - async def override_get_db(): - yield _FakeSession() - - app.dependency_overrides[get_db] = override_get_db +async def test_legacy_vessels_geojson_rejects_even_with_bbox(): transport = ASGITransport(app=app) - try: - async with AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get("/api/v1/visualization/geo/vessels") + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/visualization/geo/vessels", + params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000}, + ) - assert response.status_code == 200 - data = response.json() - names = {feature["properties"]["mmsi"]: feature["properties"]["name"] for feature in data["features"]} - assert data["count"] == 2 - assert names == {1: "AISSTREAM SHIP", 2: "BARENTSWATCH ONLY"} - assert data["diagnostics"]["legacy_backfilled_mmsi"] == 1 - finally: - app.dependency_overrides.clear() + assert response.status_code == 410 @pytest.mark.asyncio diff --git a/backend/tests/test_websocket_manager.py b/backend/tests/test_websocket_manager.py index 498447a9..aa8ac02b 100644 --- a/backend/tests/test_websocket_manager.py +++ b/backend/tests/test_websocket_manager.py @@ -1,6 +1,8 @@ import pytest +import importlib from app.core.websocket.manager import ConnectionManager +from app.core.websocket.broadcaster import DataBroadcaster class FakeWebSocket: @@ -44,3 +46,93 @@ async def test_disconnect_removes_channel_subscriptions(): assert socket.sent == [] assert "dashboard" not in manager.channel_subscriptions + + +@pytest.mark.asyncio +async def test_vessel_subscribers_receive_only_matching_bbox_updates(): + manager = ConnectionManager() + oslo_socket = FakeWebSocket() + bergen_socket = FakeWebSocket() + + await manager.connect(oslo_socket, "user-1") + await manager.connect(bergen_socket, "user-2") + manager.subscribe_vessels( + oslo_socket, + {"bbox": [10, 59, 11, 60], "zoom": 12, "limit": 1000}, + ) + manager.subscribe_vessels( + bergen_socket, + {"bbox": [5, 60, 6, 61], "zoom": 12, "limit": 1000}, + ) + + await manager.broadcast_vessels( + { + "action": "upsert", + "vessels": [ + {"mmsi": 1, "lat": 59.9, "lon": 10.7}, + {"mmsi": 2, "lat": 60.3, "lon": 5.3}, + ], + } + ) + + assert oslo_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 1, "lat": 59.9, "lon": 10.7}] + assert bergen_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 2, "lat": 60.3, "lon": 5.3}] + + +@pytest.mark.asyncio +async def test_vessel_broadcast_removes_slow_connections(): + manager = ConnectionManager() + + class BrokenWebSocket(FakeWebSocket): + async def send_json(self, message): + raise RuntimeError("client is gone") + + socket = BrokenWebSocket() + await manager.connect(socket, "user-1") + manager.subscribe_vessels(socket, {"bbox": [10, 59, 11, 60], "zoom": 12}) + + await manager.broadcast_vessels({"vessels": [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]}) + + assert socket not in manager.vessel_subscriptions + + +def test_vessel_subscription_rejects_large_bbox(): + manager = ConnectionManager() + + with pytest.raises(ValueError, match="bbox is too large"): + manager.subscribe_vessels(FakeWebSocket(), {"bbox": [-180, -90, 180, 90], "zoom": 1}) + + +@pytest.mark.asyncio +async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch): + sent = [] + + async def fake_broadcast_vessels(payload): + sent.append(payload) + + broadcaster_module = importlib.import_module("app.core.websocket.broadcaster") + monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels) + broadcaster = DataBroadcaster() + broadcaster.enqueue_vessel_update( + { + "source": "aisstream_vessels", + "vessels": [ + {"mmsi": 1, "lat": 59.0, "lon": 10.0}, + {"mmsi": 1, "lat": 59.1, "lon": 10.1}, + ], + } + ) + + await broadcaster.flush_vessel_updates() + + assert len(sent) == 1 + assert sent[0]["vessels"] == [ + { + "mmsi": 1, + "lat": 59.1, + "lon": 10.1, + "source": "aisstream_vessels", + "action": "upsert", + "created": None, + } + ] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d9f5b3b0..8e6c5e7a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.52.0] — 2026-05-12 + +Released: 2026-05-12 + +### Highlights +- 新增邮箱验证码注册/找回密码链路、SMTP 设置面板和连接测试组件,补齐公开账号自助入口。 +- 重构 AIS 船只实时链路:新增受控船只 snapshot、WebSocket vessels 订阅、AISStream 长连接状态与节流广播。 +- 新增数据产品统计接口、受控 `/layers/*` 图层接口骨架,以及数据源产品域筛选和批量采集。 + +### Added / Fixed / Improved +- 新增 `/api/v1/data-products`、`/api/v1/layers/*` 和 `/api/v1/datasources/trigger-batch`,拆分全量统计与地图渲染数据。 +- 将 Earth 船只层迁移到 `/api/v1/vessels/snapshot`,并修正 vessels WebSocket 订阅 payload。 +- 更新中英文手册、采集器文档、运维手册和计划状态,覆盖注册、SMTP、AISStream、数据产品和图层保护流程。 + +--- + ## [0.51.1] — 2026-05-11 Released: 2026-05-11 diff --git a/docs/documentation-coverage-rules.md b/docs/documentation-coverage-rules.md index a3b517bf..5ed0fca4 100644 --- a/docs/documentation-coverage-rules.md +++ b/docs/documentation-coverage-rules.md @@ -2,6 +2,16 @@ This file contains Planet-specific documentation coverage rules. Documentation skills and agents should read this file before deciding which docs to update. Keep tool-specific workflow in skills; keep product and repository rules here. +## Audience Routing (mandatory) + +Before deciding scope, classify the change by who performs the action: + +- **Browser/UI end user** (login, account settings, configuring collectors or AI via UI, using Earth/Console pages): update `docs/technical/{zh,en}/manual.md` and `quickstart.md` only. Never put shell commands, log file paths, `planet.sh`, Docker operations, or `netsh portproxy` rules into these files. +- **Operations / deployment / on-call** (`planet.sh`, log paths, SMTP fallbacks like `createuser`, LAN/portproxy, env-var tuning, troubleshooting order, Bun build conventions): update `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`). Never put UI button labels or screenshots into these files. +- **Second-party developers** (component context, render order, internal pipelines): update the existing `*-context.md` / `backend-*.md` / `earth-*.md` files. + +If the same action has both a UI and a CLI path (e.g. user creation), describe the UI path in `manual.md` and the CLI path in `ops-runbook.md`, and cross-link them with a single sentence each. + ## Scope Rules - User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`. diff --git a/docs/plans/data-products-layer-guard-redesign-plan.md b/docs/plans/data-products-layer-guard-redesign-plan.md new file mode 100644 index 00000000..021cdd56 --- /dev/null +++ b/docs/plans/data-products-layer-guard-redesign-plan.md @@ -0,0 +1,146 @@ +# 数据产品流水线、数据源批量运维与抗击穿图层接口计划 + +## Summary + +前端展示分两类数据: + +- **图层数据**:按 viewport、bbox、zoom、limit 返回,可降级、截断、缓存,用来保护服务器。 +- **聚合面板统计**:必须是全量统计,不受当前 viewport 限制,但不能实时扫全表;通过产品状态表或预计算统计提供。 + +也就是说:地图上低 zoom 可以只画摘要或局部数据,但面板里的“总船只数、总海缆数、BGP 活跃事件数、卫星数”等应该代表全局数据产品状态。 + +## Implementation Status + +- 已新增 `POST /api/v1/datasources/trigger-batch`,支持按选中 `source_ids` 或筛选条件批量触发,并返回 `triggered/skipped/failed`。 +- 已改造 `/datasources` 页面,支持产品域、层级、启用状态、最近执行状态、是否已有数据和关键词筛选,并支持复选框批量采集。 +- 已新增 `/api/v1/data-products` 和 `/api/v1/data-products/{product_id}/status`,聚合面板可以读取全量/全局统计口径。 +- 已新增 `/api/v1/layers/*` 受控图层接口骨架,要求 `bbox/zoom/limit`,响应包含 `visible_count/returned_count/diagnostics`。 +- 非船只图层当前先复用已有 GeoJSON 转换再做保护层;下一步应把 cables/BGP/satellites 的 bbox 过滤继续下推到各自产品查询,避免转换前仍加载过多候选。 + +## Key Changes + +- 新增数据产品状态/统计层: + - 每个产品维护全量统计:总实体数、活跃数、最近更新时间、使用源、缺失源、冲突数、构建状态。 + - 统计在采集成功或产品投影完成后更新,不在用户打开页面时临时全表聚合。 + - 前端聚合面板统一读取产品统计接口,而不是从图层返回量推断总数。 +- 新增接口: + - `GET /api/v1/data-products` + - `GET /api/v1/data-products/{product_id}/status` + - `GET /api/v1/layers/{product}/...` + - `POST /api/v1/datasources/trigger-batch` +- `/layers/*` 只负责可视化数据: + - 支持 bbox、zoom、limit、since。 + - 可以返回 `degraded`、`truncated`、`cache_hit`。 + - 返回 `visible_count` 和 `returned_count`,但不作为全量统计来源。 +- `/data-products/*/status` 负责全量统计: + - 返回 `total_count`、`active_count`、`source_counts`、`last_built_at`、`health`。 + - 数据来自预计算状态或轻量索引统计。 + - 即使图层降级,统计也保持全量口径。 + +## Product Processing + +- 船只: + - 图层:bbox snapshot + WS 聚合流,受限返回。 + - 统计:全量唯一 MMSI、最近窗口活跃 MMSI、AISStream/BarentsWatch/source counts。 +- 海缆: + - 图层:viewport 内 cable segments/landing points,低 zoom 可简化路线。 + - 统计:全量 cable count、landing point count、relation count、graph 构建状态。 + - 处理:专用 cable graph assembler,区分路线源、登陆点源、关系源、补充源,不使用统一字段融合函数。 +- BGP: + - 图层:active incidents/anomalies/collectors,按窗口和 limit 返回。 + - 统计:全量活跃事件、最近 24h/7d 事件数、collector 数、incident/anomaly 分布。 + - 处理:专用事件流水线,区分 observation、anomaly、incident、geo hint、infrastructure inference。 +- 卫星: + - 图层:可见卫星或受控 limit。 + - 统计:全量卫星数、最新 TLE epoch、源覆盖情况。 + - 处理:按 NORAD id 生成轨道快照,TLE epoch 最新优先。 + +## Data Source Page + +- 增加筛选: + - 产品类型、启用/禁用、最近成功/失败/运行中/未执行、已采集/未采集、凭证状态、文本搜索。 +- 增加复选框批量操作: + - 批量启用、禁用、采集、强制采集。 + - 一键采集改为:采集全部启用源、采集筛选结果、采集选中源。 +- 后端 batch 逻辑: + - 禁用源 skipped。 + - 运行中源按 force 处理。 + - 单个失败不影响其他源。 + - 返回 `triggered`、`skipped`、`failed`,并包含每个 source 的原因和 task_id。 + +## Protection Rules + +- 所有 `/layers/*` 接口必须有保护层: + - limit clamp。 + - bbox/zoom 校验。 + - 低 zoom 降级。 + - 短 TTL 缓存。 + - 慢查询超时。 + - diagnostics 返回降级原因。 +- 全量统计不走图层查询: + - 不允许为了面板统计在请求时 `.all()` 全量加载。 + - 统计由采集/投影任务异步更新。 + - 统计缺失时返回 `unknown` 或 `stale`,不触发重型实时计算。 +- 缓存失效规则: + - 采集成功后失效对应产品缓存。 + - 海缆 graph cache 在路线、登陆点或关系源成功采集后失效。 + - BGP incident/anomaly 生成后失效 BGP layer cache。 + - 船只实时流使用短 TTL 或 viewport 级缓存,不清全局缓存。 +- 接口观测: + - 记录每个 layer endpoint 的耗时、返回数量、是否降级、是否缓存命中、limit 是否被 clamp。 + - 对高频 viewport 请求增加简单 per-IP 或 per-user rate limit。 + +## Frontend UX + +- 数据源页: + - 顶部统计可作为快捷筛选入口:全部、启用、禁用、运行中、失败、未采集。 + - 表格左侧增加复选框。 + - 工具栏显示“已选择 N 个”,并提供批量按钮。 + - 筛选结果和选中结果分清楚,避免误触发全部源。 + - 批量采集完成后弹出摘要:触发、跳过、失败数量,可展开查看原因。 +- 设置/配置页: + - “采集器设置”改为“数据产品配置”。 + - 产品内按源角色分组展示,而不是简单列出 collector。 + - 海缆显示路线源、登陆点源、关系源、补充源。 + - BGP 显示实时观测源、历史回填源、地理 hint 源、检测输出。 + - 船只显示实时 AIS、轮询 AIS、自定义补充源。 +- Earth 图层交互: + - 聚合面板统计读取 `/data-products/*/status`,保持全量口径。 + - 图层面板展示当前图层是否降级、截断、缓存命中。 + - 对象详情展示来源证据: + - 船只:字段来源、冲突。 + - 海缆:路线源、登陆点源、关系源。 + - BGP:事件证据、分组依据、地理推断依据。 + - 产品 degraded 时仍显示可用部分,并提示缺失源角色。 + +## Test Plan + +- 图层接口: + - 大 limit 被 clamp。 + - 低 zoom 降级。 + - 大数据集不全量内存过滤。 + - diagnostics 正确说明截断、缓存、降级。 +- 全量统计: + - 面板统计不受 bbox 影响。 + - 图层返回 1000 条时,产品统计仍显示全量总数。 + - 统计陈旧时返回 `stale=true` 和 `last_built_at`。 + - 采集成功后对应产品统计刷新。 +- 数据源批量: + - 筛选、选中、批量采集行为正确。 + - skipped/failed/triggered 分组正确。 + - 禁用源在 batch 中被 skipped。 + - 运行中源按 force 参数处理。 + - batch 单源失败不阻断整体。 +- 产品处理: + - 海缆缺 relation 时产品状态 degraded,但 cable layer 可用。 + - BGP observation 不直接变成前端 marker,必须经过 anomaly/incident 投影。 + - 船只 bbox snapshot 和 WS 节流继续有效。 + - 卫星列表不返回无限轨道点。 + +## Assumptions + +- 前端聚合面板以后只读 `/data-products/*/status`。 +- 地图图层只读 `/layers/*`。 +- 统计可以短暂 stale,但不能因实时全量统计击穿服务器。 +- 保留现有 collector,不为了重构而删除 BarentsWatch 或其他源。 +- 当前开发阶段允许前端从旧 `/visualization/geo/*` 迁移到 `/layers/*`。 diff --git a/docs/plans/docs-audience-split-plan.md b/docs/plans/docs-audience-split-plan.md new file mode 100644 index 00000000..299ce690 --- /dev/null +++ b/docs/plans/docs-audience-split-plan.md @@ -0,0 +1,116 @@ +# 文档受众分层重构计划 + +**状态**:待实施 +**创建日期**:2026-05-12 +**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。 + +## 背景 + +当前 `manual.md` 把客户实际使用和开发/运维操作混在一份文档里:开头 200 多行讲的是 `planet.sh start/stop/restart/log/health/createuser/--allow-lan`、AI Provider 镜像构建、`netsh portproxy` 和故障排查顺序,后面才进入 Earth、控制台、AI、Docs 这些客户真正会用到的功能。 + +客户读到一半会被 shell 命令吓住,开发者想找运维细节又要在大段 UI 操作里翻。`documentation-coverage-rules.md` 现在也没有受众分层规则,未来文档继续混着写。 + +本计划假定客户已经能拿到账号登录使用 — 注册/验证流程本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。 + +## 新的文档地形 + +| 文档 | 受众 | Gatekeeper 组 | 范围 | +| --- | --- | --- | --- | +| `manual.md` (zh+en) | 纯客户/最终用户 | `public` | 注册、登录、账户、设置 UI、collector 配置、AI 配置、Console 页面、Earth、Docs 浏览 | +| `quickstart.md` (zh+en) | 纯客户 | `public` | "我刚拿到 Planet 怎么开始用" — 打开 URL → 注册 → 验证 → 登录 → 第一次配置 | +| `ops-runbook.md` (zh+en, **新增**) | 运维/部署人员 | `docs_admin` | `planet.sh` 完整命令、健康检查、日志位置、LAN/portproxy、故障排查顺序、createuser CLI、Bun 构建约定 | +| `ops-planet-sh-startup.md` (已存在) | 运维 | `docs_admin` | 启动性能、AI Provider 镜像、`PLANET_LOAD_ZSHRC_ENV` 深度调优 — 保持不动 | +| 现有 `*-context.md` / `backend-*.md` | 二次开发者 | `docs_developer` | 保持现状 | + +`backend-system-service-control.md` 偏后端服务控制原理,**不**和 `ops-runbook.md` 重复 — runbook 讲"运维要敲什么命令",service-control 讲"后端怎么实现服务管控"。 + +## manual.md 重写后的章节顺序(客户旅程) + +1. **欢迎与入口** — Planet 是什么、几个入口(Earth 公开 / Console 需登录 / Docs / API) +2. **注册账户** — 打开 `/login` → 点"注册" → 填用户名/邮箱/密码 → 收邮件 → 输入 6 位验证码 → 登录 +3. **登录与找回密码** — 登录页、忘记密码流程 +4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出 +5. **Console 总览** — 左侧菜单结构、各路由用途 +6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子 +7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR) +8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件) +9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组 +10. **数据探索** — `/datasources`、`/data`、`/bgp`、`/alerts/*` +11. **AI 测试台** — `/ai?tab=playground` +12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端) +13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明) + +不再出现:`planet.sh`、`./planet.sh log`、`netsh portproxy`、`source ~/.zshrc && bun run build`、"故障排查顺序"、"开发命令约定"。 + +## quickstart.md 重写 + +当前 quickstart 假设读者会自己 `git clone` 然后 `./planet.sh start`,这是给开发者看的。改为: + +- 打开管理员给你的 URL +- 注册账号 + 邮箱验证 +- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型) +- 看 Earth + +部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。 + +## ops-runbook.md 内容大纲 + +抽自现 manual.md,重新组织: + +1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123`、`linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`) +2. 启停与按模块重启 — `start/stop/restart` 及 `-b -f -a -d` +3. 健康检查 — `./planet.sh health` +4. 日志 — `./planet.sh log` 及 `-f -b -a`,日志文件路径 +5. 创建用户(CLI 兜底)— `./planet.sh createuser`;说明这是公开注册不可用(SMTP 未配置)时的兜底 +6. 局域网/WSL 访问 — `--allow-lan`、`netsh portproxy`、防火墙 +7. AI Provider 环境变量与构建 — `aiprovider/.env`、`~/.zshrc`、`PLANET_LOAD_ZSHRC_ENV` +8. 故障排查顺序 — 现 manual 末尾那段,原样搬来 +9. 开发命令约定 — Bun、`bun run build`、为什么不用 npm + +## documentation-coverage-rules.md 增量 + +在现有"覆盖清单"末尾新增一段: + +> **受众分层(强制)** +> +> - 客户/最终用户能在浏览器里完成的操作 → 只写到 `manual.md` / `quickstart.md` +> - 需要 SSH/shell/Docker/`planet.sh`/日志文件路径/端口转发 → 只写到 `ops-runbook.md`(或现有 `ops-*.md`),**禁止**出现在 manual/quickstart +> - 同一动作两种入口(如"创建用户"既能 UI 也能 CLI)→ UI 路径写 manual.md,CLI 路径写 ops-runbook.md,互相用一句话相互引用 +> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts` +> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en + +## .claude/commands/docs.md 增量 + +在 "Step 2 — Decide Scope" 后插一段: + +> **Document Audience Routing (Planet)** +> +> 在 Planet 仓库内,写文档前先判断动作的执行者: +> +> - 浏览器 UI 用户 → `docs/technical/{zh,en}/manual.md` / `quickstart.md` +> - shell/容器/运维 → `docs/technical/{zh,en}/ops-runbook.md` 或现有 `ops-*.md` +> - 二次开发者 → 现有 `*-context.md` / `backend-*.md` +> +> 永远不要把 shell 命令、日志路径、Docker 操作写进 manual/quickstart;永远不要把 UI 截图/按钮路径写进 ops-*。 + +## 关键文件清单 + +- `docs/technical/zh/manual.md` & `en/manual.md` — 重写 +- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写 +- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)* +- `docs/documentation-coverage-rules.md` — 加受众分层段 +- `.claude/commands/docs.md` — 加 Document Audience Routing 段 +- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook` 到 `DOCS_METADATA`(`docs_admin` 组) + +## 依赖 + +manual.md 的"注册账户"和"登录与找回密码"两章需要前后端注册/验证流程已经落地,否则文档会描述不存在的功能。注册功能本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。建议先实现注册再重写 manual,避免文档与代码错位。 + +## 验证 + +- `rg -n 'planet\.sh' docs/technical/zh/manual.md docs/technical/en/manual.md docs/technical/zh/quickstart.md docs/technical/en/quickstart.md` 应该为空 +- `rg -n '注册账户|register|邮箱验证' docs/technical/zh/manual.md docs/technical/en/manual.md` 应该有命中 +- `rg -n 'planet\.sh' docs/technical/zh/ops-runbook.md docs/technical/en/ops-runbook.md` 应该有命中 +- `frontend/src/pages/Docs/docs-content.ts` 中 `ops-runbook` 出现且分组为 `docs_admin` +- zh/en manual 章节标题对齐(按 `documentation-coverage-rules.md` 现有要求) +- Docs 站点访问:未登录看 manual/quickstart 正常;非 `docs_admin` 用户看不到 `ops-runbook`;`admin` 能看到 diff --git a/docs/plans/earth-vessel-ais-aggregation-plan.md b/docs/plans/earth-vessel-ais-aggregation-plan.md index c46f2ac5..07456a41 100644 --- a/docs/plans/earth-vessel-ais-aggregation-plan.md +++ b/docs/plans/earth-vessel-ais-aggregation-plan.md @@ -176,10 +176,12 @@ freshness: ## 聚合接口 +状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 不再兼容返回数据,而是返回 `410 Gone`;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws` 的 `vessels` 订阅。 + 现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。 ```text -GET /api/v1/visualization/geo/vessels +GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 GET /api/v1/visualization/vessels/{mmsi} GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts @@ -317,7 +319,7 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图 目标是让展示接口开始消费聚合结果,但前端形状保持兼容。 1. 实现 AIS 聚合服务,先兼容读取现有表,再逐步切换到原始观测层。 -2. 将 `/geo/vessels` 和 `/vessels/{mmsi}` 改为走聚合服务。 +2. 将船只列表迁移到 `/api/v1/vessels/snapshot`,并让 `/vessels/{mmsi}` 走聚合服务。 3. 将 `/vessels/{mmsi}/track` 改为走轨迹聚合逻辑。 4. 返回 `field_sources`、`selected_reasons`、`quality_flags`、`conflict_count`。 5. 加入 freshness fallback 和异常位置保护。 @@ -342,13 +344,13 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图 3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。 4. 保留 `field_sources` 和 `selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。 5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。 -6. `/geo/vessels` 不再默认限制 5000 艘;不传 `limit` 或传 `limit=0` 表示全量返回,前端默认也不再二次裁剪到 5000。 +6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。 -### v3.1 — 聚合完整性修复(v4 前置) +### v3.1 — 聚合完整性修复(已被新快照接口取代) -目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。 +原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot`。 -当前风险是 `/geo/vessels` 只要 raw observation 聚合返回非空,就直接使用 raw 聚合结果,不再补读兼容层 `vessel_position + vessel_static`。如果 raw observation 中只存在 AISStream 的几百艘船,或 BarentsWatch 历史数据没有完整回填到 raw 层,最终 Earth 就会只显示 AISStream 子集。 +因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录: 1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。 2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。 @@ -416,7 +418,7 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 - `freshness.realtime_stream_seconds` / `polling_seconds` 必须为非负整数; - `mode=locked` 必须带非空 `locked_source`。 4. 聚合服务 `vessel_ais_aggregation.py` 在 `_select_position_observation` 中按 `freshness` 把过期实时流降级到 stale 候选;在 `_select_static_field` 中按 `field_rules.mode = source_priority / locked / newest / non_empty` 选源。 -5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/geo/vessels` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。 +5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/api/v1/vessels/snapshot` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。 6. API: - `GET /api/v1/vessel-aggregation/strategy` - `PUT /api/v1/vessel-aggregation/strategy`(校验失败 400) @@ -460,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 - 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。 - 同一时间窗口内多来源相近轨迹点只展示一个点。 - AISStream 重连或回放导致的重复消息不会重复进入聚合结果。 -- raw observation 聚合结果和 legacy latest position 结果会按 MMSI 合并,BarentsWatch-only 船只不会因为 AISStream 子集存在而消失。 -- 不传 `limit` 或传 `limit=0` 时,`/geo/vessels` 全量返回合并后的船只集合。 +- `/api/v1/vessels/snapshot` 只读取 AIS raw observation 聚合结果;legacy latest position 不再参与船只快照。 +- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API。 - AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。 - AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。 - `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。 diff --git a/docs/plans/earth-vessel-rendering-performance-plan.md b/docs/plans/earth-vessel-rendering-performance-plan.md index 7db14adc..7d560def 100644 --- a/docs/plans/earth-vessel-rendering-performance-plan.md +++ b/docs/plans/earth-vessel-rendering-performance-plan.md @@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再 ### 1. 请求视口范围 -前端请求 `/api/v1/visualization/geo/vessels` 时带上当前视口 `bbox`,减少无关船只。 +前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox`、`zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。 ### 2. 后端排序策略 diff --git a/docs/plans/user-registration-email-verification-plan.md b/docs/plans/user-registration-email-verification-plan.md new file mode 100644 index 00000000..6b1814b9 --- /dev/null +++ b/docs/plans/user-registration-email-verification-plan.md @@ -0,0 +1,155 @@ +# 用户公开注册与邮箱验证计划 + +**状态**:待实施 +**创建日期**:2026-05-12 +**核心目标**:给 Planet 增加公开注册流程 + 邮箱验证码 + 忘记密码,让客户无需管理员介入就能开通账号;同时把 SMTP 邮件作为可复用基础服务接入 `/settings`。 + +## 背景 + +当前认证只暴露 `/auth/login`、`/auth/refresh`、`/auth/logout`、`/auth/me`(`backend/app/api/v1/auth.py`),账号只能由 `super_admin` 在 `/users` 后台创建。User 模型 `backend/app/models/user.py` 没有 `email_verified` 字段,仓库也没有任何 SMTP/邮件发送基础设施。 + +客户旅程想从"打开浏览器→注册→验证→登录"开始走(见 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md)),就必须先把这条链路在代码里跑通。 + +注册策略(已确认): + +- 开放公开注册,任何人可在 `/register` 自助开通 +- 默认角色 `viewer` +- 邮箱验证后立即可登录(无需管理员审批) +- 验证仅走 SMTP 邮件,6 位数字码,10 分钟 TTL + +## 数据模型 + +`backend/app/models/user.py` 加两列: + +```python +email_verified = Column(Boolean, default=False, nullable=False) +pending_email = Column(String(255), nullable=True) # 改邮箱时临时落地待验证地址 +``` + +迁移路径:仓库目前没看到 alembic 目录,沿用 `backend/app/db/session.py` 的初始化风格在启动时跑 `ALTER TABLE users ADD COLUMN IF NOT EXISTS ...`。先确认是否存在 alembic,若有则正规迁移。 + +不另建 `verification_codes` 表 — OTP 走 **Redis**(系统已有 Redis,token blacklist 也走 Redis): + +``` +key: otp:{purpose}:{email} purpose ∈ {register, verify_email, reset_password} +value: { code_hash: bcrypt, attempts: int, issued_at: ts } +TTL: 600 秒 +``` + +`{purpose}:{email}` 同时配一个限流键 `otp_rate:{purpose}:{email}`,TTL 60 秒,用于"60 秒内禁止重发"。 + +## 服务拆分 + +按项目 `services/` 单职责风格拆两个: + +**`backend/app/services/otp.py`**(通用 OTP 原语,未来 2FA / 手机号验证可直接复用): + +```python +async def issue_code(email: str, purpose: OtpPurpose) -> str # 生成 6 位、写 Redis、返回明码(调用方负责送达) +async def verify_code(email: str, purpose: OtpPurpose, code: str) -> bool # 校验并消耗 +async def check_resend_allowed(email: str, purpose: OtpPurpose) -> None # 抛 RateLimited 异常 +``` + +- 6 位数字,密码学随机 +- Redis 存 `bcrypt(code)`,不存明码 +- 校验失败计数 ≥ 5 直接失效该 key +- 重发触发即失效旧 code + +**`backend/app/services/email.py`**(通用 SMTP 发送,告警/摘要等后续可复用): + +```python +async def send_email(to: str, subject: str, html: str, text: str | None = None) -> None +async def send_verification_email(to: str, code: str, purpose: OtpPurpose) -> None # 模板封装 +``` + +- 用 `aiosmtplib` 异步发送 +- 从 `system_settings` 的 `smtp` 命名空间读配置(host/port/username/password/from/use_tls) +- 未配置抛 `EmailNotConfiguredError` +- 模板用简单 HTML + 纯文本双段,按 `purpose` 切换文案 + +编排("签码 → 发邮件")在 `api/v1/auth.py` 端点里调两个服务,不在 service 内互相调用,保持单测可单独 mock。 + +## 后端端点 + +新增到 `backend/app/api/v1/auth.py`: + +| 端点 | 入参 | 行为 | +| --- | --- | --- | +| `POST /auth/register` | `username, email, password` | 用户名/邮箱查重 → 写 User `is_active=True, email_verified=False, role="viewer"` → 调 `otp.issue_code(email, "register")` → 调 `email.send_verification_email` | +| `POST /auth/verify-email` | `email, code` | `otp.verify_code` → 置 `email_verified=True` → 直接返回 access/refresh token | +| `POST /auth/resend-code` | `email, purpose` | `check_resend_allowed` → `issue_code` → `send_verification_email` | +| `POST /auth/forgot-password` | `email` | 即便邮箱不存在也返回 200(防枚举);存在则签 `reset_password` 码并发邮件 | +| `POST /auth/reset-password` | `email, code, new_password` | `verify_code(..., "reset_password")` → `user.set_password(new_password)` | + +`/auth/login` 改造:邮箱未验证用户登录返回 `403 { code: "EMAIL_NOT_VERIFIED", email }`,前端拿到后跳验证页。 + +## SMTP 设置 + +复用 `backend/app/api/v1/settings.py` 现有 setting store,新增 `smtp` 命名空间: + +- `smtp_host`、`smtp_port`、`smtp_username`、`smtp_password`、`smtp_from`、`smtp_from_name`、`smtp_use_tls` +- 密码走与 collector 凭证相同的加密路径(看 `backend/app/services/` 是否已有 `credentials_encryption` 之类工具,若有直接复用) +- `POST /settings/smtp/test` — 用当前未保存的入参试发一封到指定地址,不落库 + +未配置 SMTP 时 `/auth/register` 应返回明确错误 `503 { code: "EMAIL_PROVIDER_NOT_CONFIGURED" }`,提示管理员先去 `/settings` 配 SMTP 或用 `./planet.sh createuser` 兜底。 + +## 前端 + +**新页面**: + +- `frontend/src/pages/Register/Register.tsx` — 两步表单:(1) 用户名/邮箱/密码 (2) 6 位验证码;60s 重发冷却;验证成功写 token,自动跳 `/admin` +- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` — 给登录拦截 `EMAIL_NOT_VERIFIED` 时落地的页,仅"输码 + 重发" +- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` — 两步:(1) 输邮箱 (2) 输码 + 新密码 + +**改动**: + +- `frontend/src/pages/Login/Login.tsx` — 表单下加"注册账号"、"忘记密码"链接;接 `EMAIL_NOT_VERIFIED` 跳 `/verify-email` +- `frontend/src/pages/Settings/Settings.tsx` — 新增 SMTP 子 tab(host/port/username/password/from/TLS + 测试发送按钮),用工作区里新建的 `frontend/src/components/ConnectionTestInput/` 做连通测试输入 +- 路由表(`frontend/src/App.tsx` 或 `frontend/src/router/*`)— 加 `/register`、`/forgot-password`、`/verify-email` + +## 关键文件清单 + +后端: + +- `backend/app/models/user.py` — 加字段 +- `backend/app/schemas/user.py` — 新增 `UserRegister`、`VerifyCode`、`ResetPasswordRequest` schema +- `backend/app/api/v1/auth.py` — 新端点 + 登录校验 +- `backend/app/services/otp.py` *(新)* +- `backend/app/services/email.py` *(新)* +- `backend/app/api/v1/settings.py` — SMTP 命名空间 + 测试发送 +- `backend/app/core/config.py` — SMTP 默认值/特性开关 +- `backend/app/db/session.py` — DDL 兜底(若无 alembic) + +前端: + +- `frontend/src/pages/Register/Register.tsx` *(新)* +- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` *(新)* +- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` *(新)* +- `frontend/src/pages/Login/Login.tsx` +- `frontend/src/pages/Settings/Settings.tsx` +- 路由文件 + +## 实施顺序 + +1. 后端:User 模型字段 + DDL 兜底 +2. 后端:`services/otp.py`(先纯单测跑通) +3. 后端:`services/email.py`(用 MailHog 本地试发) +4. 后端:`/auth/register` + `/auth/verify-email` + `/auth/resend-code` + 登录拦截 +5. 后端:`/auth/forgot-password` + `/auth/reset-password` +6. 后端:`/settings/smtp` 命名空间 + 测试发送 +7. 前端:`Settings.tsx` 加 SMTP 子 tab +8. 前端:Register / VerifyEmail / ForgotPassword 页 + Login 入口 + +文档同步在 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md) 落地。 + +## 验证 + +- **后端单测**(仿 `backend/tests/test_settings_ai_provider.py`): + - 注册端点用户名/邮箱查重 + - OTP 过期、错码计数、重发限流 + - 邮箱未验证用户登录返回 `EMAIL_NOT_VERIFIED` + - SMTP 未配置时注册端点返回 `EMAIL_PROVIDER_NOT_CONFIGURED` + - 忘记密码对不存在邮箱仍返回 200 +- **后端集测**:本机起 MailHog 或 Mailtrap,把 SMTP 指到上面,跑 register → 收码 → verify → login 一遍 +- **前端**:`source ~/.zshrc && bun run build`;启 dev server 走 `/register` → `/verify-email` → `/admin` 全流程,再试 `/forgot-password` +- **手测**:新邮箱注册 → 收码 → 输错 → 重发 → 输对 → 登录 → 改密码 → 用新密码再登;管理员在 `/settings` 改 SMTP → 测试发送 diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index edbea3b2..7fa662d2 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -309,17 +309,50 @@ AIS observations do not directly replace final vessel records. They are first sa - Dynamic fields such as position, speed, and course are selected by freshness and source priority. - Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views. -Earth still reads vessel data from: +Earth vessel rendering now consumes the bounded snapshot endpoint and realtime delta channel: ```http -GET /api/v1/visualization/geo/vessels +GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 GET /api/v1/visualization/vessels/{mmsi} GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts -GET /api/v1/visualization/vessels/aggregation/diagnostics ``` -`/geo/vessels` merges raw observation aggregation with the legacy BarentsWatch latest-position tables so adding AISStream does not hide historical BarentsWatch-only vessels. +`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It reads only aggregated `ais_raw_observations`; it no longer merges legacy `vessel_position` / `vessel_static` rows. The old `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. + +Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport: + +```json +{ + "type": "subscribe", + "data": { + "channel": "vessels", + "bbox": [120.8, 30.7, 122.1, 31.8], + "zoom": 12, + "limit": 1000 + } +} +``` + +The backend stores lightweight subscription filters per connection and only sends vessel updates that match the subscriber bbox. Collector broadcasts enter a 1-second throttle queue; within each flush window, only the latest update per MMSI is retained. + +### Layer APIs And Global Stats + +Earth is moving to two API families: + +```http +GET /api/v1/data-products +GET /api/v1/data-products/{product_id}/status +GET /api/v1/layers/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/cables?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/landing-points?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/satellites?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/anomalies?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/incidents?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +``` + +`/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper. ## X. Collector Settings And Connectivity Validation @@ -390,4 +423,12 @@ curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \ -H "Authorization: Bearer " ``` +Batch collection uses: + +```http +POST /api/v1/datasources/trigger-batch +``` + +The request body may pass `source_ids` for selected rows. Without `source_ids`, the backend filters by `product`, `module`, `is_active`, `run_status`, `collected`, `credential_status`, and `q`. The endpoint skips disabled sources, sources already running without `force`, and sources still inside their frequency window, then returns `triggered`, `skipped`, and `failed` groups. + **Core file**: `backend/app/api/v1/datasources.py` diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 40193768..77d857bc 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -288,6 +288,16 @@ Normalization: Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key. +Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while the `aisstream_vessels` collector is `streaming` / `connected` and its message count plus `last_seen_at` keep advancing. + +The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call: + +```http +GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +``` + +That endpoint reads local aggregated `ais_raw_observations` only. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI. + ## Custom REST / WebSocket Mapping Runtime Files: diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 7928d3da..5e7a4811 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -152,7 +152,11 @@ The `earth:compute-center-location-saved` reconciliation pipeline is deliberatel ### AIS Vessel Layer -The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap. +The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection. + +The legacy `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend no longer merges legacy `vessel_position` / `vessel_static` rows into vessel snapshots, so the frontend must not depend on old BarentsWatch-only fallback rows. + +The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport. Vessel color and vessel type text must use the same normalized classification. `vessels.js` derives `type` from both `vessel_type_name` and the AIS numeric `vessel_type` code; that `type` drives marker color. It also derives `vessel_type_display`, which `main.js` uses for the info card, hover summary, and search result subtitle. Do not make the info card read only the raw `vessel_type_name`, because AISStream can provide a numeric type while the raw name is still `Other`. diff --git a/docs/technical/en/frontend-admin-frontend-context.md b/docs/technical/en/frontend-admin-frontend-context.md index b7e153f8..dcc87602 100644 --- a/docs/technical/en/frontend-admin-frontend-context.md +++ b/docs/technical/en/frontend-admin-frontend-context.md @@ -163,7 +163,25 @@ Current constraints: - Internal document links should be converted to `/docs/:slug` through `transformLink` - Heading anchors are injected through `getHeadingId`, keeping route state outside the renderer -### 6. `TableActions` +### 6. `ConnectionTestInput` + +File: + +- [ConnectionTestInput.tsx](/home/ray/dev/linkong/planet/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx) + +Purpose: + +- Console form fields that combine an endpoint/Base URL value with a connection check +- Connection-test entry points for AI Provider and WebSearch +- Future collector configuration fields should reuse it when the test action belongs inside the input + +Current constraints: + +- The input suffix shows a single plug/connector icon, not an adjacent text button +- Disabled integrations must grey out both the input and its connection-test action +- The component only combines the input and action; callers still own form state, loading, disabled state, and the request itself + +### 7. `TableActions` File: @@ -205,10 +223,13 @@ File: Responsibilities: - `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings` -- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test -- The `工具` tab manages WebSearch provider, search key, base URL, timeout, result count, and advanced provider options +- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating +- The `工具` tab first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR +- WebSearch configuration includes provider, search key, base URL, timeout, result count, and advanced provider options +- OCR configuration includes provider, Base URL, API key, model/engine, languages, timeout, file-size limit, and output format - The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging - The page reuses the Settings single-screen tabs, panel card, and internal scrolling style +- AI Provider and WebSearch connection tests use `ConnectionTestInput`, with the connector icon fixed at the end of the Base URL input; when WebSearch is disabled, every configuration field and the test entry point are greyed out except the switch Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`. Legacy `/playground` should redirect to `/ai?tab=playground`. diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 132b6f0f..e323680e 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -1,666 +1,332 @@ # Planet Manual -This manual is for daily use, demos, development integration, and local operations. It covers four core entry points: +This manual is for Planet end users. Starting from the browser, it covers account registration, login, configuring collectors, configuring AI, using Earth and the console, and reading the docs site. Every action happens in a browser. -- `planet.sh`: local start, stop, restart, health check, and log access -- Earth: public 3D situational awareness page -- Console: admin backend (login required) -- Docs: backend Gatekeeper-controlled documentation; basic usage docs are public, while developer and operations docs require permission groups - -For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md). For common troubleshooting, see the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md). +If you are responsible for deployment or on-call duty, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead — it covers shell commands, log paths, and CLI fallbacks for user creation. ## Entry Overview -After a default startup, the common URLs are: - | Name | URL | Login Required | Description | | --- | --- | --- | --- | -| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness | -| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups | -| FAQ | `http://localhost:3000/docs/faq` | No | Windows / WSL, ports, dependencies, motion capture, credentials, and permission troubleshooting | -| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation | -| AI | `http://localhost:3000/ai` | Yes | Model providers, AI tools, and testbench | -| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation | +| Earth | `http:///earth` | No | Public 3D situational awareness page | +| Docs | `http:///docs` | Partly | Public docs need no login; developer/ops docs need Gatekeeper groups | +| Register / Login / Forgot Password | `/register`, `/login`, `/forgot-password` | No | Self-serve account creation and recovery | +| Console | `http:///admin` | Yes | Data, collectors, alerts, AI, users, settings | +| AI | `http:///ai` | Yes | Model providers, tools, testbench | +| Backend API Docs | `http://:8000/docs` | Depends | FastAPI / OpenAPI | -## planet.sh +URLs below use the local default `http://localhost:3000`. Replace the prefix with your deployment URL in production. -`planet.sh` is the main control script for local development and demos. Use it to manage services rather than manually starting frontend, backend, database, and AI Provider separately. +## Register an Account -### Start +1. Open `http://localhost:3000/login` and click "Register" under the form. +2. On `/register`, fill in: + - **Username**: 3–50 characters, used to log in + - **Email**: receives the verification code; editable later in account settings + - **Password**: at least 8 characters +3. After submission you are taken to the verify page. A 6-digit code is sent to your email. It expires in 10 minutes. +4. Enter the code and click "Verify and Sign In". On success the system stores a session and sends you to the console. -```bash -./planet.sh start -``` +If no email arrives within 60 seconds: -Default behavior: +- Check spam, promotions, and any enterprise mail gateway +- The "Resend Code" button shows a 60-second countdown; you can resend after it ends +- After 5 wrong attempts the code is invalidated; you must resend a new one -- Starts PostgreSQL and Redis -- Starts AI Provider -- Starts the backend API -- Starts the frontend Vite dev server -- Outputs Earth, console, Playground, and backend API doc URLs +If you see "Email service not configured", the administrator has not yet set up SMTP. Ask the administrator to fill SMTP at `/settings -> SMTP Email`. -Specify custom ports: +The default role for a self-registered user is `viewer`, which can sign in and view public content. To see collector / user / settings pages, ask an `admin` or `super_admin` to promote your role at `/users`. -```bash -./planet.sh start -b 8001 -f 3001 -a 8101 -``` +## Sign In and Recover Password -Parameters: +### Sign In -| Flag | Meaning | -| --- | --- | -| `-b ` | Backend port | -| `-f ` | Frontend port | -| `-a ` | AI Provider port | -| `--allow-lan` | Enable LAN access | -| `--verbose` | Show more command output during execution | +Open `/login`, enter username and password. On success you are taken to `/admin`. -### AI Provider Environment and Builds +If you see "Email not verified", the page automatically redirects to `/verify-email` — follow the prompts to enter the code. -AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup. +### Forgot Password -Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes: +1. On `/login`, click "Forgot Password?", or open `/forgot-password` directly. +2. Enter your registered email and click "Send Code". The same confirmation is shown regardless of whether the email is registered (to avoid enumeration). +3. After receiving the code, enter it together with a new password (≥ 8 characters) and click "Reset Password". +4. The system sends you back to `/login` — sign in with the new password. -```bash -./planet.sh restart -a -``` +## Account Settings -For complex shell expansion in `~/.zshrc`, opt in explicitly: +Click your username at the top-right of the console to open account settings: -```bash -PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a -``` +- Change password: enter current password + new password +- Change email: the system sends a verification code to the new address; the change applies only after verification +- View Gatekeeper groups: lists current groups (`docs_user` / `docs_developer` / `docs_admin`) +- Log out: clears the current session -To ignore `~/.zshrc` during troubleshooting: +## Console Overview -```bash -PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a -``` - -The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build. - -### Stop - -```bash -./planet.sh stop -``` - -Stops: - -- Backend -- AI Provider -- Frontend -- PostgreSQL -- Redis - -### Restart - -Full restart: - -```bash -./planet.sh restart -``` - -Per-module restart: - -```bash -./planet.sh restart -b -./planet.sh restart -f -./planet.sh restart -a -./planet.sh restart -d -``` - -| Flag | Effect | -| --- | --- | -| `-b` | Backend only | -| `-f` | Frontend only | -| `-a` | AI Provider only | -| `-d` | Database only | - -Per-module restarts are preferred during development — they avoid interrupting unrelated services. - -### Create User - -```bash -./planet.sh createuser -``` - -Used to create a console login account before first use. The script interactively prompts for username, password, and role. - -### Health Check - -```bash -./planet.sh health -``` - -Checks: - -- `planet_*` container status -- Backend `/health` -- AI Provider `/health` -- Frontend reachability - -If something shows offline, check the corresponding logs first. - -### Logs - -Recent logs: - -```bash -./planet.sh log -``` - -Follow logs: - -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` - -| Flag | Log source | -| --- | --- | -| `-f` / `--frontend` | `/tmp/planet_frontend.log` | -| `-b` / `--backend` | `/tmp/planet_backend.log` | -| `-a` / `--ai-provider` | `planet_aiprovider` container logs | - -### LAN Access - -```bash -./planet.sh start --allow-lan -``` - -Useful for: - -- Starting in WSL, accessing from Windows browser -- Demos on phone or tablet -- Another machine on the same LAN accessing the same dev instance - -`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but access from a phone or another computer through `http://:3000` still depends on Windows port forwarding and firewall rules. - -Use this order to diagnose: - -```bash -# From WSL or the shell running Planet -curl http://localhost:3000 -curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' -``` - -If this shows `0.0.0.0:3000` and `0.0.0.0:8000`, but the LAN IP still fails, configure Windows from an elevated PowerShell: - -```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 - -New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 -``` - -## Earth - -Earth is the public 3D situational awareness page, accessed at: - -```text -http://localhost:3000/earth -``` - -It is a standalone frontend. The actual page lives at: - -- `frontend/public/earth/index.html` -- `frontend/public/earth/js/` -- `frontend/public/earth/css/` - -The React route `/earth` simply hosts it in an iframe. - -### Main Uses - -Earth is used to observe in a single globe view: - -- BGP events, anomalies, and situational posture -- Satellites and orbital trails -- Submarine cables and landing points -- Compute centers -- AIS vessels -- Border lines, grid lines, HD texture, cloud layer, terrain -- Live news streams and situational news -- Search and focused object details - -### Layer Control - -The right-side layer panel toggles visualization layers on or off. - -Common layers include: - -- Grid lines -- Border lines -- HD texture -- Atmospheric cloud layer -- Submarine cables -- Compute centers -- BGP observation -- AIS vessels -- Satellites -- Orbital trails -- Terrain - -Some layers have dependencies: - -- Terrain requires HD texture -- Trails require Satellites -- When HD texture is off, the globe shows the base map and edge glow effect - -### Legend - -The lower-left legend follows the currently focused or enabled layer. - -Current legend modes include: - -- Cables -- Satellites -- Border lines -- Compute centers -- BGP -- AIS vessels - -AIS vessel legend entries are grouped by vessel type: cargo, tanker, passenger, fishing, military, anchored/slow, and other. Triangle markers represent moving vessels; dots represent anchored or slow vessels. - -### Search - -Earth search finds current globe objects, such as: - -- Submarine cables -- Landing points -- Satellites -- Compute centers -- BGP events -- BGP collectors - -Search results can be used to quickly locate objects and open their details. - -### Location Candidate Collection - -Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. If those regular sources return no candidates, the current default AI Provider is used once as an LLM factcheck fallback. Stored BGP collector locations are used as query context only and are not emitted as candidates. - -Candidates can be previewed directly on Earth. Compute-center candidates can be saved into the `compute_center_locations` dimension table from the detail card, then the layer refreshes immediately. The notification badge on the compute-center layer row shows unresolved records that cannot be rendered; clicking it opens the queue, where users can collect individual candidates or use `一键采用` to save the highest-confidence candidate top-to-bottom. Records without candidates stay in the queue and are not replaced by country centroids or hard-coded hints. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) for the full workflow. - -### Settings - -The settings panel contains: - -- Rotation mode / cruise mode / motion mode -- Cruise modules: BGP, News, Compute Centers, Vessels, Cables, Satellites -- View settings: satellite display style, day/night mode, panel visibility -- Motion Debug Mode, Motion Input Source, skeleton-only debug view -- Globe default size -- Terrain opacity -- Reset settings - -These settings are stored in browser local storage. They revert to defaults if you switch browsers or clear site data. - -### View Controls - -Earth supports mouse, touchpad, and touchscreen interaction. - -Common controls: - -| Action | Result | -| --- | --- | -| Left-button drag | Rotates the globe | -| One-finger drag | Rotates the globe on touch devices | -| Mouse wheel | Zooms the view in or out | -| Two-finger pinch | Zooms the view on touch devices | -| Zoom buttons | Adjust zoom in fixed steps | -| Click the zoom percent | Resets to the default zoom | - -When zooming, the top capsule briefly shows the current zoom level, for example `Zoom 180%`. This indicates view zoom only, not data loading progress. Loading status takes priority and will not be interrupted by zoom feedback. - -Drag sensitivity adjusts automatically based on the current zoom. Around the default view it keeps the normal rotation feel; when zoomed in, dragging becomes progressively finer for inspecting a region, vessel, satellite, or BGP event; when zoomed out, dragging is slightly faster for global browsing. - -### Motion Capture Controls - -Earth has a motion-capture control entry point for large-screen and future 3D displays. There are two realtime input sources: the default `Browser Camera` source uses webpage `getUserMedia` and recognizes gestures locally in the browser; the advanced `Motion Agent` source uses `camera/RTSP/HTTP -> local Agent -> local WebSocket -> Earth page`. Neither path sends camera frames or realtime gesture decisions to the cloud, and neither path reuses the news/RSS aggregation APIs. - -It is disabled by default. Enable `Motion Debug Mode` in settings, open Earth with `?motion=1`, or set `planet-earth-motion-control-enabled=true` in browser local storage to start the selected source. The default source is `Browser Camera`; it requires HTTPS or localhost and a granted browser camera permission, but does not require installing an app. For dual cameras, USB indexes, phone/network camera streams, client integration, or edge devices, switch the setting to `Motion Agent`. The default Agent URL is `ws://127.0.0.1:8765/ws/gestures`; the `motionAgent` URL parameter can override it. - -URL parameters can also force the source: `?motion=1&motionProvider=browser` uses the browser camera, `?motion=1&motionProvider=agent` uses Motion Agent, and providing `motionAgent=ws://...` automatically selects Motion Agent. - -Current gesture semantics: - -| Gesture event | Result | -| --- | --- | -| `rotate_left` | Rotates the globe left | -| `rotate_right` | Rotates the globe right | -| `rotate_up` | Rotates the globe upward | -| `rotate_down` | Rotates the globe downward | -| `zoom_in` | Zooms in | -| `zoom_out` | Zooms out | -| `focus_prev` / `focus_next` | Switches targets within the current motion layer | -| `layer_prev` / `layer_next` | Switches the motion candidate layer and cruises to the nearest target in that layer | -| `confirm` | Confirms the currently selected target; browser recognition currently keeps the two-hands-up confirm gesture disabled | - -The settings panel also includes `Motion Debug Mode`, which opens the debug panel. With the Browser Camera source, the panel shows a local live preview and draws joints and bones over it. With the Motion Agent source, the Agent sends normalized skeleton events only and does not send raw video frames. The `Skeleton Only` switch hides the video preview and keeps the dark canvas plus skeleton; `Stop Matching Gestures` pauses gesture execution while preview and skeleton drawing can continue for debugging. Unmatched skeletons are red; once a gesture matches, the skeleton turns green and the matched gesture name is shown. Both this entry and the `Motion Input Source` control already carry Gatekeeper permission markers for future authorization control. - -### Cruise Mode - -Cruise mode makes Earth automatically cycle through focus targets. - -Current cruise modules: - -- BGP -- News -- Compute Centers -- Vessels -- Cables -- Satellites - -Suitable for demos, monitoring displays, or unattended presentations. - -### Mobile - -Earth has a mobile drawer layout. On small screens: - -- Layer controls open in a mobile drawer -- Search, settings, and details use mobile panels -- Main interactions remain centered on globe object clicks, search, and layer toggles - -### Common Issues - -#### Earth Won't Open - -Check whether the frontend is online: - -```bash -./planet.sh health -./planet.sh log -f -``` - -If the frontend port is not `3000`, use the actual port shown at startup. - -#### Layer Has No Data - -Check the backend and data sources: - -```bash -./planet.sh health -./planet.sh log -b -``` - -Then open the console and check: - -- `/datasources` -- `/data` -- `/bgp` - -#### Satellites, BGP, or Cables Load Slowly - -These layers may depend on backend APIs, external data sources, or first-run collection tasks. Wait for startup tasks to finish before checking logs and console data source status. - -## Console - -Console entry point: - -```text -http://localhost:3000/admin -``` - -The console requires login. Create a user first if this is your first time: - -```bash -./planet.sh createuser -``` - -### Page Structure - -The console uses React + Ant Design, with a left-side menu organized by work domain. - -Common pages: +The console at `http://localhost:3000/admin` is built with React + Ant Design. The left menu is organized by work domain. | Page | Route | Purpose | | --- | --- | --- | | Dashboard | `/admin` | System overview | -| Earth | `/earth` | Opens the public Earth page | -| Data Sources | `/datasources` | View data sources and trigger collection | -| Collected Data | `/data` | View collected data | -| BGP Observation | `/bgp` | BGP situational data | +| Earth | `/earth` | Open the public Earth page | +| Datasources | `/datasources` | Source directory and collection triggers | +| Collected Data | `/data` | Data already ingested | +| BGP | `/bgp` | BGP situational view | | System Alerts | `/alerts/system` | System-level alerts | | BGP Alerts | `/alerts/bgp` | BGP-related alerts | -| Situational Alerts | `/alerts/situational` | Situational assessment alerts | -| AI | `/ai` | Model providers, WebSearch-style tools, and testbench | -| System Logs | `/logs` | View system logs (typically super admin only) | -| Users | `/users` | User management | -| Settings | `/settings` | System config and TV live stream sources | +| Situational Alerts | `/alerts/situational` | Situational analysis alerts | +| AI | `/ai` | Model providers, tools, testbench | +| Logs | `/logs` | Usually visible only to super admin | +| Users | `/users` | Create/delete users, change roles/groups | +| Settings | `/settings` | System, SMTP, TV, collectors | -### Data Sources +Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups. -`/datasources` shows collection sources and triggers collection. It is now a data source directory that lists built-in and custom sources in one table. +## Configure Data Collectors -Common operations: +`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials. -- View data source status -- Trigger collection -- View recent collection tasks -- Open the read-only detail drawer for endpoint, headers, runtime config, and built-in/custom source type +Steps: -If a category of objects is missing on Earth, start here to confirm the data source is available. +1. Pick a collector in the dropdown. +2. Inspect status tags: + - `No credentials` / `Credentials required` + - Owning module + - `Enabled` / `Disabled` + - `Unchecked` / `Reachable` / `Unreachable` +3. Click the plug icon next to the dropdown to run a health check. On success the status becomes `Reachable`. +4. Edit endpoint, headers, timeout, retries; click save. -The data source name opens an information drawer only. Endpoint, credentials, headers, and custom source configuration are maintained under `/settings` collector settings. +For free collectors without credentials, the health check hits the endpoint directly. For credential-bearing collectors it runs the credential flow. If credentials or endpoint changed since the last successful check, click connect again. -When collection tasks are running, the progress area shows a clickable `Collecting N` pill. Clicking it opens a modal with each running task's phase, progress, and processed count. +"Connected" means either: data was successfully collected with the current config, or the connect button passed validation with the current config. -### Collected Data +### BarentsWatch AIS Credentials -`/data` shows the collected data table. - -Useful for diagnosing: - -- Whether data has entered the system -- Whether data update times match expectations -- Whether a data source produced valid records - -### BGP Observation - -`/bgp` is the BGP-focused page. - -It complements the BGP layer on Earth: - -- Earth emphasizes spatial posture and visual focus -- The console BGP page emphasizes lists, status, details, and assessment - -### Alerts - -Alert entry points: - -- `/alerts/system` -- `/alerts/bgp` -- `/alerts/situational` - -Used to view system, network, and situational alerts. - -### System Settings - -`/settings` manages system-level configuration. - -Current common uses: - -- System settings -- TV live stream source configuration -- Collector settings - -### AI - -`/ai` manages the AI runtime chain and is now separate from system settings. Legacy `/playground` redirects to `/ai?tab=playground`. - -It currently contains: - -- `模型供应商`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, and connection test -- `工具`: WebSearch provider, search API key, base URL, max results, timeout, and advanced provider options -- `测试台`: AI Provider status, preset prompts, and real analysis-chain debugging - -Legacy `/settings?tab=ai` redirects to `/ai?tab=providers`. - -Available configuration depends on the current user's role. - -#### Collector Settings - -`/settings?tab=collector_credentials` is currently displayed as Collector Settings. It manages connection settings for all collectors, not only credentials. - -Use it to: - -1. Select a collector from the dropdown. -2. Review tags such as `Requires credentials`, module, enabled state, and `Unchecked` / `Available` / `Unavailable`. -3. Click the plug icon next to the selector to run a health check. -4. Edit endpoint, request headers, timeout, and retry settings. -5. Save the collector settings. - -Free collectors are checked by requesting their endpoint directly. Credentialed collectors use their credential provider. If endpoint or credential fingerprint changes after the last successful validation, the collector must be checked again. - -The system treats a collector as connected when the current configuration has either collected data successfully or passed the manual connection check. - -#### BarentsWatch AIS Credentials - -`BarentsWatch AIS` is a credentialed built-in collector. Its credential card appears above the basic configuration card. - -Configured fields: +`BarentsWatch AIS` is a credential-required built-in collector. Selecting it surfaces the credential section above the base configuration: - `Client ID` - `Client Secret` - `Endpoint` -If a secret is already configured, the input shows a masked preview. Keeping that preview unchanged preserves the stored secret; entering a new value replaces it. +If a secret was saved previously, the input shows a masked preview. Saving while keeping the preview unchanged preserves the original secret; entering a new secret overwrites it. -BarentsWatch AIS credentials can be read from: +When the connection fails, the page opens a credential guide. You can: -1. Collector settings saved in the console. -2. Backend environment variables: - - `BARENTSWATCH_CLIENT_ID` - - `BARENTSWATCH_CLIENT_SECRET` - - historical spellings: `BARRENTSWATCH_CLIENT_ID`, `BARRENTSWATCH_CLIENT_SECRET` -3. matching `export` lines in `~/.zshrc`. +- View the default guide +- Click "Guide not helpful" to ask AI Provider to regenerate from the default prompt +- Click "Reset" to restore the default guide -If connection fails, the page opens the credential guide. The guide can be regenerated through AI Provider or reset to the default guide. The default guide points users to the official BarentsWatch tutorial and emphasizes selecting `AIS - API`, not the regular `BarentsWatch - API`. +The default guide follows the BarentsWatch official tutorial and reminds you to choose `AIS - API` for Live AIS. -### System Logs +### AISStream Realtime Vessels -`/logs` views system logs. If the menu item is not visible, the current user likely lacks the required role. +`AISStream Realtime Vessels` is the global AIS WebSocket collector. A passing connection test only confirms API key + endpoint format. Actual global vessel data requires the backend `aisstream_vessels` collector to stay connected and write to `ais_raw_observations`. -Common troubleshooting sequence: +Steps: -```bash -./planet.sh health -./planet.sh log -``` +1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels` +2. Fill the AISStream API Key +3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream` +4. Click the plug icon to test; confirm it reports `Reachable` +5. Save collector settings +6. Trigger the `aisstream_vessels` collector from the collection scheduler +7. Watch the `AISStream Runtime` panel: + - `streaming` / `connected` means the live stream is being consumed + - `messages this round` should keep growing + - `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect -Then open `/logs` for more structured runtime information. +## Configure AI Credentials -## Docs +`/ai?tab=providers` is the AI management entry. Two key sub-tabs: -Documentation site: +- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test +- `Tools`: a dropdown for specific tools — currently WebSearch and OCR -```text -http://localhost:3000/docs -``` +### Model Providers -Docs content is read through backend APIs by permission. The frontend no longer bundles all Markdown files directly. Source files still live in: +Providers and models accept presets or arbitrary custom IDs. Common fields: -```text -docs/technical/zh/ (Chinese) -docs/technical/en/ (English) -``` +- Provider: e.g. `minimax`, `openai`, `anthropic`, `ollama` +- Protocol: `OpenAI Chat Completions` / `Anthropic Messages` / `Ollama Generate` +- Base URL: model API URL +- Default Model: e.g. `gpt-5.1`, `MiniMax-M2.7` +- API Key: stored on save; displayed masked afterwards +- Max Tokens, Anthropic Version: keep defaults if unsure +- Timeout / Retry: timeout and retry attempts -Anonymous visitors only see `public` docs such as the overview, quickstart, and manual. Logged-in users can see more technical docs when assigned Gatekeeper groups: +The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply. -- `docs_user`: user-operation docs. -- `docs_developer`: Earth, frontend, backend, collector, and AI Provider development docs. -- `docs_admin`: service control, operations, environment variable, and sensitive-operation docs. +### Tools -`admin` receives admin-doc access by default, and `super_admin` can read all Docs content. Gatekeeper groups are configured in the console Users page. +- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out +- **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format -Docs supports: +The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`. -- Category navigation -- Markdown rendering -- Tables and code blocks -- In-document table of contents -- Search across currently visible docs -- Internal links between technical documents +## System Settings -When adding a new technical document, check: +`/settings` manages system-level configuration. Sub-tabs: -- Does it have a clear top-level heading -- Does it need to be added to backend Docs metadata for category and ordering -- Should it be classified as `public`, `docs_user`, `docs_developer`, or `docs_admin` +- **System Display**: name, refresh interval, retention, max concurrent tasks +- **Notifications**: alert email switch, recipient, critical/warning/daily summary +- **Security**: session timeout, max login attempts, password policy +- **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only) +- **TV Livestream**: TV source management +- **AI / WebSearch / OCR**: see above -## Development Command Conventions +### SMTP Email Settings -Frontend commands must use Bun: +Public registration and verification codes depend on this section. An `admin` or `super_admin` opens `/settings -> SMTP Email` and fills: -```bash -cd frontend -bun install -bun run dev -bun run build -``` +- SMTP host, port +- Username, password +- From address (required), from name +- STARTTLS (typical for port 587) or implicit TLS (port 465) +- Timeout in seconds -Do not use `npm run ...`. The project uses Bun in WSL / Windows mixed environments to avoid Node/npm path compatibility issues. +Save, then click "Send Test Email" and enter a recipient address to verify delivery. Once that works, regular users can self-register at `/register`. -Verify the frontend build: +Leaving the masked password preview unchanged keeps the original password. Enter a new value to replace it. -```bash -source ~/.zshrc && bun run build -``` +## User Management (Admins) -## Troubleshooting Order +`/users` is `super_admin`-only for create/delete. The page supports: -When something goes wrong, follow this sequence: +- Listing users (username, email, role, active, email verified) +- Creating users (equivalent to public registration but skips email verification — administrator vouching) +- Changing roles: `viewer` / `operator` / `admin` / `super_admin` +- Editing Gatekeeper groups: `docs_user` / `docs_developer` / `docs_admin`, controlling which docs are visible +- Disabling / enabling accounts -1. Check service status: +To let a regular user read developer or operations docs, add `docs_developer` or `docs_admin` at `/users`. -```bash -./planet.sh health -``` +## Data Exploration -2. Check recent logs: +- `/datasources`: source directory. It can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list +- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" +- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth +- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts -```bash -./planet.sh log -``` +## AI Testbench -3. Check per-module logs: +`/ai?tab=playground` is for real-pipeline debugging: -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` +- Pick the active provider +- Run preset requests or custom prompts +- Watch AI Provider status and response -4. Restart only the affected module: +The legacy link `/playground` redirects here. -```bash -./planet.sh restart -f -./planet.sh restart -b -./planet.sh restart -a -``` +## Earth Public Page -5. If database or cache is abnormal, restart the database: +Earth at `http://localhost:3000/earth` is the public 3D situational page; no login required. The React route `/earth` wraps a standalone frontend (in `frontend/public/earth/`) via iframe. -```bash -./planet.sh restart -d -``` +### Primary Uses -6. If still unrecovered, do a full restart: +A single globe view of: BGP events and observations, satellites and tracks, cables and landing points, compute centers, country boundaries / graticules / high-res tiles / clouds / terrain, news live streams and situational news, search and focus details. -```bash -./planet.sh restart -``` +### Layer Control + +The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, tracks, terrain. + +Dependencies: + +- Terrain depends on high-res tiles +- Tracks depend on satellites +- With high-res tiles disabled, the globe shows the base map with edge highlighting + +### Legend + +The bottom-left legend follows the focused or enabled layer. Covered: cables, satellites, country boundaries, compute centers, BGP, AIS vessels. + +AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, moored/slow, other. Triangles indicate moving vessels; dots indicate moored or slow targets. + +### Search + +Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object. + +### Coordinate Candidate Collection + +Compute center and BGP observer detail cards support automatic coordinate-candidate collection. Click the object then use "Collect Coordinate Candidates" or "Re-collect Coordinates". The backend assembles candidates from source coordinates, public-org registry APIs, and online geocoders. When regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback. BGP observers' stored coordinates only fill query context; they are not returned as candidates. + +Candidates preview on Earth directly. Saving a compute-center candidate writes to the `compute_center_locations` dimension table and refreshes the layer immediately. The notification badge at the top-left of the compute-center layer shows the unresolved count; clicking it opens the queue, supports single collection, or "Adopt All" to save the top-confidence candidates from top to bottom. Records without candidates stay in the queue rather than being faked to country centroids. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md). + +### Settings + +The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset. + +These settings live in browser local storage; switching browsers or clearing site data resets them. + +### View Controls + +| Action | Effect | +| --- | --- | +| Mouse drag | Rotate the globe | +| Single-finger drag | Touch rotate | +| Mouse wheel | Zoom in/out | +| Pinch | Touch zoom | +| Zoom button | Stepped zoom | +| Click zoom percentage | Reset to default zoom | + +A small pill at the top briefly shows the current zoom while zooming. This is not a data loading indicator; if data is loading, the loading state takes precedence. + +Drag sensitivity adjusts to zoom: near the default it is normal; zoomed in it is finer for targeted inspection; zoomed out it is slightly faster for global browsing. + +### Motion Capture + +Earth supports motion capture. Two live inputs: + +- **Browser Camera** (default): uses `getUserMedia` in the page. No install needed but the page must run on HTTPS or localhost, and the user must grant camera permission +- **Motion Agent**: camera/RTSP/HTTP → local agent → local WebSocket → Earth. Used for dual cameras, USB index, phone/IP camera streams + +Enable via the settings toggle "Motion Debug Mode", or with URL parameter `?motion=1`. Motion Agent defaults to `ws://127.0.0.1:8765/ws/gestures`; override with `motionAgent`. You can also pin the input with `?motion=1&motionProvider=browser` or `?motion=1&motionProvider=agent`. + +Neither mode uploads camera frames or live gestures; neither reuses the news/RSS aggregation API. + +Gesture semantics: + +| Event | Effect | +| --- | --- | +| `rotate_left/right/up/down` | Rotate accordingly | +| `zoom_in/out` | Zoom | +| `focus_prev/next` | Cycle focusable targets within the current layer | +| `layer_prev/next` | Switch layer and pan to nearest target | +| `confirm` | Confirm the current selection | + +In the debug panel: the browser camera input shows the live preview with skeleton overlay; Motion Agent sends only normalized skeleton events, never raw frames. "Skeleton Only" hides the video and keeps just the skeleton; "Stop Matching" pauses gesture firing while keeping preview and skeleton. Unmatched skeleton is red; matched turns green and shows the action name. + +### Cruise Mode + +Cruise mode auto-rotates focused targets. Current modules: BGP, news, compute centers, vessels, cables, satellites. Suitable for demos, control rooms, and unattended displays. + +### Mobile + +Mobile uses a drawer layout: layer control moves into a drawer; search/settings/details use mobile panels. Main interaction is still object tap, search, and layer toggles. + +### Common Issues + +- **Earth does not open**: confirm the frontend is online; if not on port `3000`, use the port printed by the startup log +- **Layers have no data**: open `/datasources` to check source status, collected-record state, and the latest run result; then `/data` or `/bgp` for records +- **Satellites / BGP / cables load slowly**: those layers depend on backend APIs and external data sources; the first load waits for startup tasks + +## Docs Site + +Docs at `http://localhost:3000/docs` are served by the backend with access control, not bundled into the frontend build. + +Anonymous visitors see only `public` docs: README, Quickstart, Manual, FAQ, Earth Location Candidate Collection User Guide. Authenticated users with Gatekeeper groups see more: + +- `docs_user`: end-user operational docs +- `docs_developer`: Earth, frontend, backend, collectors, AI Provider development docs +- `docs_admin`: service control, operations, environment variables, sensitive operations (including the Ops Runbook) + +`admin` has `docs_admin` by default; `super_admin` has all docs permissions. Gatekeeper groups are managed at `/users`. + +Docs supports: category navigation, Markdown rendering, tables and code blocks, in-doc table of contents, search across currently visible docs, internal links between technical docs. ## Related Docs - [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md) -- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md) -- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md) -- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md) +- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md) - [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) -- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md) -- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md) +- [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) diff --git a/docs/technical/en/ops-runbook.md b/docs/technical/en/ops-runbook.md new file mode 100644 index 00000000..396d26fe --- /dev/null +++ b/docs/technical/en/ops-runbook.md @@ -0,0 +1,244 @@ +# Planet Ops Runbook + +This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting. + +## First Startup + +```bash +./planet.sh start +``` + +Default behavior: + +- Starts PostgreSQL and Redis +- Starts AI Provider +- Starts the backend API +- Starts the frontend Vite dev server +- Prints Earth, console, Playground, and backend API doc URLs + +First startup seeds two default accounts (see `DEFAULT_LOGIN_USERS` in `backend/app/db/session.py`): + +| Username | Password | Role | +| --- | --- | --- | +| `admin` | `admin123` | `super_admin` | +| `linkong` | `12345678` | `super_admin` | + +Both seed accounts are created with `email_verified = TRUE` and can log into the console immediately. Any other account must either go through the public registration flow described in the Manual, or be created via `./planet.sh createuser`. + +Specify custom ports: + +```bash +./planet.sh start -b 8001 -f 3001 -a 8101 +``` + +| Flag | Meaning | +| --- | --- | +| `-b ` | Backend port | +| `-f ` | Frontend port | +| `-a ` | AI Provider port | +| `--allow-lan` | Enable LAN access | +| `--verbose` | Show extra command output | + +## Stop and Per-Module Restart + +Stop everything: + +```bash +./planet.sh stop +``` + +Stops backend, AI Provider, frontend, PostgreSQL, Redis. + +Per-module restart: + +```bash +./planet.sh restart # full +./planet.sh restart -b # backend +./planet.sh restart -f # frontend +./planet.sh restart -a # AI Provider +./planet.sh restart -d # database +``` + +Per-module restart is preferred during development to avoid interrupting unrelated services. + +## Health Check + +```bash +./planet.sh health +``` + +Checks: + +- `planet_*` container status +- Backend `/health` +- AI Provider `/health` +- Frontend reachability + +If anything reports offline, check the corresponding logs first. + +## Logs + +Recent logs: + +```bash +./planet.sh log +``` + +Follow: + +```bash +./planet.sh log -f # frontend: /tmp/planet_frontend.log +./planet.sh log -b # backend: /tmp/planet_backend.log +./planet.sh log -a # AI Provider: planet_aiprovider container logs +``` + +## CLI User Creation + +```bash +./planet.sh createuser +``` + +Interactively prompts for username, password, and role; writes the user with `email_verified = TRUE` directly. + +Use when: + +- SMTP is not yet configured but an admin account is needed now +- Pre-seeding internal test accounts +- Public registration is unavailable for any reason and a fallback is required + +For ordinary user onboarding, configure SMTP at `/settings -> SMTP Email` first and let users self-register at `/register`. + +## LAN / WSL Access + +```bash +./planet.sh start --allow-lan +``` + +Useful for: + +- Starting in WSL, accessing from Windows browser +- Demoing Earth from a phone or tablet +- Other LAN machines reaching the same dev instance + +`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines hitting `http://:3000` still need Windows port forwarding and firewall rules. + +Diagnose in this order: + +```bash +# From the shell running Planet +curl http://localhost:3000 +curl http://localhost:8000/health +ss -ltnp | grep -E ':3000|:8000' +``` + +If WSL shows `0.0.0.0:3000` / `0.0.0.0:8000` but the LAN IP still fails, configure Windows from an elevated PowerShell: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` + +## AI Provider Environment and Builds + +AI Provider runtime configuration lives in two places: + +| Location | Best for | Notes | +| --- | --- | --- | +| `aiprovider/.env` | Team-shared local defaults | Read by Docker Compose as `env_file` | +| `~/.zshrc` | Personal provider/model/key/proxy | `planet.sh` reads common `AI_*`, `SERVICE_*`, `PYTHON_IMAGE`, `UV_IMAGE` lines | + +Recommended form: + +```bash +export AI_PROVIDER=minimax +export AI_PROVIDER_API=anthropic-messages +export AI_BASE_URL=https://api.example.com/anthropic +export AI_API_KEY=sk-change-me +export AI_MODEL=MiniMax-M2.7 +export AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +By default `planet.sh` only statically parses simple `export KEY=value` lines from `~/.zshrc`. When complex shell expansion is required, opt in explicitly: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +To ignore `~/.zshrc` entirely: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + +The AI Provider image only rebuilds when code, Dockerfile, Compose config, or Python dependencies change. After changing keys or base URL, restarting the container is enough: + +```bash +./planet.sh restart -a +``` + +Diagnose slow builds: + +| Symptom | Common cause | Fix | +| --- | --- | --- | +| Large `transferring context` | build context includes unrelated frontend / data files | `.dockerignore` ships only required files | +| `uv sync` is slow | first build or cold cache | wait for the first build; later runs reuse BuildKit cache | +| Old keys still in effect after edit | container not restarted | `./planet.sh restart -a` | + +## SMTP Email (Required for Public Registration) + +Public registration and email verification depend on SMTP. Administrators configure host, port, username, password, from-address, and TLS mode at `/settings -> SMTP Email` in the console, then use the "Send Test Email" button to verify. Settings are persisted in the `system_settings.smtp` row. + +When SMTP is unset, `POST /api/v1/auth/register` returns `503 EMAIL_PROVIDER_NOT_CONFIGURED` and the frontend surfaces a clear error. The operational fallback is `./planet.sh createuser`. + +One-time codes are stored in Redis under `otp:{purpose}:{email}` with a 600-second TTL. The key is invalidated after 5 invalid attempts. Resend cooldown is 60 seconds, enforced via `otp_rate:{purpose}:{email}`. + +## Troubleshooting Order + +```bash +./planet.sh health # 1. service state +./planet.sh log # 2. recent logs +./planet.sh log -f # 3. per-module logs +./planet.sh log -b +./planet.sh log -a +./planet.sh restart -f # 4. restart only the affected module +./planet.sh restart -b +./planet.sh restart -a +./planet.sh restart -d # 5. database / cache issues +./planet.sh restart # 6. full restart if still broken +``` + +## Development Command Conventions + +Frontend must use Bun: + +```bash +cd frontend +bun install +bun run dev +bun run build +``` + +Do not use `npm run ...`. In the WSL / Windows mixed environment Bun avoids Node/npm path inconsistencies. + +Validate the frontend build: + +```bash +source ~/.zshrc && bun run build +``` + +Backend dependencies are managed with uv: + +```bash +uv sync +uv run pytest backend/tests/test_otp_service.py +``` + +## Related Docs + +- [planet.sh Startup Mechanism](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md) +- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md) +- [Docker + Compose + Buildx Upgrade](/home/ray/dev/linkong/planet/docs/technical/en/ops-docker-compose-buildx-upgrade.md) +- [Data Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md) diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 347cffa7..a5baee29 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -1,236 +1,66 @@ # Quickstart -This guide is for developers or demo operators starting Planet for the first time. The goal is to get services running via the shortest path and know which URLs to open. +This quickstart is for Planet end users who just received an access URL and need the shortest path from "open the browser" to "first useful configuration done". Every action happens in the browser. -If you run into port conflicts, Windows / WSL LAN access, `uv` / `bun`, camera, or Docs permission issues, start with the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md). +If you are responsible for deployment or operations, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead. -## Prerequisites +## 1. Open the URL -Recommended: run in a WSL / Linux shell. +Open the URL your administrator gave you, e.g. `http://planet.example.com`. A local demo is usually `http://localhost:3000`. -You need: +Entry points are split in two: -- Docker / Docker Compose available -- `uv` and `bun` accessible in the current shell -- Repository cloned locally +- Public: `/earth` (3D situational view), `/docs` (public documentation) +- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration) -On a new machine, run the bootstrap script first: +## 2. Register -```bash -./scripts/bootstrap-dev.sh -``` +1. Open `/login` and click "Register" under the form. +2. On `/register`, fill in username, email, password (≥ 8 characters). +3. After submission, check your inbox for a 6-digit verification code (valid for 10 minutes). +4. Enter the code on the verify page and click "Verify and Sign In". You are taken to the console automatically. -This script checks and syncs common dependencies, and generates if missing: +If the email does not arrive: -- `backend/.env` -- `aiprovider/.env` -- `frontend/.env.local` +- Check spam and your enterprise mail gateway +- The "Resend Code" button has a countdown; you can resend once it ends +- A "Email service not configured" message means your administrator has not yet set up SMTP — please ping them -Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the AI Provider container. After changing model, key, or base URL, restart only AI Provider: +The default role is `viewer`: you can sign in but only see public pages. For collectors, user management, or system settings, ask the admin to promote your role or add Gatekeeper groups. -```bash -./planet.sh restart -a -``` +## 3. First Sign-In Checklist -Collector credentials such as AISStream and BarentsWatch can also start in `~/.zshrc` for connectivity validation: +After landing on the `/admin` dashboard, here's a recommended walk-through: -```bash -export AISSTREAM_API_KEY="..." -export BARENTSWATCH_CLIENT_ID="..." -export BARENTSWATCH_CLIENT_SECRET="..." -``` +1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first +2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional +3. `/datasources` or `/data`: check whether the collectors have produced data +4. `/alerts/system`: verify system alerts look right +5. `/users` (super_admin only): open accounts for teammates or adjust their groups -For actual collection, prefer saving credentials in `Settings -> Collector Settings`, especially for AISStream's long-lived WebSocket collector. That keeps connectivity validation, backend collection tasks, and Earth realtime vessel aggregation on the same configuration source. +## 4. Open Earth -## 1. Start Services - -From the repository root: - -```bash -./planet.sh start -``` - -After startup, the key URLs are: - -| Entry | Default URL | Purpose | -| --- | --- | --- | -| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization | -| Console | `http://localhost:3000/admin` | Admin console (login required) | -| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups | -| AI | `http://localhost:3000/ai` | Model provider, tool, and testbench entry (login required) | -| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs | - -If the default ports are taken, specify custom ports: - -```bash -./planet.sh start -f 3001 -b 8001 -a 8101 -``` - -If backend port `8000` is occupied by a Windows listener or an old portproxy rule, follow the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md) troubleshooting order. - -## 2. Create a Login User - -The console requires login. For first-time use: - -```bash -./planet.sh createuser -``` - -Follow the prompts to enter username, password, and role. - -To read developer or operations docs, log in as `super_admin` and assign Gatekeeper groups from the Users page. Use `docs_developer` for development docs and `docs_admin` for service-control and operations docs. - -## 3. Open Earth - -Visit: - -```text -http://localhost:3000/earth -``` - -Earth is a public page — no login required. +Visit `/earth`. This is a public page — no login required. Once in, verify: -- The globe renders correctly -- The right-side layer panel can toggle layers on/off -- Search can find cables, satellites, compute centers, BGP events -- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; when regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback; the compute-center unresolved badge can open the queue and save candidates -- Mouse drag, wheel zoom, and zoom percent feedback work correctly -- Settings panel can switch rotate / cruise / motion mode, day/night mode, and satellite display style; Motion Debug Mode can show the local Browser Camera preview plus skeleton overlay +- The globe renders, and the right-side layer panel can toggle layers +- Search finds cables, satellites, compute centers, BGP events +- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth +- Mouse drag, wheel zoom, and the zoom percentage indicator work +- The settings panel can switch rotate / cruise / motion modes -## 4. Open the Console +## 5. Recover a Lost Password -Visit: +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). -```text -http://localhost:3000/admin -``` +## 6. Read the Docs -The console manages data sources, collected data, situational observation, alerts, system logs, and configuration. - -First-time inspection checklist: - -- `/datasources`: data source directory and collection triggers; endpoint, headers, and credentials are configured under `/settings` collector settings -- `/data`: collected data -- `/bgp`: BGP situational view -- `/ai`: AI page for model providers, WebSearch-style tools, and the testbench -- `/alerts/system`: system alerts -- `/settings`: system configuration - -## 5. Check Service Health - -```bash -./planet.sh health -``` - -This shows container status and checks: - -- Backend -- AI Provider -- Frontend - -## 6. View Logs - -Recent logs: - -```bash -./planet.sh log -``` - -Follow a specific service: - -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` - -Flags: - -- `-f`: frontend logs -- `-b`: backend logs -- `-a`: AI Provider logs - -## 7. Common Restarts - -Frontend only: - -```bash -./planet.sh restart -f -``` - -Backend only: - -```bash -./planet.sh restart -b -``` - -AI Provider only: - -```bash -./planet.sh restart -a -``` - -Database only: - -```bash -./planet.sh restart -d -``` - -Full restart: - -```bash -./planet.sh restart -``` - -## 8. LAN Access - -To allow a Windows browser, phone, or another device on the same network: - -```bash -./planet.sh start --allow-lan -``` - -This makes the frontend and backend listen on a LAN-accessible address. - -Note: `--allow-lan` only makes Planet listen on `0.0.0.0`; it does not automatically expose WSL services through the Windows LAN IP. A common pattern is: - -- `localhost:3000` / `localhost:8000` works inside WSL -- `localhost:3000` / `localhost:8000` works on Windows -- `http://:3000` fails from a phone or another computer - -That usually means Windows still needs port forwarding or firewall rules. - -If access fails, check from the shell running Planet: - -```bash -curl http://localhost:3000 -curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' -``` - -If WSL is listening on `0.0.0.0:3000` and `0.0.0.0:8000` but the LAN IP still fails, configure Windows forwarding and firewall rules from an elevated PowerShell: - -```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 - -New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 -``` - -## 9. Stop Services - -```bash -./planet.sh stop -``` - -This shuts down the frontend, backend, AI Provider, PostgreSQL, and Redis. +`/docs` is the docs site. Without login you can read: this Quickstart, the Manual, the FAQ. Authenticated users with `docs_user` / `docs_developer` / `docs_admin` groups see additional technical documents. ## Next Steps -- Full usage guide: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md) -- Console structure: [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md) -- Earth structure: [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md) -- Backend collectors: [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md) +- Full UI walkthrough: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md) +- Troubleshooting and configuration questions: [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md) +- Detailed Earth coordinate candidate flow: [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) +- Deployment / operations commands: [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index eb60fd95..6afd1b66 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -336,17 +336,50 @@ AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw - 位置、速度、航向等动态字段会按 freshness 和来源优先级选择。 - 静态字段优先保留非空值;冲突候选会记录到详情接口,便于排查多源差异。 -Earth 使用的接口仍是: +Earth 船只展示现在使用受控快照接口和实时增量通道: ```http -GET /api/v1/visualization/geo/vessels +GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 GET /api/v1/visualization/vessels/{mmsi} GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts -GET /api/v1/visualization/vessels/aggregation/diagnostics ``` -`/geo/vessels` 会合并 raw observation 聚合结果和 legacy BarentsWatch latest position 结果,避免只接入 AISStream 后把历史 BarentsWatch 船只遮蔽掉。 +`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,默认 `limit=1000`,最大 `limit=5000`。它只消费 `ais_raw_observations` 聚合结果,不再读取 legacy `vessel_position` / `vessel_static` 作为兜底。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。 + +实时增量通过 `/ws` 的 `vessels` channel 推送。客户端订阅时必须带当前视口: + +```json +{ + "type": "subscribe", + "data": { + "channel": "vessels", + "bbox": [120.8, 30.7, 122.1, 31.8], + "zoom": 12, + "limit": 1000 + } +} +``` + +后端按连接保存轻量订阅条件,只向 bbox 命中的连接发送船只更新。collector 广播会先进入 1 秒节流队列,同一 MMSI 在一个 flush 周期内只保留最新位置,避免高频实时流拖垮 WebSocket。 + +### 图层接口与全量统计分离 + +Earth 后续迁移到两类接口: + +```http +GET /api/v1/data-products +GET /api/v1/data-products/{product_id}/status +GET /api/v1/layers/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/cables?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/landing-points?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/satellites?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/anomalies?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/incidents?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +``` + +`/api/v1/data-products/*` 面向聚合面板,统计口径是全量/全局,不受地图 bbox 影响。`/api/v1/layers/*` 面向地图渲染,必须携带 `bbox` 和 `zoom`,默认 `limit=1000`,最大 `limit=5000`;低 zoom 会降级到更小的返回上限,并在 `diagnostics` 中暴露 `degraded`、`truncated`、`limit_clamped` 和 `stats_scope=viewport`。当前版本的非船只图层先复用已有 GeoJSON 转换再做保护层,后续可继续把 bbox 下推到各产品专用查询。 ## 十、采集器设置与连接验证 @@ -418,4 +451,12 @@ curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \ -H "Authorization: Bearer " ``` +批量采集使用: + +```http +POST /api/v1/datasources/trigger-batch +``` + +请求体可以传 `source_ids` 精确触发选中项;如果不传 `source_ids`,后端按 `product`、`module`、`is_active`、`run_status`、`collected`、`credential_status` 和 `q` 过滤后触发。接口会跳过禁用源、正在运行且未 `force` 的源,以及未到频率窗口的源,并返回 `triggered`、`skipped`、`failed` 三组结果。 + **核心文件**: `backend/app/api/v1/datasources.py` diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index 09af46e9..708bed24 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -290,6 +290,16 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` 连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。 +连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置,WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` collector 任务处于 `streaming` / `connected`,并且 `本轮消息数`、`last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。 + +新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用: + +```http +GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 +``` + +该接口只查询本地 `ais_raw_observations` 聚合结果。实时更新走 `/ws` 的 `vessels` channel,订阅时必须提供 `bbox`、`zoom` 和 `limit`。服务端按连接过滤 bbox,并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。 + ## 自定义 REST / WebSocket 映射运行时 文件: diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 0dfb9db6..3cf13d9c 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -286,8 +286,9 @@ AIS 船只图层入口: 船只图层当前负责: -- 请求 `/api/v1/visualization/geo/vessels` -- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;请求默认不传 `limit`,后端和前端都不再默认裁剪到 5000 艘 +- 请求 `/api/v1/vessels/snapshot` 获取当前视口初始快照;请求必须携带 `bbox`、`zoom`,并传入受控 `limit` +- 通过 `/ws` 的 `vessels` channel 订阅后续增量;订阅 payload 同样必须携带当前视口 `bbox`、`zoom`、`limit` +- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;后端默认 `limit=1000`,最大 `limit=5000` - 通过 `createInteractableLayer()` 注册 Interactable 图标层 - 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker - 按船型映射颜色;`vessels.js` 会用 `vessel_type_name` 和 AIS `vessel_type` 数字共同归一化船型 @@ -310,6 +311,10 @@ AIS 船只图层入口: AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。 +旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端不再把 legacy `vessel_position` / `vessel_static` 合并进船只快照,前端也不应依赖旧表里的 BarentsWatch-only 兜底数据。 + +新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox`、`zoom` 和受控 `limit`,响应会返回 `visible_count`、`returned_count` 和 `diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products` 或 `/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。 + 船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()`。`main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成: 1. 拖动地球或惯性旋转时跳过 hover picking。 diff --git a/docs/technical/zh/frontend-admin-frontend-context.md b/docs/technical/zh/frontend-admin-frontend-context.md index a8ed40fa..1bb61b47 100644 --- a/docs/technical/zh/frontend-admin-frontend-context.md +++ b/docs/technical/zh/frontend-admin-frontend-context.md @@ -163,7 +163,25 @@ - 文档内部链接应通过 `transformLink` 转成 `/docs/:slug` - 标题锚点由 `getHeadingId` 注入,避免渲染器自己理解路由状态 -### 6. `TableActions` +### 6. `ConnectionTestInput` + +文件: + +- [ConnectionTestInput.tsx](/home/ray/dev/linkong/planet/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx) + +用途: + +- Endpoint、Base URL 这类“输入值 + 连接验证”的控制台表单项 +- AI Provider 和 WebSearch 的连接测试入口 +- 后续采集器配置如果把连接测试放进输入框,也应复用它 + +当前约束: + +- 输入框末端只显示一个插头/连接器图标,不再并排放“测试连接”文字按钮 +- 禁用的集成能力必须同时置灰输入框和连接测试按钮 +- 组件只负责输入框与测试入口组合,不保存业务状态;调用方仍负责表单值、loading、disabled 和连接请求 + +### 7. `TableActions` 文件: @@ -205,10 +223,13 @@ 职责: - `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中 -- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试 -- `工具` tab 管理 WebSearch provider、搜索 key、base URL、超时、结果数和高级 provider 参数 +- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试;provider 和模型输入使用可输入组合框,models.dev 目录停更时用户仍可手动填新 provider/model +- `工具` tab 先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR +- WebSearch 配置包含 provider、搜索 key、base URL、超时、结果数和高级 provider 参数 +- OCR 配置包含 provider、Base URL、API Key、模型/engine、语言、超时、文件大小上限和输出格式 - `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试 - 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式 +- AI Provider / WebSearch 的连接测试使用 `ConnectionTestInput`,连接器图标固定在 Base URL 输入框末端;WebSearch 未启用时,除开关外的配置项和测试入口都置灰 旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`。 旧的 `/playground` 应跳转到 `/ai?tab=playground`。 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index fff5f31c..5cc96134 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -1,543 +1,92 @@ # Planet 使用手册 -这份手册面向日常使用、演示、开发联调和本地运维。它覆盖四个核心入口: +这份手册面向 Planet 的最终用户。从打开浏览器开始,覆盖注册账号、登录、配置数据采集器、配置 AI、使用 Earth 和控制台、阅读文档站。所有操作都在浏览器里完成。 -- `planet.sh`:本地启动、停止、重启、健康检查和日志入口 -- Earth:公开 3D 地球态势页面 -- 控制台:登录后的管理后台 -- Docs:后端 Gatekeeper 受控的文档站,基础使用文档公开,开发/运维文档按权限组开放 - -快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。常见排障见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。 +如果你是负责部署或值班的运维,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md),里面是 shell 命令、日志位置、SMTP 兜底创建用户等内容。 ## 入口总览 -默认启动后,常用地址如下: - | 名称 | 地址 | 是否需要登录 | 说明 | | --- | --- | --- | --- | -| Earth | `http://localhost:3000/earth` | 否 | 3D 地球、图层、BGP、卫星、海缆、新闻态势 | -| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 | -| FAQ | `http://localhost:3000/docs/faq` | 否 | Windows / WSL、端口、依赖、动捕、凭证和权限排障 | -| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 | -| AI | `http://localhost:3000/ai` | 是 | 模型供应商、AI 工具和测试台 | -| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 | +| Earth | `http://<域名>/earth` | 否 | 公开 3D 地球态势页面 | +| Docs | `http://<域名>/docs` | 部分需要 | 公共文档免登录,开发/运维文档按 Gatekeeper 权限组开放 | +| 注册 / 登录 / 找回密码 | `/register`、`/login`、`/forgot-password` | 否 | 自助开通和恢复账号 | +| 控制台 | `http://<域名>/admin` | 是 | 数据、采集器、告警、AI、用户、设置 | +| AI | `http://<域名>/ai` | 是 | 模型供应商、工具和测试台 | +| 后端 API 文档 | `http://<域名>:8000/docs` | 视接口而定 | FastAPI / OpenAPI | -## planet.sh +下面所有 URL 都基于本机演示的默认地址 `http://localhost:3000`,部署到正式环境时把前缀换成你们的访问域名即可。 -`planet.sh` 是本地开发和演示的主控脚本。优先使用它管理服务,而不是手动分别启动前端、后端、数据库和 AI Provider。 +## 注册账号 -### 启动 +1. 打开 `http://localhost:3000/login`,点击表单下方"注册账户"。 +2. 在 `/register` 填写: + - **用户名**:3–50 位字符,登录时使用 + - **邮箱**:用于接收验证码,可在账户设置中修改 + - **密码**:至少 8 位 +3. 提交后会跳到验证页,已将 6 位验证码发到你的邮箱。10 分钟内有效。 +4. 输入验证码,点击"验证并登录"。验证通过后系统会自动写入登录态并跳到控制台。 -```bash -./planet.sh start -``` +如果 60 秒内没收到邮件: -默认行为: +- 检查垃圾邮件、订阅推广、企业邮件网关 +- 验证页右下角的"重新发送验证码"会显示 60 秒倒计时,倒计时结束后可重发 +- 连续输错 5 次后该验证码会失效,需要重发新码 -- 启动 PostgreSQL 和 Redis -- 启动 AI Provider -- 启动后端 API -- 启动前端 Vite dev server -- 输出 Earth、控制台、Playground 和后端 API 文档入口 +如果系统提示"邮件服务尚未配置",说明管理员还没填 SMTP,请联系管理员开通 SMTP 或在控制台 `/settings -> SMTP 邮件` 完成配置。 -可指定端口: +默认注册角色为 `viewer`,可以登录控制台浏览公共内容。要看采集器、用户管理、系统设置等管理类页面,需要 `admin` 或 `super_admin` 在 `/users` 给你升角色。 -```bash -./planet.sh start -b 8001 -f 3001 -a 8101 -``` +## 登录与找回密码 -参数含义: +### 登录 -| 参数 | 含义 | -| --- | --- | -| `-b ` | 后端端口 | -| `-f ` | 前端端口 | -| `-a ` | AI Provider 端口 | -| `--allow-lan` | 允许局域网访问 | -| `--verbose` | 在执行过程中显示更多命令输出 | +打开 `/login`,输入用户名和密码即可。登录成功后跳到 `/admin`。 -### AI Provider 环境变量和构建 +如果提示"邮箱未验证",页面会自动跳到 `/verify-email`,按提示输入验证码完成验证。 -AI Provider 的运行期配置可以放在两处: +### 忘记密码 -| 位置 | 适合内容 | 说明 | -| --- | --- | --- | -| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 会作为 `env_file` 读取 | -| `~/.zshrc` | 个人机器上的 provider、模型、密钥和代理变量 | `planet.sh` 启动时会读取常见的 `AI_*`、`SERVICE_*`、`PYTHON_IMAGE`、`UV_IMAGE`、代理变量 | +1. 在 `/login` 点击"忘记密码?",或直接打开 `/forgot-password`。 +2. 输入注册邮箱,点击"发送验证码"。无论邮箱是否注册,页面都会显示同一句提示(避免账号枚举)。 +3. 收到验证码后,在下一步填入验证码 + 新密码(至少 8 位),点击"重置密码"。 +4. 系统会跳回 `/login`,用新密码登录即可。 -推荐写法: +## 账户设置 -```bash -export AI_PROVIDER=minimax -export AI_PROVIDER_API=anthropic-messages -export AI_BASE_URL=https://api.example.com/anthropic -export AI_API_KEY=sk-change-me -export AI_MODEL=MiniMax-M2.7 -export AI_PROVIDER_SERVICE_TOKEN=change_me -``` +控制台右上角点击你的用户名进入账户设置,可以: -默认情况下,`planet.sh` 只静态解析 `~/.zshrc` 中简单的 `export KEY=value` 或 `KEY=value` 行,避免 shell 主题、插件或交互初始化拖慢启动。如果变量依赖复杂 shell 展开,可以显式启用 source 模式: +- 修改密码:输入当前密码 + 新密码 +- 修改邮箱:输入新邮箱后系统会发验证码到新地址,验证通过后才生效 +- 查看权限组:列出你目前拥有的 Gatekeeper 权限组(`docs_user` / `docs_developer` / `docs_admin`) +- 登出:清除当前会话 -```bash -PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a -``` +## 控制台总览 -如需完全忽略 `~/.zshrc`: - -```bash -PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a -``` - -AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改 `aiprovider/.env` 或 `~/.zshrc` 中的模型、密钥、Base URL 不会触发镜像重建;重启 AI Provider 即可让容器读取新配置: - -```bash -./planet.sh restart -a -``` - -构建较慢时,优先判断当前卡在哪一层: - -| 现象 | 常见原因 | 处理方式 | -| --- | --- | --- | -| `transferring context` 很大 | Docker build context 包含前端资源、PDF、数据目录等无关文件 | 当前仓库通过 `.dockerignore` 只发送 AI Provider 必需文件 | -| `uv sync` 下载依赖较慢 | 首次构建或缓存为空,网络访问 Python 包较慢 | 等待首次构建完成;后续会复用 BuildKit 的 uv 下载缓存 | -| 改密钥后仍显示旧配置 | 容器尚未重启 | 执行 `./planet.sh restart -a` | - -### 停止 - -```bash -./planet.sh stop -``` - -会停止: - -- 后端 -- AI Provider -- 前端 -- PostgreSQL -- Redis - -### 重启 - -全量重启: - -```bash -./planet.sh restart -``` - -按模块重启: - -```bash -./planet.sh restart -b -./planet.sh restart -f -./planet.sh restart -a -./planet.sh restart -d -``` - -| 参数 | 作用 | -| --- | --- | -| `-b` | 只重启后端 | -| `-f` | 只重启前端 | -| `-a` | 只重启 AI Provider | -| `-d` | 只重启数据库 | - -按模块重启适合日常开发,能避免无关服务被打断。 - -### 创建用户 - -```bash -./planet.sh createuser -``` - -用于首次进入控制台前创建登录账号。脚本会交互式提示用户名、密码和角色。 - -### 健康检查 - -```bash -./planet.sh health -``` - -会检查: - -- `planet_*` 容器状态 -- 后端 `/health` -- AI Provider `/health` -- 前端页面可达性 - -如果某项显示 offline,优先查看对应日志。 - -### 日志 - -最近日志: - -```bash -./planet.sh log -``` - -持续跟随日志: - -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` - -| 参数 | 日志来源 | -| --- | --- | -| `-f` / `--frontend` | `/tmp/planet_frontend.log` | -| `-b` / `--backend` | `/tmp/planet_backend.log` | -| `-a` / `--ai-provider` | `planet_aiprovider` 容器日志 | - -### 局域网访问 - -```bash -./planet.sh start --allow-lan -``` - -适合: - -- WSL 中启动,Windows 浏览器访问 -- 手机或平板演示 Earth -- 局域网其他机器访问同一个开发实例 - -`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。如果服务运行在 WSL 中,Windows 本机通常可以通过 `localhost` 访问,但手机或其他电脑访问 `http://:3000` 还依赖 Windows 端口转发和防火墙放行。 - -推荐按顺序判断: - -```bash -# 在 WSL 或运行 Planet 的 shell 中 -curl http://localhost:3000 -curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' -``` - -如果这里能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 访问失败,请在管理员 PowerShell 中配置: - -```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 - -New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 -``` - -## Earth - -Earth 是公开的 3D 态势页面,入口: - -```text -http://localhost:3000/earth -``` - -它是独立前端,实际页面位于: - -- `frontend/public/earth/index.html` -- `frontend/public/earth/js/` -- `frontend/public/earth/css/` - -React 路由中的 `/earth` 只是用 iframe 承载它。 - -### 主要用途 - -Earth 用于在一个地球视图中观察: - -- BGP 事件、异常和观测态势 -- 卫星和轨迹 -- 海缆与登陆点 -- 算力中心 -- 国界线、经纬线、高清材质、云图、地形 -- 新闻直播和态势新闻 -- 搜索和聚焦对象详情 - -### 图层控制 - -右侧图层面板用于打开或关闭可视图层。 - -常见图层包括: - -- 经纬线 -- 国界线 -- 高清材质 -- 大气云图 -- 海缆 -- 算力中心 -- BGP 观测 -- 卫星 -- AIS 船只 -- 轨迹 -- 地形 - -部分图层存在依赖关系: - -- 地形依赖高清材质 -- 轨迹依赖卫星 -- 高清材质关闭时,地球会显示基座地图和边缘识别效果 - -### 图例 - -左下角图例会跟随当前聚焦或启用的图层切换。 - -当前已覆盖: - -- 海缆 -- 卫星 -- 国界线 -- 算力中心 -- BGP -- AIS 船只 - -AIS 船只图例按船型显示颜色: - -- 货轮 -- 油轮 -- 客船 -- 渔船 -- 军舰 -- 停泊/低速 -- 其他船只 - -船只图例中的三角形对应地图上的航行船只标记,圆点对应停泊或低速状态。 - -### 搜索 - -Earth 搜索支持查找当前地球对象,例如: - -- 海缆 -- 登陆点 -- 卫星 -- 算力中心 -- BGP 事件 -- BGP 观测站 - -搜索结果可以用于快速定位对象,并打开对应详情。 - -### 位置候选采集 - -算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后,使用详情卡中的 `自动采集坐标候选` 或 `重新自动采集坐标` 按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选位置;这些常规来源没有候选时,会使用当前默认 AI Provider 做一次 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。 - -候选可以直接在 Earth 上预览。算力中心候选点击 `保存` 后会写入 `compute_center_locations` 维表,并立即刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击后可查看列表,单条采集候选,或用 `一键采用` 从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。 - -### 设置 - -设置面板包含: - -- 旋转模式 / 巡航模式 / 动捕模式 -- 巡航模块:BGP、新闻、算力中心、船只、海缆、卫星 -- 视图设置:卫星显示风格、日夜模式、面板显示开关 -- 动捕调试模式、动捕输入源、只显示骨骼 -- 地球默认大小 -- 地形透明度 -- 重置设置 - -这些设置会保存在浏览器本地存储中。换浏览器或清理站点数据后会恢复默认值。 - -### 视角控制 - -Earth 支持鼠标、触控板和触屏操作。 - -常用控制方式: - -| 操作 | 作用 | -| --- | --- | -| 鼠标左键拖动 | 旋转地球 | -| 手指单指拖动 | 在触屏设备上旋转地球 | -| 鼠标滚轮 | 放大或缩小视角 | -| 双指捏合 | 在触屏设备上放大或缩小视角 | -| 缩放按钮 | 按固定步长调整缩放 | -| 点击缩放百分比 | 重置到默认缩放 | - -缩放时,顶部胶囊会短暂显示当前缩放比例,例如 `缩放 180%`。这个提示只表示当前视角缩放,不代表数据加载进度;如果页面正在加载数据,加载提示优先显示,缩放提示不会打断加载状态。 - -拖动灵敏度会根据当前缩放自动调整。默认视角附近保持常规旋转速度;放大后拖动会逐步变细,适合检查某个区域、船只、卫星或 BGP 事件;缩小后拖动会略快,方便快速浏览全球态势。 - -### 动作捕捉控制 - -Earth 预留了动作捕捉控制入口,面向大屏和未来 3D 展示。实时链路有两种输入源:默认的 `浏览器摄像头` 会直接用网页 `getUserMedia` 在本机浏览器识别;高级的 `Motion Agent` 会走 `摄像头/RTSP/HTTP -> 本地 Agent -> 本地 WebSocket -> Earth 页面`。两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。 - -默认不自动启用。打开设置里的 `动捕调试模式`,或用 `?motion=1` 打开 Earth 动捕连接后,系统会启动当前选择的输入源。输入源默认是 `浏览器摄像头`,无需安装应用,但页面必须运行在 HTTPS 或 localhost,且用户需要允许浏览器摄像头权限。需要双摄、USB index、手机/网络摄像头流或客户端/边缘设备时,可在设置中切到 `Motion Agent`。默认 Agent 地址是 `ws://127.0.0.1:8765/ws/gestures`,也可用 URL 参数 `motionAgent` 覆盖。 - -URL 参数也可以直接指定输入源:`?motion=1&motionProvider=browser` 使用浏览器摄像头;`?motion=1&motionProvider=agent` 使用 Motion Agent;传入 `motionAgent=ws://...` 时会自动选择 Motion Agent。 - -当前手势语义: - -| 手势事件 | 作用 | -| --- | --- | -| `rotate_left` | 地球向左旋转 | -| `rotate_right` | 地球向右旋转 | -| `rotate_up` | 地球向上旋转 | -| `rotate_down` | 地球向下旋转 | -| `zoom_in` | 放大视角 | -| `zoom_out` | 缩小视角 | -| `focus_prev` / `focus_next` | 在当前动捕图层内切换可交互目标 | -| `layer_prev` / `layer_next` | 切换动捕候选图层,并巡航到新图层最近目标 | -| `confirm` | 确认当前已选目标;当前浏览器识别暂未启用双手上举确认 | - -设置面板中的 `动捕调试模式` 会打开调试面板。浏览器摄像头输入源会在面板内显示本机实时预览,并在其上绘制关节点和连线;`Motion Agent` 输入源只发送归一化骨架事件,不发送原始视频帧。面板里的 `只显示骨骼` 会隐藏视频预览、只保留深色背景和骨架;`停止匹配动作` 会暂停手势触发,但摄像头预览和骨架绘制仍可继续用于调试。未匹配动作时骨架为红色,匹配后变绿并显示当前动作名称。该入口和 `动捕输入源` 控件都已预留 Gatekeeper 权限标记,后续可接入鉴权控制。 - -### 巡航模式 - -巡航模式会让 Earth 自动轮播聚焦目标。 - -当前巡航模块包括: - -- BGP -- 新闻 -- 算力中心 -- 船只 -- 海缆 -- 卫星 - -适合演示、监控大屏或无人值守展示。 - -### 移动端 - -Earth 有移动端抽屉布局。小屏下: - -- 图层控制进入移动抽屉 -- 搜索、设置、详情会使用移动端面板 -- 主要交互仍围绕地球对象点击、搜索和图层开关 - -### 常见问题 - -#### Earth 打不开 - -先检查前端是否在线: - -```bash -./planet.sh health -./planet.sh log -f -``` - -如果前端端口不是 `3000`,使用启动时输出的实际端口。 - -#### 图层没有数据 - -检查后端和数据源: - -```bash -./planet.sh health -./planet.sh log -b -``` - -然后进入控制台查看: - -- `/datasources` -- `/data` -- `/bgp` - -#### 卫星、BGP 或海缆加载慢 - -这些图层可能依赖后端接口、外部数据源或首次加载任务。先等待启动任务完成,再查看日志和控制台数据源状态。 - -## 控制台 - -控制台入口: - -```text -http://localhost:3000/admin -``` - -控制台需要登录。首次使用先创建用户: - -```bash -./planet.sh createuser -``` - -### 页面结构 - -控制台使用 React + Ant Design,左侧菜单按工作域组织。 - -常见入口: +控制台 `http://localhost:3000/admin` 使用 React + Ant Design,左侧菜单按工作域组织。 | 页面 | 路由 | 用途 | | --- | --- | --- | | 仪表盘 | `/admin` | 系统概览 | -| Earth | `/earth` | 打开公开 Earth 页面 | -| 数据源 | `/datasources` | 查看数据源和触发采集 | -| 采集数据 | `/data` | 查看采集后的数据 | -| BGP 观测 | `/bgp` | 查看 BGP 专题数据 | +| Earth | `/earth` | 跳到公开 Earth 页面 | +| 数据源 | `/datasources` | 数据源目录、触发采集 | +| 采集数据 | `/data` | 已落库的数据 | +| BGP 观测 | `/bgp` | BGP 专题观测 | | 系统告警 | `/alerts/system` | 系统级告警 | | BGP 告警 | `/alerts/bgp` | BGP 相关告警 | | 态势告警 | `/alerts/situational` | 态势研判告警 | -| AI | `/ai` | 模型供应商、WebSearch 等工具和测试台 | -| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 | -| 用户管理 | `/users` | 管理用户 | -| 系统配置 | `/settings` | 系统配置和电视直播源等设置 | +| AI | `/ai` | 模型供应商、工具、测试台 | +| 系统日志 | `/logs` | 通常仅 super admin 可见 | +| 用户管理 | `/users` | 创建/删除/改角色/调权限组 | +| 系统配置 | `/settings` | 系统、SMTP、TV、采集器设置 | -### 数据源 +权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。 -`/datasources` 用于查看采集来源和触发采集。当前页面是“数据源目录”,会把内置数据源和自定义数据源放在同一张列表里展示。 +## 配置数据采集器 -常见操作: +`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。 -- 查看数据源状态 -- 触发采集 -- 查看最近采集任务 -- 打开详情抽屉查看 endpoint、请求头、基础配置和是否为内置数据源 - -如果 Earth 上某类对象缺失,通常先到这里确认数据源是否可用。 - -数据源列表中的名称点击后只打开信息抽屉,不再承担编辑入口。接口地址、凭证、请求头和自定义数据源配置统一到 `/settings` 的“采集器设置”里维护。 - -当有采集任务正在运行时,总体进度下方会出现 `采集中 N` 标签。这个标签和其他状态标签放在同一排,但带有可点击样式;点击后会弹出当前采集中任务列表,显示每个任务的阶段、进度和处理数量。 - -### 采集数据 - -`/data` 用于查看采集后的数据表。 - -适合排查: - -- 数据是否已经进入系统 -- 数据更新时间是否符合预期 -- 某个数据源是否产出了有效记录 - -### BGP 观测 - -`/bgp` 是 BGP 专题页面。 - -它和 Earth 的 BGP 图层互补: - -- Earth 强调空间态势和可视聚焦 -- 控制台 BGP 页面强调列表、状态、详情和研判 - -### 告警 - -告警入口包括: - -- `/alerts/system` -- `/alerts/bgp` -- `/alerts/situational` - -用于查看系统、网络和态势相关告警。 - -### 系统配置 - -`/settings` 用于管理系统级配置。 - -当前常见用途包括: - -- 系统设置 -- 电视直播源配置 -- 采集器设置 - -### AI - -`/ai` 用于管理 AI 运行链路,已经从系统配置中独立出来。旧链接 `/playground` 会跳转到 `/ai?tab=playground`。 - -当前包含: - -- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试 -- `工具`:WebSearch provider、搜索 API Key、Base URL、最大结果数、超时和高级 provider 参数 -- `测试台`:AI Provider 状态、预设请求和真实分析链路调试 - -旧链接 `/settings?tab=ai` 会跳转到 `/ai?tab=providers`。 - -具体可用配置取决于当前登录用户权限。 - -#### 采集器设置 - -`/settings?tab=collector_credentials` 当前显示为“采集器设置”。这里统一维护所有采集器的连接配置,而不是只维护凭证。 - -使用方式: +操作步骤: 1. 在下拉框选择采集器。 2. 查看状态标签: @@ -545,167 +94,242 @@ http://localhost:3000/admin - 所属模块 - `启用` / `禁用` - `未检查` / `可用` / `不可用` -3. 点击下拉框右侧的插头图标执行健康检查。 -4. 如果检查通过,状态会变为 `可用`。 -5. 修改 endpoint、请求头、超时或重试次数后保存。 +3. 点击下拉框右侧的插头图标执行健康检查。检查通过状态变为 `可用`。 +4. 修改 endpoint、请求头、超时、重试次数等,然后保存。 -对于免费且不需要凭证的采集器,连接检查会直接请求对应 endpoint。对于需要凭证的采集器,连接检查会走对应凭证链路;如果凭证或 endpoint 相比上次验证成功时发生变化,需要重新点击连接。 +对于免费且不需要凭证的采集器,连接检查直接打 endpoint;对于需要凭证的采集器走对应凭证链路。如果凭证或 endpoint 相比上次验证成功时发生变化,需要重新点击连接。 -系统判断“已连接”的条件是: +系统判断"已连接"的条件: -- 当前配置已经成功采集过数据;或 -- 当前配置已经点击过连接按钮并验证成功。 +- 当前配置已经成功采集过数据,或 +- 当前配置已经点击过连接按钮并验证成功 -#### BarentsWatch AIS 凭证 +### BarentsWatch AIS 凭证 -`BarentsWatch AIS` 是需要凭证的内置采集器。选择该采集器后,凭证区域会显示在基础配置上方。 - -配置项: +`BarentsWatch AIS` 是需要凭证的内置采集器。选择该采集器后,凭证区域显示在基础配置上方: - `Client ID` - `Client Secret` - `Endpoint` -如果已经配置过 secret,输入框会显示脱敏预览。保存时如果保持这个脱敏预览不变,系统会保留原 secret;只有输入新的 secret 才会替换。 - -BarentsWatch AIS 支持从以下位置读取凭证: - -1. 控制台采集器设置中保存的凭证。 -2. 后端环境变量: - - `BARENTSWATCH_CLIENT_ID` - - `BARENTSWATCH_CLIENT_SECRET` - - 兼容历史拼写:`BARRENTSWATCH_CLIENT_ID`、`BARRENTSWATCH_CLIENT_SECRET` -3. `~/.zshrc` 中的同名 `export`。 +如果已配置过 secret,输入框会显示脱敏预览。保存时保持脱敏预览不变就会保留原 secret;只有输入新的 secret 才会替换。 如果连接失败,页面会弹出凭证获取教程。教程支持: -- 查看默认教程。 -- 点击“教程不好用”让 AI Provider 根据默认 prompt 重新生成教程。 -- 点击“重置”恢复默认教程。 +- 查看默认教程 +- 点击"教程不好用"让 AI Provider 用默认 prompt 重新生成 +- 点击"重置"恢复默认教程 -默认教程以 BarentsWatch 官方 tutorial 为准,并提醒 Live AIS 应选择 `AIS - API`,不是普通 `BarentsWatch - API`。 +默认教程以 BarentsWatch 官方 tutorial 为准,并提醒 Live AIS 应选择 `AIS - API`。 -### 系统日志 +### AISStream 实时船舶 -`/logs` 用于查看系统日志。若菜单中不可见,通常是当前用户角色没有权限。 +`AISStream 实时船舶` 是全球 AIS WebSocket 采集器。连接测试通过只说明 API Key 和 endpoint 格式可用;真正的全球船只数据来自后台 `aisstream_vessels` collector 长连接运行并写入 `ais_raw_observations`。 -排查问题时常用组合: +操作步骤: -```bash -./planet.sh health -./planet.sh log -``` +1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels` +2. 在 `AISStream 凭证` 填入 API Key +3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream` +4. 点击插头图标进行连接测试,确认显示 `可用` +5. 保存采集器设置 +6. 到采集调度入口运行 `aisstream_vessels` collector +7. 在 `AISStream 运行状态` 中观察: + - `streaming` / `connected` 表示正在接收实时流 + - `本轮消息数` 应持续增长 + - 如果显示 `disconnected` 且错误为 `ConnectionResetError`,需要重新触发或等待重连 -再进入 `/logs` 查看更结构化的运行信息。 +## 配置 AI 凭证 -## Docs +`/ai?tab=providers` 是 AI 模型管理入口。包含两个核心子 tab: -文档站入口: +- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试 +- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR -```text -http://localhost:3000/docs -``` +### 模型供应商 -Docs 正文由后端 API 按权限读取,不再把全部 Markdown 直接打进前端构建产物。当前文档源文件仍位于: +provider 和模型既可选预设也可直接输入自定义 id/name。常用字段: -```text -docs/technical/zh/*.md -docs/technical/en/*.md -``` +- Provider:例如 `minimax`、`openai`、`anthropic`、`ollama` +- 协议适配:`OpenAI Chat Completions` / `Anthropic Messages` / `Ollama Generate` +- Base URL:模型 API 地址 +- 默认模型:例如 `gpt-5.1`、`MiniMax-M2.7` +- API Key:填入后保存;保存的 Key 在 UI 中显示为脱敏预览 +- Max Tokens、Anthropic Version:可保持默认 +- Timeout / Retry:超时和重试次数 -未登录访客默认只能看到 `public` 文档,例如首页、快速开始和使用手册。登录用户如果被分配 Gatekeeper 权限组,可以看到更多技术文档: +Base URL 输入框尾端的插头图标会触发连接测试。测试通过会显示当前模型返回的简短回复。 -- `docs_user`:用户操作类文档。 -- `docs_developer`:Earth、前端、后端、采集器和 AI Provider 等开发文档。 -- `docs_admin`:服务控制、运维、环境变量和敏感操作文档。 +### 工具 -`admin` 默认拥有管理文档权限,`super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在控制台“用户管理”中配置。 +- **WebSearch**:provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰 +- **OCR**:provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式 -Docs 支持: +旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。 -- 分类导航 -- Markdown 渲染 -- 表格和代码块 -- 文档内目录 -- 对当前可见文档搜索 -- technical 文档之间的内部链接跳转 +## 系统设置 -如果新增 technical 文档,应同步检查: +`/settings` 用于管理系统级配置,常用子 tab: -- 是否有清晰的一级标题 -- 是否需要加入后端 Docs metadata 的人工分类和排序 -- 应归入 `public`、`docs_user`、`docs_developer` 还是 `docs_admin` +- **系统显示**:系统名称、刷新间隔、数据保留天数、最大并发任务 +- **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知 +- **安全策略**:会话超时、最大登录尝试、密码策略 +- **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见) +- **电视直播**:电视直播源管理 +- **AI / WebSearch / OCR**:见上节 -## 开发命令约定 +### SMTP 邮件设置 -前端命令必须使用 Bun: +公开注册和验证码功能依赖这一项。`admin` 或 `super_admin` 用户在 `/settings` 进入 `SMTP 邮件` 子 tab,填: -```bash -cd frontend -bun install -bun run dev -bun run build -``` +- SMTP 主机、端口 +- 账号、密码 +- 发件地址(必填)、发件人名称 +- STARTTLS(端口 587 常用) 或 隐式 TLS(端口 465) +- 超时秒数 -不要使用 `npm run ...`。项目在 WSL / Windows 混合环境中优先依赖 Bun,避免 Node/npm 路径差异带来的兼容问题。 +填完保存,再点"发送测试邮件"按钮,输入收件人地址试发一封。测试通过后即可让普通用户走 `/register` 自助注册。 -验证前端构建: +如果保留密码字段中的脱敏预览不动,保存时不会覆盖原密码;要换密码就输入新值。 -```bash -source ~/.zshrc && bun run build -``` +## 用户管理(管理员) -## 故障排查顺序 +`/users` 仅 `super_admin` 可创建/删除用户。该页支持: -遇到问题时,建议按这个顺序排查: +- 查看用户列表(用户名、邮箱、角色、是否激活、邮箱是否已验证) +- 创建用户:与公开注册等价,但跳过邮箱验证(管理员认账) +- 修改角色:`viewer` / `operator` / `admin` / `super_admin` +- 调整 Gatekeeper 权限组:`docs_user` / `docs_developer` / `docs_admin`,影响 Docs 站可见文档范围 +- 禁用 / 启用账号 -1. 看服务状态: +要让普通用户能看开发或运维文档,进 `/users` 给他加 `docs_developer` 或 `docs_admin`。 -```bash -./planet.sh health -``` +## 数据探索 -2. 看最近日志: +- `/datasources`:数据源目录。可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“采集当前筛选”只触发当前筛选范围。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 +- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" +- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 +- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警 -```bash -./planet.sh log -``` +## AI 测试台 -3. 按模块查看日志: +`/ai?tab=playground` 用于真实分析链路调试。可以: -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` +- 选择当前 provider +- 用预设请求或自定义 prompt 触发分析 +- 观察 AI Provider 状态和返回内容 -4. 只重启有问题的模块: +旧链接 `/playground` 会跳到这里。 -```bash -./planet.sh restart -f -./planet.sh restart -b -./planet.sh restart -a -``` +## Earth 公开页面 -5. 如果数据库或缓存异常,再重启数据库: +Earth `http://localhost:3000/earth` 是公开 3D 态势页面,不需要登录。React 路由中的 `/earth` 用 iframe 承载独立前端(位于 `frontend/public/earth/`)。 -```bash -./planet.sh restart -d -``` +### 主要用途 -6. 仍无法恢复时,执行全量重启: +在一个地球视图中观察:BGP 事件与观测态势、卫星和轨迹、海缆与登陆点、算力中心、国界线/经纬线/高清材质/云图/地形、新闻直播和态势新闻、搜索和聚焦对象详情。 -```bash -./planet.sh restart -``` +### 图层控制 + +右侧图层面板用于打开或关闭图层。常见图层:经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、轨迹、地形。 + +依赖关系: + +- 地形依赖高清材质 +- 轨迹依赖卫星 +- 高清材质关闭时,地球显示基座地图和边缘识别效果 + +### 图例 + +左下角图例会跟随当前聚焦或启用的图层切换。已覆盖:海缆、卫星、国界线、算力中心、BGP、AIS 船只。 + +AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军舰、停泊/低速、其他船只。三角形对应航行船只标记,圆点对应停泊或低速状态。 + +### 搜索 + +支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。 + +### 位置候选采集 + +算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后用"自动采集坐标候选"或"重新自动采集坐标"按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选;常规来源没有候选时使用当前默认 AI Provider 做 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。 + +候选可以直接在 Earth 预览。算力中心候选点击"保存"后写入 `compute_center_locations` 维表并刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击查看列表,单条采集候选,或用"一键采用"从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。 + +### 设置 + +设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 + +这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。 + +### 视角控制 + +| 操作 | 作用 | +| --- | --- | +| 鼠标左键拖动 | 旋转地球 | +| 手指单指拖动 | 触屏旋转地球 | +| 鼠标滚轮 | 放大或缩小 | +| 双指捏合 | 触屏放大或缩小 | +| 缩放按钮 | 固定步长调整缩放 | +| 点击缩放百分比 | 重置到默认缩放 | + +缩放时顶部胶囊会短暂显示当前缩放比例。这个提示不代表数据加载进度;如果页面正在加载数据,加载提示优先显示。 + +拖动灵敏度会根据当前缩放自动调整:默认视角附近保持常规旋转速度;放大后拖动会逐步变细,缩小后拖动会略快。 + +### 动作捕捉控制 + +Earth 预留了动作捕捉控制入口。实时链路两种输入源: + +- **浏览器摄像头**(默认):直接用网页 `getUserMedia` 在本机浏览器识别;无需安装应用,但页面必须运行在 HTTPS 或 localhost,且需允许浏览器摄像头权限 +- **Motion Agent**:摄像头/RTSP/HTTP → 本地 Agent → 本地 WebSocket → Earth 页面;用于双摄、USB index、手机/网络摄像头流 + +打开方式:设置中开启"动捕调试模式",或加 URL 参数 `?motion=1` 打开 Earth 动捕连接。Motion Agent 默认地址 `ws://127.0.0.1:8765/ws/gestures`,可用 `motionAgent` URL 参数覆盖。也可以直接 `?motion=1&motionProvider=browser` 或 `?motion=1&motionProvider=agent`。 + +两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。 + +手势语义: + +| 手势事件 | 作用 | +| --- | --- | +| `rotate_left/right/up/down` | 地球向对应方向旋转 | +| `zoom_in/out` | 放大或缩小视角 | +| `focus_prev/next` | 在当前动捕图层内切换可交互目标 | +| `layer_prev/next` | 切换动捕候选图层并巡航到新图层最近目标 | +| `confirm` | 确认当前已选目标 | + +调试面板中:浏览器摄像头输入会显示本机实时预览并绘制关节点和连线;Motion Agent 只发归一化骨架事件,不发原始帧。"只显示骨骼"会隐藏视频预览只保留骨架;"停止匹配动作"会暂停手势触发但保留预览和骨架。未匹配时骨架红色,匹配后变绿并显示动作名称。 + +### 巡航模式 + +巡航模式让 Earth 自动轮播聚焦目标。当前巡航模块:BGP、新闻、算力中心、船只、海缆、卫星。适合演示、监控大屏或无人值守。 + +### 移动端 + +移动端抽屉布局:图层控制进入移动抽屉,搜索、设置、详情使用移动端面板。主要交互仍围绕地球对象点击、搜索和图层开关。 + +### 常见问题 + +- **Earth 打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口 +- **图层没有数据**:进 `/datasources` 看数据源状态、是否已采集和最近执行结果,再到 `/data` 或 `/bgp` 看是否有记录 +- **卫星 / BGP / 海缆加载慢**:这些图层依赖后端接口和外部数据源,首次加载需要等启动任务完成 + +## Docs 文档站 + +文档站 `http://localhost:3000/docs` 由后端按权限读取,不再把全部 Markdown 直接打进前端构建产物。 + +未登录访客默认只能看到 `public` 文档:首页、快速开始、使用手册、常见问题、Earth 位置候选采集使用手册。登录用户被分配 Gatekeeper 权限组后可以看到更多技术文档: + +- `docs_user`:用户操作类文档 +- `docs_developer`:Earth、前端、后端、采集器和 AI Provider 等开发文档 +- `docs_admin`:服务控制、运维、环境变量和敏感操作文档(包括运维手册) + +`admin` 默认拥有 `docs_admin`,`super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在"用户管理"中配置。 + +Docs 支持分类导航、Markdown 渲染、表格和代码块、文档内目录、对当前可见文档搜索、technical 文档间内部链接跳转。 ## 相关文档 - [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md) -- [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md) -- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md) -- [Earth 图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md) +- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md) - [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md) -- [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md) -- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md) -- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md) +- [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md) diff --git a/docs/technical/zh/ops-runbook.md b/docs/technical/zh/ops-runbook.md new file mode 100644 index 00000000..9e75cc4d --- /dev/null +++ b/docs/technical/zh/ops-runbook.md @@ -0,0 +1,244 @@ +# Planet 运维手册 + +这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。 + +## 首次启动 + +```bash +./planet.sh start +``` + +默认行为: + +- 启动 PostgreSQL 和 Redis +- 启动 AI Provider +- 启动后端 API +- 启动前端 Vite dev server +- 输出 Earth、控制台、Playground 和后端 API 文档入口 + +首次启动会自动写入两个默认账号(见 `backend/app/db/session.py` 的 `DEFAULT_LOGIN_USERS`): + +| 用户名 | 密码 | 角色 | +| --- | --- | --- | +| `admin` | `admin123` | `super_admin` | +| `linkong` | `12345678` | `super_admin` | + +两个默认账号 `email_verified` 为 `true`,可直接登录控制台。任何在该列表之外的账号都必须走公开注册 + 邮箱验证流程(见使用手册),或用 `./planet.sh createuser` 命令式创建。 + +可指定端口: + +```bash +./planet.sh start -b 8001 -f 3001 -a 8101 +``` + +| 参数 | 含义 | +| --- | --- | +| `-b ` | 后端端口 | +| `-f ` | 前端端口 | +| `-a ` | AI Provider 端口 | +| `--allow-lan` | 允许局域网访问 | +| `--verbose` | 显示更多命令输出 | + +## 启停与按模块重启 + +停止全部: + +```bash +./planet.sh stop +``` + +会停止后端、AI Provider、前端、PostgreSQL、Redis。 + +按模块重启: + +```bash +./planet.sh restart # 全量 +./planet.sh restart -b # 后端 +./planet.sh restart -f # 前端 +./planet.sh restart -a # AI Provider +./planet.sh restart -d # 数据库 +``` + +按模块重启适合日常开发,能避免无关服务被打断。 + +## 健康检查 + +```bash +./planet.sh health +``` + +会检查: + +- `planet_*` 容器状态 +- 后端 `/health` +- AI Provider `/health` +- 前端页面可达性 + +如果某项显示 offline,优先看对应日志。 + +## 日志 + +最近日志: + +```bash +./planet.sh log +``` + +持续跟随: + +```bash +./planet.sh log -f # 前端: /tmp/planet_frontend.log +./planet.sh log -b # 后端: /tmp/planet_backend.log +./planet.sh log -a # AI Provider: planet_aiprovider 容器日志 +``` + +## 命令式创建用户 + +```bash +./planet.sh createuser +``` + +交互式提示用户名、密码、角色,直接落库并标记 `email_verified = TRUE`。 + +适用场景: + +- SMTP 还没配置好,但需要先发账号给一名管理员 +- 想批量预置内部测试账号 +- 公开注册流程因任何原因不可用,需要应急兜底 + +正式用户开通推荐走控制台 `/settings -> SMTP 邮件` 配好发件后,让用户在 `/register` 自助注册。 + +## 局域网 / WSL 访问 + +```bash +./planet.sh start --allow-lan +``` + +适用于: + +- WSL 中启动,Windows 浏览器访问 +- 手机或平板演示 Earth +- 局域网其他机器访问同一开发实例 + +`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 `http://:3000` 还需要 Windows 端口转发和防火墙放行。 + +建议按顺序排查: + +```bash +# 在运行 Planet 的 shell 中 +curl http://localhost:3000 +curl http://localhost:8000/health +ss -ltnp | grep -E ':3000|:8000' +``` + +如果看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 仍访问失败,在管理员 PowerShell 中配置: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 + +New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 +New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +``` + +## AI Provider 环境变量与构建 + +AI Provider 运行期配置可以放在两处: + +| 位置 | 适合内容 | 说明 | +| --- | --- | --- | +| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 作为 `env_file` 读取 | +| `~/.zshrc` | 个人 provider、模型、密钥、代理 | `planet.sh` 启动时读取常见 `AI_*`、`SERVICE_*`、`PYTHON_IMAGE`、`UV_IMAGE` | + +推荐写法: + +```bash +export AI_PROVIDER=minimax +export AI_PROVIDER_API=anthropic-messages +export AI_BASE_URL=https://api.example.com/anthropic +export AI_API_KEY=sk-change-me +export AI_MODEL=MiniMax-M2.7 +export AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +`planet.sh` 默认只静态解析 `~/.zshrc` 中简单的 `export KEY=value` 或 `KEY=value` 行。需要复杂 shell 展开时显式启用: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +完全忽略 `~/.zshrc`: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + +AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改密钥或 Base URL 后只需重启容器: + +```bash +./planet.sh restart -a +``` + +构建较慢时按层排查: + +| 现象 | 常见原因 | 处理方式 | +| --- | --- | --- | +| `transferring context` 很大 | build context 含前端资源等无关文件 | `.dockerignore` 只发送必需文件 | +| `uv sync` 下载较慢 | 首次构建或缓存为空 | 等待首次完成,后续复用 BuildKit 缓存 | +| 改密钥后仍是旧配置 | 容器未重启 | `./planet.sh restart -a` | + +## SMTP 邮件(公开注册依赖) + +公开注册和邮箱验证依赖 SMTP。管理员在控制台 `/settings -> SMTP 邮件` 子 tab 中填写主机、端口、账号、密码、发件地址、TLS 模式,然后用"发送测试邮件"按钮验证。配置同时写入 `system_settings.smtp` 表行。 + +未配置时 `POST /api/v1/auth/register` 返回 `503 EMAIL_PROVIDER_NOT_CONFIGURED`,前端的注册流程会给出明确提示。运维兜底方式是 `./planet.sh createuser`。 + +OTP 一次性验证码走 Redis,key 格式 `otp:{purpose}:{email}`,TTL 600 秒。错误尝试 5 次后该 key 失效;重发冷却 60 秒,由 `otp_rate:{purpose}:{email}` 控制。 + +## 故障排查顺序 + +```bash +./planet.sh health # 1. 看服务状态 +./planet.sh log # 2. 看最近日志 +./planet.sh log -f # 3. 按模块查看 +./planet.sh log -b +./planet.sh log -a +./planet.sh restart -f # 4. 只重启有问题的模块 +./planet.sh restart -b +./planet.sh restart -a +./planet.sh restart -d # 5. 数据库 / 缓存异常时 +./planet.sh restart # 6. 仍无法恢复时全量重启 +``` + +## 开发命令约定 + +前端必须使用 Bun: + +```bash +cd frontend +bun install +bun run dev +bun run build +``` + +不要使用 `npm run ...`。项目在 WSL / Windows 混合环境优先依赖 Bun,避免 Node/npm 路径差异。 + +验证前端构建: + +```bash +source ~/.zshrc && bun run build +``` + +后端依赖通过 uv 管理: + +```bash +uv sync +uv run pytest backend/tests/test_otp_service.py +``` + +## 相关文档 + +- [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md) +- [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md) +- [Docker + Compose + Buildx 升级](/home/ray/dev/linkong/planet/docs/technical/zh/ops-docker-compose-buildx-upgrade.md) +- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md) diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 0541b09f..4187a47d 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -1,236 +1,66 @@ # 快速开始 -这份快速开始面向第一次启动 Planet 的开发者或演示操作者。目标是用最短路径把服务跑起来,并知道应该打开哪些入口。 +这份快速开始面向 Planet 的最终用户:你拿到了管理员给的访问地址,要从打开浏览器到第一次完成配置之间的最短路径。所有操作都在浏览器里完成。 -如果遇到端口占用、Windows / WSL 局域网访问、`uv` / `bun`、摄像头或 Docs 权限问题,先看 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。 +如果你是负责部署或运维的同事,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。 -## 前置条件 +## 1. 打开访问地址 -推荐在 WSL / Linux shell 中运行。 +打开管理员给的 URL,例如 `http://planet.example.com`。本地演示一般是 `http://localhost:3000`。 -需要具备: +入口分两类: -- Docker / Docker Compose 可用 -- 当前 shell 能访问 `uv` 和 `bun` -- 仓库已 clone 到本机 +- 公开页:`/earth`(3D 态势)、`/docs`(公共文档) +- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统配置) -如果是新机器,优先执行仓库自带初始化脚本: +## 2. 注册账号 -```bash -./scripts/bootstrap-dev.sh -``` +1. 打开 `/login`,点击表单下方"注册账户"。 +2. 在 `/register` 填用户名、邮箱、密码(至少 8 位)。 +3. 提交后,到邮箱查收 6 位验证码(10 分钟内有效)。 +4. 在验证页输入验证码,点击"验证并登录",系统会自动跳到控制台。 -这个脚本会检查并同步常用依赖,并在缺少时生成: +收不到邮件时: -- `backend/.env` -- `aiprovider/.env` -- `frontend/.env.local` +- 看一下垃圾邮件或企业邮件网关 +- 验证页右下角的"重新发送验证码"会显示倒计时,结束后可以重发 +- 提示"邮件服务尚未配置"时联系管理员开 SMTP -AI Provider 的个人配置也可以放在 `~/.zshrc`。`planet.sh` 会读取简单的 `export AI_...=...` 或 `AI_...=...` 行,并在启动 AI Provider 时传给容器。修改模型、密钥或 Base URL 后,通常只需要重启 AI Provider: +默认角色是 `viewer`,可登录但只能看公共内容。要看采集器、用户管理、系统设置,请联系管理员升级角色或加 Gatekeeper 权限组。 -```bash -./planet.sh restart -a -``` +## 3. 第一次登录 -AISStream、BarentsWatch 等采集器凭证也可以先写在 `~/.zshrc` 里供连接验证读取,例如: +进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台: -```bash -export AISSTREAM_API_KEY="..." -export BARENTSWATCH_CLIENT_ID="..." -export BARENTSWATCH_CLIENT_SECRET="..." -``` +1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret +2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 +3. `/datasources` 或 `/data`:看采集器是否已经产出数据 +4. `/alerts/system`:看系统告警是否正常 +5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组 -正式采集更推荐在控制台 `设置 -> 采集器设置` 保存凭证,尤其是 AISStream 这类长连接 WebSocket collector。这样连接验证、后端采集任务和 Earth 实时船只聚合会使用同一份配置。 +## 4. 打开 Earth -## 1. 启动服务 +访问 `/earth`,公开页面,不需要登录。 -在仓库根目录执行: +进入后建议确认: -```bash -./planet.sh start -``` - -启动完成后,常用入口是: - -| 入口 | 默认地址 | 用途 | -| --- | --- | --- | -| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 | -| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 | -| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 | -| AI | `http://localhost:3000/ai` | 登录后的模型供应商、工具和测试台入口 | -| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 | - -如果默认端口被占用,可以指定端口: - -```bash -./planet.sh start -f 3001 -b 8001 -a 8101 -``` - -后端 `8000` 被 Windows listener 或旧 portproxy 占用时,排查顺序见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。 - -## 2. 创建登录用户 - -控制台需要登录。首次使用可以执行: - -```bash -./planet.sh createuser -``` - -按提示输入用户名、密码和角色。 - -如果需要阅读开发或运维文档,用 `super_admin` 登录控制台后,在“用户管理”里给目标用户分配 Gatekeeper 权限组:`docs_developer` 用于开发文档,`docs_admin` 用于服务控制和运维文档。 - -## 3. 打开 Earth - -访问: - -```text -http://localhost:3000/earth -``` - -Earth 是公开页面,不需要登录。 - -进入后可以先确认: - -- 地球正常显示 -- 右侧图层控制可打开/关闭图层 +- 地球正常显示,右侧图层面板可以打开/关闭 - 搜索可以查找海缆、卫星、算力中心、BGP 事件 -- 算力中心和 BGP 观测站详情卡可以自动采集并预览坐标候选;常规来源无候选时会用当前默认 AI Provider 做 LLM factcheck 兜底;算力中心待定位气泡可以打开列表并保存候选 -- 鼠标拖动、滚轮缩放和缩放百分比提示正常工作 -- 设置面板可以切换旋转 / 巡航 / 动捕模式、日夜模式、卫星显示风格;动捕调试模式下浏览器摄像头可显示本机预览和骨架 +- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览 +- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常 +- 设置面板的旋转 / 巡航 / 动捕模式可以切换 -## 4. 打开控制台 +## 5. 找回密码 -访问: +忘记密码时打开 `/forgot-password`,输入邮箱并接收验证码,再输入验证码 + 新密码即可。系统对未注册的邮箱也会返回同样的提示(防止账号枚举)。 -```text -http://localhost:3000/admin -``` +## 6. 看文档 -控制台用于数据源、采集数据、专题观测、告警、系统日志和配置管理。 - -首次排查建议查看: - -- `/datasources`:数据源目录和采集触发;接口、请求头和凭证配置在 `/settings` 的“采集器设置” -- `/data`:已采集数据 -- `/bgp`:BGP 专题观测 -- `/ai`:AI,管理模型供应商、WebSearch 等工具和测试台 -- `/alerts/system`:系统告警 -- `/settings`:系统配置 - -## 5. 查看运行状态 - -```bash -./planet.sh health -``` - -这个命令会显示容器状态,并检查: - -- 后端 -- AI Provider -- 前端 - -## 6. 查看日志 - -最近日志: - -```bash -./planet.sh log -``` - -持续查看某个服务: - -```bash -./planet.sh log -f -./planet.sh log -b -./planet.sh log -a -``` - -含义: - -- `-f`:前端日志 -- `-b`:后端日志 -- `-a`:AI Provider 日志 - -## 7. 常用重启 - -只重启前端: - -```bash -./planet.sh restart -f -``` - -只重启后端: - -```bash -./planet.sh restart -b -``` - -只重启 AI Provider: - -```bash -./planet.sh restart -a -``` - -只重启数据库: - -```bash -./planet.sh restart -d -``` - -全量重启: - -```bash -./planet.sh restart -``` - -## 8. 局域网访问 - -如果希望 Windows 浏览器、手机或同一局域网的其他设备访问: - -```bash -./planet.sh start --allow-lan -``` - -这会让前端和后端监听局域网可访问地址。 - -注意:`--allow-lan` 只负责让 Planet 服务监听 `0.0.0.0`,不等于自动把 WSL 服务暴露到 Windows 局域网 IP。常见情况是: - -- WSL 内 `localhost:3000` / `localhost:8000` 能访问 -- Windows 本机 `localhost:3000` / `localhost:8000` 能访问 -- 但手机或其他电脑访问 `http://:3000` 失败 - -这通常说明 Windows 端还缺少端口转发或防火墙放行。 - -如果访问失败,先在运行 Planet 的 shell 中检查: - -```bash -curl http://localhost:3000 -curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' -``` - -如果确认 WSL 中已监听 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 仍不能访问,请在管理员 PowerShell 中配置 Windows 端转发和防火墙: - -```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 - -New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 -New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 -``` - -## 9. 停止服务 - -```bash -./planet.sh stop -``` - -停止后会关闭前端、后端、AI Provider、PostgreSQL 和 Redis。 +`/docs` 是文档站。未登录可看:本快速开始、使用手册、常见问题。登录后被分配 `docs_user` / `docs_developer` / `docs_admin` 权限组的用户可以看更多技术文档。 ## 下一步 -- 完整操作说明见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) -- 控制台结构见 [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md) -- Earth 结构见 [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md) -- 后端采集器见 [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md) +- 完整 UI 操作说明:[Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) +- 排障与配置疑问:[常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md) +- Earth 坐标候选采集详细流程:[Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md) +- 部署 / 运维相关命令:[Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md) diff --git a/docs/version-history.md b/docs/version-history.md index 90609d3a..f8f8a49a 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.51.1` +- `dev` 当前开发分支历史推导到:`0.52.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 | | `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 | | `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 | | `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ | diff --git a/frontend/package.json b/frontend/package.json index 417af8a6..0831d18a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.51.1", + "version": "0.52.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index b6e06248..5779831e 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -206,7 +206,7 @@ export const PATHS = { cablesApi: '/api/v1/visualization/geo/cables', landingPointsApi: '/api/v1/visualization/geo/landing-points', computeCentersApi: '/api/v1/visualization/geo/compute-centers', - vesselsApi: '/api/v1/visualization/geo/vessels', + vesselsApi: '/api/v1/vessels/snapshot', vesselTrackApi: (mmsi) => `/api/v1/visualization/vessels/${encodeURIComponent(mmsi)}/track`, bgpApi: '/api/v1/visualization/geo/bgp-anomalies', bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents', diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index ed5b8a6f..ae832f2b 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -258,6 +258,11 @@ const vesselIconLayer = createInteractableLayer({ }), }); +const DEFAULT_VESSEL_VIEWPORT = { + bbox: [-180, -90, 180, 90], + zoom: 2, +}; + export function getVesselMarkers() { return vesselIconLayer.getMarkers(); } @@ -313,6 +318,14 @@ export function clearVesselData(earth) { export async function loadVessels(_scene, earth, options = {}) { const params = new URLSearchParams(); const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers); + const bbox = Array.isArray(options.bbox) && options.bbox.length === 4 + ? options.bbox + : DEFAULT_VESSEL_VIEWPORT.bbox; + const zoom = Number.isFinite(Number(options.zoom)) + ? Number(options.zoom) + : DEFAULT_VESSEL_VIEWPORT.zoom; + params.set("bbox", bbox.join(",")); + params.set("zoom", String(zoom)); if (Number.isFinite(requestedLimit) && requestedLimit > 0) { params.set("limit", String(requestedLimit)); } @@ -352,7 +365,15 @@ export function startVesselRealtime(earth, { onUpdate } = {}) { connected: true, }; onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() }); - socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } })); + socket.send(JSON.stringify({ + type: "subscribe", + data: { + channel: "vessels", + bbox: DEFAULT_VESSEL_VIEWPORT.bbox, + zoom: DEFAULT_VESSEL_VIEWPORT.zoom, + limit: VESSEL_CONFIG.maxRenderedMarkers, + }, + })); }; socket.onmessage = (event) => { let message; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18c129c7..12239f44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,14 @@ import { Suspense, lazy } from 'react' import { Spin } from 'antd' -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './stores/auth' import Login from './pages/Login/Login' +const Register = lazy(() => import('./pages/Register/Register')) +const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail')) +const ForgotPassword = lazy(() => import('./pages/ForgotPassword/ForgotPassword')) const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts')) const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts')) const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts')) @@ -25,7 +28,8 @@ const EARTH_ROUTE = '/earth' const DOCS_ROUTE = '/docs' const DOCS_ROUTE_PATTERN = '/docs/:slug' const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/` -const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE]) +const AUTH_ROUTES = new Set(['/login', '/register', '/verify-email', '/forgot-password']) +const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE, ...AUTH_ROUTES]) function isPublicPath(pathname: string) { return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX) @@ -33,7 +37,8 @@ function isPublicPath(pathname: string) { function App() { const { token } = useAuthStore() - const isPublicRoute = isPublicPath(window.location.pathname) + const { pathname } = useLocation() + const isPublicRoute = isPublicPath(pathname) if (!token && !isPublicRoute) { return @@ -48,6 +53,10 @@ function App() { )} > + } /> + } /> + } /> + } /> } /> } /> } /> @@ -71,4 +80,4 @@ function App() { ) } -export default App +export default App diff --git a/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx b/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx new file mode 100644 index 00000000..dd436444 --- /dev/null +++ b/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react' +import { Button, Input, Tooltip, type InputProps } from 'antd' + +export function PlugConnectIcon() { + return ( + + ) +} + +interface ConnectionTestInputProps extends Omit { + testTitle?: ReactNode + testing?: boolean + testDisabled?: boolean + onTest?: () => void + extraSuffix?: ReactNode +} + +export default function ConnectionTestInput({ + testTitle = '测试连接', + testing = false, + testDisabled = false, + disabled, + onTest, + extraSuffix, + ...inputProps +}: ConnectionTestInputProps) { + const actionDisabled = disabled || testDisabled + + return ( + + + - - - - - ({ - value: preset.provider, - label: preset.label, - }))} - onChange={(value) => applyWebSearchProviderSelection(value)} + value={selectedToolKey} + options={TOOL_OPTIONS} + onChange={setSelectedToolKey} /> -
- - - - - - -
- - - - - {selectedWebSearchSecret?.configured ? '已配置' : '未配置'} - - 保存时只会更新当前 WebSearch Provider 的 key。 - - - : } - onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }} - aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'} - /> - )} + {selectedToolKey === 'web_search' ? ( + <> + + + + + + + { void testWebSearchConnection() }} + /> - - + + + + + {selectedWebSearchSecret?.configured ? '已配置' : '未配置'} + + 保存时只会更新当前 WebSearch Provider 的 key。 + + + + +
+ + + } placeholder="注册时使用的邮箱" size="large" autoComplete="email" /> + + + + +
+ navigate('/login')}>返回登录 +
+ + ) : ( +
+ + 已将验证码发送至 {email},10 分钟内有效。 + + + } placeholder="6 位验证码" size="large" maxLength={6} /> + + + } placeholder="新密码 (至少 8 位)" size="large" autoComplete="new-password" /> + + + + +
+ setStep('request')}>修改邮箱 + +
+
+ )} +
+ + ) +} + +export default ForgotPassword diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx index 74f07bda..f1844009 100644 --- a/frontend/src/pages/Login/Login.tsx +++ b/frontend/src/pages/Login/Login.tsx @@ -1,9 +1,16 @@ import { useState } from 'react' -import { Input, Button, Form, message } from 'antd' -import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { Button, Form, Input, Typography, message } from 'antd' +import { LockOutlined, UserOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { useAuthStore } from '../../stores/auth' +interface LoginError { + response?: { + data?: { detail?: string | { code?: string; email?: string; message?: string } } + status?: number + } +} + function Login() { const [loading, setLoading] = useState(false) const navigate = useNavigate() @@ -16,8 +23,16 @@ function Login() { message.success('登录成功') navigate('/admin', { replace: true }) } catch (error: unknown) { - const err = error as { response?: { data?: { detail?: string } } } - message.error(err.response?.data?.detail || '登录失败') + const err = error as LoginError + const detail = err.response?.data?.detail + if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') { + message.warning('邮箱未验证,请先完成邮箱验证') + const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : '' + navigate(`/verify-email${email}`) + return + } + const fallback = typeof detail === 'string' ? detail : detail?.message + message.error(fallback || '登录失败') } finally { setLoading(false) } @@ -36,6 +51,7 @@ function Login() { prefix={} placeholder="用户名" size="large" + autoComplete="username" />
} placeholder="密码" size="large" + autoComplete="current-password" /> @@ -59,6 +76,10 @@ function Login() { 登录 +
+ navigate('/register')}>注册账户 + navigate('/forgot-password')}>忘记密码? +
diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 5483a054..f7b0443d 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -225,6 +225,7 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean } const [helpExpanded, setHelpExpanded] = useState(true) const [settingsOpen, setSettingsOpen] = useState(false) const [servicePanelOpen, setServicePanelOpen] = useState(false) + const [helpPanelOpen, setHelpPanelOpen] = useState(false) const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key) const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].values.title) const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective) @@ -582,14 +583,16 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean } className="playground-card playground-card--provider" title="Provider 状态" extra={( - +
+
+ navigate('/login')}>已有账号,去登录 +
+ + ) : ( +
+ + 已将 6 位验证码发送至 {email},10 分钟内有效。 + + + } + placeholder="6 位验证码" + size="large" + maxLength={6} + inputMode="numeric" + /> + + + + +
+ setStep('register')}>修改邮箱 + +
+
+ )} + + + ) +} + +export default Register diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 40e487f8..e1cc465b 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -18,6 +18,7 @@ import { } from '@ant-design/icons' import { Alert, + AutoComplete, Button, Card, Checkbox, @@ -37,11 +38,13 @@ import { } from 'antd' import axios from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' +import ConnectionTestInput, { PlugConnectIcon } from '../../components/ConnectionTestInput/ConnectionTestInput' import Scrollbar from '../../components/Scrollbar/Scrollbar' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' import { formatDateTimeZhCN } from '../../utils/datetime' import { useNavigate, useSearchParams } from 'react-router-dom' +import SmtpPanel from './SmtpPanel' const { Title, Text } = Typography const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200 @@ -318,62 +321,6 @@ const formatLagSeconds = (value: number | null | undefined) => { return `${Math.round(value / 3600)} 小时` } -function PlugConnectIcon() { - return ( - - ) -} - function SettingsPanel({ loading, children, @@ -441,6 +388,7 @@ function Settings() { const [tvEditForm] = Form.useForm() const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm) const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm) + const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm) const selectedAiProviderSecret = selectedAiProvider ? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key : integrations?.ai_provider.api_key @@ -1907,6 +1855,11 @@ function Settings() { ), }, + { + key: 'smtp', + label: 'SMTP 邮件', + children: , + }, { key: 'tv', label: '电视直播', @@ -2036,42 +1989,43 @@ function Settings() { > LLM Provider
}> - - - - - - - - - + + { void testAiProviderConnection() }} + extraSuffix={( + + + { void testWebSearchConnection() }} + /> @@ -2208,15 +2163,19 @@ function Settings() { : } - onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }} - aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'} - /> + + + + + + setTestOpen(false)} + onOk={onTest} + okText="发送" + okButtonProps={{ loading: testing }} + cancelText="取消" + > + + 会用当前表单中的 SMTP 配置(未保存的修改也会生效)发送一封测试邮件。 + + setTestTo(event.target.value)} + autoFocus + /> + + + ) +} diff --git a/frontend/src/pages/VerifyEmail/VerifyEmail.tsx b/frontend/src/pages/VerifyEmail/VerifyEmail.tsx new file mode 100644 index 00000000..845f1999 --- /dev/null +++ b/frontend/src/pages/VerifyEmail/VerifyEmail.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react' +import { Button, Form, Input, message, Typography } from 'antd' +import { MailOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import axios from 'axios' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useAuthStore } from '../../stores/auth' + +const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' + +interface ErrorBody { + response?: { + data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } } + } +} + +function extractDetail(error: unknown): string { + const err = error as ErrorBody + const detail = err.response?.data?.detail + if (typeof detail === 'string') return detail + if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败' + return '操作失败' +} + +function VerifyEmail() { + const navigate = useNavigate() + const [search] = useSearchParams() + const [email, setEmail] = useState(search.get('email') || '') + const [loading, setLoading] = useState(false) + const [resending, setResending] = useState(false) + const [cooldown, setCooldown] = useState(0) + + useEffect(() => { + if (cooldown <= 0) return + const timer = setTimeout(() => setCooldown((v) => v - 1), 1000) + return () => clearTimeout(timer) + }, [cooldown]) + + const onVerify = async (values: { code: string }) => { + setLoading(true) + try { + const response = await axios.post(`${API_URL}/auth/verify-email`, { email, code: values.code }) + const { access_token, user } = response.data + useAuthStore.setState({ token: access_token, user }) + axios.defaults.headers.common.Authorization = `Bearer ${access_token}` + message.success('邮箱验证成功,正在登录…') + navigate('/admin', { replace: true }) + } catch (error) { + message.error(extractDetail(error)) + } finally { + setLoading(false) + } + } + + const onResend = async () => { + if (!email || cooldown > 0) return + setResending(true) + try { + await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' }) + setCooldown(60) + message.success('验证码已重发') + } catch (error) { + const err = error as ErrorBody + const detail = err.response?.data?.detail + if (detail && typeof detail === 'object' && detail.retry_after_seconds) { + setCooldown(detail.retry_after_seconds) + } + message.error(extractDetail(error)) + } finally { + setResending(false) + } + } + + return ( +
+
+

验证邮箱

+
+ + } + size="large" + value={email} + onChange={(event) => setEmail(event.target.value)} + placeholder="注册时使用的邮箱" + autoComplete="email" + /> + + + } + placeholder="6 位验证码" + size="large" + maxLength={6} + inputMode="numeric" + /> + + + + +
+ navigate('/login')}>返回登录 + +
+
+
+
+ ) +} + +export default VerifyEmail diff --git a/pyproject.toml b/pyproject.toml index e1079e66..fdf2d673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.51.1" +version = "0.52.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ @@ -21,6 +21,7 @@ dependencies = [ "aiofiles>=23.2.1", "python-dotenv>=1.0.0", "email-validator>=2.1.0", + "aiosmtplib>=3.0.0", "apscheduler>=3.10.4", "networkx>=3.0", "mediapipe>=0.10.35", diff --git a/rules.md b/rules.md index 2b10ae72..4b064dab 100644 --- a/rules.md +++ b/rules.md @@ -255,6 +255,7 @@ Changing layout, visual hierarchy, controls, interaction states, responsive beha - Use icons in buttons for common tools/actions when an established icon exists. - Keep icon-only buttons accessible with `aria-label` and `title`. - Use segmented controls for modes, switches/checkboxes for binary settings, sliders/inputs for numeric values, menus/selects for option sets, and tabs for views. +- Connection-test actions for endpoint/Base URL inputs must use the shared `ConnectionTestInput` pattern: a single plug/connector icon at the input suffix, no adjacent text button; disabled integrations should grey out the field and its test action. - Do not put cards inside cards. - Do not use visible in-app text to explain obvious UI features or styling. - Text must fit within its parent on mobile and desktop. diff --git a/uv.lock b/uv.lock index 47bd5465..502cc3af 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] +[[package]] +name = "aiosmtplib" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -748,10 +757,11 @@ wheels = [ [[package]] name = "planet" -version = "0.51.1" +version = "0.52.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, + { name = "aiosmtplib" }, { name = "apscheduler" }, { name = "asyncpg" }, { name = "bcrypt" }, @@ -785,6 +795,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=23.2.1" }, + { name = "aiosmtplib", specifier = ">=3.0.0" }, { name = "apscheduler", specifier = ">=3.10.4" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "bcrypt", specifier = ">=4.0.0" }, diff --git a/websocket_protocol.md b/websocket_protocol.md index 60faf14e..5adb7833 100644 --- a/websocket_protocol.md +++ b/websocket_protocol.md @@ -422,25 +422,72 @@ Same as `data_frame` with `update_type: "full"` ## Subscription Management +The current backend accepts `type: "subscribe"` / `type: "unsubscribe"` messages. Older examples that use `type: "subscription"` describe the same intent but should not be used for new clients. + ### Subscribe ```json { - "type": "subscription", + "type": "subscribe", "timestamp": "2024-01-20T10:30:00.000Z", "data": { - "action": "subscribe", "channels": ["gpu_clusters", "alerts"] } } ``` +### Vessel Viewport Subscribe + +The `vessels` channel is viewport-scoped. Clients must provide the current bbox, zoom, and limit. The server stores only this lightweight subscription filter and sends updates that match the subscribed bbox. + +```json +{ + "type": "subscribe", + "timestamp": "2026-05-12T10:30:00.000Z", + "data": { + "channel": "vessels", + "bbox": [120.8, 30.7, 122.1, 31.8], + "zoom": 12, + "limit": 1000 + } +} +``` + +`limit` is capped at 5000 per subscription, and each WebSocket data frame is capped to 1000 vessel updates. Collector updates are throttled to one flush per second; within a flush window, only the latest update per MMSI is retained. + +Vessel data frames use the normal `data_frame` envelope: + +```json +{ + "type": "data_frame", + "channel": "vessels", + "timestamp": "2026-05-12T10:30:01.000Z", + "payload": { + "action": "upsert", + "vessels": [ + { + "mmsi": 257123000, + "lat": 59.91, + "lon": 10.73, + "sog": 12.4, + "cog": 214, + "received_at": "2026-05-12T10:30:00Z" + } + ], + "subscription": { + "bbox": [120.8, 30.7, 122.1, 31.8], + "zoom": 12, + "limit": 1000 + } + } +} +``` + ### Unsubscribe ```json { - "type": "subscription", + "type": "unsubscribe", "timestamp": "2024-01-20T10:30:00.000Z", "data": { - "action": "unsubscribe", "channels": ["alerts"] } } -- 2.49.1 From d9efd98d2610452e4a68f8184f940487502e2d87 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 13 May 2026 08:05:43 +0800 Subject: [PATCH 04/19] release: bump version to 0.53.0 --- .dockerignore | 5 + .gitea/workflows/ci.yaml | 64 +++ .gitea/workflows/deploy-staging.yaml | 67 +++ .gitea/workflows/release.yaml | 58 +++ README.md | 2 +- VERSION | 2 +- aiprovider/Dockerfile | 5 +- backend/Dockerfile | 6 +- backend/app/api/main.py | 2 + backend/app/api/v1/datasources.py | 23 +- backend/app/api/v1/layers.py | 2 +- backend/app/api/v1/realtime_sources.py | 280 +++++++++++ backend/app/api/v1/visualization.py | 100 ++-- backend/app/services/scheduler.py | 5 + backend/tests/test_api.py | 58 ++- backend/tests/test_layers.py | 26 ++ backend/tests/test_realtime_sources.py | 101 ++++ backend/tests/test_vessels.py | 55 ++- deploy/helm/planet/Chart.yaml | 6 + deploy/helm/planet/templates/_helpers.tpl | 33 ++ deploy/helm/planet/templates/aiprovider.yaml | 66 +++ deploy/helm/planet/templates/backend.yaml | 66 +++ deploy/helm/planet/templates/configmap.yaml | 27 ++ deploy/helm/planet/templates/frontend.yaml | 61 +++ deploy/helm/planet/templates/ingress.yaml | 29 ++ deploy/helm/planet/templates/secrets.yaml | 14 + .../planet/templates/single-node-deps.yaml | 116 +++++ deploy/helm/planet/values.single-node.yaml | 29 ++ deploy/helm/planet/values.yaml | 73 +++ docs/CHANGELOG.md | 17 + .../earth-vessel-ais-aggregation-plan.md | 12 +- ...earth-vessel-rendering-performance-plan.md | 2 +- docs/plans/earth-vessel-tracking-plan.md | 11 +- ...uction-delivery-cicd-stabilization-plan.md | 55 +++ docs/technical/en/backend-collectors.md | 13 +- ...asource-collector-settings-connectivity.md | 4 +- docs/technical/en/earth-frontend-context.md | 2 +- docs/technical/en/manual.md | 11 +- docs/technical/en/ops-planet-sh-startup.md | 62 ++- docs/technical/en/quickstart.md | 2 +- docs/technical/zh/backend-collectors.md | 13 +- ...asource-collector-settings-connectivity.md | 4 +- docs/technical/zh/earth-frontend-context.md | 2 +- docs/technical/zh/manual.md | 13 +- docs/technical/zh/ops-planet-sh-startup.md | 62 ++- docs/technical/zh/quickstart.md | 2 +- docs/version-history.md | 3 +- frontend/.dockerignore | 6 + frontend/Dockerfile | 13 +- frontend/nginx.conf | 56 +++ frontend/package.json | 2 +- frontend/public/earth/js/vessels.js | 39 +- .../src/pages/DataSources/DataSources.tsx | 330 ++++++++++++- planet.sh | 440 +++++++++++++----- pyproject.toml | 2 +- uv.lock | 2 +- 56 files changed, 2318 insertions(+), 243 deletions(-) create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitea/workflows/deploy-staging.yaml create mode 100644 .gitea/workflows/release.yaml create mode 100644 backend/app/api/v1/realtime_sources.py create mode 100644 backend/tests/test_realtime_sources.py create mode 100644 deploy/helm/planet/Chart.yaml create mode 100644 deploy/helm/planet/templates/_helpers.tpl create mode 100644 deploy/helm/planet/templates/aiprovider.yaml create mode 100644 deploy/helm/planet/templates/backend.yaml create mode 100644 deploy/helm/planet/templates/configmap.yaml create mode 100644 deploy/helm/planet/templates/frontend.yaml create mode 100644 deploy/helm/planet/templates/ingress.yaml create mode 100644 deploy/helm/planet/templates/secrets.yaml create mode 100644 deploy/helm/planet/templates/single-node-deps.yaml create mode 100644 deploy/helm/planet/values.single-node.yaml create mode 100644 deploy/helm/planet/values.yaml create mode 100644 docs/plans/production-delivery-cicd-stabilization-plan.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/nginx.conf diff --git a/.dockerignore b/.dockerignore index d683496a..b3daf053 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,9 +2,14 @@ !pyproject.toml !uv.lock +!VERSION +!backend/ +!backend/** !aiprovider/ !aiprovider/** +backend/.env +backend/.env.* aiprovider/.env aiprovider/.env.* !aiprovider/.env.example diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 00000000..4e5c0705 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,64 @@ +name: ci + +on: + push: + branches: + - dev + - main + pull_request: + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Sync Python dependencies + run: ~/.local/bin/uv sync --group dev + - name: Run backend smoke tests + working-directory: backend + run: PYTHONPATH=. "$GITHUB_WORKSPACE/.venv/bin/python" -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Bun + run: curl -fsSL https://bun.sh/install | bash + - name: Build frontend + working-directory: frontend + run: | + ~/.bun/bin/bun install --frozen-lockfile + ~/.bun/bin/bun run build + + delivery: + runs-on: ubuntu-latest + needs: + - backend + - frontend + steps: + - uses: actions/checkout@v4 + - name: Install Helm + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz + tar -xzf /tmp/helm.tar.gz -C /tmp + mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Docker build smoke + run: | + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/frontend:${GITHUB_SHA}" ./frontend + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/backend:${GITHUB_SHA}" -f backend/Dockerfile . + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/aiprovider:${GITHUB_SHA}" -f aiprovider/Dockerfile . + - name: Helm template smoke + run: | + helm lint deploy/helm/planet + helm template planet-staging deploy/helm/planet \ + --namespace planet-staging \ + -f deploy/helm/planet/values.single-node.yaml \ + --set image.tag="${GITHUB_SHA}" >/tmp/planet-rendered.yaml diff --git a/.gitea/workflows/deploy-staging.yaml b/.gitea/workflows/deploy-staging.yaml new file mode 100644 index 00000000..43fc8fe8 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yaml @@ -0,0 +1,67 @@ +name: deploy-staging + +on: + workflow_dispatch: + push: + branches: + - main + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + RELEASE_NAME: planet-staging + NAMESPACE: planet-staging + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install deploy tools + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz + tar -xzf /tmp/helm.tar.gz -C /tmp + mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm" + curl -fsSL https://dl.k8s.io/release/v1.30.5/bin/linux/amd64/kubectl -o /tmp/kubectl + install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Configure kubeconfig + run: | + mkdir -p "$HOME/.kube" + printf "%s" "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > "$HOME/.kube/config" + chmod 600 "$HOME/.kube/config" + - name: Deploy Helm release + run: | + kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + helm upgrade --install "$RELEASE_NAME" deploy/helm/planet \ + --namespace "$NAMESPACE" \ + -f deploy/helm/planet/values.single-node.yaml \ + --set global.imageRegistry="$REGISTRY" \ + --set global.imageNamespace="$IMAGE_NAMESPACE" \ + --set image.tag="${GITHUB_SHA}" + - name: Wait for rollout + run: | + kubectl rollout status deployment/planet-frontend -n "$NAMESPACE" --timeout=180s + kubectl rollout status deployment/planet-backend -n "$NAMESPACE" --timeout=180s + kubectl rollout status deployment/planet-aiprovider -n "$NAMESPACE" --timeout=180s + - name: Smoke test services + run: | + kubectl run planet-smoke-${GITHUB_RUN_NUMBER} \ + --rm -i --restart=Never \ + --namespace "$NAMESPACE" \ + --image=curlimages/curl:8.11.1 \ + --command -- sh -c ' + set -eu + curl -fsS http://planet-frontend:3000/ >/dev/null + curl -fsS http://planet-frontend:3000/health >/dev/null + curl -fsS http://planet-frontend:3000/api/health >/dev/null + curl -fsS http://planet-backend:8000/health >/dev/null + curl -fsS http://planet-aiprovider:8010/health >/dev/null + ' + - name: Collect diagnostics on failure + if: failure() + run: | + kubectl get all -n "$NAMESPACE" -o wide || true + kubectl describe pods -n "$NAMESPACE" || true + kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE_NAME" --all-containers --tail=200 || true diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 00000000..cf7fcbea --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,58 @@ +name: release + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + +jobs: + images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Resolve image tags + id: meta + run: | + echo "sha_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + if printf "%s" "${GITHUB_REF}" | grep -q '^refs/tags/v'; then + echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + echo "release_tag=" >> "$GITHUB_OUTPUT" + fi + - name: Login to registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Build and push images + run: | + for service in frontend backend aiprovider; do + case "$service" in + frontend) + dockerfile="./frontend/Dockerfile" + context="./frontend" + ;; + backend) + dockerfile="backend/Dockerfile" + context="." + ;; + aiprovider) + dockerfile="aiprovider/Dockerfile" + context="." + ;; + esac + + image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.sha_tag }}" + docker build -t "$image" -f "$dockerfile" "$context" + docker push "$image" + + if [ -n "${{ steps.meta.outputs.release_tag }}" ]; then + release_image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.release_tag }}" + docker tag "$image" "$release_image" + docker push "$release_image" + fi + done diff --git a/README.md b/README.md index 16fbb393..7857cc89 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ DATABASE_RETRY_INTERVAL=10 \ - `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒 - `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3` - `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `3` -- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒 +- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `60` 次、`2` 秒 - `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` 秒 - `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒 diff --git a/VERSION b/VERSION index 4f9b378b..7f422a16 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.0 +0.53.0 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile index b3651008..6b6f6f69 100644 --- a/aiprovider/Dockerfile +++ b/aiprovider/Dockerfile @@ -27,4 +27,7 @@ COPY aiprovider /app/aiprovider EXPOSE 8010 -CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"] +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8010/health >/dev/null || exit 1 + +CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"] diff --git a/backend/Dockerfile b/backend/Dockerfile index 3a190666..8546fe7a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,6 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV UV_COMPILE_BYTECODE=1 ENV UV_LINK_MODE=copy +ENV PYTHONPATH=/app/backend RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ @@ -25,4 +26,7 @@ COPY VERSION /app/VERSION EXPOSE 8000 -CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/health >/dev/null || exit 1 + +CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 8e30980e..1ed5c9f5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -18,6 +18,7 @@ from app.api.v1 import ( vessels, bgp, news, + realtime_sources, system_control, tv, ) @@ -50,3 +51,4 @@ api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) api_router.include_router(news.router, prefix="/news", tags=["news"]) +api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"]) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 7c03a90a..668555a1 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -18,6 +18,8 @@ from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.task import CollectionTask from app.models.user import User +from app.models.vessel import AISRawObservation +from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA from app.services.scheduler import ( cancel_running_collector_now, get_latest_task_id_for_datasource, @@ -161,7 +163,26 @@ async def _load_collected_record_counts( .where(CollectedData.is_current.is_(True)) .group_by(CollectedData.source) ) - return {source: int(count or 0) for source, count in result.all()} + counts = {source: int(count or 0) for source, count in result.all()} + + vessel_sources = [ + source + for source in sources + if datasource_metadata(source)["credential_provider"] in {"aisstream", "barentswatch"} + or "vessel" in source + or "ais" in source + ] + if vessel_sources: + raw_result = await db.execute( + select(AISRawObservation.source, func.count(AISRawObservation.id)) + .where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA) + .where(AISRawObservation.source.in_(vessel_sources)) + .group_by(AISRawObservation.source) + ) + for source, count in raw_result.all(): + counts[source] = max(counts.get(source, 0), int(count or 0)) + + return counts async def _load_datasource_endpoint_overrides( diff --git a/backend/app/api/v1/layers.py b/backend/app/api/v1/layers.py index 5d6ab164..64bc518f 100644 --- a/backend/app/api/v1/layers.py +++ b/backend/app/api/v1/layers.py @@ -117,7 +117,7 @@ async def get_vessel_layer_snapshot( bbox=parsed_bbox, zoom=zoom, limit=limit, - vessel_type=vessel_type, + type_filter=vessel_type, since_minutes=since_minutes, ) diff --git a/backend/app/api/v1/realtime_sources.py b/backend/app/api/v1/realtime_sources.py new file mode 100644 index 00000000..bc75f6c0 --- /dev/null +++ b/backend/app/api/v1/realtime_sources.py @@ -0,0 +1,280 @@ +"""Realtime datasource operations and runtime statistics.""" + +from datetime import UTC, datetime, timedelta +import os +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import distinct, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.data_sources import get_data_sources_config +from app.core.security import get_current_user +from app.core.time import to_iso8601_utc +from app.db.session import get_db +from app.models.datasource import DataSource +from app.models.datasource_config import DataSourceConfig +from app.models.user import User +from app.models.vessel import AISRawObservation, AISSourceHealth +from app.services.custom_datasource_runtime import ( + get_custom_stream_status, + start_custom_stream, + stop_custom_stream, +) +from app.services.scheduler import ( + cancel_running_collector_now, + is_collector_running, + run_collector_now, +) +from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA, update_ais_source_health + +router = APIRouter() + +BUILTIN_REALTIME_SOURCES = {"aisstream_vessels"} +REALTIME_SOURCE_TYPES = {"websocket", "ws"} + + +def _is_realtime_config(config: DataSourceConfig) -> bool: + return str(config.source_type or "").lower() in REALTIME_SOURCE_TYPES + + +def _safe_config_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _credential_configured(config: DataSourceConfig | None) -> bool: + if config is not None: + auth_config = _safe_config_dict(config.auth_config) + config_payload = _safe_config_dict(config.config) + if auth_config.get("api_key") or config_payload.get("api_key"): + return True + return bool(os.getenv("AISSTREAM_API_KEY")) + + +async def _load_realtime_stats(db: AsyncSession, source: str) -> dict[str, Any]: + now = datetime.now(UTC) + observed_24h = now - timedelta(hours=24) + observed_1h = now - timedelta(hours=1) + payload_mmsi = AISRawObservation.entity_key + + result = await db.execute( + select( + func.count(AISRawObservation.id).label("total_observations"), + func.count(AISRawObservation.id) + .filter(AISRawObservation.observed_at >= observed_24h) + .label("observations_24h"), + func.count(AISRawObservation.id) + .filter(AISRawObservation.observed_at >= observed_1h) + .label("observations_1h"), + func.count(distinct(payload_mmsi)).label("unique_mmsi_total"), + func.count(distinct(payload_mmsi)) + .filter(AISRawObservation.observed_at >= observed_24h) + .label("unique_mmsi_24h"), + func.max(AISRawObservation.observed_at).label("latest_observed_at"), + func.max(AISRawObservation.collected_at).label("latest_collected_at"), + ) + .where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA) + .where(AISRawObservation.source == source) + ) + row = result.mappings().one() + return { + "total_observations": int(row["total_observations"] or 0), + "observations_24h": int(row["observations_24h"] or 0), + "observations_1h": int(row["observations_1h"] or 0), + "unique_mmsi_total": int(row["unique_mmsi_total"] or 0), + "unique_mmsi_24h": int(row["unique_mmsi_24h"] or 0), + "latest_observed_at": to_iso8601_utc(row["latest_observed_at"]), + "latest_collected_at": to_iso8601_utc(row["latest_collected_at"]), + } + + +def _runtime_status_for_builtin(source: str) -> dict[str, Any]: + running = is_collector_running(source) + return { + "running": running, + "done": False, + "runtime": "collector", + } + + +def _runtime_status_for_custom(config_id: int) -> dict[str, Any]: + status = get_custom_stream_status(config_id) + return { + "running": bool(status.get("running")), + "done": bool(status.get("done")), + "runtime": "custom_stream", + } + + +async def _serialize_builtin_aisstream( + db: AsyncSession, + datasource: DataSource, + config: DataSourceConfig | None, +) -> dict[str, Any]: + health = await db.get(AISSourceHealth, datasource.source) + config_payload = _safe_config_dict(config.config if config else {}) + endpoint = ( + (config.endpoint if config else None) + or get_data_sources_config().get_yaml_url(datasource.source) + ) + return { + "source": datasource.source, + "name": datasource.name, + "display_name": "AISStream 实时船舶", + "kind": "builtin", + "source_type": "websocket", + "endpoint": endpoint, + "is_active": bool(datasource.is_active), + "credential_configured": _credential_configured(config), + "message_types": config_payload.get("message_types") or ["PositionReport", "ShipStaticData"], + "bounding_boxes": config_payload.get("bounding_boxes") or [[[-90, -180], [90, 180]]], + "config": config_payload, + "runtime": _runtime_status_for_builtin(datasource.source), + "health": health.to_dict() if health else None, + "stats": await _load_realtime_stats(db, datasource.source), + } + + +async def _serialize_custom_stream( + db: AsyncSession, + config: DataSourceConfig, +) -> dict[str, Any]: + health = await db.get(AISSourceHealth, config.name) + config_payload = _safe_config_dict(config.config) + return { + "source": config.name, + "name": config.name, + "display_name": config.description or config.name, + "kind": "custom", + "config_id": config.id, + "source_type": config.source_type, + "endpoint": config.endpoint, + "is_active": bool(config.is_active), + "credential_configured": config.auth_type == "none" or bool(_safe_config_dict(config.auth_config)), + "message_types": config_payload.get("message_types") or [], + "bounding_boxes": config_payload.get("bounding_boxes") or [], + "config": config_payload, + "runtime": _runtime_status_for_custom(config.id), + "health": health.to_dict() if health else None, + "stats": await _load_realtime_stats(db, config.name), + } + + +async def _load_builtin_aisstream(db: AsyncSession) -> tuple[DataSource | None, DataSourceConfig | None]: + result = await db.execute(select(DataSource).where(DataSource.source == "aisstream_vessels")) + datasource = result.scalar_one_or_none() + config_result = await db.execute( + select(DataSourceConfig) + .where(DataSourceConfig.name == "aisstream_vessels") + .where(DataSourceConfig.is_active.is_(True)) + .order_by(DataSourceConfig.id.desc()) + .limit(1) + ) + return datasource, config_result.scalar_one_or_none() + + +async def _load_custom_realtime_config(db: AsyncSession, source: str) -> DataSourceConfig | None: + result = await db.execute( + select(DataSourceConfig) + .where(DataSourceConfig.name == source) + .order_by(DataSourceConfig.id.desc()) + .limit(1) + ) + config = result.scalar_one_or_none() + return config if config is not None and _is_realtime_config(config) else None + + +@router.get("") +async def list_realtime_sources( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + sources: list[dict[str, Any]] = [] + datasource, builtin_config = await _load_builtin_aisstream(db) + if datasource is not None: + sources.append(await _serialize_builtin_aisstream(db, datasource, builtin_config)) + + custom_result = await db.execute( + select(DataSourceConfig) + .where(func.lower(DataSourceConfig.source_type).in_(REALTIME_SOURCE_TYPES)) + .order_by(DataSourceConfig.name) + ) + for config in custom_result.scalars().all(): + if config.name in BUILTIN_REALTIME_SOURCES: + continue + sources.append(await _serialize_custom_stream(db, config)) + + return {"total": len(sources), "data": sources} + + +async def _ensure_builtin_startable(db: AsyncSession) -> DataSourceConfig | None: + datasource, config = await _load_builtin_aisstream(db) + if datasource is None: + raise HTTPException(status_code=404, detail="Realtime source not found") + if not datasource.is_active: + raise HTTPException(status_code=400, detail="Realtime source is disabled") + if not _credential_configured(config): + raise HTTPException(status_code=400, detail="AISStream API key is not configured") + return config + + +@router.post("/{source}/start") +async def start_realtime_source( + source: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if source == "aisstream_vessels": + await _ensure_builtin_startable(db) + if is_collector_running(source): + return {"status": "already_running", "source": source, "runtime": _runtime_status_for_builtin(source)} + if not run_collector_now(source): + raise HTTPException(status_code=409, detail="Realtime source could not be started") + return {"status": "started", "source": source, "runtime": _runtime_status_for_builtin(source)} + + config = await _load_custom_realtime_config(db, source) + if config is None: + raise HTTPException(status_code=404, detail="Realtime source not found") + if not config.is_active: + raise HTTPException(status_code=400, detail="Realtime source is disabled") + started = start_custom_stream(config.id) + return { + "status": "started" if started else "already_running", + "source": source, + "runtime": _runtime_status_for_custom(config.id), + } + + +@router.post("/{source}/stop") +async def stop_realtime_source( + source: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if source == "aisstream_vessels": + stopped = await cancel_running_collector_now(source) + await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None) + await db.commit() + return {"status": "stopped" if stopped else "not_running", "source": source, "runtime": _runtime_status_for_builtin(source)} + + config = await _load_custom_realtime_config(db, source) + if config is None: + raise HTTPException(status_code=404, detail="Realtime source not found") + stopped = await stop_custom_stream(config.id) + await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None) + await db.commit() + return { + "status": "stopped" if stopped else "not_running", + "source": source, + "runtime": _runtime_status_for_custom(config.id), + } + + +@router.post("/{source}/restart") +async def restart_realtime_source( + source: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await stop_realtime_source(source, current_user=current_user, db=db) + return await start_realtime_source(source, current_user=current_user, db=db) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 2fa6e65d..e8cac588 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -68,6 +68,7 @@ TERRAIN_TILE_BATCH_MAX_ITEMS = 128 TERRAIN_TILE_BATCH_CONCURRENCY = 16 _terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict() VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE) +VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True class TerrariumTileRequest(BaseModel): @@ -941,6 +942,39 @@ def _merge_vessel_features( } +async def _load_legacy_vessel_snapshot_features( + db: AsyncSession, + *, + bbox: tuple[float, float, float, float] | None, + limit: int, +) -> list[dict[str, Any]]: + latest_positions = select( + VesselPosition.mmsi.label("mmsi"), + func.max(VesselPosition.received_at).label("received_at"), + ) + if bbox is not None: + lon_min, lat_min, lon_max, lat_max = bbox + latest_positions = latest_positions.where(VesselPosition.lon >= lon_min) + latest_positions = latest_positions.where(VesselPosition.lon <= lon_max) + latest_positions = latest_positions.where(VesselPosition.lat >= lat_min) + latest_positions = latest_positions.where(VesselPosition.lat <= lat_max) + + latest_positions = latest_positions.group_by(VesselPosition.mmsi).subquery() + result = await db.execute( + select(VesselPosition, VesselStatic) + .join( + latest_positions, + (VesselPosition.mmsi == latest_positions.c.mmsi) + & (VesselPosition.received_at == latest_positions.c.received_at), + ) + .outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi) + .order_by(VesselPosition.received_at.desc()) + .limit(limit) + ) + legacy_geojson = convert_vessels_to_geojson(list(result.all())) + return legacy_geojson.get("features", [])[:limit] + + def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]: by_type: dict[str, int] = {} underway = 0 @@ -2073,33 +2107,6 @@ async def _load_compute_center_record(db: AsyncSession, source_id: str) -> Colle return result.scalars().first() -@router.get("/geo/vessels") -async def get_vessels_geojson( - bbox: Optional[str] = Query( - None, - description="Viewport bbox as lon_min,lat_min,lon_max,lat_max", - ), - type: Optional[str] = Query( - None, - description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other", - ), - limit: Optional[int] = Query( - None, - ge=0, - description="Maximum vessel features to return. Omit or pass 0 for no limit.", - ), - db: AsyncSession = Depends(get_db), -): - """Legacy vessel endpoint removed in favor of /api/v1/vessels/snapshot.""" - raise HTTPException( - status_code=410, - detail=( - "Legacy vessel GeoJSON endpoint has been removed. " - "Use /api/v1/vessels/snapshot with bbox, zoom, and limit." - ), - ) - - async def _load_raw_vessel_snapshot_features( db: AsyncSession, *, @@ -2121,18 +2128,38 @@ async def _load_raw_vessel_snapshot_features( observed_since=observed_since, ) raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels) - features = raw_geojson.get("features", []) + raw_features = raw_geojson.get("features", []) + features = raw_features + legacy_features: list[dict[str, Any]] = [] + legacy_fallback_used = False + if not raw_features and VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED: + legacy_features = await _load_legacy_vessel_snapshot_features( + db, + bbox=bbox, + limit=limit, + ) + features, _merge_diagnostics = _merge_vessel_features(raw_features, legacy_features) + legacy_fallback_used = bool(legacy_features) + return features, { - "raw_feature_count": len(features), + "raw_feature_count": len(raw_features), "raw_unique_mmsi": len( { key - for key in (_feature_mmsi_key(feature) for feature in features) + for key in (_feature_mmsi_key(feature) for feature in raw_features) if key is not None } ), - "legacy_feature_count": 0, - "legacy_backfilled_mmsi": 0, + "legacy_feature_count": len(legacy_features), + "legacy_backfilled_mmsi": len( + { + key + for key in (_feature_mmsi_key(feature) for feature in legacy_features) + if key is not None + } + ), + "legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED, + "legacy_fallback_used": legacy_fallback_used, "final_unique_mmsi": len( { key @@ -2466,7 +2493,12 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)): select(func.count(func.distinct(VesselPosition.mmsi))) ) legacy_unique_mmsi = int(legacy_unique_result.scalar() or 0) - vessel_count = max(raw_unique_mmsi, legacy_unique_mmsi) + legacy_fallback_active = ( + VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED + and raw_unique_mmsi == 0 + and legacy_unique_mmsi > 0 + ) + vessel_count = legacy_unique_mmsi if legacy_fallback_active else raw_unique_mmsi aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels") return { @@ -2477,6 +2509,8 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)): "satellite_count": satellite_count, "compute_center_count": compute_center_count, "vessel_count": vessel_count, + "vessel_count_source": "legacy_fallback" if legacy_fallback_active else "raw_recent", + "vessel_legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED, "vessel_raw_unique_mmsi": raw_unique_mmsi, "vessel_raw_unique_window_hours": raw_unique_window_hours, "vessel_legacy_unique_mmsi": legacy_unique_mmsi, diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index d96ddf98..9d71fd82 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -374,6 +374,11 @@ def run_collector_now(collector_name: str) -> bool: return False +def is_collector_running(collector_name: str) -> bool: + task = get_running_collector_task(collector_name) + return bool(task is not None and not task.done()) + + async def cancel_running_collector_now(collector_name: str) -> bool: task = get_running_collector_task(collector_name) if task is None or task.done(): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index d917ab6a..48ac8246 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -18,6 +18,17 @@ from app.schemas.ai import ( ) +class _FakeRedisClient: + def sismember(self, *_args, **_kwargs): + return False + + +@pytest.fixture(autouse=True) +def fake_token_blacklist(monkeypatch): + """Keep API auth tests independent from an external Redis service.""" + monkeypatch.setattr("app.core.security.redis_client", _FakeRedisClient()) + + @pytest.fixture def auth_headers(): """Create authentication headers""" @@ -62,20 +73,43 @@ async def test_dashboard_stats_without_auth(): @pytest.mark.asyncio async def test_dashboard_stats_with_auth(auth_headers): """Test dashboard stats with authentication""" - with patch("app.api.v1.dashboard.cache.get", return_value=None): - with patch("app.api.v1.dashboard.cache.set", return_value=True): - with patch("app.db.session.get_db") as mock_get_db: - mock_session = AsyncMock() - mock_result = AsyncMock() - mock_result.scalar.return_value = 0 - mock_result.fetchall.return_value = [] - mock_session.execute.return_value = mock_result + class _StatsResult: + def __init__(self, row): + self._row = row - async def mock_db_context(): - yield mock_session + def one(self): + return self._row - mock_get_db.return_value = mock_db_context() + class _FakeStatsSession: + def __init__(self): + self._rows = [ + type("DatasourceStats", (), {"custom_count": 0, "custom_active": 0})(), + type("TaskStats", (), {"tasks_today": 0, "success_tasks": 0})(), + type( + "AlertStats", + (), + {"critical_alerts": 0, "warning_alerts": 0, "info_alerts": 0}, + )(), + ] + async def execute(self, _query): + return _StatsResult(self._rows.pop(0)) + + def override_get_current_user(): + return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True) + + async def override_get_db(): + yield _FakeStatsSession() + + app.dependency_overrides.update( + { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + get_db: override_get_db, + } + ) + try: + with patch("app.api.v1.dashboard.cache.get", return_value=None): + with patch("app.api.v1.dashboard.cache.set", return_value=True): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get( @@ -85,6 +119,8 @@ async def test_dashboard_stats_with_auth(auth_headers): assert response.status_code == 200 data = response.json() assert "total_datasources" in data + finally: + app.dependency_overrides.clear() @pytest.mark.asyncio diff --git a/backend/tests/test_layers.py b/backend/tests/test_layers.py index ab8d9417..4f7a41cd 100644 --- a/backend/tests/test_layers.py +++ b/backend/tests/test_layers.py @@ -1,4 +1,5 @@ from fastapi import HTTPException +import pytest from app.api.v1 import layers @@ -42,3 +43,28 @@ def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit(): assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT assert result["diagnostics"]["limit_clamped"] is True assert result["diagnostics"]["degraded"] is True + + +@pytest.mark.asyncio +async def test_vessel_layer_snapshot_passes_type_filter(monkeypatch): + captured = {} + + async def fake_build_vessel_snapshot_response(db, **kwargs): + captured.update(kwargs) + return {"type": "FeatureCollection", "features": []} + + monkeypatch.setattr(layers, "build_vessel_snapshot_response", fake_build_vessel_snapshot_response) + + result = await layers.get_vessel_layer_snapshot( + bbox="10,59,11,60", + zoom=12, + limit=1000, + vessel_type="cargo", + since_minutes=30, + db=object(), + ) + + assert result["features"] == [] + assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0) + assert captured["type_filter"] == "cargo" + assert "vessel_type" not in captured diff --git a/backend/tests/test_realtime_sources.py b/backend/tests/test_realtime_sources.py new file mode 100644 index 00000000..5c804bec --- /dev/null +++ b/backend/tests/test_realtime_sources.py @@ -0,0 +1,101 @@ +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.api.v1 import realtime_sources +from app.models.datasource import DataSource +from app.models.datasource_config import DataSourceConfig +from app.models.vessel import AISSourceHealth + + +@pytest.mark.asyncio +async def test_serialize_builtin_aisstream_includes_health_config_and_stats(monkeypatch): + datasource = DataSource( + id=28, + name="AISStream Vessels", + source="aisstream_vessels", + module="L4", + priority="P1", + collector_class="aisstream_vessels", + is_active=True, + ) + config = DataSourceConfig( + id=3, + name="aisstream_vessels", + source_type="websocket", + endpoint="wss://stream.aisstream.io/v0/stream", + auth_type="api_key", + auth_config={"api_key": "test-key"}, + config={ + "message_types": ["PositionReport"], + "bounding_boxes": [[[-10, 50], [35, 75]]], + }, + is_active=True, + ) + health = AISSourceHealth( + source="aisstream_vessels", + connection_state="connected", + last_seen_at=datetime(2026, 5, 13, 1, 0, tzinfo=UTC), + ) + + class _Session: + async def get(self, _model, key): + assert key == "aisstream_vessels" + return health + + monkeypatch.setattr( + realtime_sources, + "_load_realtime_stats", + AsyncMock( + return_value={ + "total_observations": 10, + "observations_24h": 4, + "observations_1h": 1, + "unique_mmsi_total": 8, + "unique_mmsi_24h": 3, + "latest_observed_at": "2026-05-13T01:00:00Z", + "latest_collected_at": "2026-05-13T01:00:01Z", + } + ), + ) + monkeypatch.setattr(realtime_sources, "is_collector_running", lambda source: False) + + payload = await realtime_sources._serialize_builtin_aisstream(_Session(), datasource, config) + + assert payload["source"] == "aisstream_vessels" + assert payload["kind"] == "builtin" + assert payload["credential_configured"] is True + assert payload["message_types"] == ["PositionReport"] + assert payload["runtime"]["running"] is False + assert payload["health"]["connection_state"] == "connected" + assert payload["stats"]["total_observations"] == 10 + + +@pytest.mark.asyncio +async def test_start_builtin_realtime_source_rejects_disabled(monkeypatch): + datasource = DataSource( + id=28, + name="AISStream Vessels", + source="aisstream_vessels", + module="L4", + priority="P1", + collector_class="aisstream_vessels", + is_active=False, + ) + monkeypatch.setattr( + realtime_sources, + "_load_builtin_aisstream", + AsyncMock(return_value=(datasource, None)), + ) + + with pytest.raises(HTTPException) as excinfo: + await realtime_sources.start_realtime_source( + "aisstream_vessels", + current_user=object(), + db=object(), + ) + + assert excinfo.value.status_code == 400 + assert "disabled" in excinfo.value.detail diff --git a/backend/tests/test_vessels.py b/backend/tests/test_vessels.py index d5d09907..bb660e95 100644 --- a/backend/tests/test_vessels.py +++ b/backend/tests/test_vessels.py @@ -574,13 +574,12 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch): @pytest.mark.asyncio -async def test_legacy_vessels_geojson_endpoint_is_gone(): +async def test_legacy_vessels_geojson_route_is_not_registered(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/visualization/geo/vessels") - assert response.status_code == 410 - assert "/api/v1/vessels/snapshot" in response.json()["detail"] + assert response.status_code == 404 @pytest.mark.asyncio @@ -669,15 +668,49 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch): @pytest.mark.asyncio -async def test_legacy_vessels_geojson_rejects_even_with_bbox(): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get( - "/api/v1/visualization/geo/vessels", - params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000}, - ) +async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch): + now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc) + monkeypatch.setattr( + visualization, + "get_aggregated_vessels_snapshot", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr( + visualization, + "_load_legacy_vessel_snapshot_features", + AsyncMock( + return_value=[ + { + "type": "Feature", + "id": 257123000, + "geometry": {"type": "Point", "coordinates": [10.73, 59.91]}, + "properties": { + "mmsi": 257123000, + "name": "OSLO TRADER", + "vessel_type": 70, + "vessel_type_name": "Cargo", + "received_at": now.isoformat(), + }, + } + ] + ), + ) - assert response.status_code == 410 + result = await visualization.build_vessel_snapshot_response( + object(), + bbox=(10.0, 59.0, 11.0, 60.0), + zoom=12, + type_filter=None, + limit=1000, + since_minutes=60, + ) + + assert result["count"] == 1 + assert result["features"][0]["properties"]["name"] == "OSLO TRADER" + assert result["diagnostics"]["raw_feature_count"] == 0 + assert result["diagnostics"]["legacy_feature_count"] == 1 + assert result["diagnostics"]["legacy_backfilled_mmsi"] == 1 + assert result["diagnostics"]["legacy_fallback_used"] is True @pytest.mark.asyncio diff --git a/deploy/helm/planet/Chart.yaml b/deploy/helm/planet/Chart.yaml new file mode 100644 index 00000000..2d0959b7 --- /dev/null +++ b/deploy/helm/planet/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: planet +description: Planet situational awareness platform +type: application +version: 0.1.0 +appVersion: "0.52.0" diff --git a/deploy/helm/planet/templates/_helpers.tpl b/deploy/helm/planet/templates/_helpers.tpl new file mode 100644 index 00000000..b1f9d38d --- /dev/null +++ b/deploy/helm/planet/templates/_helpers.tpl @@ -0,0 +1,33 @@ +{{- define "planet.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "planet.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := include "planet.name" . -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "planet.labels" -}} +app.kubernetes.io/name: {{ include "planet.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "planet.selectorLabels" -}} +app.kubernetes.io/name: {{ include "planet.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "planet.image" -}} +{{- printf "%s/%s/%s:%s" .root.Values.global.imageRegistry .root.Values.global.imageNamespace .repository .root.Values.image.tag -}} +{{- end -}} diff --git a/deploy/helm/planet/templates/aiprovider.yaml b/deploy/helm/planet/templates/aiprovider.yaml new file mode 100644 index 00000000..e934375c --- /dev/null +++ b/deploy/helm/planet/templates/aiprovider.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "planet.fullname" . }}-aiprovider + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: aiprovider +spec: + replicas: {{ .Values.aiprovider.replicaCount }} + selector: + matchLabels: + {{- include "planet.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: aiprovider + template: + metadata: + labels: + {{- include "planet.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: aiprovider + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: aiprovider + image: {{ include "planet.image" (dict "root" . "repository" .Values.aiprovider.image.repository) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8010 + envFrom: + - configMapRef: + name: {{ include "planet.fullname" . }}-config + - secretRef: + name: {{ include "planet.fullname" . }}-secrets + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + resources: + {{- toYaml .Values.aiprovider.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "planet.fullname" . }}-aiprovider + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: aiprovider +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.aiprovider.service.port }} + targetPort: http + selector: + {{- include "planet.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: aiprovider diff --git a/deploy/helm/planet/templates/backend.yaml b/deploy/helm/planet/templates/backend.yaml new file mode 100644 index 00000000..1aca2977 --- /dev/null +++ b/deploy/helm/planet/templates/backend.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "planet.fullname" . }}-backend + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "planet.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + labels: + {{- include "planet.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: backend + image: {{ include "planet.image" (dict "root" . "repository" .Values.backend.image.repository) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8000 + envFrom: + - configMapRef: + name: {{ include "planet.fullname" . }}-config + - secretRef: + name: {{ include "planet.fullname" . }}-secrets + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "planet.fullname" . }}-backend + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.backend.service.port }} + targetPort: http + selector: + {{- include "planet.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend diff --git a/deploy/helm/planet/templates/configmap.yaml b/deploy/helm/planet/templates/configmap.yaml new file mode 100644 index 00000000..f7988c22 --- /dev/null +++ b/deploy/helm/planet/templates/configmap.yaml @@ -0,0 +1,27 @@ +{{- $postgresHost := .Values.postgresql.external.host -}} +{{- if .Values.postgresql.internal.enabled -}} +{{- $postgresHost = printf "%s-postgresql" (include "planet.fullname" .) -}} +{{- end -}} +{{- $redisHost := .Values.redis.external.host -}} +{{- if .Values.redis.internal.enabled -}} +{{- $redisHost = printf "%s-redis" (include "planet.fullname" .) -}} +{{- end -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "planet.fullname" . }}-config + labels: + {{- include "planet.labels" . | nindent 4 }} +data: + DATABASE_URL: "postgresql+asyncpg://{{ .Values.postgresql.external.username }}:{{ .Values.postgresql.external.password }}@{{ $postgresHost }}:{{ .Values.postgresql.external.port }}/{{ .Values.postgresql.external.database }}" + REDIS_SERVER: {{ $redisHost | quote }} + REDIS_PORT: {{ .Values.redis.external.port | quote }} + REDIS_DB: {{ .Values.redis.external.db | quote }} + AI_BASE_URL: "http://{{ include "planet.fullname" . }}-aiprovider:{{ .Values.aiprovider.service.port }}" + AI_PROVIDER_API: "http://{{ include "planet.fullname" . }}-aiprovider:{{ .Values.aiprovider.service.port }}" + {{- range $key, $value := .Values.backend.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- range $key, $value := .Values.aiprovider.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/planet/templates/frontend.yaml b/deploy/helm/planet/templates/frontend.yaml new file mode 100644 index 00000000..b4cfc215 --- /dev/null +++ b/deploy/helm/planet/templates/frontend.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "planet.fullname" . }}-frontend + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + selector: + matchLabels: + {{- include "planet.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + labels: + {{- include "planet.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: frontend + image: {{ include "planet.image" (dict "root" . "repository" .Values.frontend.image.repository) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 3000 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "planet.fullname" . }}-frontend + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.frontend.service.port }} + targetPort: http + selector: + {{- include "planet.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend diff --git a/deploy/helm/planet/templates/ingress.yaml b/deploy/helm/planet/templates/ingress.yaml new file mode 100644 index 00000000..768b0c15 --- /dev/null +++ b/deploy/helm/planet/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.frontend.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "planet.fullname" . }} + labels: + {{- include "planet.labels" . | nindent 4 }} + {{- with .Values.frontend.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.frontend.ingress.className | quote }} + {{- with .Values.frontend.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.frontend.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "planet.fullname" . }}-frontend + port: + name: http +{{- end }} diff --git a/deploy/helm/planet/templates/secrets.yaml b/deploy/helm/planet/templates/secrets.yaml new file mode 100644 index 00000000..6971fd51 --- /dev/null +++ b/deploy/helm/planet/templates/secrets.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "planet.fullname" . }}-secrets + labels: + {{- include "planet.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $value := .Values.backend.secretEnv }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- range $key, $value := .Values.aiprovider.secretEnv }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/planet/templates/single-node-deps.yaml b/deploy/helm/planet/templates/single-node-deps.yaml new file mode 100644 index 00000000..4805cde3 --- /dev/null +++ b/deploy/helm/planet/templates/single-node-deps.yaml @@ -0,0 +1,116 @@ +{{- if .Values.postgresql.internal.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "planet.fullname" . }}-postgresql + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + serviceName: {{ include "planet.fullname" . }}-postgresql + replicas: 1 + selector: + matchLabels: + {{- include "planet.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: postgresql + template: + metadata: + labels: + {{- include "planet.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: postgresql + spec: + containers: + - name: postgresql + image: postgres:15 + ports: + - name: postgres + containerPort: 5432 + env: + - name: POSTGRES_USER + value: {{ .Values.postgresql.external.username | quote }} + - name: POSTGRES_PASSWORD + value: {{ .Values.postgresql.external.password | quote }} + - name: POSTGRES_DB + value: {{ .Values.postgresql.external.database | quote }} + readinessProbe: + exec: + command: ["pg_isready", "-U", {{ .Values.postgresql.external.username | quote }}] + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 8Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "planet.fullname" . }}-postgresql + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +spec: + ports: + - name: postgres + port: 5432 + targetPort: postgres + selector: + {{- include "planet.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: postgresql +{{- end }} +{{- if .Values.redis.internal.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "planet.fullname" . }}-redis + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + replicas: 1 + selector: + matchLabels: + {{- include "planet.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "planet.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - name: redis + containerPort: 6379 + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "planet.fullname" . }}-redis + labels: + {{- include "planet.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + ports: + - name: redis + port: 6379 + targetPort: redis + selector: + {{- include "planet.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis +{{- end }} diff --git a/deploy/helm/planet/values.single-node.yaml b/deploy/helm/planet/values.single-node.yaml new file mode 100644 index 00000000..027d91c5 --- /dev/null +++ b/deploy/helm/planet/values.single-node.yaml @@ -0,0 +1,29 @@ +global: + imageRegistry: gitea.rclaw.top + imageNamespace: linkong/planet + +image: + tag: latest + +frontend: + ingress: + enabled: true + host: planet.local + +postgresql: + internal: + enabled: true + external: + host: planet-postgresql + port: 5432 + database: planet_db + username: postgres + password: postgres + +redis: + internal: + enabled: true + external: + host: planet-redis + port: 6379 + db: 0 diff --git a/deploy/helm/planet/values.yaml b/deploy/helm/planet/values.yaml new file mode 100644 index 00000000..8b5f903f --- /dev/null +++ b/deploy/helm/planet/values.yaml @@ -0,0 +1,73 @@ +global: + imageRegistry: gitea.rclaw.top + imageNamespace: linkong/planet + imagePullSecrets: [] + +fullnameOverride: planet + +image: + tag: latest + pullPolicy: IfNotPresent + +frontend: + replicaCount: 1 + image: + repository: frontend + service: + port: 3000 + ingress: + enabled: true + className: nginx + host: planet.example.com + annotations: {} + tls: [] + resources: {} + +backend: + replicaCount: 1 + image: + repository: backend + service: + port: 8000 + env: + PROJECT_NAME: Planet + CORS_ORIGINS: '["*"]' + secretEnv: + SECRET_KEY: change-me + resources: {} + +aiprovider: + replicaCount: 1 + image: + repository: aiprovider + service: + port: 8010 + env: + AI_PROVIDER: openai + AI_MODEL: gpt-4o-mini + secretEnv: + AI_API_KEY: "" + resources: {} + +postgresql: + internal: + enabled: false + external: + host: postgres.example.com + port: 5432 + database: planet_db + username: postgres + password: postgres + +redis: + internal: + enabled: false + external: + host: redis.example.com + port: 6379 + db: 0 + +smoke: + frontendPath: / + backendHealthPath: /health + aiProviderHealthPath: /health diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8e6c5e7a..f97be7b8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.53.0] — 2026-05-13 + +Released: 2026-05-13 + +### Highlights +- 新增正式交付基线:Gitea Actions CI、镜像发布、staging 自动部署,以及 Kubernetes Helm chart。 +- 前端生产镜像改为 `vite build` 静态产物 + nginx 托管,后端与 AI Provider 镜像移除开发 reload 并加入容器健康检查。 +- 明确 `planet.sh` 只作为开发启动入口,生产环境交给 Kubernetes Service、Ingress、readiness/liveness probe 和 rollout 管理。 + +### Added / Fixed / Improved +- 新增 frontend/backend/aiprovider 镜像 build smoke、Helm lint/template、staging rollout 与 HTTP smoke test 流水线。 +- 新增 Helm values、single-node 演示依赖、ConfigMap/Secret 引用、ClusterIP 服务和 frontend Ingress。 +- 更新运维文档与交付计划,保留 Vite 生产构建路线,不新增 Webpack 双构建链,Electron 暂不进入主线。 +- 继续收口启动脚本风险:状态目录、健康检查端口默认值、PID 校验、端口诊断和开发环境跨平台边界说明。 + +--- + ## [0.52.0] — 2026-05-12 Released: 2026-05-12 diff --git a/docs/plans/earth-vessel-ais-aggregation-plan.md b/docs/plans/earth-vessel-ais-aggregation-plan.md index 07456a41..14c0b41f 100644 --- a/docs/plans/earth-vessel-ais-aggregation-plan.md +++ b/docs/plans/earth-vessel-ais-aggregation-plan.md @@ -176,7 +176,7 @@ freshness: ## 聚合接口 -状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 不再兼容返回数据,而是返回 `410 Gone`;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws` 的 `vessels` 订阅。 +状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 路由已移除;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws` 的 `vessels` 订阅。 现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。 @@ -346,11 +346,11 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图 5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。 6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。 -### v3.1 — 聚合完整性修复(已被新快照接口取代) +### v3.1 — 聚合完整性修复(已被受控 fallback 取代) -原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot`。 +原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 优先读取 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,才受控回退到 `vessel_position + vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 标记。 -因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录: +因此以下旧 `/geo/vessels` 全量 merge 要求作废,保留在文档中只作为历史决策记录: 1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。 2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。 @@ -462,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 - 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。 - 同一时间窗口内多来源相近轨迹点只展示一个点。 - AISStream 重连或回放导致的重复消息不会重复进入聚合结果。 -- `/api/v1/vessels/snapshot` 只读取 AIS raw observation 聚合结果;legacy latest position 不再参与船只快照。 -- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API。 +- `/api/v1/vessels/snapshot` 优先读取 AIS raw observation 聚合结果;当当前 raw 窗口为空时,允许受控 fallback 到 legacy latest position。 +- `/api/v1/visualization/geo/vessels` 路由已移除,客户端必须迁移到新 snapshot API。 - AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。 - AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。 - `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。 diff --git a/docs/plans/earth-vessel-rendering-performance-plan.md b/docs/plans/earth-vessel-rendering-performance-plan.md index 7d560def..44a59f9b 100644 --- a/docs/plans/earth-vessel-rendering-performance-plan.md +++ b/docs/plans/earth-vessel-rendering-performance-plan.md @@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再 ### 1. 请求视口范围 -前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox`、`zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。 +前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox`、`zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 路由已移除。 ### 2. 后端排序策略 diff --git a/docs/plans/earth-vessel-tracking-plan.md b/docs/plans/earth-vessel-tracking-plan.md index e1ccfc05..c853c824 100644 --- a/docs/plans/earth-vessel-tracking-plan.md +++ b/docs/plans/earth-vessel-tracking-plan.md @@ -120,11 +120,12 @@ CREATE UNIQUE INDEX ON vessel_latest(mmsi); #### 1.3 API 端点 -``` -GET /api/v1/visualization/geo/vessels - ?bbox=lon_min,lat_min,lon_max,lat_max # 视口裁剪 +```http +GET /api/v1/vessels/snapshot + ?bbox=lon_min,lat_min,lon_max,lat_max # 必填,视口裁剪 + ?zoom=12 # 必填,当前缩放 ?type=cargo,tanker,passenger # 船型过滤 - ?limit=0 # 可选;不传或 0 表示不裁剪数量 + ?limit=1000 # 默认 1000,最大 5000 → GeoJSON FeatureCollection(Point) GET /api/v1/visualization/vessels/{mmsi} # 单船详情 @@ -163,7 +164,7 @@ GeoJSON Feature 格式: - 后端 BarentsWatch collector 继续以 HTTP polling 方式采集 - AISStream 等实时源以独立 WebSocket collector 写入原始观测层 - 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值 -- 前端默认不再给 `/geo/vessels` 传 `limit=5000`,`VESSEL_CONFIG.maxRenderedMarkers = 0` 表示不做前端数量裁剪;后续如性能不足再引入显式 LOD 上限 +- 旧 `/api/v1/visualization/geo/vessels` 路由已移除,前端必须使用受控 snapshot 接口。 - marker 颜色、详情卡、hover 和搜索结果必须共享 `vessel_type_display` 船型归一化结果,避免 AIS 数字类型码已驱动颜色但卡片仍显示 `Other` - 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源 diff --git a/docs/plans/production-delivery-cicd-stabilization-plan.md b/docs/plans/production-delivery-cicd-stabilization-plan.md new file mode 100644 index 00000000..3bbc3d05 --- /dev/null +++ b/docs/plans/production-delivery-cicd-stabilization-plan.md @@ -0,0 +1,55 @@ +# Planet 正式交付与 CI/CD 稳定化计划 + +## Summary + +- CI/CD 平台采用 Gitea Actions,由自托管 `act_runner` 执行。 +- 正式交付目标采用 Kubernetes,端口、健康检查、重启和滚动发布交给 Service、Ingress、readiness/liveness probe。 +- Vite 继续保留,但只作为开发服务器和生产构建工具;生产运行 nginx 托管 `vite build` 产物。 +- 不新增 Webpack 双构建链。Electron 仅在离线桌面交付成为明确目标后再评估。 + +## Key Changes + +- 生产镜像: + - frontend 多阶段构建,`bun install` + `bun run build`,最终 nginx 托管 `dist`。 + - backend/aiprovider 移除 `--reload`,加入容器健康检查。 + - 镜像标签使用 `//:`,发布 tag 额外推 `vX.Y.Z`。 +- Kubernetes: + - 新增 Helm chart:`deploy/helm/planet`。 + - frontend 暴露 Ingress;backend/aiprovider 默认 ClusterIP。 + - PostgreSQL/Redis 默认外部依赖,`values.single-node.yaml` 提供演示/测试内置依赖。 +- Gitea Actions: + - `ci.yaml`:后端测试、前端构建、Docker build smoke、Helm render。 + - `release.yaml`:构建并推送三类镜像。 + - `deploy-staging.yaml`:部署 staging、等待 rollout、执行 smoke tests。 +- 开发脚本边界: + - `planet.sh` 保留为本地开发便利脚本。 + - CI/CD 与正式部署不调用 `planet.sh start`。 + +## Vite / Webpack / Electron Decision + +中肯结论:不要因为“企业生产环境”这件事去做 Webpack 版本;继续用 Vite,但把“开发服务器”和“生产构建/部署”分清楚。Electron 也不要现在做,除非正式版目标明确是离线桌面软件。 + +Vite 可以用于生产构建。生产环境运行的是 `vite build` 产出的静态资源,不是 Vite dev server。当前项目已经使用 React + Vite + Bun、`import.meta.env`、`public/earth` 静态资产路径和大量 Three.js/ES module 资源引用。维护 Webpack 双构建链会显著增加路径、资源、环境变量和回归测试成本。 + +如果未来客户环境确实要求更接近 Webpack 生态,优先做 Rsbuild/Rspack 技术 spike,而不是直接维护 Webpack 并行构建。Electron 适合离线运行、本地硬件/文件访问、系统托盘、自动更新和安装包分发;但 Planet 目前还包含 backend、database、Redis、AI Provider、Motion Agent 等服务编排,桌面壳不能解决正式交付的核心问题。 + +## Test Plan + +- CI gates: + - `uv sync --group dev` + - `uv run pytest backend/tests/test_api.py backend/tests/test_realtime_sources.py -q` + - `cd frontend && bun install --frozen-lockfile && bun run build` + - Docker build frontend/backend/aiprovider + - `helm lint deploy/helm/planet` + - `helm template planet-staging deploy/helm/planet -f deploy/helm/planet/values.single-node.yaml` +- Staging deployment: + - `helm upgrade --install planet-staging deploy/helm/planet --namespace planet-staging` + - 等待 frontend/backend/aiprovider rollout。 + - smoke test frontend `/`、frontend `/health`、backend `/health`、aiprovider `/health`。 + +## Acceptance Criteria + +- main/dev 提交能通过 CI。 +- 发布 workflow 能生成可追踪镜像。 +- staging 可从零部署并完成滚动升级。 +- 正式部署不依赖本机端口清理,也不运行 Vite dev server。 diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index 7fa662d2..d1b876c0 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -299,6 +299,17 @@ State semantics: AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it. +The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters: + +```http +GET /api/v1/realtime-sources +POST /api/v1/realtime-sources/{source}/start +POST /api/v1/realtime-sources/{source}/stop +POST /api/v1/realtime-sources/{source}/restart +``` + +`aisstream_vessels` and custom `source_type=websocket` sources appear in that API. They do not participate in one-click collection percentages; the UI interprets them as long-lived services with message counters, lag, last success, and last error. + ### AIS Raw Observations And Aggregation AIS observations do not directly replace final vessel records. They are first saved as raw observations: @@ -318,7 +329,7 @@ GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts ``` -`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It reads only aggregated `ais_raw_observations`; it no longer merges legacy `vessel_position` / `vessel_static` rows. The old `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. +`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It prefers aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and marks that path with `diagnostics.legacy_fallback_used`. The old `/api/v1/visualization/geo/vessels` route has been removed. Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport: diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 77d857bc..93dd2d16 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -288,7 +288,7 @@ Normalization: Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key. -Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while the `aisstream_vessels` collector is `streaming` / `connected` and its message count plus `last_seen_at` keep advancing. +Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while `aisstream_vessels` is `streaming` / `connected` and its realtime stream counters plus `last_seen_at` keep advancing. Start, stop, reconnect, health, and counters are exposed through `/datasources -> Realtime Streams` and `/api/v1/realtime-sources`; AISStream is not counted in normal one-click collection progress. The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call: @@ -296,7 +296,7 @@ The new vessel list entry point is no longer the legacy `/api/v1/visualization/g GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 ``` -That endpoint reads local aggregated `ais_raw_observations` only. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI. +That endpoint prefers local aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and exposes that through `diagnostics.legacy_fallback_used`. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI. ## Custom REST / WebSocket Mapping Runtime diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 5e7a4811..da9fd1b4 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -154,7 +154,7 @@ The `earth:compute-center-location-saved` reconciliation pipeline is deliberatel The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection. -The legacy `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend no longer merges legacy `vessel_position` / `vessel_static` rows into vessel snapshots, so the frontend must not depend on old BarentsWatch-only fallback rows. +The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend only falls back to legacy `vessel_position` / `vessel_static` rows when the current raw window is empty; frontend code can detect that state through `diagnostics.legacy_fallback_used`. The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport. diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index e323680e..9c4f9fd9 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -130,11 +130,12 @@ Steps: 3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream` 4. Click the plug icon to test; confirm it reports `Reachable` 5. Save collector settings -6. Trigger the `aisstream_vessels` collector from the collection scheduler -7. Watch the `AISStream Runtime` panel: +6. Open the `Realtime Streams` tab on `/datasources` and find `AISStream Realtime Vessels` +7. Use `Start`, `Stop`, or `Reconnect` there. The normal `Collection Tasks` tab does not count AISStream in one-click collection or percentage progress +8. Watch the realtime stream panel: - `streaming` / `connected` means the live stream is being consumed - - `messages this round` should keep growing - - `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect + - `total stored`, `last 24h`, `last 1h`, and `unique MMSI` show historical collection volume + - `disconnected` with a recent error means the upstream or network dropped; click `Reconnect` ## Configure AI Credentials @@ -203,7 +204,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or ## Data Exploration -- `/datasources`: source directory. It can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list +- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list - `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" - `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth - `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts diff --git a/docs/technical/en/ops-planet-sh-startup.md b/docs/technical/en/ops-planet-sh-startup.md index c2c1d0af..4b3f2943 100644 --- a/docs/technical/en/ops-planet-sh-startup.md +++ b/docs/technical/en/ops-planet-sh-startup.md @@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match ### Fix -The stamp file moved to a persistent cache path: +The stamp file moved from a temporary location to a persistent cache path: ```bash -AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" +AI_PROVIDER_BUILD_STAMP_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/planet/aiprovider_build.sha256" ``` Writing the stamp creates the directory first: @@ -92,7 +92,7 @@ COPY aiprovider /app/aiprovider ### Runtime Configuration -Before starting AI Provider, `planet.sh` generates a temporary env-file and passes it to Compose or the manual `docker run` fallback. Configuration priority: +Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose or the manual `docker run` fallback. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority: 1. `aiprovider/.env` 2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc` @@ -188,6 +188,62 @@ Before the stamp path fix: After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`. +## State Files, Logs, and Failed-Start Cleanup + +`planet.sh` no longer writes PID files, logs, or runtime env-files to fixed `/tmp/planet_*` paths. The default state directory is: + +```bash +${XDG_STATE_HOME:-$HOME/.local/state}/planet +``` + +At startup the script creates this directory and tries to set it to `700`. The current files include: + +- `backend.pid` / `frontend.pid` / `motion_agent.pid` +- `backend.log` / `frontend.log` / `motion_agent.log` +- `aiprovider_build.log` +- `aiprovider_runtime.env` +- `ports.env` + +PID writes validate that the PID is a positive integer, include a trailing newline, and try to set file mode `600`. PID reads ignore invalid content instead of passing it to `kill`. + +After a successful `start`, the script records the ports in `ports.env`. Later `./planet.sh health` calls prefer the last started ports; if the state file is missing, health checks fall back to the defaults `8000`, `3000`, `8010`, and `8765`. This avoids checking default ports after starting with custom ports. + +Startup now has light failed-start cleanup. If `start` exits before completing, the script only cleans local processes that this run already started: backend, frontend, and Motion Agent. It does not stop services after a successful start. AI Provider, PostgreSQL, and Redis keep their existing container lifecycle behavior. + +## Health Checks and Hardening + +HTTP readiness checks now use `curl -fsS --max-time`, so 4xx and 5xx responses are no longer treated as healthy. + +Process termination now validates: + +- signal names are limited to `TERM`, `KILL`, `INT`, and `HUP`; +- PIDs must be positive integers; +- process group IDs must be positive integers. + +This prevents bad PID files or invalid signals from reaching `kill`. + +Frontend and Motion Agent startup failures now call `print_port_listener_details()`, matching backend port diagnostics. The Windows-side listener and cleanup path still only runs when WSL is detected. + +## Cross-Platform Notes + +The script is currently Linux-first with WSL enhancements. Normal Linux runs do not execute the PowerShell path; WSL gets extra Windows listener, portproxy, and camera guidance. + +To make this single script fully portable across Linux, macOS, and WSL, the remaining platform differences should be wrapped behind compatibility helpers: + +- `stat --format`, `sort -V`, and `xargs -r` are GNU-style and are not fully compatible with default macOS BSD tools. +- `hostname -I`, `ss`, `fuser`, and `systemctl` are usually unavailable on macOS. +- `tac` may be missing on macOS; use `awk` or Python as a fallback. +- Docker Desktop on macOS does not use `systemctl` daemon diagnostics. +- Camera auto-detection relies on `/dev/video*` / `v4l2-ctl`, which is Linux-specific; macOS should use explicit camera URLs or a separate AVFoundation detector. + +The recommended direction is a small platform compatibility layer for port listener detection, version comparison, file metadata, reverse tail, LAN IP discovery, and Docker daemon diagnostics, instead of scattering more platform branches throughout service startup logic. + +## Production Delivery Boundary + +`planet.sh` is a local development convenience script, not the production startup entrypoint. Production delivery should use Kubernetes `Deployment`, `Service`, `Ingress`, and readiness/liveness probes for ports, health checks, restarts, and rolling upgrades. This removes the need for a host script to reclaim local ports and avoids running the Vite dev server in production. + +The production frontend shape is `vite build` static output served by nginx/Caddy or an equivalent HTTP server. Do not use `bun run dev` or `vite preview` in production. The project does not maintain a parallel Webpack build chain; if a future enterprise requirement needs closer Webpack-ecosystem compatibility, run an Rsbuild/Rspack spike first. Electron should only be evaluated when the official target becomes an offline desktop application. + ## Optional Motion Agent Startup `planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable. diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index a5baee29..fb7743e6 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through: 1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first 2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional -3. `/datasources` or `/data`: check whether the collectors have produced data +3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters 4. `/alerts/system`: verify system alerts look right 5. `/users` (super_admin only): open accounts for teammates or adjust their groups diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index 6afd1b66..2b5ddad8 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -326,6 +326,17 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默 AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。 +控制台通过 `/datasources -> 实时流` 管理 AISStream,而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数: + +```http +GET /api/v1/realtime-sources +POST /api/v1/realtime-sources/{source}/start +POST /api/v1/realtime-sources/{source}/stop +POST /api/v1/realtime-sources/{source}/restart +``` + +`aisstream_vessels` 和自定义 `source_type=websocket` 数据源会出现在该接口中。它们不参与一键采集百分比;前端按长连接服务展示消息计数、延迟、最近成功和最近错误。 + ### AIS 原始观测与聚合 AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation: @@ -345,7 +356,7 @@ GET /api/v1/visualization/vessels/{mmsi}/track GET /api/v1/visualization/vessels/{mmsi}/conflicts ``` -`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,默认 `limit=1000`,最大 `limit=5000`。它只消费 `ais_raw_observations` 聚合结果,不再读取 legacy `vessel_position` / `vessel_static` 作为兜底。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。 +`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,默认 `limit=1000`,最大 `limit=5000`。它优先消费 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并在 `diagnostics.legacy_fallback_used` 中标明。旧 `/api/v1/visualization/geo/vessels` 路由已移除。 实时增量通过 `/ws` 的 `vessels` channel 推送。客户端订阅时必须带当前视口: diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index 708bed24..63699190 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -290,7 +290,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` 连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。 -连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置,WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` collector 任务处于 `streaming` / `connected`,并且 `本轮消息数`、`last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。 +连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置,WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` 处于 `streaming` / `connected`,并且实时流计数、`last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。启动、停止、重连、健康状态和计数统一从 `/datasources -> 实时流` 和 `/api/v1/realtime-sources` 查看;AISStream 不再参与普通一键采集进度。 新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用: @@ -298,7 +298,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations` GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 ``` -该接口只查询本地 `ais_raw_observations` 聚合结果。实时更新走 `/ws` 的 `vessels` channel,订阅时必须提供 `bbox`、`zoom` 和 `limit`。服务端按连接过滤 bbox,并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。 +该接口优先查询本地 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 暴露。实时更新走 `/ws` 的 `vessels` channel,订阅时必须提供 `bbox`、`zoom` 和 `limit`。服务端按连接过滤 bbox,并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。 ## 自定义 REST / WebSocket 映射运行时 diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 3cf13d9c..5b908aa7 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -311,7 +311,7 @@ AIS 船只图层入口: AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。 -旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端不再把 legacy `vessel_position` / `vessel_static` 合并进船只快照,前端也不应依赖旧表里的 BarentsWatch-only 兜底数据。 +旧 `/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端只在当前 raw 窗口为空时受控回退到 legacy `vessel_position` / `vessel_static`,前端可通过 `diagnostics.legacy_fallback_used` 识别该状态。 新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox`、`zoom` 和受控 `limit`,响应会返回 `visible_count`、`returned_count` 和 `diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products` 或 `/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 5cc96134..1ca1ed0a 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -133,11 +133,12 @@ 3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream` 4. 点击插头图标进行连接测试,确认显示 `可用` 5. 保存采集器设置 -6. 到采集调度入口运行 `aisstream_vessels` collector -7. 在 `AISStream 运行状态` 中观察: - - `streaming` / `connected` 表示正在接收实时流 - - `本轮消息数` 应持续增长 - - 如果显示 `disconnected` 且错误为 `ConnectionResetError`,需要重新触发或等待重连 +6. 打开 `/datasources` 的 `实时流` tab,找到 `AISStream 实时船舶` +7. 点击 `启动`、`停止` 或 `重连` 管理长连接;这里不显示百分比进度 +8. 在实时流卡片中观察: + - `connected` 表示正在接收实时流 + - `累计入库`、`近 24h`、`近 1h` 和 `唯一 MMSI` 用于判断历史采集量 + - 如果显示 `disconnected` 且有最近错误,可以点击 `重连` ## 配置 AI 凭证 @@ -206,7 +207,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会 ## 数据探索 -- `/datasources`:数据源目录。可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“采集当前筛选”只触发当前筛选范围。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 +- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 - `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" - `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 - `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警 diff --git a/docs/technical/zh/ops-planet-sh-startup.md b/docs/technical/zh/ops-planet-sh-startup.md index fe9f3bfc..d939811f 100644 --- a/docs/technical/zh/ops-planet-sh-startup.md +++ b/docs/technical/zh/ops-planet-sh-startup.md @@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match ### 修复 -将戳文件路径从 `/tmp/` 改到持久路径: +将戳文件路径从临时目录改到持久缓存路径: ```bash -AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" +AI_PROVIDER_BUILD_STAMP_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/planet/aiprovider_build.sha256" ``` 写入时确保目录存在: @@ -94,7 +94,7 @@ COPY aiprovider /app/aiprovider ### 运行期配置来源 -`planet.sh` 启动 AI Provider 前会生成临时 env-file,并把它传给 Compose 或手动 `docker run` fallback。配置优先来自: +`planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file,并把它传给 Compose 或手动 `docker run` fallback。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自: 1. `aiprovider/.env` 2. `~/.zshrc` 中简单的 `export AI_...=...` 或 `AI_...=...` 行 @@ -202,6 +202,62 @@ PY 修复戳文件路径后,无参 `restart` 同样使用 `stop + start`,fingerprint 检查正常生效,行为与 `restart -b` 完全一致。无需额外代码变更。 +## 状态文件、日志与失败清理 + +`planet.sh` 不再把 PID、日志和运行期 env-file 写入固定 `/tmp/planet_*` 路径。默认状态目录为: + +```bash +${XDG_STATE_HOME:-$HOME/.local/state}/planet +``` + +脚本启动时会创建该目录并尽量设置为 `700`。当前使用的文件包括: + +- `backend.pid` / `frontend.pid` / `motion_agent.pid` +- `backend.log` / `frontend.log` / `motion_agent.log` +- `aiprovider_build.log` +- `aiprovider_runtime.env` +- `ports.env` + +PID 文件写入前会校验 PID 为正整数,写入时带换行并尽量设置为 `600`。读取 PID 文件时,如果内容不是数字,脚本会忽略该文件,不会把垃圾内容传给 `kill`。 + +`start` 成功后会把本次端口写入 `ports.env`。后续执行 `./planet.sh health` 时,会优先检查上次启动端口;如果没有状态文件,则回退到默认端口 `8000`、`3000`、`8010`、`8765`。这避免了用自定义端口启动后,健康检查仍只看默认端口的问题。 + +启动过程有轻量失败清理:如果 `start` 中途失败,脚本只清理本轮已经拉起的本地进程(backend、frontend、Motion Agent),不会在正常启动完成后停止服务。AI Provider、PostgreSQL 和 Redis 容器仍按原有容器生命周期管理。 + +## 健康检查与安全加固 + +HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4xx/5xx 不再被视为在线。 + +进程终止路径现在会校验: + +- signal 只允许 `TERM`、`KILL`、`INT`、`HUP`; +- PID 必须是正整数; +- 进程组 PGID 必须是正整数。 + +这可以避免坏 PID 文件或错误 signal 造成不可预期的 `kill` 行为。 + +前端和 Motion Agent 启动失败时,现在也会调用 `print_port_listener_details()`,输出与后端一致的端口监听诊断。WSL 下如果端口看起来被 Windows 侧占用,脚本仍只在检测到 WSL 时才调用 PowerShell 诊断或清理路径。 + +## 跨平台注意事项 + +当前脚本是 Linux-first,并带有 WSL 增强。普通 Linux 不会执行 WSL PowerShell 逻辑;WSL 下会额外提供 Windows listener、portproxy 和摄像头提示。 + +如果要把同一份脚本扩展为 Linux、macOS、WSL 三平台通用,还需要继续封装这些命令差异: + +- `stat --format`、`sort -V`、`xargs -r` 是 GNU 风格,macOS 默认 BSD 工具不完全兼容。 +- `hostname -I`、`ss`、`fuser`、`systemctl` 在 macOS 上通常不可用。 +- `tac` 在 macOS 上不一定存在,可用 `awk` 或 Python 兜底。 +- Docker Desktop on macOS 不适用 `systemctl` daemon 诊断。 +- 摄像头自动发现依赖 `/dev/video*` / `v4l2-ctl`,这是 Linux 路线;macOS 应显式使用 camera URL 或另做 AVFoundation 检测。 + +维护方向是增加一个小的 platform compatibility 层,把端口监听检测、版本比较、文件元信息、反向 tail、LAN IP 获取和 Docker daemon 诊断集中处理,而不是在业务启动流程里继续散落平台判断。 + +## 正式交付边界 + +`planet.sh` 是本地开发便利脚本,不作为正式生产启动入口。正式交付应通过 Kubernetes 的 `Deployment`、`Service`、`Ingress`、readiness/liveness probe 管理端口、健康检查、重启和滚动发布。这样生产环境不需要脚本抢占宿主机端口,也不会依赖 Vite dev server。 + +前端生产形态是 `vite build` 生成静态资源,再由 nginx/Caddy 等 HTTP 服务器托管。不要在生产中使用 `bun run dev` 或 `vite preview`。当前不维护 Webpack 双构建链;如果未来需要评估更企业化的构建生态,优先做 Rsbuild/Rspack spike。Electron 仅在正式目标变成离线桌面软件时再单独评估。 + ## Motion Agent 可选启动 `planet.sh` 现在可以管理本地动作捕捉 Agent,但默认不会启动它,避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 4187a47d..0313d6e0 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -34,7 +34,7 @@ 1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret 2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 -3. `/datasources` 或 `/data`:看采集器是否已经产出数据 +3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数 4. `/alerts/system`:看系统告警是否正常 5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组 diff --git a/docs/version-history.md b/docs/version-history.md index f8f8a49a..5d1d0e38 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.52.0` +- `dev` 当前开发分支历史推导到:`0.53.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test,明确 Vite 生产构建和 planet.sh 开发入口边界 | | `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 | | `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 | | `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 | diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..35bbc043 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +*.log +coverage +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b4c8de68..12b62ede 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1-alpine +FROM oven/bun:1-alpine AS build WORKDIR /app @@ -6,7 +6,16 @@ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . +RUN bun run build + +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 3000 -CMD ["bun", "run", "dev", "--", "--host", "0.0.0.0"] +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/health >/dev/null || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..71444a62 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,56 @@ +server { + listen 3000; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + location = /health { + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + location = /api/health { + proxy_pass http://planet-backend:8000/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://planet-backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ws { + proxy_pass http://planet-backend:8000/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /index.html { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json index 0831d18a..6c8597ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.52.0", + "version": "0.53.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/vessels.js b/frontend/public/earth/js/vessels.js index ae832f2b..231d77ba 100644 --- a/frontend/public/earth/js/vessels.js +++ b/frontend/public/earth/js/vessels.js @@ -259,9 +259,10 @@ const vesselIconLayer = createInteractableLayer({ }); const DEFAULT_VESSEL_VIEWPORT = { - bbox: [-180, -90, 180, 90], - zoom: 2, + bbox: [-10, 50, 35, 75], + zoom: 4, }; +const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500; export function getVesselMarkers() { return vesselIconLayer.getMarkers(); @@ -353,8 +354,34 @@ export async function loadVessels(_scene, earth, options = {}) { }; } -export function startVesselRealtime(earth, { onUpdate } = {}) { +function normalizeVesselViewportOptions(options = {}) { + const bbox = Array.isArray(options.bbox) && options.bbox.length === 4 + ? options.bbox.map(Number) + : DEFAULT_VESSEL_VIEWPORT.bbox; + const [lonA, latA, lonB, latB] = bbox; + const normalizedBbox = [ + Math.max(-180, Math.min(lonA, lonB)), + Math.max(-90, Math.min(latA, latB)), + Math.min(180, Math.max(lonA, lonB)), + Math.min(90, Math.max(latA, latB)), + ]; + const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]); + const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA + ? normalizedBbox + : DEFAULT_VESSEL_VIEWPORT.bbox; + const zoom = Number.isFinite(Number(options.zoom)) + ? Number(options.zoom) + : DEFAULT_VESSEL_VIEWPORT.zoom; + return { + bbox: safeBbox, + zoom: Math.max(1, Math.min(20, Math.round(zoom))), + limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers, + }; +} + +export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) { if (vesselStreamSocket || typeof WebSocket === "undefined") return; + const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit }); const connect = () => { if (!showVessels || vesselStreamSocket) return; const socket = new WebSocket(getVesselStreamUrl()); @@ -369,9 +396,9 @@ export function startVesselRealtime(earth, { onUpdate } = {}) { type: "subscribe", data: { channel: "vessels", - bbox: DEFAULT_VESSEL_VIEWPORT.bbox, - zoom: DEFAULT_VESSEL_VIEWPORT.zoom, - limit: VESSEL_CONFIG.maxRenderedMarkers, + bbox: subscriptionOptions.bbox, + zoom: subscriptionOptions.zoom, + limit: subscriptionOptions.limit, }, })); }; diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index b339fc2c..a0f6d4cd 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -15,6 +15,7 @@ import { Select, Space, Table, + Tabs, Tag, Tooltip, Typography, @@ -84,6 +85,50 @@ interface CustomDataSourceOverride { updated_at: string | null } +interface RealtimeSourceHealth { + connection_state: string + last_seen_at: string | null + last_success_at: string | null + last_error: string | null + message_rate: number | null + lag_seconds: number | null + updated_at: string | null +} + +interface RealtimeSourceStats { + total_observations: number + observations_24h: number + observations_1h: number + unique_mmsi_total: number + unique_mmsi_24h: number + latest_observed_at: string | null + latest_collected_at: string | null +} + +interface RealtimeSourceRuntime { + running: boolean + done: boolean + runtime: string +} + +interface RealtimeSource { + source: string + name: string + display_name: string + kind: 'builtin' | 'custom' + config_id?: number + source_type: string + endpoint?: string + is_active: boolean + credential_configured: boolean + message_types: string[] + bounding_boxes: unknown[] + config: Record + runtime: RealtimeSourceRuntime + health: RealtimeSourceHealth | null + stats: RealtimeSourceStats +} + interface EditableDataSourceConfig { id: number name: string @@ -200,6 +245,10 @@ const PRODUCT_TAG_COLORS: Record = { other: 'default', } +function isRealtimeBuiltinSource(source: BuiltInDataSource | UnifiedDataSource) { + return source.source === 'aisstream_vessels' +} + function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource { return { key: `builtin:${source.id}`, @@ -242,7 +291,11 @@ function DataSources() { const [modal, modalContextHolder] = Modal.useModal() const [builtInSources, setBuiltInSources] = useState([]) const [customOverrides, setCustomOverrides] = useState([]) + const [realtimeSources, setRealtimeSources] = useState([]) const [loading, setLoading] = useState(false) + const [realtimeLoading, setRealtimeLoading] = useState(false) + const [realtimeActionSource, setRealtimeActionSource] = useState(null) + const [activeTab, setActiveTab] = useState('tasks') const [triggerAllLoading, setTriggerAllLoading] = useState(false) const [forceTriggerAll, setForceTriggerAll] = useState(false) const [selectedRowKeys, setSelectedRowKeys] = useState([]) @@ -259,7 +312,11 @@ function DataSources() { const [tableHeight, setTableHeight] = useState(360) const tableRegionRef = useRef(null) - const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources]) + const taskBuiltInSources = useMemo( + () => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)), + [builtInSources], + ) + const allSources = useMemo(() => taskBuiltInSources.map(normalizeBuiltin), [taskBuiltInSources]) const selectedSourceIds = useMemo( () => selectedRowKeys .map((key) => allSources.find((source) => source.key === key)?.id) @@ -267,13 +324,13 @@ function DataSources() { [allSources, selectedRowKeys], ) - const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length - const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length - const runningBuiltInSources = builtInSources.filter((source) => source.is_running) + const activeBuiltInCount = taskBuiltInSources.filter((source) => source.is_active).length + const collectedBuiltInCount = taskBuiltInSources.filter((source) => source.has_collected_data).length + const runningBuiltInSources = taskBuiltInSources.filter((source) => source.is_running) const runningBuiltInCount = runningBuiltInSources.length const aggregateProgress = runningBuiltInCount > 0 ? Math.round( - builtInSources + taskBuiltInSources .filter((source) => source.is_running) .reduce((sum, source) => sum + (source.progress || 0), 0) / runningBuiltInCount, ) @@ -304,10 +361,27 @@ function DataSources() { } }, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter]) + const fetchRealtimeSources = useCallback(async () => { + setRealtimeLoading(true) + try { + const response = await axios.get('/api/v1/realtime-sources') + setRealtimeSources(response.data.data || []) + } catch (error) { + console.error('Failed to fetch realtime sources:', error) + messageApi.error('获取实时流状态失败') + } finally { + setRealtimeLoading(false) + } + }, [messageApi]) + useEffect(() => { void fetchData() }, [fetchData]) + useEffect(() => { + void fetchRealtimeSources() + }, [fetchRealtimeSources]) + useEffect(() => { const visibleKeys = new Set(allSources.map((source) => source.key)) setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key)))) @@ -324,6 +398,20 @@ function DataSources() { return () => observer.disconnect() }, [allSources.length]) + const runRealtimeAction = async (source: string, action: 'start' | 'stop' | 'restart') => { + try { + setRealtimeActionSource(`${source}:${action}`) + await axios.post(`/api/v1/realtime-sources/${encodeURIComponent(source)}/${action}`) + messageApi.success(action === 'start' ? '实时流已启动' : action === 'stop' ? '实时流已停止' : '实时流已重连') + await Promise.all([fetchRealtimeSources(), fetchData()]) + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string } } } + messageApi.error(err.response?.data?.detail || '实时流操作失败') + } finally { + setRealtimeActionSource(null) + } + } + const fetchDatasourceTaskStatus = async (id: number) => { const res = await axios.get(`/api/v1/datasources/${id}/task-status`) return res.data @@ -481,6 +569,160 @@ function DataSources() { } } + const formatCount = (value?: number | null) => Number(value || 0).toLocaleString() + const formatLagSeconds = (value?: number | null) => { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-' + const seconds = Number(value) + if (seconds < 60) return `${Math.round(seconds)} 秒` + if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟` + return `${Math.round(seconds / 3600)} 小时` + } + const getConnectionColor = (state?: string | null) => { + if (state === 'connected') return 'success' + if (state === 'connecting' || state === 'reconnecting') return 'processing' + if (state === 'disconnected') return 'default' + return 'warning' + } + const renderConfigPreview = (value: unknown) => { + if (!Array.isArray(value) || value.length === 0) return '-' + return JSON.stringify(value) + } + + const realtimeTotals = realtimeSources.reduce( + (acc, source) => ({ + running: acc.running + (source.runtime?.running ? 1 : 0), + total: acc.total + (source.stats?.total_observations || 0), + recent24h: acc.recent24h + (source.stats?.observations_24h || 0), + }), + { running: 0, total: 0, recent24h: 0 }, + ) + + const renderRealtimeSource = (source: RealtimeSource) => { + const state = source.health?.connection_state || (source.runtime?.running ? 'running' : 'disconnected') + const actionBusyPrefix = `${source.source}:` + return ( + + {source.display_name || source.name} + {source.kind === 'builtin' ? '内置' : '自定义'} + {source.is_active ? '启用' : '禁用'} + + {source.credential_configured ? '凭证已配置' : '缺少凭证'} + + + {source.runtime?.running ? '运行中' : '未运行'} + + + )} + extra={( + + + + + + )} + > + + + + 连接状态 +
{state}
+ + + 累计入库 +
{formatCount(source.stats?.total_observations)}
+ + + 近 24h +
{formatCount(source.stats?.observations_24h)}
+ + + 近 1h +
{formatCount(source.stats?.observations_1h)}
+ + + 唯一 MMSI +
{formatCount(source.stats?.unique_mmsi_total)}
+ + + 近 24h MMSI +
{formatCount(source.stats?.unique_mmsi_24h)}
+ + + 最近收到 +
{formatDateTimeZhCN(source.health?.last_seen_at)}
+ + + 数据延迟 +
{formatLagSeconds(source.health?.lag_seconds)}
+ +
+ + + Endpoint + + + + Message Types + + + + Bounding Boxes + + + + + + 最近成功 +
{formatDateTimeZhCN(source.health?.last_success_at)}
+ + + 状态更新时间 +
{formatDateTimeZhCN(source.health?.updated_at)}
+ + + 最近观测 +
{formatDateTimeZhCN(source.stats?.latest_observed_at)}
+ + + 最近入库 +
{formatDateTimeZhCN(source.stats?.latest_collected_at)}
+ +
+ {source.health?.last_error ? ( + + ) : null} +
+
+ ) + } + const columns = [ { title: '名称', @@ -591,8 +833,16 @@ function DataSources() {

数据源

-
-
+ +
采集实时进度
@@ -609,7 +859,7 @@ function DataSources() {
内置 - {builtInSources.length} + {taskBuiltInSources.length}
已启用 @@ -646,10 +896,10 @@ function DataSources() { setForceTriggerAll(event.target.checked)}> - 强制全部采集 + 强制重新采集
@@ -741,7 +991,65 @@ function DataSources() { />
-
+
+ ), + }, + { + key: 'realtime', + label: '实时流', + children: ( +
+
+
+
实时流健康
+
+
+ 运行中 + {realtimeTotals.running} +
+ 0 ? 'active' : 'normal'} + showInfo={false} + strokeColor="#1677ff" + /> +
+
+
+ 实时源 + {realtimeSources.length} +
+
+ 运行中 + {realtimeTotals.running} +
+
+ 累计入库 + {formatCount(realtimeTotals.total)} +
+
+ 近 24h + {formatCount(realtimeTotals.recent24h)} +
+
+
+ +
+ + {realtimeSources.length ? realtimeSources.map(renderRealtimeSource) : ( + + 当前没有实时流数据源。 + + )} + +
+ ), + }, + ]} + />
/dev/null || true + prepend_path_once() { local path_entry="$1" [ -n "$path_entry" ] || return 0 @@ -152,6 +171,98 @@ ensure_local_runtime_bins_on_path() { ensure_local_runtime_bins_on_path # Shell / Docker helpers +is_pid() { + [[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ] +} + +is_safe_signal() { + case "${1:-}" in + TERM|KILL|INT|HUP) + return 0 + ;; + *) + return 1 + ;; + esac +} + +read_pid_file() { + local pid_file="$1" + local pid="" + + [ -f "$pid_file" ] || return 1 + pid="$(head -n 1 "$pid_file" 2>/dev/null | tr -d '[:space:]' || true)" + is_pid "$pid" || return 1 + printf "%s\n" "$pid" +} + +write_pid_file() { + local pid_file="$1" + local pid="$2" + + is_pid "$pid" || return 1 + mkdir -p "$(dirname "$pid_file")" + printf "%s\n" "$pid" > "$pid_file" + chmod 600 "$pid_file" 2>/dev/null || true +} + +remove_pid_file() { + local pid_file="$1" + + rm -f "$pid_file" +} + +http_ok() { + local url="$1" + + curl -fsS --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1 +} + +log_matches() { + local log_file="$1" + shift + + [ -f "$log_file" ] || return 1 + [ "$#" -gt 0 ] || return 1 + grep -Eiq "$*" "$log_file" 2>/dev/null +} + +read_port_state_value() { + local key="$1" + local fallback="$2" + local value="" + + if [ -f "$PLANET_PORT_STATE_FILE" ]; then + value="$(sed -nE "s/^${key}=([0-9]+)$/\\1/p" "$PLANET_PORT_STATE_FILE" | tail -n 1)" + fi + + if [ -n "$value" ]; then + printf "%s" "$value" + else + printf "%s" "$fallback" + fi +} + +write_port_state() { + local backend_port="$1" + local frontend_port="$2" + local ai_provider_port="$3" + local motion_agent_port="$4" + + validate_port "$backend_port" + validate_port "$frontend_port" + validate_port "$ai_provider_port" + validate_port "$motion_agent_port" + + { + printf "BACKEND_PORT=%s\n" "$backend_port" + printf "FRONTEND_PORT=%s\n" "$frontend_port" + printf "AI_PROVIDER_PORT=%s\n" "$ai_provider_port" + printf "MOTION_AGENT_PORT=%s\n" "$motion_agent_port" + } > "$PLANET_PORT_STATE_FILE" + chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true +} + compose_available() { docker compose version >/dev/null 2>&1 } @@ -253,36 +364,10 @@ render_wait_spinner() { local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" local detail="${WAIT_SPINNER_DETAIL:- }" - local verbose_line="" - local verbose_lines=() - local line_count=0 - local cursor_up=1 - - if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then - while IFS= read -r verbose_line; do - verbose_line="$(sanitize_wait_detail "$verbose_line")" - [ -n "$verbose_line" ] || continue - verbose_lines+=("$verbose_line") - done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null) - - while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do - verbose_lines=("${verbose_lines[@]:1}") - done - fi + flush_wait_verbose_log printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2 - - if [ "$VERBOSE" -eq 1 ]; then - line_count=1 - while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do - verbose_line="${verbose_lines[$line_count]:- }" - printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2 - line_count=$((line_count + 1)) - done - cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1)) - fi - - printf "\033[%sA\r" "$cursor_up" >&2 + printf "\033[1A\r" >&2 } animate_wait_spinner() { @@ -305,11 +390,6 @@ animate_wait_spinner() { clear_wait_spinner() { local line_count=0 - local clear_verbose_lines=0 - - if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then - clear_verbose_lines=1 - fi printf "\r\033[K" >&2 line_count=0 @@ -318,15 +398,8 @@ clear_wait_spinner() { line_count=$((line_count + 1)) done - if [ "$clear_verbose_lines" -eq 1 ]; then - line_count=0 - while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do - printf "\n\033[K" >&2 - line_count=$((line_count + 1)) - done - fi - - printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2 + printf "\033[1A\r" >&2 + flush_wait_verbose_log } start_wait_session() { @@ -366,6 +439,8 @@ reset_wait_spinner_state() { WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" WAIT_VERBOSE_LOG_FILE="" + WAIT_VERBOSE_LAST_LOG_FILE="" + WAIT_VERBOSE_EMITTED_LINES=0 } sanitize_wait_detail() { @@ -382,6 +457,39 @@ sanitize_wait_detail() { printf "%s" "$line" } +flush_wait_verbose_log() { + local line="" + local line_number=0 + local total_lines=0 + + [ "$VERBOSE" -eq 1 ] || return 0 + [ -n "$WAIT_VERBOSE_LOG_FILE" ] || return 0 + [ -f "$WAIT_VERBOSE_LOG_FILE" ] || return 0 + + if [ "$WAIT_VERBOSE_LAST_LOG_FILE" != "$WAIT_VERBOSE_LOG_FILE" ]; then + WAIT_VERBOSE_LAST_LOG_FILE="$WAIT_VERBOSE_LOG_FILE" + WAIT_VERBOSE_EMITTED_LINES=0 + fi + + total_lines="$(wc -l < "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null | tr -d '[:space:]')" + [ -n "$total_lines" ] || total_lines=0 + if [ "$total_lines" -lt "$WAIT_VERBOSE_EMITTED_LINES" ]; then + WAIT_VERBOSE_EMITTED_LINES=0 + fi + [ "$total_lines" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || return 0 + + printf "\r\033[K" >&2 + while IFS= read -r line; do + line_number=$((line_number + 1)) + [ "$line_number" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || continue + line="$(sanitize_wait_detail "$line")" + [ -n "$line" ] || continue + printf "${DIM} %s${NC}\n" "$line" >&2 + done < "$WAIT_VERBOSE_LOG_FILE" + + WAIT_VERBOSE_EMITTED_LINES="$total_lines" +} + set_wait_detail() { WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")" if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then @@ -400,7 +508,7 @@ run_command_with_spinner() { fi if [ "$VERBOSE" -eq 1 ]; then - verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)" + verbose_log_file="$(mktemp "$PLANET_STATE_DIR/verbose.XXXXXX.log")" WAIT_VERBOSE_LOG_FILE="$verbose_log_file" "$@" > "$verbose_log_file" 2>&1 & else @@ -494,7 +602,13 @@ log_error() { } log_note() { + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + clear_wait_spinner + fi printf "${DIM} %s${NC}\n" "$1" + if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then + resume_wait_session + fi } log_success() { @@ -704,7 +818,7 @@ remove_ai_provider_containers() { local container_ids container_ids="$(list_ai_provider_containers)" [ -n "$container_ids" ] || return 0 - echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true + printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true } run_ai_provider_container_manually() { @@ -1086,7 +1200,7 @@ wait_for_http() { set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do - if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then + if http_ok "$url"; then finish_wait_spinner "${service_name}已就绪" return 0 fi @@ -1179,7 +1293,7 @@ wait_for_frontend_ready() { fi if [ $(( tick % probe_every_ticks )) -eq 0 ] && - curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then + http_ok "http://localhost:${frontend_port}"; then if [ "$owns_wait_session" -eq 1 ]; then stop_wait_session finish_wait_spinner "前端已就绪" @@ -1289,6 +1403,7 @@ force_cleanup_external_port_listener() { local saw_no_windows_listener=0 local saw_cleanup_failure=0 + validate_port "$port" is_wsl_environment || return 1 command -v powershell.exe >/dev/null 2>&1 || return 1 @@ -1378,12 +1493,12 @@ EOF # Service startup helpers wait_for_database_health() { - wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && - wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" + wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" && + wait_for_container_health "planet_redis" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "Redis" } wait_for_postgres_health() { - wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" + wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" } start_database_services() { @@ -1401,6 +1516,19 @@ start_postgres_service() { # Backend lifecycle helpers cleanup_backend_processes() { local backend_port="${1:-$DEFAULT_BACKEND_PORT}" + local tracked_pid="" + + if [ -f "$BACKEND_PID_FILE" ]; then + if tracked_pid="$(read_pid_file "$BACKEND_PID_FILE")"; then + terminate_process_group TERM "$tracked_pid" + terminate_process_tree TERM "$tracked_pid" + sleep 1 + terminate_process_group KILL "$tracked_pid" + terminate_process_tree KILL "$tracked_pid" + fi + remove_pid_file "$BACKEND_PID_FILE" + fi + terminate_backend_processes TERM "$backend_port" if ! wait_for_port_release "$backend_port" 15 0.2; then @@ -1424,11 +1552,12 @@ start_backend_with_retry() { fail_unreleased_port "$backend_port" fi cd "$SCRIPT_DIR/backend" - : > /tmp/planet_backend.log - PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & + : > "$BACKEND_LOG_FILE" + PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 & BACKEND_PID=$! + write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID" if [ "$VERBOSE" -eq 1 ]; then - WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log" + WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE" fi if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then @@ -1436,14 +1565,14 @@ start_backend_with_retry() { fi kill "$BACKEND_PID" 2>/dev/null || true - if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then + if backend_log_indicates_port_conflict "$BACKEND_LOG_FILE"; then cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port" if wait_for_port_release "$backend_port" 5 0.2; then log_warn "后端端口冲突已清理,立即重试启动" retry=$((retry + 1)) continue fi - report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" + report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" return 1 fi animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" @@ -1451,7 +1580,7 @@ start_backend_with_retry() { done clear_wait_spinner - report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true + report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" || true return 1 } @@ -1500,8 +1629,7 @@ ai_provider_service_healthy() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1 - curl -s --max-time "$HTTP_CHECK_MAX_TIME" \ - "http://localhost:${ai_provider_port}/health" >/dev/null 2>&1 + http_ok "http://localhost:${ai_provider_port}/health" } ensure_database_services_healthy() { @@ -1596,7 +1724,7 @@ start_backend_service() { ensure_uv_backend_deps if ! start_backend_with_retry "$backend_port"; then log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" - tail -10 /tmp/planet_backend.log + tail -10 "$BACKEND_LOG_FILE" exit 1 fi } @@ -1650,11 +1778,12 @@ terminate_process_group() { local pid="$2" local pgid="" - [ -n "$pid" ] || return 0 + is_safe_signal "$signal" || return 1 + is_pid "$pid" || return 0 kill -0 "$pid" 2>/dev/null || return 0 pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" - [ -n "$pgid" ] || return 0 + is_pid "$pgid" || return 0 kill "-${signal}" -- "-${pgid}" 2>/dev/null || true } @@ -1665,7 +1794,7 @@ terminate_backend_processes() { local pids="" local pid="" - pids="$(pgrep -f "uvicorn" 2>/dev/null || true)" + pids="$(pgrep -f "uvicorn .*--port ${backend_port}" 2>/dev/null || true)" for pid in $pids; do terminate_process_group "$signal" "$pid" terminate_process_tree "$signal" "$pid" @@ -1683,12 +1812,13 @@ terminate_process_tree() { local pid="$2" local child_pid - [ -n "$pid" ] || return 0 + is_safe_signal "$signal" || return 1 + is_pid "$pid" || return 0 kill -0 "$pid" 2>/dev/null || return 0 if command -v pgrep >/dev/null 2>&1; then while IFS= read -r child_pid; do - [ -n "$child_pid" ] || continue + is_pid "$child_pid" || continue terminate_process_tree "$signal" "$child_pid" done < <(pgrep -P "$pid" 2>/dev/null || true) fi @@ -1821,15 +1951,13 @@ kill_port_if_requested() { frontend_log_indicates_port_conflict() { local log_file="$1" - [ -f "$log_file" ] || return 1 - grep -q "Port .* is already in use" "$log_file" 2>/dev/null + log_matches "$log_file" "Port .* is already in use" } backend_log_indicates_port_conflict() { local log_file="$1" - [ -f "$log_file" ] || return 1 - grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null + log_matches "$log_file" "Address already in use|Errno 98" } print_windows_port_listener_details() { @@ -1927,18 +2055,16 @@ cleanup_frontend_processes() { local tracked_pid="" if [ -f "$FRONTEND_PID_FILE" ]; then - tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)" - if [ -n "$tracked_pid" ]; then + if tracked_pid="$(read_pid_file "$FRONTEND_PID_FILE")"; then terminate_process_tree TERM "$tracked_pid" sleep 1 terminate_process_tree KILL "$tracked_pid" fi - rm -f "$FRONTEND_PID_FILE" + remove_pid_file "$FRONTEND_PID_FILE" fi pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true - pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true } start_frontend_with_retry() { @@ -1954,25 +2080,25 @@ start_frontend_with_retry() { log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认" fi cd "$SCRIPT_DIR/frontend" - : > /tmp/planet_frontend.log + : > "$FRONTEND_LOG_FILE" local -a frontend_args frontend_args=("$FRONTEND_VITE_ENTRY") if [ "$frontend_lan_enabled" -eq 1 ]; then frontend_args+=(--host 0.0.0.0) fi frontend_args+=(--port "$frontend_port" --strictPort) - nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 & + nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 & FRONTEND_PID=$! - printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" + write_pid_file "$FRONTEND_PID_FILE" "$FRONTEND_PID" - if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then + if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "$FRONTEND_LOG_FILE" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then return 0 fi cleanup_frontend_processes "$frontend_port" reset_wait_spinner_state - if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then + if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "$FRONTEND_LOG_FILE"; then kill_port_if_requested "$frontend_port" "前端" fi @@ -2007,7 +2133,8 @@ start_frontend_service() { if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" - tail -10 /tmp/planet_frontend.log + tail -10 "$FRONTEND_LOG_FILE" + print_port_listener_details "$frontend_port" exit 1 fi stop_wait_session @@ -2139,7 +2266,7 @@ cleanup_exit_containers() { exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)" if [ -n "$exit_containers" ]; then log_step "清理残留 Exit 容器" - echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true + printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true log_success "残留容器已清理" fi } @@ -2157,7 +2284,7 @@ stop_container_if_running() { } stop_backend_service() { - if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then + if pgrep -f "uvicorn .*--port ${DEFAULT_BACKEND_PORT}" >/dev/null 2>&1 || [ -f "$BACKEND_PID_FILE" ] || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then cleanup_backend_processes "$DEFAULT_BACKEND_PORT" log_halt "后端服务已停止" fi @@ -2168,7 +2295,7 @@ stop_ai_provider_service() { } stop_frontend_service() { - if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then + if pgrep -f "${FRONTEND_VITE_ENTRY} .*--port ${DEFAULT_FRONTEND_PORT}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then cleanup_frontend_processes "$FRONTEND_PORT" @@ -2183,11 +2310,39 @@ stop_frontend_service() { fi } +print_http_health_status() { + local service_name="$1" + local url="$2" + + if http_ok "$url"; then + echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}" + else + echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}" + fi +} + +print_process_health_status() { + local service_name="$1" + local pattern="$2" + local pid_file="${3:-}" + local tracked_pid="" + + if [ -n "$pid_file" ]; then + tracked_pid="$(read_pid_file "$pid_file" || true)" + fi + + if pgrep -f "$pattern" >/dev/null 2>&1 || { [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null; }; then + echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}" + else + echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}" + fi +} + motion_agent_pids() { local pids="" if [ -f "$MOTION_AGENT_PID_FILE" ]; then - pids="$(cat "$MOTION_AGENT_PID_FILE" 2>/dev/null || true)" + pids="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)" if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then printf "%s\n" "$pids" fi @@ -2206,7 +2361,7 @@ cleanup_motion_agent_processes() { terminate_process_tree "$signal" "$pid" done < <(motion_agent_pids | awk '!seen[$0]++') - rm -f "$MOTION_AGENT_PID_FILE" + remove_pid_file "$MOTION_AGENT_PID_FILE" } wait_for_motion_agent_ready() { @@ -2298,7 +2453,7 @@ start_motion_agent_service() { cd "$SCRIPT_DIR" nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 & MOTION_AGENT_PID=$! - printf "%s" "$MOTION_AGENT_PID" > "$MOTION_AGENT_PID_FILE" + write_pid_file "$MOTION_AGENT_PID_FILE" "$MOTION_AGENT_PID" if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then log_success "Motion Agent 已就绪" @@ -2308,6 +2463,7 @@ start_motion_agent_service() { log_error "Motion Agent 启动失败" tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true + print_port_listener_details "$motion_agent_port" log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。" log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。" log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。" @@ -2323,6 +2479,24 @@ stop_motion_agent_service() { fi } +cleanup_failed_start() { + [ "$START_RUN_ACTIVE" -eq 1 ] || return 0 + [ "$START_RUN_COMPLETED" -eq 0 ] || return 0 + + clear_wait_spinner + log_warn "启动未完成,清理本轮已拉起的本地进程" + + if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then + cleanup_motion_agent_processes TERM + fi + if [ "$STARTED_FRONTEND_THIS_RUN" -eq 1 ]; then + cleanup_frontend_processes "${FRONTEND_PORT:-$DEFAULT_FRONTEND_PORT}" + fi + if [ "$STARTED_BACKEND_THIS_RUN" -eq 1 ]; then + cleanup_backend_processes "${BACKEND_PORT:-$DEFAULT_BACKEND_PORT}" || true + fi +} + create_user() { local username local password @@ -2433,14 +2607,27 @@ start() { parse_service_args "$@" cleanup_exit_containers + START_RUN_ACTIVE=1 + START_RUN_COMPLETED=0 + STARTED_BACKEND_THIS_RUN=0 + STARTED_FRONTEND_THIS_RUN=0 + STARTED_MOTION_AGENT_THIS_RUN=0 + print_splash start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" + STARTED_BACKEND_THIS_RUN=1 start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" + STARTED_FRONTEND_THIS_RUN=1 if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" + STARTED_MOTION_AGENT_THIS_RUN=1 fi + write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT" + START_RUN_COMPLETED=1 + START_RUN_ACTIVE=0 + log_success "启动完成" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" @@ -2468,6 +2655,10 @@ stop() { restart() { parse_service_args "$@" cleanup_exit_containers + local state_backend_port="" + local state_frontend_port="" + local state_ai_provider_port="" + local state_motion_agent_port="" if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then stop @@ -2506,6 +2697,24 @@ restart() { start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" fi + state_backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")" + state_frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")" + state_ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")" + state_motion_agent_port="$(read_port_state_value MOTION_AGENT_PORT "$DEFAULT_MOTION_AGENT_PORT")" + if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then + state_backend_port="$BACKEND_PORT" + fi + if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then + state_frontend_port="$FRONTEND_PORT" + fi + if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then + state_ai_provider_port="$AI_PROVIDER_PORT" + fi + if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then + state_motion_agent_port="$MOTION_AGENT_PORT" + fi + write_port_state "$state_backend_port" "$state_frontend_port" "$state_ai_provider_port" "$state_motion_agent_port" + echo "" log_success "重启完成" if [ "$DATABASE_REQUESTED" -eq 1 ]; then @@ -2529,34 +2738,23 @@ restart() { } health() { + local backend_port="" + local frontend_port="" + local ai_provider_port="" + + backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")" + frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")" + ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")" + echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}" - docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || log_note "Docker daemon 不可用,跳过容器状态" echo "" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}" - if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}" - else - echo -e "${DIM} 后端:${NC} ${RED}offline${NC}" - fi - - if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then - echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}" - else - echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}" - fi - - if curl -s http://localhost:3000 > /dev/null 2>&1; then - echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}" - else - echo -e "${DIM} 前端:${NC} ${RED}offline${NC}" - fi - - if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || [ -f "$MOTION_AGENT_PID_FILE" ]; then - echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}" - else - echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}" - fi + print_http_health_status "后端" "http://localhost:${backend_port}/health" + print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health" + print_http_health_status "前端" "http://localhost:${frontend_port}" + print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE" } log() { @@ -2564,12 +2762,12 @@ log() { -f|--frontend) log_step "查看前端日志" log_note "按 Ctrl+C 退出" - tail -f /tmp/planet_frontend.log + tail -f "$FRONTEND_LOG_FILE" ;; -b|--backend) log_step "查看后端日志" log_note "按 Ctrl+C 退出" - tail -f /tmp/planet_backend.log + tail -f "$BACKEND_LOG_FILE" ;; -a|--ai-provider) log_step "查看 AI Provider 日志" @@ -2584,11 +2782,11 @@ log() { *) log_step "查看最近日志" log_note "后端" - tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志" + tail -20 "$BACKEND_LOG_FILE" 2>/dev/null || log_note "无日志" log_note "AI Provider" docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志" log_note "前端" - tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志" + tail -20 "$FRONTEND_LOG_FILE" 2>/dev/null || log_note "无日志" log_note "Motion Agent" tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志" ;; @@ -2611,6 +2809,8 @@ parse_global_args() { GLOBAL_ARG_REMAINDER=("$@") } +trap cleanup_failed_start EXIT + parse_global_args "$@" set -- "${GLOBAL_ARG_REMAINDER[@]}" @@ -2637,7 +2837,7 @@ case "$1" in ;; *) log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" - log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出" + log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志" log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose" log_note "stop 停止服务" log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose" diff --git a/pyproject.toml b/pyproject.toml index fdf2d673..8a0c60e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.52.0" +version = "0.53.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 502cc3af..3edaaddc 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.52.0" +version = "0.53.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From 3b4347c87dc5c2946eef848d05ddf017a882e603 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 13 May 2026 14:26:47 +0800 Subject: [PATCH 05/19] release: bump version to 0.54.0 --- VERSION | 2 +- docs/CHANGELOG.md | 16 +++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- .../assets/icons/compute-supercomputer.svg | 7 ++- frontend/public/earth/index.html | 30 ++++++--- frontend/public/earth/js/constants.js | 12 ++-- frontend/public/earth/js/controls.js | 61 +++++++++++++------ frontend/public/earth/js/satellites.js | 6 +- pyproject.toml | 2 +- uv.lock | 2 +- 11 files changed, 98 insertions(+), 45 deletions(-) diff --git a/VERSION b/VERSION index 7f422a16..524456c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.53.0 +0.54.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f97be7b8..e177f121 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.54.0] — 2026-05-13 + +Released: 2026-05-13 + +### Highlights +- 新增 Earth 轨迹显示设置,将卫星轨迹从图层列表迁移到桌面与移动设置面板,便于独立控制轨迹线显示。 +- 优化卫星静止呼吸与低缩放圆点闪烁参数,让卫星态势视觉节奏更柔和、层次更清晰。 +- 更新算力中心图标资产,统一 Earth 交互图标在高分辨率显示下的视觉规格。 + +### Added / Fixed / Improved +- 新增轨迹显示偏好持久化,并在卫星图层不可用时同步禁用轨迹设置入口。 +- 调整卫星呼吸、透明度和 twinkle 参数,降低闪烁频率并增强可读性。 +- 将轨迹控制状态同步到桌面设置、移动设置和图层卡片刷新流程。 + +--- + ## [0.53.0] — 2026-05-13 Released: 2026-05-13 diff --git a/docs/version-history.md b/docs/version-history.md index 5d1d0e38..31781348 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.53.0` +- `dev` 当前开发分支历史推导到:`0.54.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 | | `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test,明确 Vite 生产构建和 planet.sh 开发入口边界 | | `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 | | `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 | diff --git a/frontend/package.json b/frontend/package.json index 6c8597ba..3ffafb4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.53.0", + "version": "0.54.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/icons/compute-supercomputer.svg b/frontend/public/earth/assets/icons/compute-supercomputer.svg index 5b11d15c..b28df2f6 100644 --- a/frontend/public/earth/assets/icons/compute-supercomputer.svg +++ b/frontend/public/earth/assets/icons/compute-supercomputer.svg @@ -1,3 +1,6 @@ - - + + + + + diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index f3513dde..716ae641 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -199,16 +199,6 @@ -
- timeline -
- 轨迹 - Trails -
- -
satellite_alt
@@ -889,6 +879,16 @@ +
低缩放圆点 @@ -1190,6 +1190,16 @@ +
低缩放圆点 diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 5779831e..a53fd202 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -339,13 +339,13 @@ export const SATELLITE_CONFIG = { dotZoomScalePower: 1, ringSize: 0.07, apiPath: '/api/v1/visualization/geo/satellites', - breathingSpeed: 0.08, - breathingScaleAmplitude: 0.15, - breathingOpacityMin: 0.5, - breathingOpacityMax: 0.8, + breathingSpeed: 0.036, + breathingScaleAmplitude: 0.22, + breathingOpacityMin: 0.34, + breathingOpacityMax: 0.9, dotBreathingSpeed: 0.12, - dotBreathingScaleAmplitude: 0.2, - dotOpacityMin: 0.7, + dotBreathingScaleAmplitude: 0.28, + dotOpacityMin: 0.42, dotOpacityMax: 1.0 }; diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 486ec2e3..17cd91e6 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -782,6 +782,7 @@ function getCurrentSharedSettingsSnapshot() { rotationMode, cruiseModules: getCruiseModules(), satelliteDisplayStyle: getSatelliteDisplayStyle(), + trailsEnabled: getShowTrails(), layerVisibility: Object.fromEntries( getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]), ), @@ -834,6 +835,7 @@ function cloneEarthSettings(settings) { cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)], satelliteDisplayStyle: settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE, + trailsEnabled: settings.shared.trailsEnabled !== false, terrainOpacity: settings.shared.terrainOpacity, dayNightEnabled: settings.shared.dayNightEnabled, defaultEarthZoom: settings.shared.defaultEarthZoom, @@ -978,6 +980,11 @@ function normalizeEarthSettings(rawSettings, defaults) { typeof sharedSettings?.interactableCompactDotsEnabled === "boolean" ? sharedSettings.interactableCompactDotsEnabled : defaults.shared.interactableCompactDotsEnabled; + const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean" + ? sharedSettings.trailsEnabled + : typeof inputLayerVisibility.trails === "boolean" + ? inputLayerVisibility.trails + : defaults.shared.trailsEnabled; return { version: EARTH_SETTINGS_VERSION, @@ -987,6 +994,7 @@ function normalizeEarthSettings(rawSettings, defaults) { ? nextCruiseModules : [...DEFAULT_CRUISE_MODULES], satelliteDisplayStyle: nextSatelliteDisplayStyle, + trailsEnabled: nextTrailsEnabled, layerVisibility: normalizedLayerVisibility, terrainOpacity: Number.isFinite(nextTerrainOpacity) ? nextTerrainOpacity @@ -1371,6 +1379,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) { persist: false, suppressStatus: true, }); + setTrailsDisplayEnabled(settings.shared.trailsEnabled, { + persist: false, + silent: true, + }); setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, { persist: false, suppressStatus: true, @@ -1774,14 +1786,34 @@ async function setVesselsLayerEnabled(button, enabled, { persist = true, silent } } -function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) { +function syncTrailsDisplayControls() { + const trailsEnabled = getShowTrails(); + const disabledState = getLayerDisabledState("trails"); + document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => { + if (!(toggle instanceof HTMLInputElement)) return; + toggle.checked = trailsEnabled; + toggle.disabled = disabledState.disabled; + const label = toggle.closest("label"); + label?.classList.toggle("is-disabled", disabledState.disabled); + if (label instanceof HTMLElement) { + if (disabledState.disabled && disabledState.tooltip) { + label.title = disabledState.tooltip; + } else { + label.removeAttribute("title"); + } + } + }); +} + +function setTrailsDisplayEnabled(enabled, { persist = true, silent = false } = {}) { toggleTrails(enabled); const disabledState = getLayerDisabledState("trails"); - setLayerButtonState(button, { + setLayerButtonState(getLayerButton("trails"), { active: enabled, disabled: disabledState.disabled, tooltip: disabledState.tooltip || (enabled ? "隐藏轨迹" : "显示轨迹"), }); + syncTrailsDisplayControls(); syncMobileLayerCards(); if (persist) persistEarthSettings(); if (!silent) { @@ -1798,6 +1830,7 @@ function syncTrailsAvailability() { disabled: disabledState.disabled, tooltip: disabledState.tooltip || (trailsEnabled ? "隐藏轨迹" : "显示轨迹"), }); + syncTrailsDisplayControls(); syncMobileLayerCards(); } @@ -2021,23 +2054,6 @@ function getBuiltinLayerDefinitions() { setVisible: (visible, options = {}) => setSatellitesLayerEnabled(getLayerButton("satellites"), visible, options), }, - { - id: "trails", - buttonId: "toggle-trails", - icon: "timeline", - label: "轨迹", - meta: "Trails", - keywords: "轨迹 trails", - defaultActive: true, - displayOrder: 20, - startupPriority: null, - startupMode: "visible", - startupLabel: "轨迹", - startupMessage: "", - getVisible: () => getShowTrails(), - setVisible: (visible, options = {}) => - setTrailsLayerEnabled(getLayerButton("trails"), visible, options), - }, { id: "terrain", buttonId: "toggle-terrain", @@ -2698,6 +2714,13 @@ function setupSettingsControls() { }); }); + document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => { + if (!(toggle instanceof HTMLInputElement)) return; + bindListener(toggle, "change", () => { + setTrailsDisplayEnabled(toggle.checked); + }); + }); + document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((toggle) => { if (!(toggle instanceof HTMLInputElement)) return; bindListener(toggle, "change", () => { diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 9ef7a7bf..c2a67372 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -681,9 +681,9 @@ function createSatelliteTwinkleState(index) { return { phase: hashSatelliteUnit(index, 1) * Math.PI * 2, secondaryPhase: hashSatelliteUnit(index, 2) * Math.PI * 2, - speed: 0.62 + hashSatelliteUnit(index, 3) * 1.45, - floor: hashSatelliteUnit(index, 4) * 0.22, - intensity: 0.48 + hashSatelliteUnit(index, 5) * 0.52, + speed: 0.38 + hashSatelliteUnit(index, 3) * 0.72, + floor: hashSatelliteUnit(index, 4) * 0.16, + intensity: 0.68 + hashSatelliteUnit(index, 5) * 0.62, }; } diff --git a/pyproject.toml b/pyproject.toml index 8a0c60e1..67f1e2d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.53.0" +version = "0.54.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 3edaaddc..a94e1ad2 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.53.0" +version = "0.54.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From 39854b99833cae9349cf27f0e9d7a732083f1825 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 13 May 2026 15:31:29 +0800 Subject: [PATCH 06/19] release: bump version to 0.55.0 --- VERSION | 2 +- docs/CHANGELOG.md | 16 ++++ docs/technical/en/earth-frontend-context.md | 4 +- .../en/earth-layer-style-reference.md | 7 +- docs/technical/en/earth-render-layer-order.md | 11 +-- docs/technical/en/faq.md | 6 ++ docs/technical/en/manual.md | 7 +- docs/technical/en/quickstart.md | 2 +- docs/technical/zh/earth-frontend-context.md | 5 +- .../zh/earth-layer-style-reference.md | 7 +- docs/technical/zh/earth-render-layer-order.md | 9 ++- docs/technical/zh/faq.md | 6 ++ docs/technical/zh/manual.md | 7 +- docs/technical/zh/quickstart.md | 2 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 10 ++- frontend/public/earth/index.html | 20 +++++ frontend/public/earth/js/constants.js | 15 +++- frontend/public/earth/js/controls.js | 77 ++++++++++++++++++- frontend/public/earth/js/satellites.js | 53 ++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 23 files changed, 239 insertions(+), 36 deletions(-) diff --git a/VERSION b/VERSION index 524456c7..316ba4bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.54.0 +0.55.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e177f121..02069462 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.55.0] — 2026-05-13 + +Released: 2026-05-13 + +### Highlights +- 新增 Earth 卫星真实高度显示开关,默认按 TLE/SGP4 真实轨道高度压缩分层,关闭后恢复旧版同层球面。 +- 将卫星轨迹显示保留在设置面板,并让轨迹、预测轨道和锁定视觉跟随真实高度开关即时刷新。 +- 修复 Earth 设置面板 switch 圆点垂直居中问题,并同步更新用户手册、FAQ 和 Earth 开发者文档。 + +### Added / Fixed / Improved +- 新增 `satelliteRealAltitudeEnabled` 本地偏好和真实高度压缩参数,保留 TLE 缺失/传播失败时的 fallback 固定高度。 +- 更新卫星渲染高度、轨迹、预测轨道和设置持久化链路,避免同一轨迹混入两套高度模型。 +- 补充中英文 manual、quickstart、FAQ、Earth frontend context、render order 和 layer style reference。 + +--- + ## [0.54.0] — 2026-05-13 Released: 2026-05-13 diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index da9fd1b4..0ede74b8 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -205,7 +205,9 @@ Terrain should not block startup when it is not the restored visible layer. Afte Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset. -Settings that affect visual layers (terrain opacity, day/night mode, satellite display style, etc.) are read during initialization and applied immediately. +Settings that affect visual layers (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, etc.) are read during initialization and applied immediately. + +The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. ## Current Terrain Pipeline diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index d7b984ee..a739b677 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -160,10 +160,13 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Name | Variable | Current Value | Location / Notes | | --- | --- | --- | --- | -| Satellite display radius offset | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | Satellite point position | +| Satellite fallback radius offset | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | Legacy same-sphere position when real altitude is disabled or TLE propagation fails | +| Satellite altitude compression scale | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 altitude compression; larger values soften high-orbit separation | +| Satellite real-altitude cap | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | Altitude clamp for the compressed display mapping, covering GEO-range altitude | +| Satellite real-altitude display offset range | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 55` | Compressed offset added to `CONFIG.earthRadius` | | Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | Point shader size | | Satellite backdrop dot scale | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | Backdrop dot size | -| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | Breathing animation | +| Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | Breathing animation | | Satellite dot breathing speed | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | Dot opacity animation | | Satellite backdrop renderOrder | inline | `5` | `satelliteBackdropPoints.renderOrder` | | Satellite dot renderOrder | inline | `6` | `satellitePoints.renderOrder` | diff --git a/docs/technical/en/earth-render-layer-order.md b/docs/technical/en/earth-render-layer-order.md index 3f15329c..b8b59873 100644 --- a/docs/technical/en/earth-render-layer-order.md +++ b/docs/technical/en/earth-render-layer-order.md @@ -6,8 +6,8 @@ Note: the layer control panel order and the registration / startup load order ar | Order type | Current sequence | Notes | | --- | --- | --- | -| Control panel order | Cables → Trails → Satellites → Compute Centers → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance. | -| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. | +| Control panel order | Cables → Satellites → Compute Centers → Vessels → BGP → Terrain → HD Texture → Cloud Layer → Border Lines → Grid | Controlled by `displayOrder`, sorted by operational relevance; satellite trails moved to Settings and are no longer a layer-list item. | +| Registration / startup load order | Grid → Border Lines / Land-Ocean Base → HD Texture → Cloud Layer → Cables → Compute Centers → Vessels → BGP → Satellites | Controlled by registration order and `startupPriority`, sorted surface-to-sky; the startup queue reads persisted layer visibility first, skips normal layers explicitly saved as hidden, and HD Texture does not download the texture when disabled; Border Lines are the exception: the land-ocean base always preloads, while the persisted state only controls interactive border lines and hover; Trails and Terrain are dependency/optional display layers and do not participate in normal startup data loading. | ## Surface Layer Stack @@ -29,9 +29,9 @@ Note: the layer control panel order and the registration / startup load order ar | 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. | | 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. | | 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. | -| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Below satellite dots. | -| 6 | Satellite dots | `satellites.js` | Fixed renderOrder | Screen-space satellite picking | Satellite dots above footprints and compute centers. | -| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets | Satellite overlay path | Used for selected/locked satellite emphasis. | +| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..55`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. | +| 6 | Satellite dots | `satellites.js` | Same compressed / fallback height as satellite backdrop dots | Screen-space satellite picking | Satellite dots above footprints and compute centers. | +| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and returns to same-sphere mode when disabled | Satellite overlay path | Used for selected/locked satellite emphasis. | | 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. | ## Toggle Behavior @@ -43,6 +43,7 @@ Note: the layer control panel order and the registration / startup load order ar | Terrain on | Displayed above HD texture, but below country border hover, footprints, satellites, and other emphasis layers. | | Cloud layer | Only controls cloud mesh visibility. | | Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. | +| Real Satellite Altitude off | Satellite dots, trails, and predicted orbits use the legacy same-sphere height; satellites with missing TLE data or failed propagation also use this fallback height. | ## Interaction Rules diff --git a/docs/technical/en/faq.md b/docs/technical/en/faq.md index 1d7541f1..2e2f5dc2 100644 --- a/docs/technical/en/faq.md +++ b/docs/technical/en/faq.md @@ -321,6 +321,12 @@ Collecting and saving are two separate actions. Candidates can be previewed on E Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint. +### Why are satellites no longer on one sphere? + +Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. This setting also affects satellite trails and the predicted orbit shown after locking a satellite. + +Turn off "Real Satellite Altitude" in Earth Settings to restore the legacy same-sphere satellite display. Satellites with missing TLE data or failed propagation still fall back to the legacy fixed height, so they do not disappear just because a real altitude cannot be computed. + ### Why does Motion Debug not show camera video? With the Browser Camera source, the debug panel shows the local browser camera preview and draws the skeleton over it. If `Skeleton Only` is enabled, the video preview is hidden and the panel keeps only the dark canvas plus red/green skeleton. diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 9c4f9fd9..180b5fbc 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -229,7 +229,7 @@ A single globe view of: BGP events and observations, satellites and tracks, cabl ### Layer Control -The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, tracks, terrain. +The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, terrain. Satellite tracks are no longer listed as a standalone layer; they are controlled from Settings. Dependencies: @@ -255,7 +255,9 @@ Candidates preview on Earth directly. Saving a compute-center candidate writes t ### Settings -The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset. +The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset. + +"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden. These settings live in browser local storage; switching browsers or clearing site data resets them. @@ -310,6 +312,7 @@ Mobile uses a drawer layout: layer control moves into a drawer; search/settings/ - **Earth does not open**: confirm the frontend is online; if not on port `3000`, use the port printed by the startup log - **Layers have no data**: open `/datasources` to check source status, collected-record state, and the latest run result; then `/data` or `/bgp` for records - **Satellites / BGP / cables load slowly**: those layers depend on backend APIs and external data sources; the first load waits for startup tasks +- **Satellites are not all on one shell**: this is the default compressed real-altitude display. Disable "Real Satellite Altitude" in Settings to return to the legacy same-sphere view ## Docs Site diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index fb7743e6..0c46c343 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -48,7 +48,7 @@ Once in, verify: - Search finds cables, satellites, compute centers, BGP events - Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth - Mouse drag, wheel zoom, and the zoom percentage indicator work -- The settings panel can switch rotate / cruise / motion modes +- The settings panel can switch rotate / cruise / motion modes; satellite settings can toggle real-altitude layering and track display ## 5. Recover a Lost Password diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 5b908aa7..0a246f83 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -419,11 +419,14 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/ - 旋转模式 - 地球默认大小(作为重置视角、缩放重置和巡航视图的默认 zoom 真源) - HUD 面板显示/隐藏 -- 图层控制开关:`地形 / 卫星 / 轨迹 / 海缆 / BGP` +- 图层控制开关:`地形 / 卫星 / 海缆 / BGP` +- 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示` - 地形透明度 也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。 +卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时,SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。 + ## 当前地形链路 真实地形首次启用会慢,原因不只是一个: diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 05bb27a6..b93299eb 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -166,10 +166,13 @@ | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | -| 卫星显示半径偏移 | `SATELLITE_CONFIG.displayAltitudeOffset` | `8` | 卫星点位置 | +| 卫星 fallback 半径偏移 | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | 关闭真实高度或 TLE 传播失败时的旧版同层球面位置 | +| 卫星真实高度压缩尺度 | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 高度压缩映射,数值越大高轨差异越温和 | +| 卫星真实高度上限 | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | 压缩映射的高度截断,覆盖 GEO 附近高度 | +| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 55` | 压缩后映射到 `CONFIG.earthRadius + offset` | | 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size | | 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 | -| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.7 / 1.0` | 呼吸动画 | +| 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | 呼吸动画 | | 卫星点呼吸速度 | `SATELLITE_CONFIG.dotBreathingSpeed` | `0.12` | 点 opacity 动画 | | 卫星背景点颜色 | inline | `0x0b1626` | backdrop point baseColor | | 卫星背景点透明度 | inline | `0.42` | backdrop point opacity | diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index e669c9b8..e9f74ba5 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -7,7 +7,7 @@ | 顺序类型 | 当前顺序 | 说明 | | --- | --- | --- | -| 控制面板顺序 | 海缆 → 轨迹 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列。 | +| 控制面板顺序 | 海缆 → 卫星 → 算力中心 → 船只 → BGP → 地形 → 高清材质 → 大气云图 → 国界线 → 经纬线 | 由 `displayOrder` 控制,按操作关注度排列;卫星轨迹已迁到设置面板,不再作为图层列表项。 | | 注册 / 启动加载顺序 | 经纬线 → 国界线 / 海陆基座 → 高清材质 → 大气云图 → 海缆 → 算力中心 → 船只 → BGP → 卫星 | 由注册顺序和 `startupPriority` 控制,按地表到天空排列;启动队列会先读取保存的图层可见状态,明确关闭的普通图层不预加载,高清材质关闭时不下载贴图;国界线图层例外,海陆基座始终预加载,保存状态只控制可交互国界线和 hover;轨迹和地形是依赖/可选显示层,不参与常规启动数据加载。 | ## 地表图层栈 @@ -32,9 +32,9 @@ | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | | 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow,交互态叠加同尺寸 glow;低于算力中心 `4.5`。 | | 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 | -| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 位于卫星点下方。 | -| 6 | 卫星点 | `satellites.js` | 固定 renderOrder | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | -| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | +| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..55`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 | +| 6 | 卫星点 | `satellites.js` | 与卫星背景点使用同一压缩高度 / fallback 高度 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | +| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,关闭时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | | 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 | ## 开关联动 @@ -46,6 +46,7 @@ | 地形 on | 显示在高清材质之上,但低于国界 hover、footprint、卫星等强调层。 | | 大气云图 | 只控制云图 mesh 显隐。 | | 国界线 off | 只隐藏可交互国界线和 hover,高亮状态会清除;海陆基座填充仍作为 Earth 底图保留。 | +| 真实卫星高度 off | 卫星点、轨迹和预测轨道都使用旧版同层球面;缺失 TLE 或传播失败的卫星也使用同一 fallback 高度。 | ## 交互规则 diff --git a/docs/technical/zh/faq.md b/docs/technical/zh/faq.md index ad6a7592..dbf55e4f 100644 --- a/docs/technical/zh/faq.md +++ b/docs/technical/zh/faq.md @@ -323,6 +323,12 @@ Docs 按 Gatekeeper 权限组控制可见性: 算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。 +### 为什么卫星看起来不在同一个球面上? + +Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4,但高度会经过压缩映射:低轨卫星靠近地球,高轨卫星更远,同时保持在当前视图可读范围内。这个设置也会影响卫星轨迹和锁定后的预测轨道。 + +如果需要旧版所有卫星位于同一显示球面的效果,在 Earth 设置里关闭“真实卫星高度”。缺失 TLE 或传播失败的卫星仍会回退到旧版固定高度,不会因为无法计算真实高度而消失。 + ### 动捕调试面板为什么看不到摄像头画面? 如果输入源是 Browser Camera,调试面板会显示浏览器本机摄像头实时预览,并在画面上绘制骨架。如果勾选了 `只显示骨骼`,视频预览会被隐藏,只显示深色背景和红/绿骨架。 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 1ca1ed0a..459e9aef 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -232,7 +232,7 @@ Earth `http://localhost:3000/earth` 是公开 3D 态势页面,不需要登录 ### 图层控制 -右侧图层面板用于打开或关闭图层。常见图层:经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、轨迹、地形。 +右侧图层面板用于打开或关闭图层。常见图层:经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、地形。卫星轨迹不再作为单独图层出现在图层列表中,改在设置面板里控制。 依赖关系: @@ -258,7 +258,9 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军 ### 设置 -设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 +设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 + +“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。 这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。 @@ -313,6 +315,7 @@ Earth 预留了动作捕捉控制入口。实时链路两种输入源: - **Earth 打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口 - **图层没有数据**:进 `/datasources` 看数据源状态、是否已采集和最近执行结果,再到 `/data` 或 `/bgp` 看是否有记录 - **卫星 / BGP / 海缆加载慢**:这些图层依赖后端接口和外部数据源,首次加载需要等启动任务完成 +- **卫星看起来不在同一层**:这是默认的真实高度压缩显示。想回到旧版同层球面,可在设置中关闭“真实卫星高度” ## Docs 文档站 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 0313d6e0..bf1d4a43 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -48,7 +48,7 @@ - 搜索可以查找海缆、卫星、算力中心、BGP 事件 - 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览 - 鼠标拖动、滚轮缩放、缩放百分比提示工作正常 -- 设置面板的旋转 / 巡航 / 动捕模式可以切换 +- 设置面板的旋转 / 巡航 / 动捕模式可以切换;卫星相关设置里可以打开或关闭真实高度分层和轨迹显示 ## 5. 找回密码 diff --git a/docs/version-history.md b/docs/version-history.md index 31781348..79780de6 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.54.0` +- `dev` 当前开发分支历史推导到:`0.55.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关,轨迹和预测轨道跟随高度模式切换,并补齐设置面板、FAQ、用户手册和开发者文档 | | `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 | | `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test,明确 Vite 生产构建和 planet.sh 开发入口边界 | | `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 | diff --git a/frontend/package.json b/frontend/package.json index 3ffafb4d..c2b7aae8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.54.0", + "version": "0.55.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 4fe1147e..07573180 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -1390,12 +1390,13 @@ .earth-mobile-settings-switch-track::after { content: ""; position: absolute; - top: 4px; + top: 50%; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #fff; + transform: translateY(-50%); transition: transform 0.18s ease; } @@ -1404,7 +1405,7 @@ } .earth-mobile-settings-switch input:checked + .earth-mobile-settings-switch-track::after { - transform: translateX(16px); + transform: translate(16px, -50%); } label.is-disabled.earth-mobile-settings-card { @@ -2477,13 +2478,14 @@ label.is-disabled.earth-mobile-settings-card { .earth-settings-switch-track::after { content: ""; position: absolute; - top: calc(3px * var(--hud-scale)); + top: 50%; left: calc(3px * var(--hud-scale)); width: calc(16px * var(--hud-scale)); height: calc(16px * var(--hud-scale)); border-radius: 50%; background: #edf4fc; box-shadow: 0 6px 14px rgba(1, 8, 18, 0.26); + transform: translateY(-50%); transition: transform 0.18s ease; } @@ -2493,7 +2495,7 @@ label.is-disabled.earth-mobile-settings-card { } .earth-settings-switch input:checked + .earth-settings-switch-track::after { - transform: translateX(calc(16px * var(--hud-scale))); + transform: translate(calc(16px * var(--hud-scale)), -50%); } .earth-settings-item.is-disabled { diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 716ae641..17900724 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -879,6 +879,16 @@ +
轨迹显示 @@ -1190,6 +1200,16 @@ +
轨迹显示 diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index a53fd202..477e8336 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -39,6 +39,15 @@ export const SATELLITE_DISPLAY_STYLES = { export const DEFAULT_SATELLITE_DISPLAY_STYLE = SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT; +export const SURFACE_HOVER_INFO_MODES = { + COUNTRY: "country", + POSITION: "position", + FULL: "full", +}; + +export const DEFAULT_SURFACE_HOVER_INFO_MODE = + SURFACE_HOVER_INFO_MODES.FULL; + export const CRUISE_CONFIG = { dwellMs: 7_000, focusDurationMs: 1_400, @@ -331,7 +340,11 @@ export const SATELLITE_CONFIG = { hydrateFullAfterInitialLoad: false, trailLength: 10, trailLineWidth: 3, - displayAltitudeOffset: 8, + fallbackAltitudeOffset: 8, + altitudeCompressionKm: 1200, + maxDisplayAltitudeKm: 40000, + minRealAltitudeOffset: 4, + maxRealAltitudeOffset: 55, frontFacingDotThreshold: 0.015, overlayRenderOrder: 12, dotBaseSize: 2.8, diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 17cd91e6..7284a918 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -4,11 +4,13 @@ import * as THREE from "three"; import { CONFIG, CRUISE_MODULES, + DEFAULT_SURFACE_HOVER_INFO_MODE, DEFAULT_SATELLITE_DISPLAY_STYLE, DEFAULT_CRUISE_MODULES, EARTH_CONFIG, ROTATION_MODE, SATELLITE_DISPLAY_STYLES, + SURFACE_HOVER_INFO_MODES, } from "./constants.js"; import { setEarthStatValue, @@ -51,7 +53,9 @@ import { getSatelliteCount, getSatelliteDisplayStyle, getSatelliteIdleBreathingEnabled, + getSatelliteRealAltitudeEnabled, setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled, + setSatelliteRealAltitudeEnabled as applySatelliteRealAltitudeEnabled, setSatelliteDisplayStyle as applySatelliteDisplayStyle, } from "./satellites.js"; import { @@ -141,7 +145,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22; const SETTINGS_SHEET_MAX_SCALE_Y = 0.18; const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2"; const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1"; -const EARTH_SETTINGS_VERSION = 10; +const EARTH_SETTINGS_VERSION = 11; const GRID_LINES_DEFAULT_VERSION = 3; const SATELLITE_DISPLAY_DEFAULT_VERSION = 4; const MEDIA_PANEL_DEFAULT_VERSION = 5; @@ -150,6 +154,7 @@ const MOTION_PROVIDER_DEFAULT_VERSION = 7; const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8; const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9; const VISUAL_PREFERENCES_DEFAULT_VERSION = 10; +const SURFACE_HOVER_INFO_DEFAULT_VERSION = 11; const DEFAULT_EARTH_ZOOM_STEP = 0.01; const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90; const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28; @@ -177,6 +182,9 @@ const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES)); const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set( Object.values(SATELLITE_DISPLAY_STYLES), ); +const ALLOWED_SURFACE_HOVER_INFO_MODES = new Set( + Object.values(SURFACE_HOVER_INFO_MODES), +); const CRUISE_MODULE_LABELS = { [CRUISE_MODULES.BGP]: "BGP", [CRUISE_MODULES.NEWS]: "新闻", @@ -190,6 +198,12 @@ function normalizeMediaPanelActiveTab(tab) { return tab === "news" ? "news" : "live"; } +function normalizeSurfaceHoverInfoMode(mode) { + return ALLOWED_SURFACE_HOVER_INFO_MODES.has(mode) + ? mode + : DEFAULT_SURFACE_HOVER_INFO_MODE; +} + function detectLayoutMode() { const width = window.innerWidth; const height = window.innerHeight; @@ -794,7 +808,9 @@ function getCurrentSharedSettingsSnapshot() { motionDebugSkeletonOnly, mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()), satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(), + satelliteRealAltitudeEnabled: getSatelliteRealAltitudeEnabled(), interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(), + surfaceHoverInfoMode: getSurfaceHoverInfoMode(), }; } @@ -848,8 +864,13 @@ function cloneEarthSettings(settings) { mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab), satelliteIdleBreathingEnabled: settings.shared.satelliteIdleBreathingEnabled !== false, + satelliteRealAltitudeEnabled: + settings.shared.satelliteRealAltitudeEnabled !== false, interactableCompactDotsEnabled: settings.shared.interactableCompactDotsEnabled !== false, + surfaceHoverInfoMode: normalizeSurfaceHoverInfoMode( + settings.shared.surfaceHoverInfoMode, + ), layerVisibility: { ...(settings.shared.layerVisibility || {}) }, }, views: { @@ -975,11 +996,19 @@ function normalizeEarthSettings(rawSettings, defaults) { typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean" ? sharedSettings.satelliteIdleBreathingEnabled : defaults.shared.satelliteIdleBreathingEnabled; + const nextSatelliteRealAltitudeEnabled = + typeof sharedSettings?.satelliteRealAltitudeEnabled === "boolean" + ? sharedSettings.satelliteRealAltitudeEnabled + : defaults.shared.satelliteRealAltitudeEnabled; const nextInteractableCompactDotsEnabled = (rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION && typeof sharedSettings?.interactableCompactDotsEnabled === "boolean" ? sharedSettings.interactableCompactDotsEnabled : defaults.shared.interactableCompactDotsEnabled; + const nextSurfaceHoverInfoMode = + (rawSettings?.version || 0) >= SURFACE_HOVER_INFO_DEFAULT_VERSION + ? normalizeSurfaceHoverInfoMode(sharedSettings?.surfaceHoverInfoMode) + : defaults.shared.surfaceHoverInfoMode; const nextTrailsEnabled = typeof sharedSettings?.trailsEnabled === "boolean" ? sharedSettings.trailsEnabled : typeof inputLayerVisibility.trails === "boolean" @@ -1006,7 +1035,9 @@ function normalizeEarthSettings(rawSettings, defaults) { motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly, mediaPanelActiveTab: nextMediaPanelActiveTab, satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled, + satelliteRealAltitudeEnabled: nextSatelliteRealAltitudeEnabled, interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled, + surfaceHoverInfoMode: nextSurfaceHoverInfoMode, }, views: { desktop: { @@ -1195,6 +1226,15 @@ function syncSatelliteIdleBreathingToggle() { }); } +function syncSatelliteRealAltitudeToggle() { + const enabled = getSatelliteRealAltitudeEnabled(); + document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((input) => { + if (input instanceof HTMLInputElement) { + input.checked = enabled; + } + }); +} + function syncInteractableCompactDotsToggle() { const enabled = getInteractableCompactDotsEnabled(); document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => { @@ -1294,6 +1334,27 @@ export function setSatelliteIdleBreathingEnabled( return enabled; } +export function setSatelliteRealAltitudeEnabled( + nextEnabled, + { persist = true, suppressStatus = false } = {}, +) { + const enabled = applySatelliteRealAltitudeEnabled(nextEnabled); + ensureMutableEarthSettingsState(); + earthSettingsState.shared.satelliteRealAltitudeEnabled = enabled; + syncSatelliteRealAltitudeToggle(); + + if (persist) { + persistEarthSettings(); + } + if (!suppressStatus) { + showStatusMessage( + enabled ? "卫星真实高度已开启" : "卫星已切换为旧版同层高度", + "info", + ); + } + return enabled; +} + export function setInteractableCompactDotsEnabled( nextEnabled, { persist = true, suppressStatus = false } = {}, @@ -1387,6 +1448,10 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) { persist: false, suppressStatus: true, }); + setSatelliteRealAltitudeEnabled(settings.shared.satelliteRealAltitudeEnabled, { + persist: false, + suppressStatus: true, + }); setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, { persist: false, suppressStatus: true, @@ -2714,6 +2779,13 @@ function setupSettingsControls() { }); }); + document.querySelectorAll("[data-satellite-real-altitude-toggle]").forEach((toggle) => { + if (!(toggle instanceof HTMLInputElement)) return; + bindListener(toggle, "change", () => { + setSatelliteRealAltitudeEnabled(toggle.checked); + }); + }); + document.querySelectorAll("[data-trails-toggle]").forEach((toggle) => { if (!(toggle instanceof HTMLInputElement)) return; bindListener(toggle, "change", () => { @@ -2767,6 +2839,9 @@ function setupSettingsControls() { syncRotationModeButtons(); syncCruiseModuleControls(); syncSatelliteDisplayStyleControls(); + syncSatelliteIdleBreathingToggle(); + syncSatelliteRealAltitudeToggle(); + syncInteractableCompactDotsToggle(); syncDayNightToggle(dayNightEnabled); syncMotionDebugToggle(motionDebugEnabled); syncMotionProviderControls(motionProvider); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index c2a67372..7bacfc92 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -43,6 +43,7 @@ let satelliteCapacity = 0; let satelliteSatrecCache = new Map(); let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE; let satelliteIdleBreathingEnabled = true; +let satelliteRealAltitudeEnabled = true; const GROUND_FOOTPRINT_RENDER_ORDER = 3; @@ -338,6 +339,28 @@ export function setSatelliteIdleBreathingEnabled(enabled) { return satelliteIdleBreathingEnabled; } +export function getSatelliteRealAltitudeEnabled() { + return satelliteRealAltitudeEnabled; +} + +export function setSatelliteRealAltitudeEnabled(enabled) { + const nextEnabled = Boolean(enabled); + if (nextEnabled === satelliteRealAltitudeEnabled) { + return satelliteRealAltitudeEnabled; + } + + satelliteRealAltitudeEnabled = nextEnabled; + clearSatelliteTrails(); + updateSatellitePositions(0, true, { resetTrails: true }); + if ( + lockedSatelliteIndex !== null && + satellitePositions?.[lockedSatelliteIndex]?.current + ) { + showHoverRing(satellitePositions[lockedSatelliteIndex].current, true); + } + return satelliteRealAltitudeEnabled; +} + export function updateSatelliteIdleBreathingVisual(isIdle = true) { if (!satellitePoints || !satelliteBackdropPoints) return; @@ -940,8 +963,13 @@ function computeSatellitePosition(satellite, time) { } const r = Math.sqrt(x * x + y * y + z * z); - const displayRadius = - CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; + if (!Number.isFinite(r) || r <= 0) { + return null; + } + + const displayRadius = satelliteRealAltitudeEnabled + ? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r) + : CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset; const scale = displayRadius / r; return new THREE.Vector3(x * scale, y * scale, z * scale); @@ -950,6 +978,23 @@ function computeSatellitePosition(satellite, time) { } } +function getCompressedRealAltitudeOffset(radiusKm) { + const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM); + const clampedAltitudeKm = Math.min( + altitudeKm, + SATELLITE_CONFIG.maxDisplayAltitudeKm, + ); + const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm); + const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) / + Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm); + + return THREE.MathUtils.lerp( + SATELLITE_CONFIG.minRealAltitudeOffset, + SATELLITE_CONFIG.maxRealAltitudeOffset, + THREE.MathUtils.clamp(normalizedAltitude, 0, 1), + ); +} + function buildSatrecFromProperties(props, fallbackTime) { if (props.tle_line1 && props.tle_line2) { // Prefer source-provided TLE lines so the client does not need to rebuild them. @@ -1088,7 +1133,7 @@ function buildTleLinesFromElements(props, fallbackTime) { } function generateFallbackPosition(satellite, index, total, time = new Date()) { - const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; + const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset; const noradId = satellite.properties?.norad_cat_id || index; const inclination = satellite.properties?.inclination || 53; @@ -2557,7 +2602,7 @@ function calculatePredictedOrbit( if (points.length < samples * 0.5) { points.length = 0; const radius = - CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; + CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; diff --git a/pyproject.toml b/pyproject.toml index 67f1e2d8..f40d7d28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.54.0" +version = "0.55.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index a94e1ad2..cd5b132b 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.54.0" +version = "0.55.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From f14ff6ec0f075e870a9379150c029fb2bd51a8e6 Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 13 May 2026 18:21:03 +0800 Subject: [PATCH 07/19] release: bump version to 0.56.0 --- TODO.md | 112 ++++-- VERSION | 2 +- docs/CHANGELOG.md | 16 + docs/plans/README.md | 2 +- .../datasource-custom-api-mapping-plan.md | 8 +- ...arth-high-precision-boundary-tiles-plan.md | 132 ++++++ docs/plans/earth-interactable-layer-plan.md | 2 +- docs/plans/earth-surface-hover-info-plan.md | 105 +++++ docs/plans/earth-vessel-tracking-plan.md | 25 +- docs/technical/en/earth-bgp-context.md | 1 + docs/technical/en/earth-frontend-context.md | 8 +- .../en/earth-layer-style-reference.md | 6 +- docs/technical/en/earth-render-layer-order.md | 4 +- docs/technical/en/faq.md | 4 +- docs/technical/en/manual.md | 6 +- docs/technical/en/quickstart.md | 2 +- docs/technical/zh/earth-bgp-context.md | 1 + docs/technical/zh/earth-frontend-context.md | 7 +- .../zh/earth-layer-style-reference.md | 6 +- docs/technical/zh/earth-render-layer-order.md | 4 +- docs/technical/zh/faq.md | 4 +- docs/technical/zh/manual.md | 6 +- docs/technical/zh/quickstart.md | 2 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- .../assets/icons/compute-supercomputer.svg | 7 +- frontend/public/earth/css/earth-stats.css | 6 +- frontend/public/earth/css/hud.css | 23 +- frontend/public/earth/css/news-panel.css | 236 +++++++++++ frontend/public/earth/css/tv-panel.css | 119 +++--- frontend/public/earth/index.html | 152 ++++--- frontend/public/earth/js/bgp.js | 26 +- frontend/public/earth/js/constants.js | 4 +- frontend/public/earth/js/controls.js | 63 ++- frontend/public/earth/js/info-card.js | 4 +- frontend/public/earth/js/main.js | 106 ++++- frontend/public/earth/js/news.js | 291 +++++++++++++- .../earth/js/presentation-controller.js | 2 - frontend/public/earth/js/satellites.js | 177 ++++++-- frontend/public/earth/js/tv.js | 380 ++---------------- frontend/public/earth/js/ui.js | 57 +++ frontend/src/index.css | 50 ++- .../src/pages/DataSources/DataSources.tsx | 15 +- frontend/src/pages/Tasks/Tasks.tsx | 81 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 46 files changed, 1589 insertions(+), 684 deletions(-) create mode 100644 docs/plans/earth-high-precision-boundary-tiles-plan.md create mode 100644 docs/plans/earth-surface-hover-info-plan.md diff --git a/TODO.md b/TODO.md index 717e8953..e972cb58 100644 --- a/TODO.md +++ b/TODO.md @@ -1,45 +1,71 @@ # TODO -- [x] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点 -- [x] 开始做 BGP 异常和海缆/区域的关联展示 -- [x] 做 Earth 侧的 `BGP activity layer`,让低 incident 密度时地图仍然有持续可感知的观测存在感 -- [x] 给 Earth BGP 补三层状态表达:`平稳观测态 / 局部波动态 / 事件活跃态` -- [x] 把“当前无活跃事件”改造成“观测网络仍在运行、当前未发现聚合级事件”的状态表达 -- [x] 做 collector / region 近 15 分钟 activity score 聚合接口或动态聚合逻辑 -- [x] 把 Earth 的 BGP incident 改成 `紧凑事件核 + 向外扩张环形 pulse`,替换当前大面积 glow -- [x] 为 BGP incident 建立符号系统:按事件类型用不同 marker,而不是都用同一种亮点 -- [x] 把 incident 地理定位从 `collector-centric` 改成 `prefix-centric`,优先使用 `prefix_geography`,其次 `prefix_scope`,再次 ASN 区域,最后才回退到观测区域质心 -- [x] 新增 `prefix_geography` 数据层,不再把 `prefix_scope` 当成 prefix 地理归属本身 -- [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源 -- [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源 -- [x] 把 RIR delegated 设计成 prefix geography 的 fallback,而不是主来源 -- [ ] 为 `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json` 或 `yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里 -- [ ] 为市面上主流 AI 服务补专项兼容配置并固化到配置文件中,至少覆盖 `OpenAI / Anthropic / MiniMax / Ollama / Moonshot / DeepSeek / Qwen / GLM / Gemini / OpenRouter / vLLM / LM Studio / One API` -- [ ] 在兼容配置中补齐可声明项:`api adapter`、`base_url pattern`、`auth header`、`thinking default`、`reasoning block mapping`、`stream path`、`tool-call capability`、`multimodal capability`、`provider-specific request patch` -- [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层 -- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector -- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度 -- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 -- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置 -- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略 -- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板 -- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库 -- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题 -- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector -- [ ] AIS v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计 -- [ ] AIS v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service,持续写入 raw observations,通过内部 `/ws` 的 `vessels` channel 推送新船、位置和航向增量,Earth 前端按 MMSI upsert marker -- [ ] AIS v3.3:修正 AISStream 采集页面状态语义,使用 connecting/streaming/reconnecting/stopped 与 indeterminate 状态,展示运行时长、消息数、unique MMSI、message rate、最近消息和错误,不再用一次性 REST 进度条表示长连接 -- [ ] AIS v3.4:修复船只身份字段和名称聚合,MMSI/IMO/callsign 按字符串显示且不带千分位符;查询并列出所有仍以 MMSI 号码或 `MMSI ` 作为船名的记录,标注来源、最近观测、message types 和缺失原因,并把这批 fallback-name 船只纳入名称聚合修复集合 -- [ ] Earth Live Sync:建立统一态势实时同步链路,新增 `earth_summary` WS channel,任意采集器成功后广播轻量 summary invalidation,前端收到后重新拉 `/api/v1/visualization/geo/summary` 并更新 HUD;同时为 BGP 增加 `bgp` WS channel,使 BGP incidents/anomalies/collectors 在不刷新页面时也能 upsert 图层;卫星采集完成后触发 summary 刷新,必要时按 TLE 版本重新 hydrate 卫星数据 -- [ ] AIS v4:开放船只多源聚合策略配置,支持 source priority、字段级规则、freshness 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本 -- [ ] AIS v5:实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面 -- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互 -- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机 -- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON -- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py` -- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索 -- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值 -- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败 -- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置” -- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上 -- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例 +This file is the active backlog only. Completed history belongs in `docs/CHANGELOG.md`; detailed designs belong in `docs/plans/`. + +## Earth + +- [ ] High-precision country boundaries: implement the source-faithful static vector tile pipeline described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). Highest zoom must preserve trusted source geometry instead of smoothing or hand-drawing borders. +- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, and the official dashed maritime claim line before implementing visual changes. +- [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card. +- [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable. +- [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker. +- [ ] Earth news cruise: connect Earth news to the generic cruise queue via a news adapter rather than coupling news-specific sequencing into `main.js`. + +## Compute Centers And Location + +- [ ] Unknown compute-center locations: continue reducing unresolved records through the shared location pipeline, with confidence, precision, reason, and verification date preserved in GeoJSON/details. +- [ ] Compute-center registry: keep expanding the local canonical location registry with `canonical_name`, aliases, operator, country/region/city, coordinates, confidence, and source notes. +- [ ] Compute-center enrichment: improve source-page parsing for Epoch AI and related collectors by extracting location clues from detail pages, embedded JSON, schema.org, OpenGraph, script variables, PDFs, and press releases. +- [ ] Compute-center identity normalization: normalize operator / cluster / facility aliases such as `xAI / Colossus / Memphis`, `OpenAI / Stargate`, `CoreWeave`, `Lambda`, and `Crusoe`. +- [ ] Compute-center manual review: add an export/review/import workflow for unresolved or estimated locations and feed confirmed results back into the registry. + +## AIS / Vessels + +- [ ] AIS aggregation strategy v4: expose source priority, field-level merge rules, freshness windows, and protected dynamic-field rules in configuration, with validation and strategy version returned by vessel APIs. +- [ ] AIS vessel enrichment v5: add asynchronous vessel profile enrichment for ship type detail, AIS class, flag, dimensions, build year, operator, and cached media. Do not fetch third-party pages in the realtime AIS request path. +- [ ] AIS identity cleanup: continue identifying vessels whose display name is only `MMSI ` and backfill names from AISStream static messages, BarentsWatch static fields, or enrichment cache. + +## AI Provider And Agents + +- [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code. +- [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API. +- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches. +- [ ] BGP geography fallback: evaluate `inetnum` / `inet6num` whois as a finer fallback layer after `prefix_geography`, `OpenGeoFeed`, and RIR delegated data. + +## Platform + +- [ ] Earth preferences scope: keep current device-local Earth preferences in `localStorage`; only design backend user preferences if account-level synchronization becomes a real product requirement. +- [ ] System logs: finish a usable Planet log viewing flow that covers backend, frontend, AI Provider, and collector/task logs, with filtering and tailing. +- [ ] Console UI modernization: gradually replace Ant Design with Planet-owned components and a consistent Tabler Icons based icon system. +- [ ] Earth live sync: design a unified realtime invalidation path for summary/BGP/satellite updates if polling and current WebSocket channels become insufficient. + +## Archive + +Archived items stay here so old context is not lost. Completed items remain checked; obsolete, invalid, or superseded items stay unchecked and include the reason. + +### Completed + +- [x] Refined BGP observer and anomaly `hover/click` feel. +- [x] Added BGP anomaly relationship display with cables / regions. +- [x] Added the Earth BGP activity layer so the map still feels alive when incident density is low. +- [x] Added BGP state expression for stable observation, local fluctuation, and active incident states. +- [x] Reframed "no active incident" as "observation network is running; no aggregate incident detected". +- [x] Added collector / region recent activity scoring. +- [x] Replaced oversized BGP incident glow with compact incident core plus outward pulse rings. +- [x] Added BGP incident symbol types instead of using one generic bright marker. +- [x] Switched BGP incident geography from collector-centric to prefix-centric priority. +- [x] Added `prefix_geography` as a separate data layer instead of treating `prefix_scope` as prefix geography. +- [x] Added IPtoASN / IPtoCountry as the main prefix-centric geography source. +- [x] Added OpenGeoFeed as a high-quality prefix geography override source. +- [x] Made RIR delegated data a prefix geography fallback rather than the primary source. +- [x] Added route leak and path instability / flap detectors after the activity layer work. + +### Obsolete Or Superseded + +- [ ] AIS v3.1 old `/geo/vessels` full-merge requirement. Superseded by `/api/v1/vessels/snapshot`, controlled legacy fallback, and diagnostics in the AIS aggregation plan. +- [ ] AIS v3.2 old framing of AISStream as a batch collector that needed conversion. Superseded by the implemented long-lived AISStream collector and realtime stream UI. +- [ ] AIS v3.3 old one-shot REST progress semantics for AISStream. Superseded by realtime stream status handling. +- [ ] AIS v3.4 broad identity cleanup wording. Folded into the active AIS identity cleanup and v5 enrichment tasks. +- [ ] Earth surface material overlay for boundary calibration. Superseded by the high-precision boundary tile plan; future work must use source-faithful boundary/coastline data rather than overlay calibration against the coarse base map. +- [ ] Hardcoded Earth news source extraction as a standalone task. Superseded by the broader Earth news source configuration and collector plans. +- [ ] Country-level compute-center fallback placement as a standalone task. Superseded by the shared location pipeline and registry/manual-review backlog. diff --git a/VERSION b/VERSION index 316ba4bd..2f4c74eb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.55.0 +0.56.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 02069462..4c1b9519 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.56.0] — 2026-05-13 + +Released: 2026-05-13 + +### Highlights +- 修复 Earth 卫星 SGP4 坐标口径,当前点/短尾迹使用地固坐标,锁定预测轨道使用固定地球姿态下的闭合惯性轨道。 +- 优化真实高度压缩显示上限,将高轨显示控制在地球半径外约四分之一,保持 GEO/MEO/LEO 分层同时避免轨迹过远。 +- 统一 BGP 光晕与图标色调,并更新超算中心建筑图标和 Earth 新闻/HUD 面板体验。 + +### Added / Fixed / Improved +- 修正卫星详情卡近地点/远地点高度计算,避免把轨道半径误显示为离地高度。 +- 调整 BGP event / collector halo 的浅色派生规则,使红色事件、橙色活跃观测站和蓝色 idle 观测站保持各自色相。 +- 补充中英文用户手册、FAQ、Earth frontend context、render order、layer style reference 和相关计划文档。 + +--- + ## [0.55.0] — 2026-05-13 Released: 2026-05-13 diff --git a/docs/plans/README.md b/docs/plans/README.md index c78f53eb..f2d9afcc 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,7 +19,7 @@ - [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md) - [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md) - [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md) -- [earth-country-boundary-overlay-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-country-boundary-overlay-plan.md) +- [Earth 高精度国界静态瓦片计划](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md) - [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md) - [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md) - [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md) diff --git a/docs/plans/datasource-custom-api-mapping-plan.md b/docs/plans/datasource-custom-api-mapping-plan.md index 93ebbc13..021eda52 100644 --- a/docs/plans/datasource-custom-api-mapping-plan.md +++ b/docs/plans/datasource-custom-api-mapping-plan.md @@ -13,7 +13,7 @@ | 采集时是否调用 LLM | 不调用;采集链路必须确定性、可审计、可复现 | | 自定义数据如何进入 Earth | 必须映射到已支持的目标 schema,或先进入通用数据沉淀 | | 外部凭证放置位置 | Settings / 外部集成统一管理 provider token;DataSources 引用 provider profile | -| TimescaleDB | 放入 TODO;高频时序数据稳定后再评估迁移 | +| TimescaleDB | 高频时序数据稳定后再评估迁移 | --- @@ -57,7 +57,7 @@ flowchart LR | schema | 用途 | Earth 可视化 | |-------|------|-------------| | `vessel_ais` | 船只 AIS 位置、航速、航向、MMSI 等 | 进入船舶图层 | -| `geo_points` | 通用点位数据,包含经纬度、名称、类型、时间 | 进入通用 geo layer(TODO) | +| `geo_points` | 通用点位数据,包含经纬度、名称、类型、时间 | 未来进入通用 geo layer | | `news_events` | 新闻/事件类数据,带时间、地点、摘要、来源 | 复用新闻/事件链路 | | `compute_centers` | 算力中心、机房、数据中心数据 | 复用算力中心图层 | | `generic_records` | 未知结构化数据沉淀 | 不直接展示 | @@ -294,7 +294,7 @@ PostgreSQL 可以承载当前规模的采集、关系查询、JSONB 沉淀和基 - 查询模式还没稳定。 - 需要快速迭代 schema 与 mapping。 -### TODO:TimescaleDB +### TimescaleDB 后续评估 以下条件满足后,再评估 TimescaleDB: @@ -324,7 +324,7 @@ PostgreSQL 可以承载当前规模的采集、关系查询、JSONB 沉淀和基 - Settings 中保存 provider credentials。 - API 返回配置时必须 mask secret。 - LLM prompt 只能包含脱敏 sample 和 schema 说明。 -- 后续 TODO:引入字段级加密或 KMS。 +- 后续可引入字段级加密或 KMS。 ### Mapping 治理 diff --git a/docs/plans/earth-high-precision-boundary-tiles-plan.md b/docs/plans/earth-high-precision-boundary-tiles-plan.md new file mode 100644 index 00000000..2bc80993 --- /dev/null +++ b/docs/plans/earth-high-precision-boundary-tiles-plan.md @@ -0,0 +1,132 @@ +# Earth High Precision Boundary Tiles Plan + +## Status + +Planning revised after visual review. The previous hand-authored China claim-line / point-buffer approach is rejected and must not be implemented. + +Implemented separately: + +- 地表 hover 三模式已实现。 + +Still planned: + +- 权威 China POV 数据包。 +- OSM / coastline 高精度离线构建。 +- 版本化静态矢量瓦片输出。 +- 前端 bbox/tile/LRU 高精度加载器。 + +## Summary + +Earth 国界线目标从“明显提升”升级为 **最高精度档按真实地图源一比一还原**: + +- 高缩放时不能使用当前 `countries-admin0.min.geojson` 这种低精度简化线。 +- 最高 tile zoom 必须忠实保留选定权威矢量源的原始折点,不做视觉平滑,不做人工凭感觉补线。 +- 藏南、阿克赛钦等争议陆地直接作为中国国家面的一部分表达;hover 只显示普通 `中国 / CHN`,不显示特殊区域名。 +- 九段线 / 十段线必须来自官方标准地图口径或经地理配准校核后的权威矢量数据;不能手工目测画线,不能把马来西亚、菲律宾等周边陆地或近岸底盘划入中国面。 + +方案仍采用 **离线构建 + 静态矢量瓦片**。低缩放加载简化 base line;高缩放按当前视野 bbox 加载高精度 boundary tiles。第一版不使用 Redis,依靠 Nginx 静态服务、浏览器 HTTP cache 和前端 LRU。 + +## Data Source and Policy + +- 基础陆地国界使用 OSM `boundary=administrative` + `admin_level=2`,并补充高精度 coastline,避免只靠粗糙国家面导致海岸线缺失。 +- China POV 覆盖数据必须独立成包,构建时优先级高于 OSM 原始归属: + - 藏南 union 到中国面,同时从印度面 subtract。 + - 阿克赛钦 union 到中国面,同时从相关邻接面 subtract。 + - 台湾、澎湖、钓鱼岛及附属岛屿、赤尾屿、东沙、西沙、中沙、南沙等作为中国国家面/岛礁面的一部分进入 hover index。 + - 岛礁很小时可有最小可交互面,但 tooltip 仍是 `中国 / CHN`,不展示“某特殊区域归属”标签。 +- 九段线 / 十段线是独立 maritime claim line 图层: + - 只渲染 dashed line,不参与国家陆地面。 + - 不用于吞并周边国家陆地或近岸水域。 + - 坐标必须来自官方标准地图、权威矢量数据,或从官方示意图配准后人工复核,不接受手工猜测坐标。 +- 必须保留 OSM 数据归因:`© OpenStreetMap contributors, ODbL`。 + +## Precision Requirements + +- 最高精度档的验收口径是 **source-faithful**,不是“看起来更细”: + - 对高精度源线,最高 zoom tile 不允许 Douglas-Peucker 简化。 + - 坐标量化精度至少保留到 `1e-5` 度级别,构建时不得把经纬度粗暴四舍五入到低精度。 + - 球面渲染只允许 densify 长边来贴合地球曲率;不允许 CatmullRom、Bezier 或任何会改变边界走向的平滑。 + - 海岸/边界红框类区域必须与源地图折线逐点对齐;若有偏差,只能追溯并替换数据源,不能靠渲染平滑掩盖。 +- 低缩放允许简化,但必须有误差预算: + - base line 只服务远景识别。 + - 中 zoom tile 可简化到屏幕误差低于 `0.5px`。 + - 最高 zoom tile 使用无简化或近零误差版本。 + +## Implementation Plan + +### 1. Data pipeline + +新增边界构建脚本,使用 `/home/ray/.local/bin/uv` 运行: + +1. 读取 OSM PBF 或预处理后的 admin-0 边界 / coastline GeoJSON。 +2. 读取 China POV override package。 +3. 使用可靠几何库做 union / difference / validity repair,禁止手写 polygon overlay。 +4. 生成中国国家面时直接合并藏南、阿克赛钦和相关岛礁;从相邻国家面扣除同一区域。 +5. 单独读取官方口径九段线 / 十段线矢量,生成 claim-line tiles。 +6. 输出: + - 低精度 global base line。 + - 无简化 high-precision hover polygon index。 + - 高精度静态瓦片:`frontend/public/earth/data/boundaries/v1/{z}/{x}/{y}.geojson`。 + - 海上断续线瓦片:`frontend/public/earth/data/boundaries/v1/china-claims/{z}/{x}/{y}.geojson`。 + - manifest,记录数据源、覆盖规则版本、构建时间、简化误差和 attribution。 + +### 2. Tile levels and size budget + +- Earth zoom `< 1.6`:只显示 global base line 和低精度 claim line。 +- Earth zoom `1.6-2.8`:加载 tile zoom `4-5`。 +- Earth zoom `2.8-4.0`:加载 tile zoom `6-7`。 +- Earth zoom `> 4.0`:加载 tile zoom `8-10`,使用最高精度无简化折线。 +- 单 tile gzip 目标 `20-80KB`,但最高精度档优先保证几何真实性;若超限,优先提高 tile zoom 或拆 tile,而不是简化真实线。 + +### 3. Frontend loading model + +`country-boundaries.js` 拆成 base layer、tile layer 和 China claim layer: + +- 首屏加载 base line + hover index,不阻塞 Earth 初始化。 +- 高缩放时根据 camera 可见范围计算经纬 bbox,再转换为 Web Mercator tile keys。 +- bbox 由屏幕中心、四角和边中点 raycast 得到,并扩张 `10-20%` 作为预取范围。 +- 处理反经线,必要时拆成两个 bbox。 +- 视野变化请求 debounce `150-250ms`。 +- 拖拽/惯性旋转中不每帧请求;缩放档或 tile key 集合没变时不刷新。 +- 加载当前视野 tile,并预取一圈邻接 tile。 +- base line 在高精度 tile 到达后降低 opacity,避免双线。 + +### 4. Caching and memory control + +- 前端维护 `tileCache`、`inFlightTiles` 和 LRU 使用顺序。 +- tile cache 上限建议 `120-180` 个 tile;超过后释放最旧 tile 的 `BufferGeometry`。 +- Nginx 为 `/earth/data/boundaries/` 设置长缓存: + - `Cache-Control: public, max-age=31536000, immutable` + - gzip 包含 JSON。 +- tile URL 包含版本目录,例如 `v1`;数据更新时改版本目录破浏览器缓存。 +- 第一版不使用 Redis。只有改成动态裁剪 API、多 POV 同 URL、或压测证明静态服务成为瓶颈时再考虑 Redis。 + +## Acceptance Criteria + +1. 最高 zoom 的海岸线和国界线与选定源地图逐点一致,不再只是“明显提升”。 +2. 红框类海岸/边界细节在最高 zoom 下不能出现肉眼可见的低精度折线、直线切边或圆滑失真。 +3. 藏南、阿克赛钦 hover 命中普通 `中国 / CHN`;印度或其他邻接国家不再包含这些区域。 +4. 钓鱼岛、赤尾屿、南海诸岛代表点 hover 命中普通 `中国 / CHN`。 +5. 九段线 / 十段线位置与权威来源一致,不压入马来西亚、菲律宾等周边陆地或错误包围近岸底盘。 +6. 首屏不加载全球超高精度整包。 +7. 高缩放只请求当前视野附近 tile,快速旋转不会出现请求风暴。 +8. 回到已访问区域命中前端 cache 或浏览器 cache。 +9. 国界图层开关、hover 高亮、移动端中心国家高亮、悬停提示三模式保持可用。 + +## Verification + +- 构建阶段: + - 几何 validity check 全通过。 + - China POV 代表点测试全部返回 `CHN`。 + - 相邻国家代表点不得被 China override 误吞。 + - 最高 zoom tile 抽样与源数据做坐标级 diff,确认未简化。 +- 前端阶段: + - 在 `frontend` 运行 `/home/ray/.bun/bin/bun run build`。 + - 浏览器 DevTools 验证低缩放无高精度 tile 请求,高缩放请求数量受控,重复视野走 cache。 + - 用截图中的红框区域、南海断续线、藏南、钓鱼岛、赤尾屿、南海诸岛做手动视觉验收。 + +## Sources and Assumptions + +- 产品默认采用中国标准地图/公开地图合规口径;若后续支持多 POV,必须通过版本化数据目录隔离,不能让同一 URL 返回不同政治口径。 +- 当前仓库没有足够权威和足够精细的 China POV / 九段线矢量源,因此不能直接凭现有低精度 GeoJSON 完成“一比一还原”。 +- 实施前必须先引入或生成可审计的高精度源数据包;没有源数据时,只能实现加载框架,不能伪造边界。 diff --git a/docs/plans/earth-interactable-layer-plan.md b/docs/plans/earth-interactable-layer-plan.md index 29d67010..8682c013 100644 --- a/docs/plans/earth-interactable-layer-plan.md +++ b/docs/plans/earth-interactable-layer-plan.md @@ -273,7 +273,7 @@ hover / locked 使用少量 overlay: - 算力中心保留现有业务 icon,但接入统一 hover / locked / glow。(已完成) - 登陆点曾接入同一套 `Points` 渲染,但 pin 类 SVG 在地球边缘会被深度测试裁切;当前保留专用 `THREE.Sprite`,并使用 canvas 生成黄色扁平球,贴到海缆层级。 -- TODO:登陆点暂不迁移到完整 Interactable。后续若要统一交互接口,优先考虑 Sprite-backed adapter,只对齐 `getMarkers()`、`getPointerIntersections()`、`setMarkerState()`、`updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。 +- 登陆点暂不迁移到完整 Interactable。后续若要统一交互接口,优先考虑 Sprite-backed adapter,只对齐 `getMarkers()`、`getPointerIntersections()`、`setMarkerState()`、`updateVisualState()` 等外观协议,不强行复用 `THREE.Points`、atlas 和跨图层避让。 - 检查图例、搜索和 info-card 是否只依赖业务 payload,而不是依赖渲染对象类型。 ### Phase 4:形成 Earth 图标层规范 diff --git a/docs/plans/earth-surface-hover-info-plan.md b/docs/plans/earth-surface-hover-info-plan.md new file mode 100644 index 00000000..bcef5968 --- /dev/null +++ b/docs/plans/earth-surface-hover-info-plan.md @@ -0,0 +1,105 @@ +# Earth Surface Hover Info Plan + +Status: Implemented. + +## Goal + +为 Earth 桌面和 compact 鼠标地表 hover 增加可配置的提示内容,让用户可以选择只看国家信息、只看位置数据,或同时查看国家和经纬海拔。 + +当前地表 hover 已经具备两类信息: + +- 命中国家时显示国家、ISO、大洲,并高亮国界。 +- 未命中国家时显示纬度、经度、海拔。 + +新方案把这两类信息合并成一个清晰的设置项:`悬停提示`。 + +## User-facing behavior + +设置项放在桌面设置和移动端设置的 `视图` 区,使用分段控件: + +- `国家`:陆地命中国家时显示国家名、ISO、大洲;太平洋等海洋区域不显示地表 tooltip。 +- `位置`:陆地和海洋都显示纬度、经度、海拔;不触发国家 tooltip 和国家边界 hover 高亮。 +- `完整`:陆地命中国家时显示国家信息和纬度、经度、海拔;海洋区域显示纬度、经度、海拔。 + +默认值为 `完整`,因为它保留现有国家识别价值,同时满足 hover 时查看经纬海拔的需求。 + +海洋在 `国家` 模式下保持沉默,而不是显示大洋名称。原因是当前项目没有海域/大洋边界数据源;用经纬度粗判太平洋、大西洋等范围容易产生误导。如果用户需要海洋位置,使用 `位置` 或 `完整`。 + +## Implementation plan + +### Settings state + +在 `frontend/public/earth/js/constants.js` 增加: + +```js +export const SURFACE_HOVER_INFO_MODES = { + COUNTRY: "country", + POSITION: "position", + FULL: "full", +}; + +export const DEFAULT_SURFACE_HOVER_INFO_MODE = + SURFACE_HOVER_INFO_MODES.FULL; +``` + +在 `frontend/public/earth/js/controls.js`: + +- 将 `EARTH_SETTINGS_VERSION` 从 `10` 升到 `11`。 +- 在 shared settings 中新增 `surfaceHoverInfoMode`。 +- 新增导出: + - `getSurfaceHoverInfoMode()` + - `setSurfaceHoverInfoMode(mode, { persist, suppressStatus })` +- normalize 时只接受 `country | position | full`,否则回退到 `full`。 +- reset settings 后恢复为 `full`。 + +### Settings UI + +在桌面设置 `视图` 区和移动端设置 `视图` 区加入同一组按钮: + +```html + + + +``` + +控件同步规则沿用现有卫星显示风格和巡航模块的模式: + +- 当前模式按钮添加 `is-active`。 +- 当前模式按钮设置 `aria-pressed="true"`。 +- 切换后保存到 Earth settings localStorage。 + +### Hover tooltip logic + +在 `frontend/public/earth/js/main.js` 的地表 hover 分支中读取 `getSurfaceHoverInfoMode()`,统一构造 tooltip。 + +行为规则: + +- 如果没有命中地球:清除国家 hover 并隐藏 tooltip。 +- `position`: + - 调用 `clearCountryBoundaryHover()`。 + - 显示 `纬度 / 经度 / 海拔`。 +- `country`: + - 仅当国界图层开启并命中国家时显示国家 tooltip 和国界 hover。 + - 海洋、国界图层关闭、未加载国界数据时隐藏地表 tooltip。 +- `full`: + - 国界图层开启且命中国家时显示国家信息加位置信息。 + - 未命中国家或国界图层关闭时显示位置信息。 + +海拔继续使用 `sampleElevationAt(lat, lon)`。暂无采样时显示 `—`,不因 hover 主动加载地形瓦片。 + +## Acceptance criteria + +1. `完整` 模式下,hover 陆地显示国家信息和经纬海拔,hover 海洋显示经纬海拔。 +2. `国家` 模式下,hover 陆地显示国家信息,hover 太平洋等海洋不显示地表 tooltip。 +3. `位置` 模式下,hover 陆地和海洋都显示经纬海拔,国家边界不高亮。 +4. 关闭国界图层后,`完整` 模式回退为只显示位置。 +5. 船只、BGP、算力中心、海缆等对象 hover tooltip 优先级不变。 +6. 移动端中心国家高亮不受这个鼠标 hover 设置影响。 +7. 设置刷新后保持,重置后恢复为 `完整`。 + +## Verification + +- 在 `frontend` 下运行 `/home/ray/.bun/bin/bun run build`。 +- 手动验证三种模式的陆地和海洋 hover 行为。 +- 验证设置持久化和重置。 +- 验证对象 hover 仍优先于地表 hover。 diff --git a/docs/plans/earth-vessel-tracking-plan.md b/docs/plans/earth-vessel-tracking-plan.md index c853c824..7480fcf8 100644 --- a/docs/plans/earth-vessel-tracking-plan.md +++ b/docs/plans/earth-vessel-tracking-plan.md @@ -1,14 +1,14 @@ # 实时船只监控系统 — 实施计划 -**状态**:规划中 +**状态**:历史计划;实时 AIS 与聚合接口已由 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md) 接管 **创建日期**:2026-04-27 -**优先数据源**:BarentsWatch AIS(免费但需要 OAuth client credentials)→ AISHub / MarineTraffic(TODO,付费) +**优先数据源**:BarentsWatch AIS(免费但需要 OAuth client credentials)→ AISStream realtime;AISHub / MarineTraffic 保留为付费备选 ## 已确认决策 | 项目 | 决策 | |-----|------| -| 数据源 | BarentsWatch 先行;AISHub / MarineTraffic TODO | +| 数据源 | BarentsWatch 先行;AISStream realtime 已成为全球实时补充;AISHub / MarineTraffic 保留为付费备选 | | 船只规模 | BarentsWatch 阶段全部显示;全球数据接入后按需加船型过滤(默认 Cargo + Tanker + Passenger) | | 更新频率 | 准实时:前端 5 分钟轮询,后端 Collector 每分钟拉取写库 | | 历史轨迹 | 保留(`vessel_position` 表保留 24h,后期按需扩展) | @@ -23,9 +23,9 @@ | 来源类型 | 典型服务 | 覆盖范围 | 成本 | 状态 | |---------|---------|---------|------|------| | **BarentsWatch AIS API** | live.ais.barentswatch.no | 挪威海域实时 | 免费,需要 AIS API client credentials | **当前使用** | -| **AISHub** | aishub.net | 全球实时 | 免费/小额 | TODO:付费接入 | -| **MarineTraffic API** | marinetraffic.com | 全球实时 | $50–$500/月 | TODO:评估 tier | -| **VesselFinder API** | vesselfinder.com | 全球实时 | $50–$300/月 | TODO:备选 | +| **AISHub** | aishub.net | 全球实时 | 免费/小额 | 付费备选 | +| **MarineTraffic API** | marinetraffic.com | 全球实时 | $50–$500/月 | 待评估 tier | +| **VesselFinder API** | vesselfinder.com | 全球实时 | $50–$300/月 | 备选 | | **自建 SDR 接收** | RTL-SDR + AIS-catcher | 仅本地 30–50km | 硬件 $30 | 不考虑 | | **NOAA 历史数据** | Marine Cadastre | 美国近海历史 | 免费 | 可用于冷启动 | @@ -36,16 +36,9 @@ - 字段:mmsi, lat, lon, sog, cog, heading, nav_status, name, vessel_type, flag - 刷新频率:数据约 30–60s 更新一次,可随意轮询 -### TODO:多源 AIS 与实时流接入 +### 多源 AIS 与实时流接入历史 -- [ ] 接入 AISStream WebSocket 采集器,作为 BarentsWatch 覆盖不足的实时补充 -- [ ] 将 BarentsWatch、AISStream、自定义 `vessel_ais` 映射源统一写入原始观测层 -- [ ] 通过聚合接口做去重、字段合并、冲突记录和默认来源选择 -- [ ] 开放字段级聚合策略配置,让用户决定不同字段优先信任哪个来源 -- [ ] 评估 AISHub 订阅(全球覆盖,约 $30/月),接入全球实时流 -- [ ] 评估 MarineTraffic API tier,对比 AISHub 数据质量与成本 -- [ ] 实现多数据源适配器,通过 `datasource_config` 切换 -- [ ] 真实高频 AIS 稳定接入后,评估将 `vessel_position` 迁移为 TimescaleDB hypertable(保留 Postgres 原生分区作为备选) +AISStream WebSocket collector、`/api/v1/vessels/snapshot` 和 `/ws` vessels channel 已在后续计划中落地。仍有价值的后续项集中维护在根目录 [TODO](/home/ray/dev/linkong/planet/TODO.md) 的 AIS / Vessels 小节。 多源 AIS 的详细设计见 [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)。 @@ -252,7 +245,7 @@ IMO 9811000 ### Phase 4 — 性能与生产化(2–3 天) - `vessel_position` 按天分区,7 天自动清理 -- TODO:真实数据量达到百万级/日后,将 `vessel_position` 升级为 TimescaleDB hypertable,配置 retention policy 与压缩策略 +- 真实数据量达到百万级/日后,再评估是否将船只时序数据升级为 TimescaleDB hypertable,并配置 retention policy 与压缩策略 - GeoJSON endpoint 用 Redis 缓存 15s - 若需 bbox 精确查询,引入 PostGIS `geography` + `ST_DWithin` - InstancedMesh + frustum culling,目标 5 万船只 60fps diff --git a/docs/technical/en/earth-bgp-context.md b/docs/technical/en/earth-bgp-context.md index 931e286e..9d6b7924 100644 --- a/docs/technical/en/earth-bgp-context.md +++ b/docs/technical/en/earth-bgp-context.md @@ -157,6 +157,7 @@ Current design: - symbol-driven event cores - outward ring pulses - reduced diffuse glow compared with older Earth builds + - event region halos, collector coverage halos, and radar pulses derive a lighter tint from their own icon color instead of using a fixed teal; red high-severity events, orange active collectors, and blue idle collectors keep their hue family while broad halos stay softer than the icon 5. The right-side stats now show: - BGP events - collector count diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 0ede74b8..35300328 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -205,9 +205,13 @@ Terrain should not block startup when it is not the restored visible layer. Afte Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset. -Settings that affect visual layers (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, etc.) are read during initialization and applied immediately. +Settings that affect visual layers and surface interaction (terrain opacity, day/night mode, satellite display style, satellite idle breathing, real satellite altitude, track display, hover tooltip mode, etc.) are read during initialization and applied immediately. -The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. +The surface hover tooltip preference is persisted by `controls.js` as `shared.surfaceHoverInfoMode`, while `main.js` composes the actual tooltip in the globe-surface hover branch. `Country` shows country details only when a country polygon is hit and stays silent over ocean; `Position` shows latitude, longitude, and sampled terrain elevation and clears country-boundary hover; `Full` shows country + position on land and position over ocean. + +The real satellite altitude preference is persisted by `controls.js`, while the rendering state lives in `satellites.js`. When enabled, the real radius from SGP4 is compressed logarithmically into the current Earth visual radius range. When disabled, satellite dots, trails, and predicted orbits all return to the legacy same-sphere display. Toggling this setting must refresh satellite positions and clear trail buffers so a trail never mixes both height models. `maxRealAltitudeOffset = 25` is a visual cap tuned for the current camera and `earthRadius = 100`: GEO / MEO remain clearly higher than LEO, but the highest orbits stay within about 25% beyond the globe radius so selection targets, red trails, and the globe do not feel disconnected. + +SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude. ## Current Terrain Pipeline diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index a739b677..619367ac 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -163,7 +163,7 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Satellite fallback radius offset | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | Legacy same-sphere position when real altitude is disabled or TLE propagation fails | | Satellite altitude compression scale | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 altitude compression; larger values soften high-orbit separation | | Satellite real-altitude cap | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | Altitude clamp for the compressed display mapping, covering GEO-range altitude | -| Satellite real-altitude display offset range | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 55` | Compressed offset added to `CONFIG.earthRadius` | +| Satellite real-altitude display offset range | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | Compressed offset added to `CONFIG.earthRadius`; `25` is about one quarter of the current globe radius `100`, keeping high-orbit separation visible without pushing GEO/MEO trails too far away | | Satellite dot base pixel size | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | Point shader size | | Satellite backdrop dot scale | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | Backdrop dot size | | Satellite dot opacity range | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | Breathing animation | @@ -233,7 +233,9 @@ AIS vessel markers use batched `THREE.Points`, not one `THREE.Sprite` per vessel | Medium color | `BGP_CONFIG.severityColors.medium` | `0xffd166` | Medium-severity event | | Low color | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | Low-severity event | | Collector base color | `BGP_CONFIG.collectorColor` | `0x6db7ff` | Default collector color | -| Region color | `BGP_CONFIG.regionColor` | `0x2dd4bf` | Region overlay | +| BGP halo neutral tint | `BGP_CONFIG.halo.tintNeutralColor` | `0xffffff` | Broad halos derive from the matching event / collector icon color and blend toward this neutral, keeping the same hue family without becoming identical | +| BGP halo icon-color blend | `BGP_CONFIG.halo.tintBlend` | `0.72` | Higher values stay closer to the icon color; currently used by event region halos, collector coverage halos, and collector radar pulse | +| Region color | `BGP_CONFIG.regionColor` | `0x2dd4bf` | Legacy region baseline; broad BGP halos now prefer colors derived from the event or collector icon | ## Celestial and Starfield diff --git a/docs/technical/en/earth-render-layer-order.md b/docs/technical/en/earth-render-layer-order.md index b8b59873..97889ebc 100644 --- a/docs/technical/en/earth-render-layer-order.md +++ b/docs/technical/en/earth-render-layer-order.md @@ -29,9 +29,9 @@ Note: the layer control panel order and the registration / startup load order ar | 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. | | 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. | | 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. | -| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..55`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. | +| 5 | Satellite background dot | `satellites.js` | Fixed renderOrder; by default TLE/SGP4 altitude is compressed to `CONFIG.earthRadius + 4..25`; with real altitude disabled or propagation failed, uses `fallbackAltitudeOffset = 8` | Screen-space satellite picking | Below satellite dots. | | 6 | Satellite dots | `satellites.js` | Same compressed / fallback height as satellite backdrop dots | Screen-space satellite picking | Satellite dots above footprints and compute centers. | -| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and returns to same-sphere mode when disabled | Satellite overlay path | Used for selected/locked satellite emphasis. | +| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. | | 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. | ## Toggle Behavior diff --git a/docs/technical/en/faq.md b/docs/technical/en/faq.md index 2e2f5dc2..288bb4ca 100644 --- a/docs/technical/en/faq.md +++ b/docs/technical/en/faq.md @@ -323,10 +323,12 @@ Compute-center saves write to `compute_center_locations` and refresh the layer. ### Why are satellites no longer on one sphere? -Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. This setting also affects satellite trails and the predicted orbit shown after locking a satellite. +Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. The maximum display offset is `25`, about one quarter of the current globe radius; this is a readability compromise that separates GEO / MEO / LEO without drawing real kilometers to scale. This setting also affects satellite trails and the predicted orbit shown after locking a satellite. Turn off "Real Satellite Altitude" in Earth Settings to restore the legacy same-sphere satellite display. Satellites with missing TLE data or failed propagation still fall back to the legacy fixed height, so they do not disappear just because a real altitude cannot be computed. +Low-inclination high-orbit satellites should stay near the equator or a fixed longitude band, not form a near north-south ring. Earth converts the SGP4 inertial position to Earth-fixed coordinates for the current dot; the locked predicted orbit fixes the current globe pose and draws one inertial orbit, so it should close and preserve the correct inclination. If a future details card shows inclination near `0°` but the predicted orbit looks polar, check the ECI/TEME-to-ECF conversion and whether the predicted orbit accidentally uses per-sample `gstime`. + ### Why does Motion Debug not show camera video? With the Browser Camera source, the debug panel shows the local browser camera preview and draws the skeleton over it. If `Skeleton Only` is enabled, the video preview is hidden and the panel keeps only the dark canvas plus red/green skeleton. diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 180b5fbc..731d7a5a 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -255,9 +255,11 @@ Candidates preview on Earth directly. Saving a compute-center candidate writes t ### Settings -The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset. +The settings panel 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, default globe size, terrain opacity, reset. -"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden. +"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. The high-orbit display height is capped at about one quarter of the globe radius, so GEO / MEO objects remain visually separated from LEO without spreading trails and selection targets too far apart. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden. + +"Hover Tooltip" controls the tooltip shown when the pointer hovers over the globe surface: `Country` shows country details only when land matches a country, and stays silent over oceans such as the Pacific; `Position` shows latitude, longitude, and elevation over land and ocean; `Full` is the default and shows country + position over land and position over ocean. These settings live in browser local storage; switching browsers or clearing site data resets them. diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 0c46c343..6065a404 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -48,7 +48,7 @@ Once in, verify: - Search finds cables, satellites, compute centers, BGP events - Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth - Mouse drag, wheel zoom, and the zoom percentage indicator work -- The settings panel can switch rotate / cruise / motion modes; satellite settings can toggle real-altitude layering and track display +- The settings panel can switch rotate / cruise / motion modes; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display ## 5. Recover a Lost Password diff --git a/docs/technical/zh/earth-bgp-context.md b/docs/technical/zh/earth-bgp-context.md index fe578a39..cc3a8f34 100644 --- a/docs/technical/zh/earth-bgp-context.md +++ b/docs/technical/zh/earth-bgp-context.md @@ -157,6 +157,7 @@ BGP 模块正在从一个只展示异常的演示功能,演进为分层观测 - 由符号驱动的事件核心 - 向外扩散的环形脉冲 - 相比旧版 Earth 更少的弥散 glow + - 事件区域 halo、collector coverage halo 和雷达 pulse 从各自图标色派生浅色调,而不是使用固定青绿色;红色高危事件、橙色活跃 collector 和蓝色 idle collector 保持各自色相一致,面状光晕则比图标更轻 7. 右侧统计现在显示: - BGP events - collector count diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 0a246f83..1eac4fec 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -421,11 +421,16 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/ - HUD 面板显示/隐藏 - 图层控制开关:`地形 / 卫星 / 海缆 / BGP` - 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示` +- 地表 hover 提示偏好:`国家 / 位置 / 完整` - 地形透明度 也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。 -卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时,SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。 +地表 hover 提示由 `controls.js` 持久化为 `shared.surfaceHoverInfoMode`,实际 tooltip 在 `main.js` 的地表 hover 分支组合。`国家` 模式只在命中国家时显示国家信息,海洋区域不显示地表 tooltip;`位置` 模式只显示纬度、经度和地形采样海拔,并清除国家边界 hover;`完整` 模式在陆地显示国家 + 位置,在海洋显示位置。 + +卫星真实高度开关由 `controls.js` 持久化,实际渲染状态在 `satellites.js`。开启时,SGP4 得到的真实半径会按对数压缩到当前 Earth 视觉半径范围;关闭时,卫星点、轨迹和预测轨道都回到旧版同层球面。切换时必须刷新卫星位置并清理轨迹缓存,避免同一条轨迹混入两个高度模型。`maxRealAltitudeOffset = 25` 是当前相机和 `earthRadius = 100` 下的视觉上限:它让 GEO / MEO 比 LEO 明显更高,但把最高轨道控制在地球半径外约 25%,避免选择点、红色轨迹和主体地球之间出现过大的空场。 + +SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。 ## 当前地形链路 diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index b93299eb..70de0f4e 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -169,7 +169,7 @@ | 卫星 fallback 半径偏移 | `SATELLITE_CONFIG.fallbackAltitudeOffset` | `8` | 关闭真实高度或 TLE 传播失败时的旧版同层球面位置 | | 卫星真实高度压缩尺度 | `SATELLITE_CONFIG.altitudeCompressionKm` | `1200` | TLE/SGP4 高度压缩映射,数值越大高轨差异越温和 | | 卫星真实高度上限 | `SATELLITE_CONFIG.maxDisplayAltitudeKm` | `40000` | 压缩映射的高度截断,覆盖 GEO 附近高度 | -| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 55` | 压缩后映射到 `CONFIG.earthRadius + offset` | +| 卫星真实高度显示偏移范围 | `minRealAltitudeOffset / maxRealAltitudeOffset` | `4 / 25` | 压缩后映射到 `CONFIG.earthRadius + offset`;`25` 约为当前地球半径 `100` 的四分之一,用于保留高轨分层同时避免 GEO/MEO 轨迹离主体过远 | | 卫星点基础像素大小 | `SATELLITE_CONFIG.dotBaseSize` | `2.8` | 点 shader size | | 卫星背景点缩放 | `SATELLITE_CONFIG.dotBackdropScale` | `1.28` | 背景点大小 | | 卫星点透明度范围 | `dotOpacityMin / dotOpacityMax` | `0.42 / 1.0` | 呼吸动画 | @@ -252,7 +252,9 @@ AIS 船只普通态使用批量 `THREE.Points`,不是逐船 `THREE.Sprite`。 | medium 颜色 | `BGP_CONFIG.severityColors.medium` | `0xffd166` | 中危事件 | | low 颜色 | `BGP_CONFIG.severityColors.low` | `0x4dabf7` | 低危事件 | | collector 基础色 | `BGP_CONFIG.collectorColor` | `0x6db7ff` | collector 默认色 | -| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖 | +| BGP 光晕浅色基准 | `BGP_CONFIG.halo.tintNeutralColor` | `0xffffff` | 面状 halo 先取对应事件/collector 图标色,再向该中性色混合,保持同色调但不完全同色 | +| BGP 光晕图标色混合比例 | `BGP_CONFIG.halo.tintBlend` | `0.72` | 值越高越接近图标色;当前用于事件区域 halo、collector coverage halo 和 collector radar pulse | +| region 色 | `BGP_CONFIG.regionColor` | `0x2dd4bf` | 区域覆盖旧基准;新的面状 BGP halo 优先从事件或 collector 图标色派生 | | BGP ring 缩放 | `BGP_CONFIG.ring.scaleA / scaleB` | `2.5 / 3.4` | anomaly ring | | BGP ring 透明度 | `BGP_CONFIG.ring.opacity` | `0.5` | anomaly ring | | collector marker renderOrder | local `BGP_COLLECTOR_RENDER_ORDER` | `4.4` | 观测站主图标,与船只同层 | diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index e9f74ba5..1b021b99 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -32,9 +32,9 @@ | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | | 4.4 | AIS 船只 marker | `vessels.js`, `interactable.js` | `VESSEL_RENDER_ORDER`;业务高度为 `CONFIG.earthRadius + VESSEL_CONFIG.altitudeOffset`;普通 marker 为分桶 `THREE.Points`,hover / locked 为单点 `THREE.Points` overlay | `depthTest: true`;`main.js` 使用屏幕空间 picking,只取正面 marker;参与 Interactable 同坐标避让 | 航行船只用三角点纹理,停泊/低速用圆点;普通态无 glow,交互态叠加同尺寸 glow;低于算力中心 `4.5`。 | | 4.5 | 算力中心 | `compute-centers.js`, `interactable.js` | 使用 `COMPUTE_CENTER_RENDER_ORDER` 并由 `Interactable` 绘制 | 通过 `Interactable` 屏幕空间 picking,参与同坐标避让 | 地表设施层,保持在卫星下方。登陆点已下沉到海缆层。 | -| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..55`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 | +| 5 | 卫星背景点 | `satellites.js` | 固定 renderOrder;默认按 TLE/SGP4 真实高度压缩到 `CONFIG.earthRadius + 4..25`,关闭真实高度或传播失败时回退到 `fallbackAltitudeOffset = 8` | 屏幕空间卫星拾取 | 位于卫星点下方。 | | 6 | 卫星点 | `satellites.js` | 与卫星背景点使用同一压缩高度 / fallback 高度 | 屏幕空间卫星拾取 | 卫星点压过 footprint 和算力中心。 | -| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,关闭时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | +| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 | | 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 | ## 开关联动 diff --git a/docs/technical/zh/faq.md b/docs/technical/zh/faq.md index dbf55e4f..86325d8f 100644 --- a/docs/technical/zh/faq.md +++ b/docs/technical/zh/faq.md @@ -325,10 +325,12 @@ Docs 按 Gatekeeper 权限组控制可见性: ### 为什么卫星看起来不在同一个球面上? -Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4,但高度会经过压缩映射:低轨卫星靠近地球,高轨卫星更远,同时保持在当前视图可读范围内。这个设置也会影响卫星轨迹和锁定后的预测轨道。 +Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4,但高度会经过压缩映射:低轨卫星靠近地球,高轨卫星更远,同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。 如果需要旧版所有卫星位于同一显示球面的效果,在 Earth 设置里关闭“真实卫星高度”。缺失 TLE 或传播失败的卫星仍会回退到旧版固定高度,不会因为无法计算真实高度而消失。 +低倾角高轨卫星应该沿赤道附近或固定经度附近分布,而不是绕出接近南北向的大圈。Earth 会先把 SGP4 的惯性系位置转换成地固坐标再绘制当前点;锁定后的预测轨道则固定当前地球姿态来画一圈惯性轨道,所以应该闭合并保留正确倾角。如果以后看到详情卡倾角接近 `0°`,但预测轨道像极轨一样竖着绕,优先检查 ECI/TEME 到 ECF 的转换和预测轨道是否错误使用了逐采样 `gstime`。 + ### 动捕调试面板为什么看不到摄像头画面? 如果输入源是 Browser Camera,调试面板会显示浏览器本机摄像头实时预览,并在画面上绘制骨架。如果勾选了 `只显示骨骼`,视频预览会被隐藏,只显示深色背景和红/绿骨架。 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 459e9aef..82ed6438 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -258,9 +258,11 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军 ### 设置 -设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 +设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。 -“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。 +“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。 + +“悬停提示”控制鼠标悬停地表时的 tooltip 内容:`国家` 只在陆地命中国家时显示国家信息,太平洋等海洋区域不弹出地表提示;`位置` 在陆地和海洋都显示纬度、经度和海拔;`完整` 是默认模式,陆地显示国家 + 位置,海洋显示位置。 这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index bf1d4a43..8d855711 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -48,7 +48,7 @@ - 搜索可以查找海缆、卫星、算力中心、BGP 事件 - 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览 - 鼠标拖动、滚轮缩放、缩放百分比提示工作正常 -- 设置面板的旋转 / 巡航 / 动捕模式可以切换;卫星相关设置里可以打开或关闭真实高度分层和轨迹显示 +- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示 ## 5. 找回密码 diff --git a/docs/version-history.md b/docs/version-history.md index 79780de6..03e19cec 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.55.0` +- `dev` 当前开发分支历史推导到:`0.56.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 | | `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关,轨迹和预测轨道跟随高度模式切换,并补齐设置面板、FAQ、用户手册和开发者文档 | | `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 | | `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test,明确 Vite 生产构建和 planet.sh 开发入口边界 | diff --git a/frontend/package.json b/frontend/package.json index c2b7aae8..aea3afbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.55.0", + "version": "0.56.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/assets/icons/compute-supercomputer.svg b/frontend/public/earth/assets/icons/compute-supercomputer.svg index b28df2f6..8ab10984 100644 --- a/frontend/public/earth/assets/icons/compute-supercomputer.svg +++ b/frontend/public/earth/assets/icons/compute-supercomputer.svg @@ -1,6 +1,3 @@ - - - - - + + diff --git a/frontend/public/earth/css/earth-stats.css b/frontend/public/earth/css/earth-stats.css index ed35e38d..f70f29f5 100644 --- a/frontend/public/earth/css/earth-stats.css +++ b/frontend/public/earth/css/earth-stats.css @@ -1,7 +1,7 @@ /* earth-stats.css — compact KPI grid panel */ .hud-panel-stats { - top: var(--hud-offset); + bottom: var(--hud-offset); right: var(--hud-offset); border-radius: 0; /* square / angular — matches layer panel */ padding: 0; @@ -129,9 +129,9 @@ /* ── Layout-expanded: slide off-screen ───────────────────────── */ .earth-app.layout-expanded .hud-panel-stats:not([data-dragged="true"]) { - top: var(--hud-offset); + bottom: var(--hud-offset); right: var(--hud-offset); - transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset))); + transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset))); } .layout-mode-mobile .hud-panel-stats { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 07573180..5d7261e2 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -1630,9 +1630,12 @@ label.is-disabled.earth-mobile-settings-card { .earth-status-message, .earth-error-message { position: absolute; - top: calc(20px * var(--hud-scale)); - left: 50%; - transform: translate(-50%, -18px); + top: calc(var(--hud-offset) + calc(2px * var(--hud-scale))); + left: min( + calc(var(--hud-offset) + calc(340px * var(--hud-scale)) + calc(12px * var(--hud-scale))), + calc(100vw - min(calc(440px * var(--hud-scale)), 74vw) - var(--hud-offset)) + ); + transform: translateY(-18px); display: none; align-items: center; gap: calc(10px * var(--hud-scale)); @@ -1665,10 +1668,20 @@ label.is-disabled.earth-mobile-settings-card { .earth-status-message.visible, .earth-error-message.visible { - transform: translate(-50%, 0); + transform: translateY(0); opacity: 1; } +.earth-status-message.earth-status-message--ticker-stack { + top: calc(var(--hud-offset) + calc(50px * var(--hud-scale))); + left: 50%; + transform: translate(-50%, -18px); +} + +.earth-status-message.earth-status-message--ticker-stack.visible { + transform: translate(-50%, 0); +} + .earth-status-message.gesture { min-width: 0; padding-right: calc(16px * var(--hud-scale)); @@ -1676,7 +1689,7 @@ label.is-disabled.earth-mobile-settings-card { } .earth-error-message { - top: calc(62px * var(--hud-scale)); + top: calc(var(--hud-offset) + calc(92px * var(--hud-scale))); z-index: 211; min-width: min(calc(220px * var(--hud-scale)), 58vw); } diff --git a/frontend/public/earth/css/news-panel.css b/frontend/public/earth/css/news-panel.css index 10189f5f..14714c1a 100644 --- a/frontend/public/earth/css/news-panel.css +++ b/frontend/public/earth/css/news-panel.css @@ -1,5 +1,232 @@ /* news-panel.css */ +.earth-news-ticker { + --news-ticker-width: min(calc(820px * var(--hud-scale)), 58vw); + position: absolute; + top: calc(var(--hud-offset) + calc(2px * var(--hud-scale))); + left: 50%; + transform: translateX(-50%); + width: var(--news-ticker-width); + height: calc(38px * var(--hud-scale)); + border: 0; + border-top: 1px solid var(--hud-border); + border-bottom: 1px solid var(--hud-border); + border-radius: 0; + padding: 0 calc(18px * var(--hud-scale)); + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: calc(12px * var(--hud-scale)); + color: var(--hud-text); + background: + linear-gradient(90deg, transparent 0%, rgba(17, 31, 53, 0.82) 11%, rgba(7, 17, 31, 0.78) 89%, transparent 100%), + radial-gradient(circle at 50% -80%, rgba(145, 186, 255, 0.18), transparent 58%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 -1px 0 rgba(255, 255, 255, 0.03), + 0 14px 34px rgba(1, 7, 16, 0.22); + backdrop-filter: blur(16px) saturate(122%); + -webkit-backdrop-filter: blur(16px) saturate(122%); + cursor: pointer; + z-index: 24; + overflow: hidden; + transition: opacity 0.18s ease, transform 0.24s ease; +} + +.earth-news-ticker.is-hidden { + opacity: 0; + pointer-events: none; +} + +.earth-news-ticker::before, +.earth-news-ticker::after { + content: ""; + position: absolute; + top: -1px; + bottom: -1px; + width: calc(96px * var(--hud-scale)); + pointer-events: none; + z-index: 2; +} + +.earth-news-ticker::before { + left: 0; + background: linear-gradient(90deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08)); +} + +.earth-news-ticker::after { + right: 0; + background: linear-gradient(270deg, rgba(0, 0, 0, 0), rgba(8, 18, 32, 0.08)); +} + +.earth-news-ticker__region { + position: relative; + z-index: 3; + color: var(--hud-accent-strong); + font-size: calc(0.66rem * var(--hud-scale)); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + white-space: nowrap; +} + +.earth-news-ticker__viewport { + min-width: 0; + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%); +} + +.earth-news-ticker__track { + display: inline-flex; + align-items: center; + gap: calc(30px * var(--hud-scale)); + min-width: max-content; + color: rgba(232, 242, 252, 0.92); + font-size: calc(0.84rem * var(--hud-scale)); + font-weight: 600; + line-height: 1; + white-space: nowrap; + animation: earthNewsTickerScroll var(--news-ticker-duration, 28s) linear infinite; +} + +.earth-news-ticker:hover .earth-news-ticker__track, +.earth-news-ticker:focus-visible .earth-news-ticker__track { + animation-play-state: paused; +} + +.earth-news-ticker__item { + display: inline-flex; + align-items: center; + gap: calc(8px * var(--hud-scale)); +} + +.earth-news-ticker__source { + color: var(--hud-text-soft); + font-size: calc(0.64rem * var(--hud-scale)); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.earth-news-hud { + position: fixed; + left: 50%; + top: max(calc(86px * var(--hud-scale)), 10vh); + transform: translateX(-50%); + width: min(calc(720px * var(--hud-scale)), calc(100vw - 40px)); + height: min(calc(660px * var(--hud-scale)), calc(100vh - 140px)); + padding: calc(14px * var(--hud-scale)); + display: flex; + flex-direction: column; + gap: var(--hud-gap-sm); + z-index: 220; + min-width: calc(420px * var(--hud-scale)); + min-height: calc(360px * var(--hud-scale)); +} + +.earth-news-hud.hud-panel-hidden { + display: none; +} + +.earth-news-hud.is-morphing { + display: flex; + overflow: hidden; + min-width: 0; + min-height: 0; + transition: + left 0.28s cubic-bezier(0.22, 1, 0.36, 1), + top 0.28s cubic-bezier(0.22, 1, 0.36, 1), + width 0.28s cubic-bezier(0.22, 1, 0.36, 1), + height 0.28s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.2s ease; +} + +.earth-news-hud.is-morphing > * { + opacity: 0; +} + +.earth-news-hud.is-resizing { + transition: none !important; + user-select: none; +} + +.earth-news-hud__header { + flex: 0 0 auto; +} + +.earth-news-hud__body { + flex: 1 1 auto; + min-height: 0; + display: flex; +} + +.earth-news-hud-edge { + position: absolute; + z-index: 10; +} + +.earth-news-hud-edge[data-edge="r"] { + right: 0; + top: calc(12px * var(--hud-scale)); + bottom: calc(12px * var(--hud-scale)); + width: calc(6px * var(--hud-scale)); + cursor: ew-resize; +} + +.earth-news-hud-edge[data-edge="b"] { + bottom: 0; + left: calc(12px * var(--hud-scale)); + right: calc(12px * var(--hud-scale)); + height: calc(6px * var(--hud-scale)); + cursor: ns-resize; +} + +.earth-news-hud-edge[data-edge="l"] { + left: 0; + top: calc(12px * var(--hud-scale)); + bottom: calc(12px * var(--hud-scale)); + width: calc(6px * var(--hud-scale)); + cursor: ew-resize; +} + +.earth-news-hud-edge[data-edge="br"] { + right: 0; + bottom: 0; + width: calc(20px * var(--hud-scale)); + height: calc(20px * var(--hud-scale)); + cursor: nwse-resize; +} + +.earth-news-hud-edge[data-edge="bl"] { + left: 0; + bottom: 0; + width: calc(20px * var(--hud-scale)); + height: calc(20px * var(--hud-scale)); + cursor: nesw-resize; +} + +.earth-news-hud-edge[data-edge="br"]::before { + content: ""; + position: absolute; + inset: calc(4px * var(--hud-scale)); + border-right: 2px solid rgba(223, 235, 248, 0.4); + border-bottom: 2px solid rgba(223, 235, 248, 0.4); + transition: border-color 0.18s ease; +} + +.earth-news-hud-edge[data-edge="br"]:hover::before { + border-color: rgba(244, 249, 255, 0.75); +} + +@keyframes earthNewsTickerScroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + .news-panel-title-row { display: flex; align-items: center; @@ -33,6 +260,10 @@ overflow: hidden; } +.earth-news-hud .news-panel-body { + width: 100%; +} + .news-panel-focus { display: grid; grid-template-columns: 1fr auto; @@ -189,3 +420,8 @@ line-height: 1.5; padding: calc(16px * var(--hud-scale)) calc(4px * var(--hud-scale)); } + +.layout-mode-mobile .earth-news-ticker, +.layout-mode-mobile .earth-news-hud { + display: none !important; +} diff --git a/frontend/public/earth/css/tv-panel.css b/frontend/public/earth/css/tv-panel.css index 4c26f2d0..01a34083 100644 --- a/frontend/public/earth/css/tv-panel.css +++ b/frontend/public/earth/css/tv-panel.css @@ -1,15 +1,15 @@ /* media-panel * Outer HUD shell: #media-panel * Inner live pane: #tv-panel - * Inner news pane: #news-panel */ .hud-panel-media { - bottom: var(--hud-offset); + top: var(--hud-offset); right: var(--hud-offset); width: calc(420px * var(--hud-scale)); max-width: calc(100vw - 32px); max-height: calc(100vh - (2 * var(--hud-offset))); + min-height: 0; min-width: calc(300px * var(--hud-scale)); padding: calc(10px * var(--hud-scale)); display: flex; @@ -18,11 +18,8 @@ z-index: 18; } -.hud-panel-media[data-active-tab="news"]:not([data-resized="true"]) { - max-height: min( - var(--tv-news-default-max-height, calc(100vh - (2 * var(--hud-offset)))), - calc(100vh - (2 * var(--hud-offset))) - ); +.hud-panel-media:has(.tv-panel-meta-wrap.is-collapsed) { + gap: 0; } .hud-panel-media.is-reforming { @@ -37,6 +34,10 @@ gap: var(--hud-gap-xs); } +.hud-panel-media:has(.tv-panel-meta-wrap.is-collapsed) .hud-panel__header { + margin-bottom: var(--hud-gap-sm); +} + .hud-panel-media .hud-panel__title-group { flex: 0 0 auto; min-width: auto; @@ -54,8 +55,7 @@ user-select: auto; } -.hud-panel-media .hud-panel__header .tv-panel-select, -.hud-panel-media .hud-panel__header .media-panel-tab { +.hud-panel-media .hud-panel__header .tv-panel-select { cursor: pointer; user-select: auto; } @@ -68,10 +68,6 @@ min-width: 0; } -.tv-panel-header-controls--news { - justify-content: flex-end; -} - .tv-panel-content { display: flex; flex: 1 1 auto; @@ -88,6 +84,10 @@ overflow: hidden; } +.tv-tab-pane:has(.tv-panel-meta-wrap.is-collapsed) { + gap: 0; +} + .tv-panel-toolbar-actions { display: inline-flex; align-items: center; @@ -95,13 +95,6 @@ flex-shrink: 0; } -.tv-panel-status { - color: var(--hud-text-soft); - font-size: calc(0.68rem * var(--hud-scale)); - letter-spacing: 0.08em; - text-transform: uppercase; -} - .tv-panel-select { flex: 1 1 auto; min-width: 0; @@ -124,7 +117,8 @@ .tv-panel-meta-wrap { overflow: hidden; - max-height: calc(120px * var(--hud-scale)); + flex: 0 0 auto; + max-height: none; opacity: 1; transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease; } @@ -140,24 +134,70 @@ .tv-panel-meta { display: grid; gap: calc(4px * var(--hud-scale)); + min-height: 0; +} + +.tv-panel-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: calc(6px * var(--hud-scale)); + min-width: 0; } .tv-panel-title { + min-width: 0; color: var(--hud-text); font-size: calc(0.96rem * var(--hud-scale)); font-weight: 600; + overflow-wrap: anywhere; +} + +.tv-panel-tag { + flex: 0 0 auto; + border: 1px solid rgba(137, 179, 217, 0.22); + border-radius: calc(999px * var(--hud-scale)); + background: rgba(108, 153, 192, 0.12); + color: rgba(214, 232, 248, 0.86); + padding: calc(2px * var(--hud-scale)) calc(7px * var(--hud-scale)); + font-size: calc(0.62rem * var(--hud-scale)); + font-weight: 700; + line-height: 1.35; + letter-spacing: 0.04em; +} + +.tv-panel-tag--status { + border-color: rgba(111, 213, 161, 0.34); + background: rgba(111, 213, 161, 0.13); + color: rgba(174, 244, 202, 0.92); +} + +.tv-panel-tag--warning { + border-color: rgba(255, 209, 102, 0.36); + background: rgba(255, 209, 102, 0.13); + color: rgba(255, 226, 149, 0.92); +} + +.tv-panel-tag--error { + border-color: rgba(255, 117, 117, 0.36); + background: rgba(255, 117, 117, 0.13); + color: rgba(255, 184, 184, 0.94); } .tv-panel-subtitle { color: var(--hud-text-muted); font-size: calc(0.74rem * var(--hud-scale)); line-height: 1.35; + white-space: normal; + overflow-wrap: anywhere; } .tv-panel-notes { color: var(--hud-text-soft); font-size: calc(0.68rem * var(--hud-scale)); line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; } .tv-panel-catalog { @@ -165,6 +205,8 @@ font-size: calc(0.66rem * var(--hud-scale)); letter-spacing: 0.04em; text-transform: uppercase; + white-space: normal; + overflow-wrap: anywhere; } .tv-panel-player { @@ -203,37 +245,6 @@ background: #050a14; } -.media-panel-tabs { - display: grid; - grid-template-columns: 1fr 1fr; - gap: calc(8px * var(--hud-scale)); -} - -.media-panel-tab { - border: 1px solid rgba(201, 225, 247, 0.12); - border-radius: calc(12px * var(--hud-scale)); - background: rgba(255, 255, 255, 0.03); - color: var(--hud-text-muted); - padding: calc(9px * var(--hud-scale)) calc(12px * var(--hud-scale)); - font-size: calc(0.78rem * var(--hud-scale)); - font-weight: 600; - letter-spacing: 0.04em; - cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; -} - -.media-panel-tab:hover { - color: var(--hud-text); - border-color: rgba(214, 235, 255, 0.18); - background: rgba(255, 255, 255, 0.06); -} - -.media-panel-tab--active { - color: var(--hud-accent-strong); - border-color: rgba(120, 180, 255, 0.24); - background: rgba(120, 180, 255, 0.12); -} - .tv-tab-pane[hidden] { display: none !important; } @@ -345,9 +356,9 @@ } .earth-app.layout-expanded .hud-panel-media:not([data-dragged="true"]) { - bottom: var(--hud-offset); + top: var(--hud-offset); right: var(--hud-offset); - transform: translate(calc(100% - var(--hud-offset)), calc(100% - var(--hud-offset))); + transform: translate(calc(100% - var(--hud-offset)), calc(-100% + var(--hud-offset))); } /* Media panel keeps its fixed width on all screen sizes. diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 17900724..953bdc41 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -513,7 +513,7 @@
- 媒体情报 + Live 新闻
@@ -529,32 +529,22 @@
-
-
-
- - -
@@ -609,6 +573,59 @@
+ + + +
+
+
+ 悬停提示 + 控制鼠标悬停地表时显示国家、位置或完整信息 +
+
+ + + +
+
+
+
+ 悬停提示 + 控制鼠标悬停地表时显示国家、位置或完整信息。 +
+
+ + + +
+
- - - - } /> - - - - - = 90 ? '#52c41a' : '#faad14' }} - /> - - - - - - - - - - - - - +
+ + + + } /> + + + + + = 90 ? '#52c41a' : '#faad14' }} + /> + + + + + + + + + + + + + - } onClick={fetchTasks}> - 刷新 - - } - > - - - - + } onClick={fetchTasks}> + 刷新 + + } + > + +
+ + + ) } diff --git a/pyproject.toml b/pyproject.toml index f40d7d28..06fa8793 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.55.0" +version = "0.56.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index cd5b132b..6bb81645 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.55.0" +version = "0.56.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From dd176a6ae6e7aef2606fa9d944a6cd110b818113 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Thu, 14 May 2026 01:02:17 +0800 Subject: [PATCH 08/19] release: bump version to 0.57.0 --- README.md | 28 +- VERSION | 2 +- docs/CHANGELOG.md | 16 + docs/technical/en/faq.md | 15 +- docs/technical/en/ops-planet-sh-startup.md | 2 +- docs/technical/en/ops-runbook.md | 10 +- docs/technical/zh/faq.md | 15 +- docs/technical/zh/ops-planet-sh-startup.md | 2 +- docs/technical/zh/ops-runbook.md | 10 +- docs/version-history.md | 3 +- frontend/bun.lock | 5 + frontend/package.json | 3 +- frontend/tsconfig.tooling.json | 6 +- frontend/{vite.config.ts => vite.config.mts} | 12 +- planet.sh | 373 ++++++++++++++++++- pyproject.toml | 2 +- scripts/windows-lan-relay.ps1 | 103 +++++ uv.lock | 2 +- 18 files changed, 541 insertions(+), 68 deletions(-) rename frontend/{vite.config.ts => vite.config.mts} (86%) create mode 100644 scripts/windows-lan-relay.ps1 diff --git a/README.md b/README.md index 7857cc89..539a6deb 100644 --- a/README.md +++ b/README.md @@ -275,38 +275,24 @@ curl http://localhost:8000/health 在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。 -### 4. 如果需要让局域网设备访问,再做 Windows 端口转发 +### 4. 如果需要让局域网设备访问,使用临时 Windows relay -注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。 +`./planet.sh start --allow-lan` 在 WSL 下会优先使用临时 Windows relay:WSL 和 Windows 本机 `localhost` 仍使用 `3000` / `8000`,局域网设备访问 Windows 局域网 IP 的 `3000` / `8000` 时由 relay 转发。relay 由脚本启动和停止;如果 WSL 目标端口断开,relay 会自动退出并释放 Windows 局域网监听端口。 -先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`: +如果以前手动配置过持久 `portproxy`,脚本会在启动前检测 `3000` / `8000` 残留规则,并请求管理员 PowerShell 自动删除。若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用 `3000` / `8000`: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 - -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 ``` -再放行 Windows 防火墙: +脚本会检测 Windows 防火墙是否已放行 `3000` / `8000`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行: ```powershell New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 ``` -检查转发规则是否生效: - -```powershell -netsh interface portproxy show all -``` - -预期能看到: - -- `0.0.0.0:3000 -> 127.0.0.1:3000` -- `0.0.0.0:8000 -> 127.0.0.1:8000` - ### 5. 查 Windows 局域网 IP,并让其他设备访问 在 Windows PowerShell 中执行: @@ -329,7 +315,7 @@ ipconfig ### 6. 常见现象与判断 - WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000` -- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行 +- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常是 Windows 防火墙、网络配置或旧 `portproxy` 残留 - `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口 ### 7. 本项目一次性验证顺序 @@ -340,8 +326,8 @@ ipconfig 2. WSL 中执行 `curl http://localhost:8000/health` 3. Windows 中执行 `curl http://localhost:3000` 4. Windows 中执行 `curl http://localhost:8000/health` -5. 管理员 PowerShell 配置 `portproxy` 和防火墙 -6. 用手机或其他电脑访问 `http://:3000/earth` +5. 按脚本提示完成 Windows 防火墙 UAC 请求;如果曾手动配置过 `portproxy`,先清理旧规则 +6. 用手机或其他电脑访问 Windows 对外端口,例如 `http://:3000/earth` ## 启动容错参数 diff --git a/VERSION b/VERSION index 2f4c74eb..46448c71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.56.0 +0.57.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4c1b9519..b7bc9867 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,22 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.57.0] — 2026-05-14 + +Released: 2026-05-14 + +### Highlights +- 新增 WSL `--allow-lan` 临时 Windows relay,保持本机 `localhost:3000` / `localhost:8000` 不变,同时用 Windows 局域网 IP 暴露相同端口。 +- 启动脚本会检测旧 `netsh interface portproxy` 冲突并请求管理员 PowerShell 清理,避免 `svchost.exe / iphlpsvc` 持久占用 3000/8000。 +- 修复 Vite CJS Node API deprecated warning,将前端配置迁移到 ESM,并让脚本按实际后端端口注入代理目标。 + +### Added / Fixed / Improved +- 新增 [scripts/windows-lan-relay.ps1](/home/ray/dev/linkong/planet/scripts/windows-lan-relay.ps1),在 Windows 侧启动随 WSL 服务健康状态自动退出的 TCP relay。 +- `planet.sh --allow-lan` 仅在 WSL + PowerShell 可用时启用 Windows relay,并自动检查 Windows 防火墙规则;非 WSL 环境保持原有路径。 +- 更新中英文 README、FAQ 和运维文档,说明旧 portproxy 清理、UAC 防火墙授权、同端口 localhost/LAN 访问和故障恢复方式。 + +--- + ## [0.56.0] — 2026-05-13 Released: 2026-05-13 diff --git a/docs/technical/en/faq.md b/docs/technical/en/faq.md index 288bb4ca..437f8864 100644 --- a/docs/technical/en/faq.md +++ b/docs/technical/en/faq.md @@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700" For temporary troubleshooting, you can stop IP Helper from Administrator PowerShell: ```powershell -Stop-Service iphlpsvc +Stop-Service iphlpsvc -Force ``` -This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. If the Windows forwarding rule must stay, use a different Planet backend port. +This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. New `--allow-lan` runs a temporary Windows relay for `3000` / `8000`, so persistent portproxy is no longer required. -If the script prints `failed-stop-service` or `failed-stop-process`, the current shell does not have permission to clear the Windows listener. Startup stops immediately instead of launching the backend into the same port conflict. +If the script prints `failed-stop-service`, `failed-stop-process`, or Vite reports `Port 3000 is already in use` followed by `Windows listener ... services=iphlpsvc`, an old Windows listener still owns the port. Startup stops immediately and prints the recovery order: use Administrator PowerShell to inspect `netsh interface portproxy show all`, delete the matching `listenport` rule, confirm the PID and services with `netstat` / `tasklist` if no portproxy rule exists, and temporarily run `Stop-Service iphlpsvc -Force` when appropriate. After old rules are gone, rerun `./planet.sh restart --allow-lan`; the script starts temporary relays while LAN devices still use `3000` / `8000`. ### Which startup flags change default ports? @@ -110,15 +110,18 @@ The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not rec If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`. -For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell: +For traditional WSL NAT networking, `./planet.sh start --allow-lan` starts temporary Windows relays so Windows still exposes `3000` / `8000`. Before startup, the script checks for stale `portproxy` rules and requests Administrator PowerShell to delete them. It also checks Windows Firewall and, when inbound allow rules for `3000` / `8000` are missing, triggers a UAC Administrator PowerShell request to create them. If the automatic request is canceled, clean up manually: ```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 + New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 ``` +LAN devices should use the Windows external port, for example `http://:3000/earth`. + If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. Even when ordinary Windows Firewall rules exist, Hyper-V firewall can still block external devices from reaching WSL. From Administrator PowerShell, allow the required ports: ```powershell diff --git a/docs/technical/en/ops-planet-sh-startup.md b/docs/technical/en/ops-planet-sh-startup.md index 4b3f2943..4d777636 100644 --- a/docs/technical/en/ops-planet-sh-startup.md +++ b/docs/technical/en/ops-planet-sh-startup.md @@ -177,7 +177,7 @@ Frontend startup now has an additional pre-start cleanup retry layer: - `PORT_PRESTART_RETRIES`: defaults to 3 attempts. - `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds. -`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and stops startup immediately instead of launching the service into the same port error. Non-WSL environments do not run the Windows cleanup path. At that point, use Administrator PowerShell to clear the portproxy/service ownership, or choose another port. +`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and Administrator PowerShell recovery commands, then stops startup immediately instead of launching the service into the same port error. If the frontend Vite process only discovers `Port 3000 is already in use` after launch, the script prints the same Windows listener recovery commands. Non-WSL environments do not run the Windows cleanup path. On WSL, `--allow-lan` uses temporary Windows relays to expose `3000` / `8000`, so old persistent portproxy rules should be removed. ## Issue 4: `restart` Behavior diff --git a/docs/technical/en/ops-runbook.md b/docs/technical/en/ops-runbook.md index 396d26fe..cd2fa081 100644 --- a/docs/technical/en/ops-runbook.md +++ b/docs/technical/en/ops-runbook.md @@ -120,7 +120,7 @@ Useful for: - Demoing Earth from a phone or tablet - Other LAN machines reaching the same dev instance -`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines hitting `http://:3000` still need Windows port forwarding and firewall rules. +`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines reaching the Windows LAN IP still need Windows port forwarding and firewall rules. Diagnose in this order: @@ -131,16 +131,18 @@ curl http://localhost:8000/health ss -ltnp | grep -E ':3000|:8000' ``` -If WSL shows `0.0.0.0:3000` / `0.0.0.0:8000` but the LAN IP still fails, configure Windows from an elevated PowerShell: +If WSL services are running but the LAN IP still fails, let `./planet.sh start --allow-lan` launch temporary Windows relays. The relays keep Windows exposed on `3000` / `8000` and exit automatically when the WSL target port goes away. The script checks stale `portproxy` rules and requests Administrator PowerShell to delete them, then checks Windows Firewall and triggers a UAC Administrator PowerShell request when inbound allow rules are missing. Manual fallback commands: ```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 ``` +LAN devices should use the Windows external port, for example `http://:3000/earth`. + ## AI Provider Environment and Builds AI Provider runtime configuration lives in two places: diff --git a/docs/technical/zh/faq.md b/docs/technical/zh/faq.md index 86325d8f..6401bbca 100644 --- a/docs/technical/zh/faq.md +++ b/docs/technical/zh/faq.md @@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700" 临时排障可以在管理员 PowerShell 中停止 IP Helper: ```powershell -Stop-Service iphlpsvc +Stop-Service iphlpsvc -Force ``` -这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;如果必须保留 Windows 转发,改用不同后端端口更稳。 +这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;新版本 `--allow-lan` 会用临时 Windows relay 暴露 `3000` / `8000`,不再需要保留持久 portproxy。 -如果脚本输出 `failed-stop-service` 或 `failed-stop-process`,说明当前权限无法清理 Windows listener。脚本会停止启动,避免后端再次遇到同一端口冲突。 +如果脚本输出 `failed-stop-service`、`failed-stop-process`,或 Vite 报 `Port 3000 is already in use` 后显示 `Windows listener ... services=iphlpsvc`,说明旧的 Windows listener 仍在占用端口。脚本会停止启动,并提示用管理员 PowerShell 检查 `netsh interface portproxy show all`,删除对应 `listenport` 规则;如果没有 portproxy 规则,再用 `netstat` / `tasklist` 确认服务,必要时临时 `Stop-Service iphlpsvc -Force`。清理旧规则后重新运行 `./planet.sh restart --allow-lan`,脚本会启动临时 relay,局域网仍访问 `3000` / `8000`。 ### 默认端口冲突时应该改哪些参数? @@ -112,15 +112,18 @@ curl http://localhost:8000/health 如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧转发或防火墙。 -传统 WSL NAT 场景下,管理员 PowerShell 中配置 portproxy 和防火墙: +传统 WSL NAT 场景下,`./planet.sh start --allow-lan` 会启动临时 Windows relay,让 Windows 对外仍使用 `3000` / `8000`。脚本会在启动前检测并请求管理员 PowerShell 删除旧 `portproxy`,也会检测 Windows 防火墙规则;如果缺少 `3000` / `8000` 入站放行,会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理: ```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 + New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 ``` +局域网设备访问 Windows 对外端口,例如 `http://:3000/earth`。 + 如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时,Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口放行: ```powershell diff --git a/docs/technical/zh/ops-planet-sh-startup.md b/docs/technical/zh/ops-planet-sh-startup.md index d939811f..fa710f46 100644 --- a/docs/technical/zh/ops-planet-sh-startup.md +++ b/docs/technical/zh/ops-planet-sh-startup.md @@ -155,7 +155,7 @@ wait_for_port_release() { - `PORT_PRESTART_RETRIES`:默认 3 次。 - `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。 -`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情并立即停止启动,不再继续拉起服务碰同一个端口错误。非 WSL 环境不会尝试 Windows 清理路径。此时需要用管理员 PowerShell 清理 portproxy/服务占用,或改用其他端口。 +`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情和管理员 PowerShell 处理命令,然后立即停止启动,不再继续拉起服务碰同一个端口错误。前端 Vite 启动后才发现 `Port 3000 is already in use` 时,也会打印同一套 Windows listener 处理命令。非 WSL 环境不会尝试 Windows 清理路径。`--allow-lan` 在 WSL 下会使用临时 Windows relay 暴露 `3000` / `8000`,所以旧的持久 portproxy 规则应清理掉。 ## 问题三:端口检测用 Python diff --git a/docs/technical/zh/ops-runbook.md b/docs/technical/zh/ops-runbook.md index 9e75cc4d..8e5f4424 100644 --- a/docs/technical/zh/ops-runbook.md +++ b/docs/technical/zh/ops-runbook.md @@ -120,7 +120,7 @@ - 手机或平板演示 Earth - 局域网其他机器访问同一开发实例 -`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 `http://:3000` 还需要 Windows 端口转发和防火墙放行。 +`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 端口转发和防火墙放行。 建议按顺序排查: @@ -131,16 +131,18 @@ curl http://localhost:8000/health ss -ltnp | grep -E ':3000|:8000' ``` -如果看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 仍访问失败,在管理员 PowerShell 中配置: +如果看到 WSL 内部服务已经启动,但局域网 IP 仍访问失败,让 `./planet.sh start --allow-lan` 启动临时 Windows relay。relay 会让 Windows 对外继续使用 `3000` / `8000`,并在 WSL 目标端口断开后自动退出。脚本会检测并请求管理员 PowerShell 删除旧 `portproxy`,也会检测 Windows 防火墙规则;缺少入站放行时会触发一次 UAC 管理员 PowerShell 请求自动创建。手动兜底命令如下: ```powershell -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000 -netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 ``` +局域网设备访问 Windows 对外端口,例如 `http://:3000/earth`。 + ## AI Provider 环境变量与构建 AI Provider 运行期配置可以放在两处: diff --git a/docs/version-history.md b/docs/version-history.md index 03e19cec..90d7f584 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.56.0` +- `dev` 当前开发分支历史推导到:`0.57.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay,保持 localhost 与局域网同用 3000/8000,并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 | | `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 | | `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关,轨迹和预测轨道跟随高度模式切换,并补齐设置面板、FAQ、用户手册和开发者文档 | | `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 | diff --git a/frontend/bun.lock b/frontend/bun.lock index d77c982d..9fd11b98 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -19,6 +19,7 @@ "zustand": "^4.4.7", }, "devDependencies": { + "@types/node": "^24.0.0", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", @@ -226,6 +227,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], @@ -458,6 +461,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], diff --git a/frontend/package.json b/frontend/package.json index aea3afbc..38b36789 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.56.0", + "version": "0.57.0", "private": true, "packageManager": "bun@1", "dependencies": { @@ -20,6 +20,7 @@ "devDependencies": { "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", + "@types/node": "^24.0.0", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.3.3", "vite": "^5.0.10" diff --git a/frontend/tsconfig.tooling.json b/frontend/tsconfig.tooling.json index 42872c59..0a1be700 100644 --- a/frontend/tsconfig.tooling.json +++ b/frontend/tsconfig.tooling.json @@ -2,9 +2,13 @@ "compilerOptions": { "composite": true, "skipLibCheck": true, + "target": "ES2020", + "lib": ["ES2020"], "module": "ESNext", "moduleResolution": "bundler", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tooling.tsbuildinfo", + "types": ["node"], "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.mts"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.mts similarity index 86% rename from frontend/vite.config.ts rename to frontend/vite.config.mts index 2a61f691..76295674 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.mts @@ -2,6 +2,12 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' import fs from 'fs' +import { fileURLToPath } from 'url' + +const backendPort = process.env.PLANET_BACKEND_PORT || '8000' +const backendHttpTarget = `http://localhost:${backendPort}` +const backendWsTarget = `ws://localhost:${backendPort}` +const __dirname = path.dirname(fileURLToPath(import.meta.url)) export default defineConfig({ plugins: [ @@ -42,18 +48,18 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'http://localhost:8000', + target: backendHttpTarget, changeOrigin: true, secure: false, }, '/ws': { - target: 'ws://localhost:8000', + target: backendWsTarget, ws: true, changeOrigin: true, secure: false, }, '/health': { - target: 'http://localhost:8000', + target: backendHttpTarget, changeOrigin: true, secure: false, }, diff --git a/planet.sh b/planet.sh index 79f0295b..6a2eb543 100755 --- a/planet.sh +++ b/planet.sh @@ -120,6 +120,9 @@ FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid" FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log" PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env" FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js" +WINDOWS_LAN_RELAY_SCRIPT="$SCRIPT_DIR/scripts/windows-lan-relay.ps1" +WINDOWS_FRONTEND_RELAY_PID_FILE="$PLANET_STATE_DIR/windows_frontend_relay.pid" +WINDOWS_BACKEND_RELAY_PID_FILE="$PLANET_STATE_DIR/windows_backend_relay.pid" MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid" MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log" AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256" @@ -133,6 +136,10 @@ START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 STARTED_FRONTEND_THIS_RUN=0 STARTED_MOTION_AGENT_THIS_RUN=0 +WINDOWS_LAN_RELAY_ENABLED=0 +LAN_PUBLIC_FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" +LAN_PUBLIC_BACKEND_PORT="$DEFAULT_BACKEND_PORT" +WINDOWS_LAN_RELAY_LISTEN_ADDRESS="" AI_PROVIDER_RUNTIME_ENV_NAMES=( SERVICE_NAME SERVICE_VERSION @@ -639,17 +646,246 @@ EOF log_lan_access_notes() { local frontend_port="$1" local backend_port="$2" + local public_frontend_port="${3:-$frontend_port}" + local public_backend_port="${4:-$backend_port}" local recommended_lan_ip="" if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then - log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}" - log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health" + log_note "推荐访问地址: http://${recommended_lan_ip}:${public_frontend_port}" + log_note "后端健康检查: http://${recommended_lan_ip}:${public_backend_port}/health" else - log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}" - log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health" + log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${public_frontend_port}" + log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${public_backend_port}/health" fi } +cleanup_windows_lan_relays() { + local pid_file + local pid + + for pid_file in "$WINDOWS_FRONTEND_RELAY_PID_FILE" "$WINDOWS_BACKEND_RELAY_PID_FILE"; do + if [ -f "$pid_file" ]; then + if pid="$(read_pid_file "$pid_file")"; then + terminate_process_tree TERM "$pid" + sleep 0.3 + terminate_process_tree KILL "$pid" + fi + remove_pid_file "$pid_file" + fi + done +} + +start_windows_lan_relay() { + local name="$1" + local public_port="$2" + local target_port="$3" + local pid_file="$4" + local target_host="$5" + local listen_address="$6" + local script_path + local relay_pid + + script_path="$(wslpath -w "$WINDOWS_LAN_RELAY_SCRIPT" 2>/dev/null || true)" + if [ -z "$script_path" ]; then + log_warn "无法转换 Windows relay 脚本路径,跳过 ${name} 局域网 relay" + return 1 + fi + + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path" \ + -ListenAddress "$listen_address" \ + -ListenPort "$public_port" \ + -TargetHost "$target_host" \ + -TargetPort "$target_port" \ + > /dev/null 2>&1 & + relay_pid=$! + write_pid_file "$pid_file" "$relay_pid" + sleep 0.5 + + if ! kill -0 "$relay_pid" 2>/dev/null; then + remove_pid_file "$pid_file" + log_warn "${name} Windows relay 未能监听端口 ${public_port}" + print_windows_port_listener_details "$public_port" || true + return 1 + fi + + log_success "${name} Windows relay 已启动: ${listen_address}:${public_port} -> ${target_host}:${target_port}" +} + +start_windows_lan_relays() { + local target_host="127.0.0.1" + + [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ] || return 0 + + cleanup_windows_lan_relays + start_windows_lan_relay "前端" "$LAN_PUBLIC_FRONTEND_PORT" "$FRONTEND_PORT" "$WINDOWS_FRONTEND_RELAY_PID_FILE" "$target_host" "$WINDOWS_LAN_RELAY_LISTEN_ADDRESS" || return 1 + start_windows_lan_relay "后端" "$LAN_PUBLIC_BACKEND_PORT" "$BACKEND_PORT" "$WINDOWS_BACKEND_RELAY_PID_FILE" "$target_host" "$WINDOWS_LAN_RELAY_LISTEN_ADDRESS" || return 1 +} + +run_windows_admin_powershell_script() { + local script_path="$1" + local warn_context="$2" + local script_win_path="" + + chmod 600 "$script_path" 2>/dev/null || true + + script_win_path="$(wslpath -w "$script_path" 2>/dev/null || true)" + if [ -z "$script_win_path" ]; then + log_warn "${warn_context},但无法生成管理员 PowerShell 请求" + return 1 + fi + + powershell.exe -NoProfile -Command "Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${script_win_path}\"'" >/dev/null 2>&1 +} + +windows_firewall_missing_ports() { + local frontend_port="$1" + local backend_port="$2" + + command -v powershell.exe >/dev/null 2>&1 || return 1 + + powershell.exe -NoProfile -Command " +\$ErrorActionPreference = 'SilentlyContinue' +\$ports = @([int]${frontend_port}, [int]${backend_port}) +function Test-PortAllowed { + param([int]\$Port) + \$displayName = 'WSL Planet ' + \$Port + \$rule = Get-NetFirewallRule -DisplayName \$displayName -ErrorAction SilentlyContinue | + Where-Object { \$_.Enabled -eq 'True' -and \$_.Direction -eq 'Inbound' -and \$_.Action -eq 'Allow' } | + Select-Object -First 1 + if (-not \$rule) { return \$false } + \$filter = \$rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue | + Where-Object { \$_.Protocol -in @('TCP', 'Any') -and \$_.LocalPort -eq [string]\$Port } | + Select-Object -First 1 + if (\$filter) { + return \$true + } + return \$false +} +\$missing = @() +foreach (\$port in \$ports) { + if (-not (Test-PortAllowed \$port)) { \$missing += \$port } +} +if (\$missing.Count -gt 0) { + 'missing=' + (\$missing -join ',') +} +" 2>/dev/null | tr -d '\r' | sed -nE 's/^missing=//p' | tail -n 1 +} + +request_windows_firewall_rules_if_needed() { + local frontend_port="$1" + local backend_port="$2" + local missing_ports="" + local script_path="" + + [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ] || return 0 + case "${PLANET_WINDOWS_FIREWALL_AUTO_PROMPT:-1}" in + 0|false|no|off) + return 0 + ;; + esac + + missing_ports="$(windows_firewall_missing_ports "$frontend_port" "$backend_port" || true)" + [ -n "$missing_ports" ] || return 0 + + script_path="$PLANET_STATE_DIR/windows_firewall_planet.ps1" + mkdir -p "$PLANET_STATE_DIR" + cat > "$script_path" </dev/null 2>&1 || return 1 + + powershell.exe -NoProfile -Command " +\$ErrorActionPreference = 'SilentlyContinue' +\$ports = @([int]${frontend_port}, [int]${backend_port}) +\$lines = @(netsh interface portproxy show v4tov4) +\$found = @() +foreach (\$line in \$lines) { + if (\$line -match '^\\s*(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(\\d+)\\s*$') { + \$listenPort = [int]\$Matches[2] + if (\$ports -contains \$listenPort) { \$found += \$listenPort } + } +} +if (\$found.Count -gt 0) { + 'conflict=' + ((\$found | Sort-Object -Unique) -join ',') +} +" 2>/dev/null | tr -d '\r' | sed -nE 's/^conflict=//p' | tail -n 1 +} + +write_windows_portproxy_cleanup_script() { + local script_path="$1" + local frontend_port="$2" + local backend_port="$3" + + cat > "$script_path" </dev/null 2>&1; then @@ -1360,7 +1596,7 @@ fail_unreleased_port() { ss -ltnp "( sport = :${port} )" 2>/dev/null || true fi print_windows_port_listener_details "$port" || true - log_note "请手动停止占用进程,或改用其他端口。" + log_note "请手动停止占用进程后重试。" fi exit 1 @@ -1395,6 +1631,48 @@ retry_wait_for_port_release() { return 1 } +print_windows_cleanup_recovery_steps() { + local port="$1" + local service_name="$2" + local port_flag + local command_example + + case "$service_name" in + 前端) + port_flag="-f" + command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" + ;; + 后端) + port_flag="-b" + command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" + ;; + "AI Provider") + port_flag="-a" + command_example="./planet.sh restart ${port_flag} ${port}" + ;; + "Motion Agent") + port_flag="--motion-agent-port" + command_example="./planet.sh restart ${port_flag} ${port} --allow-lan" + ;; + *) + port_flag="" + command_example="./planet.sh restart --allow-lan" + ;; + esac + + log_note "Windows 侧端口被 svchost.exe / iphlpsvc 占用时,通常是管理员 PowerShell 中创建过持久 portproxy 规则,例如 listenport=${port}。" + log_note "该规则会由 Windows IP Helper 持续监听端口,项目停止后也不会自动消失;下次启动前必须先删除,否则会与 Vite/后端绑定同一端口冲突。" + log_note "请打开“管理员 PowerShell”,优先检查并删除冲突 portproxy:" + printf "${DIM} netsh interface portproxy show all${NC}\n" + printf "${DIM} netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=%s${NC}\n" "$port" + log_note "如果没有 portproxy 规则,再确认 PID/服务并临时停止对应服务:" + printf "${DIM} netstat -ano | findstr :%s${NC}\n" "$port" + printf "${DIM} tasklist /svc /fi \"PID eq \"${NC}\n" + printf "${DIM} Stop-Service iphlpsvc -Force${NC}\n" + log_note "如需局域网仍使用 ${port},请删除旧 portproxy 后重新运行 --allow-lan;脚本会使用临时 Windows relay 暴露该端口。" + log_note "重新启动示例:${command_example}" +} + force_cleanup_external_port_listener() { local port="$1" local service_name="$2" @@ -1487,6 +1765,7 @@ EOF log_warn "Windows 侧未发现端口 ${port} 监听,但端口仍不可绑定" elif [ "$saw_cleanup_failure" -eq 1 ]; then log_warn "Windows 侧监听清理失败,端口 ${port} 仍不可绑定" + print_windows_cleanup_recovery_steps "$port" "$service_name" fi return 1 } @@ -1885,6 +2164,48 @@ wait_for_port_release() { return 1 } +get_windows_lan_ipv4() { + command -v powershell.exe >/dev/null 2>&1 || return 1 + + powershell.exe -NoProfile -Command " +\$ErrorActionPreference = 'SilentlyContinue' +\$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | + Where-Object { \$_.NextHop -and \$_.NextHop -ne '0.0.0.0' } | + Sort-Object RouteMetric, InterfaceMetric | + Select-Object -First 1 +if (\$route) { + \$ip = Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex \$route.InterfaceIndex | + Where-Object { \$_.IPAddress -notmatch '^(127|169\\.254)\\.' } | + Select-Object -First 1 -ExpandProperty IPAddress + if (\$ip) { \$ip } +} +" 2>/dev/null | tr -d '\r' | sed -n '1p' +} + +prepare_windows_lan_relay_ports() { + local listen_address="" + + [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 + is_wsl_environment || return 0 + command -v powershell.exe >/dev/null 2>&1 || return 0 + [ -f "$WINDOWS_LAN_RELAY_SCRIPT" ] || return 0 + + listen_address="$(get_windows_lan_ipv4 || true)" + if [ -z "$listen_address" ]; then + log_warn "未能识别 Windows 局域网 IP,跳过 Windows 局域网 relay;本机 localhost 仍可使用 ${FRONTEND_PORT}/${BACKEND_PORT}" + return 0 + fi + + WINDOWS_LAN_RELAY_ENABLED=1 + LAN_PUBLIC_FRONTEND_PORT="$FRONTEND_PORT" + LAN_PUBLIC_BACKEND_PORT="$BACKEND_PORT" + WINDOWS_LAN_RELAY_LISTEN_ADDRESS="$listen_address" + + cleanup_windows_lan_relays + request_windows_portproxy_cleanup_if_needed "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" || true + log_note "WSL 局域网 relay: Windows ${WINDOWS_LAN_RELAY_LISTEN_ADDRESS}:${LAN_PUBLIC_FRONTEND_PORT}/${LAN_PUBLIC_BACKEND_PORT} -> localhost:${FRONTEND_PORT}/${BACKEND_PORT}" +} + kill_port_if_requested() { local port="$1" local service_name="$2" @@ -2071,6 +2392,7 @@ start_frontend_with_retry() { local frontend_port="$1" local frontend_port_requested="${2:-0}" local frontend_lan_enabled="${3:-0}" + local backend_port="${4:-$DEFAULT_BACKEND_PORT}" local retry=1 while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do @@ -2087,7 +2409,7 @@ start_frontend_with_retry() { frontend_args+=(--host 0.0.0.0) fi frontend_args+=(--port "$frontend_port" --strictPort) - nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 & + PLANET_BACKEND_PORT="$backend_port" nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 & FRONTEND_PID=$! write_pid_file "$FRONTEND_PID_FILE" "$FRONTEND_PID" @@ -2114,6 +2436,7 @@ start_frontend_service() { local frontend_port="$1" local frontend_port_requested="$2" local frontend_lan_enabled="${3:-0}" + local backend_port="${4:-$DEFAULT_BACKEND_PORT}" if [ "$frontend_port_requested" -eq 1 ]; then kill_port_if_requested "$frontend_port" "前端" @@ -2130,11 +2453,14 @@ start_frontend_service() { else set_wait_detail "启动 Vite 开发服务器" fi - if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then + if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled" "$backend_port"; then stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 "$FRONTEND_LOG_FILE" print_port_listener_details "$frontend_port" + if frontend_log_indicates_port_conflict "$FRONTEND_LOG_FILE"; then + print_windows_cleanup_recovery_steps "$frontend_port" "前端" + fi exit 1 fi stop_wait_session @@ -2485,6 +2811,7 @@ cleanup_failed_start() { clear_wait_spinner log_warn "启动未完成,清理本轮已拉起的本地进程" + cleanup_windows_lan_relays if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then cleanup_motion_agent_processes TERM @@ -2605,6 +2932,7 @@ PY # Top-level commands start() { parse_service_args "$@" + prepare_windows_lan_relay_ports cleanup_exit_containers START_RUN_ACTIVE=1 @@ -2617,7 +2945,7 @@ start() { start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" STARTED_BACKEND_THIS_RUN=1 - start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" + start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT" STARTED_FRONTEND_THIS_RUN=1 if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" @@ -2625,24 +2953,37 @@ start() { fi write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT" + if [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ]; then + start_windows_lan_relays + request_windows_firewall_rules_if_needed "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" || true + fi START_RUN_COMPLETED=1 START_RUN_ACTIVE=0 log_success "启动完成" - log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" - log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" - log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" - log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" + if [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ]; then + log_note "智能星球计划: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/earth" + log_note "智能星球仪表盘: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/admin" + log_note "AI Playground: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/playground" + log_note "智能星球开发文档: http://localhost:${LAN_PUBLIC_BACKEND_PORT}/docs" + log_note "局域网入口: http://${WINDOWS_LAN_RELAY_LISTEN_ADDRESS}:${LAN_PUBLIC_FRONTEND_PORT}/earth" + else + log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" + log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" + log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" + fi if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED" fi - if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then - log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" + if [ "$FRONTEND_LAN_ENABLED" -eq 1 ] && [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 0 ]; then + log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" fi } stop() { log_warn "停止服务" + cleanup_windows_lan_relays stop_backend_service stop_ai_provider_service stop_frontend_service @@ -2688,7 +3029,7 @@ restart() { if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then stop_frontend_service sleep 1 - start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED" + start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED" "$BACKEND_PORT" fi if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then diff --git a/pyproject.toml b/pyproject.toml index 06fa8793..8712921b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.56.0" +version = "0.57.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/scripts/windows-lan-relay.ps1 b/scripts/windows-lan-relay.ps1 new file mode 100644 index 00000000..f9988c9d --- /dev/null +++ b/scripts/windows-lan-relay.ps1 @@ -0,0 +1,103 @@ +param( + [string]$ListenAddress = "0.0.0.0", + [Parameter(Mandatory = $true)][int]$ListenPort, + [Parameter(Mandatory = $true)][string]$TargetHost, + [Parameter(Mandatory = $true)][int]$TargetPort, + [int]$IdleExitSeconds = 8 +) + +$ErrorActionPreference = "Stop" + +Add-Type -TypeDefinition @" +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +public static class PlanetTcpRelay +{ + public static async Task RunAsync(string listenAddress, int listenPort, string targetHost, int targetPort, int idleExitSeconds) + { + IPAddress address; + if (!IPAddress.TryParse(listenAddress, out address)) + { + address = IPAddress.Any; + } + var listener = new TcpListener(address, listenPort); + listener.Start(); + var lastHealthyAt = DateTime.UtcNow; + + try + { + while (true) + { + if (await CanConnectAsync(targetHost, targetPort)) + { + lastHealthyAt = DateTime.UtcNow; + } + else if ((DateTime.UtcNow - lastHealthyAt).TotalSeconds >= idleExitSeconds) + { + break; + } + + while (listener.Pending()) + { + var client = await listener.AcceptTcpClientAsync(); + _ = Task.Run(() => HandleClientAsync(client, targetHost, targetPort)); + } + + await Task.Delay(200); + } + } + finally + { + listener.Stop(); + } + } + + private static async Task CanConnectAsync(string host, int port) + { + using (var client = new TcpClient()) + { + var connectTask = client.ConnectAsync(host, port); + var completed = await Task.WhenAny(connectTask, Task.Delay(500)); + if (completed != connectTask) + { + return false; + } + + try + { + await connectTask; + return client.Connected; + } + catch + { + return false; + } + } + } + + private static async Task HandleClientAsync(TcpClient client, string targetHost, int targetPort) + { + using (client) + using (var upstream = new TcpClient()) + { + try + { + await upstream.ConnectAsync(targetHost, targetPort); + var clientStream = client.GetStream(); + var upstreamStream = upstream.GetStream(); + var toUpstream = clientStream.CopyToAsync(upstreamStream); + var toClient = upstreamStream.CopyToAsync(clientStream); + await Task.WhenAny(toUpstream, toClient); + } + catch + { + } + } + } +} +"@ + +[PlanetTcpRelay]::RunAsync($ListenAddress, $ListenPort, $TargetHost, $TargetPort, $IdleExitSeconds).GetAwaiter().GetResult() diff --git a/uv.lock b/uv.lock index 6bb81645..53fd86c6 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.56.0" +version = "0.57.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From 93eb41a9f745ca582bcf085fe3cf6a64789e6393 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 15 May 2026 17:40:07 +0800 Subject: [PATCH 09/19] release: bump version to 0.58.0 Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs. --- .gitignore | 6 + README.md | 31 +- TODO.md | 9 +- VERSION | 2 +- aiprovider/provider_service.py | 2 + backend/app/api/v1/datasource_config.py | 42 +- backend/app/api/v1/datasources.py | 93 ++- backend/app/api/v1/news.py | 7 +- backend/app/api/v1/settings.py | 91 ++- backend/app/api/v1/websocket.py | 3 +- backend/app/core/datasource_defaults.py | 50 ++ backend/app/core/earth_boundary_defaults.py | 49 ++ backend/app/core/target_schema_registry.py | 30 + backend/app/db/session.py | 1 + backend/app/main.py | 6 + backend/app/models/__init__.py | 2 + backend/app/models/earth_news.py | 35 + backend/app/services/collectors/__init__.py | 17 + .../services/collectors/earth_boundaries.py | 578 ++++++++++++++++ .../services/collectors/media_news_archive.py | 57 ++ backend/app/services/datasource_mapping.py | 53 ++ backend/app/services/earth_news.py | 616 ++++++++++++++++- backend/app/services/earth_news_queue.py | 232 +++++++ backend/app/services/earth_news_store.py | 188 ++++++ backend/app/services/earth_news_worker.py | 133 ++++ backend/app/services/scheduler.py | 26 +- backend/tests/test_earth_news.py | 578 +++++++++++++++- .../tests/test_settings_secret_placeholder.py | 12 + .../earth-boundary-pov-policy.china-v1.json | 92 +++ config/earth-boundary-sources.example.json | 115 ++++ docs/CHANGELOG.md | 17 + ...arth-high-precision-boundary-tiles-plan.md | 161 ++--- ...arth-high-resolution-basemap-tiles-plan.md | 118 ++++ ...th-mobile-center-country-highlight-plan.md | 3 +- docs/plans/earthfeed-coordinate-queue-plan.md | 131 ++++ docs/technical/en/backend-collectors.md | 11 +- ...asource-collector-settings-connectivity.md | 4 +- docs/technical/en/earth-frontend-context.md | 3 + .../en/earth-layer-style-reference.md | 19 +- docs/technical/en/earth-render-layer-order.md | 15 +- docs/technical/en/faq.md | 13 +- docs/technical/en/ops-planet-sh-startup.md | 2 +- docs/technical/en/ops-runbook.md | 21 +- docs/technical/zh/backend-collectors.md | 11 +- ...asource-collector-settings-connectivity.md | 4 +- docs/technical/zh/earth-frontend-context.md | 4 + .../zh/earth-layer-style-reference.md | 19 +- docs/technical/zh/earth-render-layer-order.md | 19 +- docs/technical/zh/faq.md | 13 +- docs/technical/zh/ops-planet-sh-startup.md | 2 +- docs/technical/zh/ops-runbook.md | 21 +- docs/version-history.md | 3 +- frontend/bun.lock | 19 + frontend/nginx.conf | 17 + frontend/package.json | 5 +- .../earth/data/countries-admin0.min.geojson | 1 - frontend/public/earth/index.html | 3 + frontend/public/earth/js/constants.js | 31 +- .../public/earth/js/country-boundaries.js | 620 ++++++++++++++++-- frontend/public/earth/js/earth.js | 23 +- frontend/public/earth/js/main.js | 11 +- .../public/earth/js/news-cruise-adapter.js | 5 + frontend/public/earth/js/news.js | 82 +++ frontend/src/pages/AISettings/AISettings.tsx | 24 +- .../src/pages/DataSources/DataSources.tsx | 50 +- frontend/src/pages/Settings/Settings.tsx | 104 ++- planet.sh | 481 +++++++------- pyproject.toml | 2 +- rules.md | 3 + .../build_earth_boundary_china_pov_geojson.py | 199 ++++++ scripts/build_earth_boundary_pmtiles.py | 163 +++++ .../check_earth_boundary_production_ready.py | 74 +++ scripts/collect_earth_boundary_sources.py | 141 ++++ scripts/windows-lan-relay.ps1 | 103 --- uv.lock | 2 +- 75 files changed, 5217 insertions(+), 716 deletions(-) create mode 100644 backend/app/core/earth_boundary_defaults.py create mode 100644 backend/app/models/earth_news.py create mode 100644 backend/app/services/collectors/earth_boundaries.py create mode 100644 backend/app/services/collectors/media_news_archive.py create mode 100644 backend/app/services/earth_news_queue.py create mode 100644 backend/app/services/earth_news_store.py create mode 100644 backend/app/services/earth_news_worker.py create mode 100644 backend/tests/test_settings_secret_placeholder.py create mode 100644 config/earth-boundary-pov-policy.china-v1.json create mode 100644 config/earth-boundary-sources.example.json create mode 100644 docs/plans/earth-high-resolution-basemap-tiles-plan.md create mode 100644 docs/plans/earthfeed-coordinate-queue-plan.md delete mode 100644 frontend/public/earth/data/countries-admin0.min.geojson create mode 100644 scripts/build_earth_boundary_china_pov_geojson.py create mode 100644 scripts/build_earth_boundary_pmtiles.py create mode 100644 scripts/check_earth_boundary_production_ready.py create mode 100644 scripts/collect_earth_boundary_sources.py delete mode 100644 scripts/windows-lan-relay.ps1 diff --git a/.gitignore b/.gitignore index dff37800..ce4fe7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,9 @@ temp/ # Runtime Data # ---------------------- data/ai/bgp-briefs/ +data/earth-boundary-sources/ + +# Generated Earth boundary tile artifacts. Keep source configs and builders in +# Git; publish PMTiles/MVT artifacts through release/deploy storage instead of +# committing thousands of generated tile files. +frontend/public/earth/data/boundaries/ diff --git a/README.md b/README.md index 539a6deb..c069cb99 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ bun run build ./planet.sh start --allow-lan ``` -这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。 +这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`,AI Provider 通过 Docker 发布到 `0.0.0.0:8010`。启动前脚本会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。 ### 2. 先确认 WSL 内部服务正常 @@ -253,14 +253,16 @@ bun run build ```bash curl http://localhost:3000 curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' +curl http://localhost:8010/health +ss -ltnp | grep -E ':3000|:8000|:8010' ``` 预期: - `3000` 返回前端 HTML - `8000/health` 返回健康检查 JSON -- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000` +- `8010/health` 返回 AI Provider 健康检查 JSON +- `ss` 中能看到 `0.0.0.0:3000`、`0.0.0.0:8000` 和 `0.0.0.0:8010`,或 Docker 已发布 `8010` 如果这一步不通,先不要继续做 Windows 转发。 @@ -271,26 +273,29 @@ ss -ltnp | grep -E ':3000|:8000' ```powershell curl http://localhost:3000 curl http://localhost:8000/health +curl http://localhost:8010/health ``` 在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。 -### 4. 如果需要让局域网设备访问,使用临时 Windows relay +### 4. 如果需要让局域网设备访问,清理端口和防火墙 -`./planet.sh start --allow-lan` 在 WSL 下会优先使用临时 Windows relay:WSL 和 Windows 本机 `localhost` 仍使用 `3000` / `8000`,局域网设备访问 Windows 局域网 IP 的 `3000` / `8000` 时由 relay 转发。relay 由脚本启动和停止;如果 WSL 目标端口断开,relay 会自动退出并释放 Windows 局域网监听端口。 +`./planet.sh start --allow-lan` 不再启动额外的 Windows 端口转发进程。它直接让开发服务对 `3000` / `8000` / `8010` 开放,并在启动前尝试释放这些端口。端口被 Windows 侧 listener 或旧 `portproxy` 占用时,脚本会请求一次管理员 PowerShell 清理。 -如果以前手动配置过持久 `portproxy`,脚本会在启动前检测 `3000` / `8000` 残留规则,并请求管理员 PowerShell 自动删除。若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用 `3000` / `8000`: +如果以前手动配置过持久 `portproxy`,若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用端口: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010 ``` -脚本会检测 Windows 防火墙是否已放行 `3000` / `8000`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行: +脚本会检测 Windows 防火墙是否已放行 `3000` / `8000` / `8010`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行: ```powershell New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010 ``` ### 5. 查 Windows 局域网 IP,并让其他设备访问 @@ -307,6 +312,8 @@ ipconfig - `http://:3000/earth` - `http://:3000/admin` +- `http://:8000/health` +- `http://:8010/health` 例如: @@ -324,10 +331,12 @@ ipconfig 1. WSL 中执行 `curl http://localhost:3000` 2. WSL 中执行 `curl http://localhost:8000/health` -3. Windows 中执行 `curl http://localhost:3000` -4. Windows 中执行 `curl http://localhost:8000/health` -5. 按脚本提示完成 Windows 防火墙 UAC 请求;如果曾手动配置过 `portproxy`,先清理旧规则 -6. 用手机或其他电脑访问 Windows 对外端口,例如 `http://:3000/earth` +3. WSL 中执行 `curl http://localhost:8010/health` +4. Windows 中执行 `curl http://localhost:3000` +5. Windows 中执行 `curl http://localhost:8000/health` +6. Windows 中执行 `curl http://localhost:8010/health` +7. 按脚本提示完成 Windows 防火墙或端口清理 UAC 请求 +8. 用手机或其他电脑访问 Windows 对外端口,例如 `http://:3000/earth` ## 启动容错参数 diff --git a/TODO.md b/TODO.md index e972cb58..c971e6ff 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,13 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL ## Earth -- [ ] High-precision country boundaries: implement the source-faithful static vector tile pipeline described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). Highest zoom must preserve trusted source geometry instead of smoothing or hand-drawing borders. -- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, and the official dashed maritime claim line before implementing visual changes. +- [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). +- [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries. +- [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder. +- [ ] Replace debug GeoJSON boundary tiles with the real `earth-boundaries-china-pov-v1.pmtiles` production artifact after audited admin-0 / coastline / claim-line sources and the PMTiles toolchain are available. +- [ ] Import authoritative China POV / coastline / claim-line source packages through the three standard Earth boundary source collectors, then rebuild a versioned PMTiles artifact so highest zoom `8-10` preserves trusted source geometry instead of seed data. +- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, Kosovo, Gaza, and the official dashed maritime claim line before implementing final visual changes. +- [ ] Earth high-resolution basemap tiles: implement the viewport-loaded imagery layer described in [Earth High Resolution Basemap Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-resolution-basemap-tiles-plan.md), using high-precision coastline as the alignment reference instead of replacing the globe with one huge texture. - [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card. - [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable. - [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker. diff --git a/VERSION b/VERSION index 46448c71..a60476bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.57.0 +0.58.0 diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index 19f75925..a7021f98 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -152,6 +152,7 @@ class ProviderService: {"role": "user", "content": prompt}, ], "temperature": 0.2, + "max_tokens": self.max_tokens, } return await self._post( path="/chat/completions", @@ -229,6 +230,7 @@ class ProviderService: "prompt": prompt, "options": { "temperature": 0.2, + "num_predict": self.max_tokens, }, } return await self._post( diff --git a/backend/app/api/v1/datasource_config.py b/backend/app/api/v1/datasource_config.py index f41146ec..852d4919 100644 --- a/backend/app/api/v1/datasource_config.py +++ b/backend/app/api/v1/datasource_config.py @@ -13,6 +13,11 @@ import httpx from app.core.target_schema_registry import get_target_schema, list_target_schemas from app.core.datasource_defaults import DEFAULT_DATASOURCES +from app.core.earth_boundary_defaults import ( + EARTH_BOUNDARY_DEFAULT_SOURCES, + EARTH_BOUNDARY_SOURCE_MAPPING, + default_earth_boundary_config, +) from app.db.session import get_db from app.models.user import User from app.models.datasource_config import DataSourceConfig @@ -50,6 +55,25 @@ from app.services.datasource_connectivity import ( router = APIRouter() +def _default_builtin_config(name: str) -> dict[str, Any]: + if name in {"earth_admin0_boundaries", "earth_coastline", "earth_claim_lines"}: + return default_earth_boundary_config(name) + if name == "earth_boundary_tiles": + return { + "timeout": 30, + "retry": 0, + "input_mode": "latest_collected_earth_boundary_sources", + } + return {"timeout": 30, "retry": 3} + + +def _default_builtin_source_type(name: str) -> str: + if name == "aisstream_vessels": + return "websocket" + if name == "earth_boundary_tiles": + return "internal" + return "http" + class DataSourceConfigCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) @@ -364,7 +388,7 @@ async def list_all_datasources( db: AsyncSession = Depends(get_db), ): """List all data sources: YAML defaults + DB overrides""" - from app.core.data_sources import COLLECTOR_URL_KEYS, get_data_sources_config + from app.core.data_sources import get_data_sources_config config = get_data_sources_config() @@ -372,20 +396,22 @@ async def list_all_datasources( db_configs = {c.name: c for c in db_query.scalars().all()} result = [] - for name, yaml_key in COLLECTOR_URL_KEYS.items(): + for name, metadata in DEFAULT_DATASOURCES.items(): yaml_url = config.get_yaml_url(name) db_config = db_configs.get(name) + default_config = _default_builtin_config(name) + default_url = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name, {}).get("endpoint") or yaml_url result.append( { "name": name, - "default_url": yaml_url, - "endpoint": db_config.endpoint if db_config else yaml_url, + "default_url": default_url, + "endpoint": db_config.endpoint if db_config else default_url, "is_overridden": db_config is not None and db_config.endpoint != yaml_url - if yaml_url + if default_url else db_config is not None, "is_active": db_config.is_active if db_config else True, - "source_type": db_config.source_type if db_config else "http", + "source_type": db_config.source_type if db_config else _default_builtin_source_type(name), "auth_type": db_config.auth_type if db_config else "none", "auth_configured": { "api_key": bool((db_config.auth_config or {}).get("api_key")) @@ -393,11 +419,11 @@ async def list_all_datasources( else False, }, "headers": db_config.headers if db_config else {}, - "config": strip_connectivity_validation(db_config.config if db_config else {}), + "config": strip_connectivity_validation(db_config.config if db_config else default_config), "config_id": db_config.id if db_config else None, "description": db_config.description if db_config - else f"Data source from YAML: {yaml_key}", + else f"内置采集器默认配置:{metadata.get('display_name') or metadata.get('name') or name}", } ) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 668555a1..1aa7d301 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -35,6 +35,7 @@ PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("cables", ("cable", "landing", "telegeography", "arcgis", "fao")), ("satellites", ("tle", "satellite", "spacetrack", "celestrak")), ("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")), + ("earth", ("earth_", "earth_boundary", "boundary", "country boundary", "coastline", "claim")), ("compute", ("top500", "gpu", "supercomputer", "compute")), ("ai", ("huggingface", "epoch_ai")), ("media", ("news", "tv", "live_stream")), @@ -150,6 +151,33 @@ async def _load_latest_task_ids( return {datasource_id: task_id for datasource_id, task_id in result.all()} +async def _load_latest_tasks( + db: AsyncSession, + datasource_ids: list[int], +) -> dict[int, CollectionTask]: + if not datasource_ids: + return {} + + ranked_tasks = ( + select( + CollectionTask.id.label("task_id"), + CollectionTask.datasource_id.label("datasource_id"), + func.row_number().over( + partition_by=CollectionTask.datasource_id, + order_by=(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc()), + ).label("row_num"), + ) + .where(CollectionTask.datasource_id.in_(datasource_ids)) + .subquery() + ) + result = await db.execute( + select(CollectionTask) + .join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id) + .where(ranked_tasks.c.row_num == 1) + ) + return {task.datasource_id: task for task in result.scalars().all()} + + async def _load_collected_record_counts( db: AsyncSession, sources: list[str], @@ -208,7 +236,7 @@ async def _load_datasource_endpoint_overrides( async def _load_datasource_list_context( db: AsyncSession, datasources: list[DataSource], -) -> tuple[dict[int, CollectionTask], dict[str, str]]: +) -> tuple[dict[int, CollectionTask], dict[int, CollectionTask], dict[str, str]]: datasource_ids = [datasource.id for datasource in datasources] sources = [datasource.source for datasource in datasources] @@ -232,8 +260,9 @@ async def _load_datasource_list_context( if stale_datasource_ids: running_tasks = await _load_latest_running_tasks(db, datasource_ids) + latest_tasks = await _load_latest_tasks(db, datasource_ids) endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources) - return running_tasks, endpoint_overrides + return running_tasks, latest_tasks, endpoint_overrides def _apply_datasource_query_filters( @@ -251,11 +280,6 @@ def _apply_datasource_query_filters( query = query.where(DataSource.is_active == is_active) if priority: query = query.where(DataSource.priority == priority) - if run_status and run_status not in {"running", "collected", "uncollected"}: - if run_status == "not_run": - query = query.where(DataSource.last_status.is_(None)) - else: - query = query.where(DataSource.last_status == run_status) if q: like_value = f"%{q.strip()}%" query = query.where( @@ -272,6 +296,7 @@ def _filter_datasources_in_memory( datasources: list[DataSource], *, running_tasks: dict[int, CollectionTask], + latest_tasks: dict[int, CollectionTask], record_counts: dict[str, int], product: Optional[str] = None, run_status: Optional[str] = None, @@ -281,6 +306,14 @@ def _filter_datasources_in_memory( filtered: list[DataSource] = [] for datasource in datasources: record_count = record_counts.get(datasource.source, 0) + latest_task = latest_tasks.get(datasource.id) + effective_status = ( + "running" + if datasource.id in running_tasks + else latest_task.status + if latest_task is not None + else datasource.last_status + ) if product and datasource_product_key(datasource) != product: continue if collected is not None and (record_count > 0) != collected: @@ -291,6 +324,10 @@ def _filter_datasources_in_memory( continue if run_status == "running" and datasource.id not in running_tasks: continue + if run_status == "not_run" and effective_status is not None: + continue + if run_status not in {None, "running", "not_run", "collected", "uncollected"} and effective_status != run_status: + continue if run_status == "collected" and record_count <= 0: continue if run_status == "uncollected" and record_count > 0: @@ -639,11 +676,12 @@ async def list_datasources( collector_list = [] config = get_data_sources_config() - running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources) + running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources) record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources]) datasources = _filter_datasources_in_memory( datasources, running_tasks=running_tasks, + latest_tasks=latest_tasks, record_counts=record_counts, product=product, run_status=run_status, @@ -652,9 +690,11 @@ async def list_datasources( ) for datasource in datasources: running_task = running_tasks.get(datasource.id) + latest_task = latest_tasks.get(datasource.id) + display_task = running_task or latest_task endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source) - last_run_at = datasource.last_run_at - last_status = datasource.last_status + last_run_at = datasource.last_run_at or (latest_task.completed_at if latest_task else None) + last_status = datasource.last_status or (latest_task.status if latest_task else None) collected_records = record_counts.get(datasource.source, 0) collector_list.append( @@ -675,16 +715,17 @@ async def list_datasources( "last_run_at": to_iso8601_utc(last_run_at), "last_status": last_status, "is_running": running_task is not None, - "task_id": running_task.id if running_task else None, - "progress": running_task.progress if running_task else None, - "phase": running_task.phase if running_task else None, - "phase_progress": running_task.phase_progress if running_task else None, - "phase_message": running_task.phase_message if running_task else None, - "phase_current": running_task.phase_current if running_task else None, - "phase_total": running_task.phase_total if running_task else None, - "phase_unit": running_task.phase_unit if running_task else None, - "records_processed": running_task.records_processed if running_task else None, - "total_records": running_task.total_records if running_task else None, + "task_id": display_task.id if display_task else None, + "progress": display_task.progress if display_task else None, + "phase": display_task.phase if display_task else None, + "phase_progress": display_task.phase_progress if display_task else None, + "phase_message": display_task.phase_message if display_task else None, + "phase_current": display_task.phase_current if display_task else None, + "phase_total": display_task.phase_total if display_task else None, + "phase_unit": display_task.phase_unit if display_task else None, + "records_processed": display_task.records_processed if display_task else None, + "total_records": display_task.total_records if display_task else None, + "error_message": display_task.error_message if display_task else None, "collected_records": collected_records, "has_collected_data": collected_records > 0, } @@ -729,11 +770,12 @@ async def trigger_datasource_batch( result = await db.execute(query) datasources = result.scalars().all() - running_tasks, _ = await _load_datasource_list_context(db, datasources) + running_tasks, latest_tasks, _ = await _load_datasource_list_context(db, datasources) record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources]) datasources = _filter_datasources_in_memory( datasources, running_tasks=running_tasks, + latest_tasks=latest_tasks, record_counts=record_counts, product=None if payload.source_ids else payload.product, run_status=None if payload.source_ids else payload.run_status, @@ -935,6 +977,14 @@ async def get_task_status( raise HTTPException(status_code=404, detail="Task not found") else: task = await get_running_task(db, datasource.id) + if task is None: + result = await db.execute( + select(CollectionTask) + .where(CollectionTask.datasource_id == datasource.id) + .order_by(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc()) + .limit(1) + ) + task = result.scalar_one_or_none() if not task: return { @@ -963,4 +1013,5 @@ async def get_task_status( "records_processed": task.records_processed, "total_records": task.total_records, "status": task.status, + "error_message": task.error_message, } diff --git a/backend/app/api/v1/news.py b/backend/app/api/v1/news.py index 03946404..12cc7d0b 100644 --- a/backend/app/api/v1/news.py +++ b/backend/app/api/v1/news.py @@ -1,5 +1,7 @@ -from fastapi import APIRouter, Query +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_db from app.services.earth_news import get_earth_news_payload router = APIRouter() @@ -9,5 +11,6 @@ router = APIRouter() async def get_earth_feed( lat: float | None = Query(None, description="Current Earth view center latitude"), lon: float | None = Query(None, description="Current Earth view center longitude"), + db: AsyncSession = Depends(get_db), ): - return await get_earth_news_payload(lat=lat, lon=lon) + return await get_earth_news_payload(lat=lat, lon=lon, db=db) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index d21d4fd0..db03cf1c 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -59,6 +59,7 @@ from app.services.scheduler import sync_datasource_job from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings router = APIRouter() +AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5 DEFAULT_SETTINGS = { "system": { @@ -484,7 +485,9 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b text = str(value).strip() if not text: return True - return text == current_preview or text.startswith("••••") or "*" in text + if text == current_preview or text.startswith("••••"): + return True + return all(char in {"*", "•", " ", "\t"} for char in text) def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict: @@ -561,6 +564,54 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict: } +def _ai_provider_runtime_fingerprint(ai_payload: dict) -> dict: + runtime_config = _runtime_config_from_ai_payload(ai_payload) + llm_config = runtime_config.get("llm_config") or {} + return { + "service_url": runtime_config.get("service_url") or "", + "service_token": runtime_config.get("service_token") or "", + "timeout_seconds": int(runtime_config.get("timeout_seconds") or 0), + "retry_attempts": int(runtime_config.get("retry_attempts") or 0), + "provider": llm_config.get("provider") or "", + "provider_api": llm_config.get("provider_api") or "", + "base_url": llm_config.get("base_url") or "", + "model": llm_config.get("model") or "", + "api_key": llm_config.get("api_key") or "", + "max_tokens": int(llm_config.get("max_tokens") or 0), + "anthropic_version": llm_config.get("anthropic_version") or "", + } + + +async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict: + runtime_config = _runtime_config_from_ai_payload(ai_payload) + client = AIProviderClient( + service_url=runtime_config["service_url"], + service_token=runtime_config["service_token"], + timeout=runtime_config["timeout_seconds"], + retry_attempts=runtime_config["retry_attempts"], + llm_config=runtime_config.get("llm_config") or {}, + ) + status_result = await client.get_status() + if not status_result.configured: + raise HTTPException( + status_code=400, + detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。", + ) + analysis_result = await client.analyze( + SituationalAnalysisRequest( + title="保存前完整连接测试", + objective="请用一句话回复连接可用。", + observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"], + constraints=["回复尽量简短。"], + ) + ) + return { + "status": status_result.model_dump(), + "provider": analysis_result.provider, + "model": analysis_result.model, + } + + def _web_search_provider_defaults(provider: str) -> dict: return web_search_provider_defaults(provider).model_dump() @@ -910,7 +961,10 @@ async def save_external_integrations_payload( update: ExternalIntegrationsUpdate, ) -> dict: current_payload = await get_setting_payload(db, "external_integrations") + current_ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {}) ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider) + if _ai_provider_runtime_fingerprint(ai_payload) != _ai_provider_runtime_fingerprint(current_ai_payload): + await _validate_ai_provider_full_connection(ai_payload) web_search_payload = _build_web_search_payload(current_payload, update.web_search) ocr_payload = _build_ocr_payload(current_payload, update.ocr) @@ -1219,12 +1273,16 @@ async def connect_ai_provider_integration( current_payload = await get_setting_payload(db, "external_integrations") draft_ai_payload = _build_ai_provider_payload(current_payload, payload) runtime_config = _runtime_config_from_ai_payload(draft_ai_payload) + quick_llm_config = { + **(runtime_config.get("llm_config") or {}), + "max_tokens": 1, + } client = AIProviderClient( service_url=runtime_config["service_url"], service_token=runtime_config["service_token"], - timeout=runtime_config["timeout_seconds"], - retry_attempts=runtime_config["retry_attempts"], - llm_config=runtime_config.get("llm_config") or {}, + timeout=min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS), + retry_attempts=1, + llm_config=quick_llm_config, ) try: @@ -1236,29 +1294,22 @@ async def connect_ai_provider_integration( "message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。", "status": status_result.model_dump(), } - analysis_result = await client.analyze( + probe_result = await client.analyze( SituationalAnalysisRequest( - title="连接测试", - objective="请用一句话回复连接可用。", - observations=["这是配置中心发起的 LLM 连接测试。"], - constraints=["回复尽量简短。"], + title="快速连接测试", + objective="Reply OK.", + observations=[], + constraints=["Output only OK."], ) ) - current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) - current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {}) - await save_setting_payload( - db, - "external_integrations", - {"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr}, - ) return { "success": True, "connected": True, - "message": "AI Provider 连接成功,已保存为全局默认配置。", + "message": "连接测试通过", "status": status_result.model_dump(), - "provider": analysis_result.provider, - "model": analysis_result.model, - "integrations": await serialize_external_integrations(db), + "provider": probe_result.provider, + "model": probe_result.model, + "mode": "quick_probe", } except HTTPException as exc: return { diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index d6a8bf94..09fdd46b 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -58,7 +58,7 @@ async def websocket_endpoint( is_anonymous = payload is None user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}" - supported_channels = ["vessels"] if is_anonymous else [ + supported_channels = ["vessels", "earth_news"] if is_anonymous else [ "gpu_clusters", "submarine_cables", "ixp_nodes", @@ -66,6 +66,7 @@ async def websocket_endpoint( "dashboard", "datasource_tasks", "vessels", + "earth_news", ] await manager.connect(websocket, user_id) diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py index be5adbf0..a621f319 100644 --- a/backend/app/core/datasource_defaults.py +++ b/backend/app/core/datasource_defaults.py @@ -257,6 +257,56 @@ DEFAULT_DATASOURCES = { "credential_provider": "aisstream", "credential_status": "supported", }, + "earth_admin0_boundaries": { + "id": 29, + "name": "Earth Admin-0 Boundaries", + "display_name": "Earth Admin-0 国界源", + "module": "L3", + "priority": "P1", + "frequency_minutes": 10080, + "is_free": True, + "requires_credentials": False, + }, + "earth_coastline": { + "id": 30, + "name": "Earth Coastline", + "display_name": "Earth 海岸线源", + "module": "L3", + "priority": "P1", + "frequency_minutes": 10080, + "is_free": True, + "requires_credentials": False, + }, + "earth_claim_lines": { + "id": 31, + "name": "Earth Claim Lines", + "display_name": "Earth 主张线源", + "module": "L3", + "priority": "P1", + "frequency_minutes": 10080, + "is_free": True, + "requires_credentials": False, + }, + "earth_boundary_tiles": { + "id": 32, + "name": "Earth PMTiles Builder", + "display_name": "Earth PMTiles 构建器", + "module": "L3", + "priority": "P1", + "frequency_minutes": 10080, + "is_free": True, + "requires_credentials": False, + }, + "media_news_archive": { + "id": 33, + "name": "Media News Archive", + "display_name": "媒体新闻归档", + "module": "L4", + "priority": "P2", + "frequency_minutes": 720, + "is_free": True, + "requires_credentials": False, + }, } ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()} diff --git a/backend/app/core/earth_boundary_defaults.py b/backend/app/core/earth_boundary_defaults.py new file mode 100644 index 00000000..67d2ba6a --- /dev/null +++ b/backend/app/core/earth_boundary_defaults.py @@ -0,0 +1,49 @@ +"""Default Earth boundary source configuration.""" + +from __future__ import annotations + +from typing import Any + + +EARTH_BOUNDARY_SOURCE_MAPPING: dict[str, Any] = { + "source": {"items_path": "$.features[*]"}, + "fields": { + "source_id": {"path": "$.properties.id", "type": "string"}, + "name": {"path": "$.properties.name", "type": "string"}, + "geometry": {"path": "$.geometry", "type": "object"}, + "properties": {"path": "$.properties", "type": "object"}, + }, +} + + +EARTH_BOUNDARY_DEFAULT_SOURCES: dict[str, dict[str, Any]] = { + "earth_admin0_boundaries": { + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson", + "source_kind": "admin0-boundaries", + "license": "Natural Earth public domain", + }, + "earth_coastline": { + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson", + "source_kind": "coastline", + "license": "Natural Earth public domain", + }, + "earth_claim_lines": { + "endpoint": "https://www.arcgis.com/sharing/rest/content/items/faaa1908c3ab43f0823c6fde9f18389c/data", + "source_kind": "claim-lines", + "license": "CC BY 4.0; source item owner mapmakersami", + }, +} + + +def default_earth_boundary_config(name: str) -> dict[str, Any]: + source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name) + if not source: + return {} + return { + "method": "GET", + "timeout": 120, + "retry": 3, + "target_schema": "earth_boundary_source", + "license": source["license"], + "mapping_json": EARTH_BOUNDARY_SOURCE_MAPPING, + } diff --git a/backend/app/core/target_schema_registry.py b/backend/app/core/target_schema_registry.py index 61ade066..1fad1e80 100644 --- a/backend/app/core/target_schema_registry.py +++ b/backend/app/core/target_schema_registry.py @@ -47,6 +47,18 @@ class GenericRecord(BaseModel): return value +class EarthBoundarySourceRecord(BaseModel): + source_id: str | None = None + source_kind: str = Field(pattern="^(admin0-boundaries|coastline|claim-lines)$") + name: str | None = None + geometry: dict[str, Any] | None = None + properties: dict[str, Any] = Field(default_factory=dict) + artifact_path: str | None = None + sha256: str | None = None + feature_count: int | None = Field(default=None, ge=0) + license: str | None = None + + @dataclass(frozen=True) class TargetField: name: str @@ -143,6 +155,24 @@ TARGET_SCHEMAS: dict[str, TargetSchema] = { TargetField("observed_at", "datetime", False, "观测时间", "2026-04-28T00:00:00Z"), ), ), + "earth_boundary_source": TargetSchema( + key="earth_boundary_source", + label="Earth 边界矢量源", + description="Admin-0 国界、海岸线、主张线等 Earth 边界源数据 artifact。", + destination="collected_data", + model=EarthBoundarySourceRecord, + fields=( + TargetField("source_id", "string", False, "来源侧 ID", "feature-1"), + TargetField("source_kind", "string", True, "边界源类型", "admin0-boundaries"), + TargetField("name", "string", False, "记录名称", "China"), + TargetField("geometry", "object", False, "GeoJSON geometry", {"type": "Polygon", "coordinates": []}), + TargetField("properties", "object", False, "GeoJSON properties", {"ISO_A3": "CHN"}), + TargetField("artifact_path", "string", False, "完整源数据本地 artifact 路径", "data/earth-boundary-sources/earth_admin0_boundaries/abc.geojson"), + TargetField("sha256", "string", False, "源数据 artifact SHA-256", "abc123"), + TargetField("feature_count", "integer", False, "源数据 feature 数", 1), + TargetField("license", "string", False, "源数据许可", "ODbL"), + ), + ), } diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 6e9286bd..a9b4a4f3 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -134,6 +134,7 @@ async def init_db(): import app.models.vessel # noqa: F401 import app.models.vessel_enrichment # noqa: F401 import app.models.datasource_mapping # noqa: F401 + import app.models.earth_news # noqa: F401 logger.warning_event( "Database pool settings active", diff --git a/backend/app/main.py b/backend/app/main.py index ae5ac8cf..2479618c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,10 @@ from app.services.scheduler import ( stop_scheduler, sync_scheduler_with_datasources, ) +from app.services.earth_news_worker import ( + start_earth_news_target_worker, + stop_earth_news_target_worker, +) configure_logging() @@ -53,7 +57,9 @@ async def lifespan(app: FastAPI): start_scheduler() await sync_scheduler_with_datasources() broadcaster.start() + start_earth_news_target_worker() yield + await stop_earth_news_target_worker() broadcaster.stop() stop_scheduler() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4e9bc11c..eaa8dbdc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.playground_message import PlaygroundMessage from app.models.system_log import SystemLog, AuditLog from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic from app.models.datasource_mapping import DataSourceMappingTemplate +from app.models.earth_news import EarthNewsItem __all__ = [ "User", @@ -43,4 +44,5 @@ __all__ = [ "AISConflictRecord", "AISSourceHealth", "DataSourceMappingTemplate", + "EarthNewsItem", ] diff --git a/backend/app/models/earth_news.py b/backend/app/models/earth_news.py new file mode 100644 index 00000000..213fe739 --- /dev/null +++ b/backend/app/models/earth_news.py @@ -0,0 +1,35 @@ +from sqlalchemy import Boolean, Column, DateTime, Float, Index, JSON, String, Text +from sqlalchemy.sql import func + +from app.db.session import Base + + +class EarthNewsItem(Base): + __tablename__ = "earth_news_items" + + id = Column(String(160), primary_key=True) + title = Column(String(500), nullable=False) + summary = Column(Text, nullable=False, default="") + url = Column(Text, nullable=False) + source = Column(String(255), nullable=False, default="") + feed_name = Column(String(255), nullable=False, default="") + region = Column(String(80), nullable=False, index=True) + homepage_url = Column(Text, nullable=False, default="") + published_at = Column(DateTime(timezone=True), nullable=True, index=True) + + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + location_label = Column(String(255), nullable=False) + location_source = Column(String(80), nullable=False, default="region_anchor") + verified = Column(Boolean, nullable=False, default=False, index=True) + location_meta = Column(JSON, nullable=False, default=dict) + + first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + resolved_at = Column(DateTime(timezone=True), nullable=True, index=True) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + Index("idx_earth_news_region_published", "region", "published_at"), + Index("idx_earth_news_region_seen", "region", "last_seen_at"), + ) diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index 9ddbf91b..0bb8ae87 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -36,8 +36,15 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector from app.services.collectors.news_live_streams import NewsLiveStreamsCollector +from app.services.collectors.media_news_archive import MediaNewsArchiveCollector from app.services.collectors.aisstream import AISStreamCollector from app.services.collectors.vessel_ais import VesselAISCollector +from app.services.collectors.earth_boundaries import ( + EarthAdmin0BoundaryCollector, + EarthBoundaryTileCollector, + EarthClaimLinesCollector, + EarthCoastlineCollector, +) collector_registry.register(TOP500Collector()) collector_registry.register(EpochAIGPUCollector()) @@ -65,8 +72,13 @@ collector_registry.register(IPtoASNPrefixGeoCollector()) collector_registry.register(OpenGeoFeedPrefixGeoCollector()) collector_registry.register(NRODelegatedPrefixGeoCollector()) collector_registry.register(NewsLiveStreamsCollector()) +collector_registry.register(MediaNewsArchiveCollector()) collector_registry.register(VesselAISCollector()) collector_registry.register(AISStreamCollector()) +collector_registry.register(EarthAdmin0BoundaryCollector()) +collector_registry.register(EarthCoastlineCollector()) +collector_registry.register(EarthClaimLinesCollector()) +collector_registry.register(EarthBoundaryTileCollector()) __all__ = [ "BaseCollector", @@ -100,6 +112,11 @@ __all__ = [ "OpenGeoFeedPrefixGeoCollector", "NRODelegatedPrefixGeoCollector", "NewsLiveStreamsCollector", + "MediaNewsArchiveCollector", "VesselAISCollector", "AISStreamCollector", + "EarthAdmin0BoundaryCollector", + "EarthCoastlineCollector", + "EarthClaimLinesCollector", + "EarthBoundaryTileCollector", ] diff --git a/backend/app/services/collectors/earth_boundaries.py b/backend/app/services/collectors/earth_boundaries.py new file mode 100644 index 00000000..706e32ce --- /dev/null +++ b/backend/app/services/collectors/earth_boundaries.py @@ -0,0 +1,578 @@ +"""Earth boundary source and static tile collector.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import shutil +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import httpx +from sqlalchemy import select + +from app.core.earth_boundary_defaults import EARTH_BOUNDARY_DEFAULT_SOURCES, default_earth_boundary_config +from app.models.collected_data import CollectedData +from app.models.datasource_config import DataSourceConfig +from app.services.collectors.base import BaseCollector +from app.services.custom_datasource_runtime import build_query_params, build_request_headers + + +REPO_ROOT = Path(__file__).resolve().parents[4] +SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources" +SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json" +BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1" +BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json" +PMTILES_ARTIFACT_PATH = REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles" +POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json" +BUILD_CONFIG = { + "builder": "scripts/build_earth_boundary_china_pov_geojson.py", + "format": "geojson-high-precision", + "production_target": "geojson-high-precision", +} + +EARTH_BOUNDARY_SOURCE_COLLECTORS = { + "earth_admin0_boundaries": "admin0-boundaries", + "earth_coastline": "coastline", + "earth_claim_lines": "claim-lines", +} + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _directory_stats(path: Path) -> dict[str, int]: + if not path.exists(): + return {"file_count": 0, "size_bytes": 0} + files = [item for item in path.rglob("*") if item.is_file()] + return { + "file_count": len(files), + "size_bytes": sum(item.stat().st_size for item in files), + } + + +def _stable_json_hash(payload: Any) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _build_input_hash(source_manifest: dict[str, Any]) -> str: + return _stable_json_hash( + { + "source_manifest_schema": source_manifest.get("schema"), + "sources": [ + { + "id": source.get("id"), + "sha256": source.get("sha256"), + "kind": source.get("kind"), + "pov": source.get("pov"), + } + for source in source_manifest.get("sources", []) + ], + "pov_policy": source_manifest.get("povPolicy"), + "build_config": BUILD_CONFIG, + } + ) + + +def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool: + if not boundary_manifest: + return False + if boundary_manifest.get("buildInputHash") != build_input_hash: + return False + if not BOUNDARY_OUTPUT_DIR.exists(): + return False + if boundary_manifest.get("tileProvider") == "pmtiles-mvt": + return PMTILES_ARTIFACT_PATH.exists() + return (BOUNDARY_OUTPUT_DIR / "base.geojson").exists() and ( + BOUNDARY_OUTPUT_DIR / "hover-index.geojson" + ).exists() + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _json_feature_count(payload: Any) -> int: + if isinstance(payload, dict) and isinstance(payload.get("features"), list): + return len(payload["features"]) + if isinstance(payload, list): + return len(payload) + return 1 if payload else 0 + + +def _json_sample_properties(payload: Any) -> dict[str, Any]: + feature = None + if isinstance(payload, dict) and isinstance(payload.get("features"), list) and payload["features"]: + feature = payload["features"][0] + elif isinstance(payload, list) and payload: + feature = payload[0] + elif isinstance(payload, dict): + feature = payload + if not isinstance(feature, dict): + return {} + props = feature.get("properties") if isinstance(feature.get("properties"), dict) else feature + return {str(key): value for key, value in list(props.items())[:20]} + + +def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str: + suffix = Path(endpoint.split("?", 1)[0]).suffix.lower() + if suffix in {".json", ".geojson", ".zip", ".pbf"}: + return suffix + if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]: + return ".geojson" + if "json" in content_type: + return ".json" + return ".dat" + + +async def _load_datasource_config(db, name: str) -> DataSourceConfig | None: + if db is None: + return None + result = await db.execute( + select(DataSourceConfig) + .where(DataSourceConfig.name == name) + .where(DataSourceConfig.is_active.is_(True)) + .order_by(DataSourceConfig.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def _latest_boundary_source_record(db, source_name: str) -> CollectedData | None: + result = await db.execute( + select(CollectedData) + .where(CollectedData.source == source_name) + .where(CollectedData.data_type == "earth_boundary_source") + .where(CollectedData.is_current.is_(True)) + .order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +class EarthBoundarySourceCollector(BaseCollector): + priority = "P1" + module = "L3" + frequency_hours = 168 + data_type = "earth_boundary_source" + fail_on_empty = True + source_kind = "unknown" + + def _default_config(self): + source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(self.name) + if not source: + return None + return SimpleNamespace( + name=self.name, + description=f"内置默认源:{self.name}", + endpoint=source["endpoint"], + source_type="http", + auth_type="none", + auth_config={}, + headers={}, + config=default_earth_boundary_config(self.name), + ) + + async def _download_payload(self, config: DataSourceConfig) -> tuple[bytes, str]: + request_config = config.config or {} + method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper() + if method not in {"GET", "POST"}: + raise RuntimeError("Earth boundary source collectors support GET and POST only") + + endpoint = str(config.endpoint or "").strip() + if not endpoint: + raise RuntimeError( + f"{self.name} requires an endpoint in Collector Settings before it can collect data" + ) + + if endpoint.startswith("file://") or Path(endpoint).expanduser().exists(): + path = Path(endpoint.removeprefix("file://")).expanduser() + return path.read_bytes(), "application/octet-stream" + + headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {}) + params = build_query_params(config.auth_type, config.auth_config or {}, request_config) + timeout = float(request_config.get("timeout", 120)) + json_body = request_config.get("json_body") or request_config.get("body") + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + response = await client.request( + method, + endpoint, + headers=headers, + params=params or None, + json=json_body if isinstance(json_body, (dict, list)) else None, + ) + response.raise_for_status() + return response.content, response.headers.get("content-type", "") + + async def fetch(self) -> list[dict[str, Any]]: + db = getattr(self, "_db_session", None) + config = await _load_datasource_config(db, self.name) or self._default_config() + if config is None: + raise RuntimeError( + f"{self.name} has no active Collector Settings config and no built-in default source." + ) + + await self.set_phase("fetching_source", message=f"正在下载 {self.source_kind} 源数据") + payload, content_type = await self._download_payload(config) + sha256 = _sha256_bytes(payload) + endpoint = str(config.endpoint or "") + extension = _artifact_extension(endpoint, content_type, payload) + source_dir = SOURCE_OUTPUT_DIR / self.name + source_dir.mkdir(parents=True, exist_ok=True) + artifact_path = source_dir / f"{sha256}{extension}" + artifact_path.write_bytes(payload) + + parsed: Any = None + if extension in {".json", ".geojson"}: + parsed = json.loads(payload.decode("utf-8")) + + feature_count = _json_feature_count(parsed) + if feature_count <= 0: + raise RuntimeError(f"{self.name} downloaded data but found no JSON/GeoJSON features") + + config_body = config.config or {} + target_schema = str(config_body.get("target_schema") or "earth_boundary_source") + if target_schema != "earth_boundary_source": + raise RuntimeError(f"{self.name} target_schema must be earth_boundary_source") + + await self.update_phase_progress( + current=1, + total=1, + unit="artifact", + message=f"已保存 {feature_count} 个 {self.source_kind} feature", + progress=100, + commit=True, + force=True, + ) + + relative_artifact_path = str(artifact_path.relative_to(REPO_ROOT)) + return [ + { + "id": f"{self.source_kind}:{sha256}", + "name": config.description or self.name, + "description": f"Earth boundary source artifact collected from configured endpoint", + "source_kind": self.source_kind, + "source_id": sha256, + "value": feature_count, + "unit": "features", + "metadata": { + "target_schema": target_schema, + "source_kind": self.source_kind, + "endpoint": endpoint, + "method": str(config_body.get("method") or config_body.get("request_method") or "GET").upper(), + "artifact_path": relative_artifact_path, + "sha256": sha256, + "feature_count": feature_count, + "size_bytes": len(payload), + "content_type": content_type, + "license": config_body.get("license"), + "mapping_json": config_body.get("mapping_json"), + "sample_properties": _json_sample_properties(parsed), + }, + } + ] + + +class EarthAdmin0BoundaryCollector(EarthBoundarySourceCollector): + name = "earth_admin0_boundaries" + source_kind = "admin0-boundaries" + + +class EarthCoastlineCollector(EarthBoundarySourceCollector): + name = "earth_coastline" + source_kind = "coastline" + + +class EarthClaimLinesCollector(EarthBoundarySourceCollector): + name = "earth_claim_lines" + source_kind = "claim-lines" + + +class EarthBoundaryTileCollector(BaseCollector): + name = "earth_boundary_tiles" + priority = "P1" + module = "L3" + frequency_hours = 168 + data_type = "earth_boundary_tiles" + fail_on_empty = False + + async def _run_step(self, args: list[str], *, allow_failure: bool = False) -> dict[str, Any]: + process = await asyncio.create_subprocess_exec( + sys.executable, + *args, + cwd=REPO_ROOT, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_bytes, stderr_bytes = await process.communicate() + stdout = stdout_bytes.decode("utf-8", errors="replace").strip() + stderr = stderr_bytes.decode("utf-8", errors="replace").strip() + if process.returncode != 0 and not allow_failure: + raise RuntimeError(stderr or stdout or f"command failed: {' '.join(args)}") + + payload: dict[str, Any] = { + "stdout": stdout, + "stderr": stderr, + "returncode": process.returncode, + } + last_line = stdout.splitlines()[-1:] or [] + if last_line: + try: + payload["result"] = json.loads(last_line[0]) + except json.JSONDecodeError: + payload["result"] = last_line[0] + return payload + + async def fetch(self) -> list[dict[str, Any]]: + db = getattr(self, "_db_session", None) + if db is None: + raise RuntimeError("Earth PMTiles builder requires an active database session") + + await self.set_phase("checking_sources", message="正在检查三类 Earth 边界源") + await self.update_phase_progress( + current=0, + total=3, + unit="steps", + message="读取 admin0 / coastline / claim-lines 最新采集结果", + progress=5, + commit=True, + force=True, + ) + + source_records: dict[str, CollectedData] = {} + missing_sources: list[str] = [] + for source_name in EARTH_BOUNDARY_SOURCE_COLLECTORS: + record = await _latest_boundary_source_record(db, source_name) + if record is None: + missing_sources.append(source_name) + else: + source_records[source_name] = record + + if missing_sources: + missing_text = ", ".join(missing_sources) + raise RuntimeError(f"未就绪:缺少 {missing_text}。不会更新 Earth 国界。") + + sources = [] + for source_name, record in source_records.items(): + metadata = record.extra_data or {} + artifact_path = metadata.get("artifact_path") + if not artifact_path or not (REPO_ROOT / str(artifact_path)).exists(): + missing_sources.append(f"{source_name}: artifact missing") + continue + sources.append( + { + "id": source_name, + "kind": metadata.get("source_kind") or EARTH_BOUNDARY_SOURCE_COLLECTORS[source_name], + "path": str(artifact_path), + "sha256": metadata.get("sha256"), + "featureCount": metadata.get("feature_count"), + "license": metadata.get("license"), + } + ) + + if missing_sources: + missing_text = ", ".join(missing_sources) + raise RuntimeError(f"未就绪:{missing_text}。不会更新 Earth 国界。") + + source_manifest = { + "schema": "planet-earth-boundary-sources/v2", + "sources": sources, + "povPolicy": _read_json(POV_POLICY_PATH), + } + SOURCE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + with SOURCE_MANIFEST_PATH.open("w", encoding="utf-8") as f: + json.dump(source_manifest, f, ensure_ascii=False, indent=2) + f.write("\n") + build_input_hash = _build_input_hash(source_manifest) + await self.update_phase_progress( + current=1, + total=3, + unit="steps", + message="三类边界源已就绪", + progress=35, + commit=True, + force=True, + ) + + await self.set_phase("checking_readiness", message="正在检查 Earth 国界构建就绪状态") + missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None] + readiness_result = { + "returncode": 0, + "result": { + "ready": True, + "failures": [f"external tool not found in PATH: {tool}" for tool in missing_tools], + "sources": sources, + "artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), + "fallback_builder": "geojson-high-precision" if missing_tools else None, + }, + } + + await self.set_phase("building_tiles", message="正在检查 Earth 国界瓦片产物") + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash) + if build_skipped: + build_result = { + "result": { + "status": "unchanged", + "reason": "source manifest and build config hash unchanged", + "buildInputHash": build_input_hash, + } + } + elif shutil.which("tippecanoe") and shutil.which("pmtiles"): + admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") + coastline = next(source for source in sources if source["kind"] == "coastline") + claim_lines = next(source for source in sources if source["kind"] == "claim-lines") + build_result = await self._run_step( + [ + "scripts/build_earth_boundary_pmtiles.py", + "--admin0-source", + admin0["path"], + "--coastline-source", + coastline["path"], + "--claims-source", + claim_lines["path"], + "--output", + str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), + "--manifest", + str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)), + "--build-input-hash", + build_input_hash, + "--pov-policy", + str(POV_POLICY_PATH.relative_to(REPO_ROOT)), + ] + ) + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + else: + admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") + coastline = next(source for source in sources if source["kind"] == "coastline") + claim_lines = next(source for source in sources if source["kind"] == "claim-lines") + build_result = await self._run_step( + [ + "scripts/build_earth_boundary_china_pov_geojson.py", + "--admin0-source", + admin0["path"], + "--coastline-source", + coastline["path"], + "--claims-source", + claim_lines["path"], + "--output-dir", + str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)), + "--build-input-hash", + build_input_hash, + ] + ) + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR) + pmtiles_exists = PMTILES_ARTIFACT_PATH.exists() + pmtiles_size = PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0 + await self.update_phase_progress( + current=2, + total=3, + unit="steps", + message=( + "边界源未变化,已跳过瓦片重建" + if build_skipped + else f"已生成 {boundary_manifest.get('tileProvider') or 'boundary'} 国界产物" + ), + progress=80, + commit=True, + force=True, + ) + + await self.set_phase("indexing_artifacts", message="正在登记边界瓦片产物") + tile_counts = boundary_manifest.get("tiles", {}).get("countsByZoom", {}) + records = [ + { + "id": "source-manifest", + "name": "Earth boundary source manifest", + "description": "Offline source collection manifest for Earth boundary tiles", + "value": len(source_manifest.get("sources", [])), + "unit": "sources", + "metadata": { + "manifest_path": str(SOURCE_MANIFEST_PATH.relative_to(REPO_ROOT)), + "manifest": source_manifest, + "collector_result": { + "status": "loaded_from_collected_data", + "sources": [source["id"] for source in sources], + }, + }, + }, + { + "id": "production-readiness", + "name": "Earth boundary production readiness", + "description": "Checks whether all source artifacts and boundary build tooling are available", + "value": 1 if readiness_result.get("returncode") == 0 else 0, + "unit": "ready", + "metadata": { + "result": readiness_result.get("result"), + "returncode": readiness_result.get("returncode"), + }, + }, + { + "id": "boundary-manifest", + "name": "Earth boundary tile manifest", + "description": "Versioned static vector tile manifest for Earth country boundaries", + "value": boundary_stats["file_count"], + "unit": "files", + "metadata": { + "manifest_path": str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)), + "output_dir": str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)), + "size_bytes": boundary_stats["size_bytes"], + "manifest": boundary_manifest, + "collector_result": build_result.get("result"), + "build_skipped": build_skipped, + "production_target": BUILD_CONFIG["production_target"], + "pmtiles_artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), + "pmtiles_exists": pmtiles_exists, + }, + }, + ] + if pmtiles_exists: + records.append( + { + "id": "pmtiles-artifact", + "name": "Earth boundary PMTiles artifact", + "description": "Single-file PMTiles/MVT artifact for Earth boundaries", + "value": pmtiles_size, + "unit": "bytes", + "metadata": { + "path": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), + "exists": pmtiles_exists, + "provider": "pmtiles-mvt", + }, + } + ) + for zoom, count in sorted(tile_counts.items(), key=lambda item: int(item[0])): + records.append( + { + "id": f"tile-z{zoom}", + "name": f"Earth boundary tiles z{zoom}", + "description": f"Generated Earth boundary tile count for zoom {zoom}", + "value": int(count), + "unit": "tiles", + "metadata": { + "zoom": int(zoom), + "tile_count": int(count), + "output_dir": str((BOUNDARY_OUTPUT_DIR / str(zoom)).relative_to(REPO_ROOT)), + }, + } + ) + + await self.update_phase_progress( + current=3, + total=3, + unit="steps", + message="边界瓦片产物已登记", + progress=100, + commit=True, + force=True, + ) + return records diff --git a/backend/app/services/collectors/media_news_archive.py b/backend/app/services/collectors/media_news_archive.py new file mode 100644 index 00000000..9b21ca5a --- /dev/null +++ b/backend/app/services/collectors/media_news_archive.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any + +from app.services.collectors.base import BaseCollector +from app.services.earth_news_store import list_all_earth_news_records + + +class MediaNewsArchiveCollector(BaseCollector): + name = "media_news_archive" + priority = "P2" + module = "L4" + frequency_hours = 12 + data_type = "news_item" + fail_on_empty = False + + async def fetch(self) -> list[dict[str, Any]]: + if not self._db_session: + return [] + + records = await list_all_earth_news_records(self._db_session) + items: list[dict[str, Any]] = [] + for record in records: + location_meta = dict(record.location_meta or {}) + target = location_meta.get("target") if isinstance(location_meta.get("target"), dict) else {} + country = target.get("country") + city = target.get("city") + items.append( + { + "id": record.id, + "source_id": record.id, + "name": record.title, + "title": record.title, + "description": record.summary, + "country": country, + "city": city, + "latitude": record.latitude, + "longitude": record.longitude, + "reference_date": record.published_at, + "metadata": { + "url": record.url, + "source": record.source, + "feed_name": record.feed_name, + "region": record.region, + "homepage_url": record.homepage_url, + "published_at": record.published_at.isoformat() if record.published_at else None, + "location_label": record.location_label, + "location_source": record.location_source, + "verified": record.verified, + "location_meta": location_meta, + "first_seen_at": record.first_seen_at.isoformat() if record.first_seen_at else None, + "last_seen_at": record.last_seen_at.isoformat() if record.last_seen_at else None, + "resolved_at": record.resolved_at.isoformat() if record.resolved_at else None, + }, + } + ) + return items diff --git a/backend/app/services/datasource_mapping.py b/backend/app/services/datasource_mapping.py index 03dbf297..90821629 100644 --- a/backend/app/services/datasource_mapping.py +++ b/backend/app/services/datasource_mapping.py @@ -8,6 +8,7 @@ import re from datetime import UTC, datetime from typing import Any +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.target_schema_registry import TargetSchema, get_target_schema @@ -254,6 +255,12 @@ def _best_field_match(field_name: str, candidates: list[str]) -> str | None: "lat": ("lat", "latitude", "y"), "lon": ("lon", "lng", "longitude", "x"), "mmsi": ("mmsi",), + "geometry": ("geometry", "geom"), + "properties": ("properties", "props"), + "source_kind": ("source_kind", "kind", "type"), + "feature_count": ("feature_count", "features_count", "count"), + "artifact_path": ("artifact_path", "path", "file"), + "sha256": ("sha256", "hash", "checksum"), "sog": ("sog", "speed", "speedOverGround"), "cog": ("cog", "course", "courseOverGround"), "received_at": ("received_at", "timestamp", "time", "updated_at"), @@ -294,6 +301,52 @@ async def persist_mapped_records( transport: str | None = None, ) -> int: """Persist validated mapped records to the destination for a target schema.""" + if target_schema == "earth_boundary_source": + from app.models.collected_data import CollectedData + + now = datetime.now(UTC) + written_count = 0 + for index, record in enumerate(records): + source_id = record.get("source_id") or record.get("sha256") or str(index) + entity_key = f"{datasource_name}:{source_id}" + previous_result = await db.execute( + select(CollectedData) + .where(CollectedData.source == datasource_name) + .where(CollectedData.entity_key == entity_key) + .where(CollectedData.is_current.is_(True)) + .order_by(CollectedData.id.desc()) + .limit(1) + ) + previous = previous_result.scalar_one_or_none() + if previous is not None: + previous.is_current = False + db.add( + CollectedData( + source=datasource_name, + source_id=str(source_id), + entity_key=entity_key, + data_type=target_schema, + name=record.get("name") or str(source_id), + description=f"Earth boundary source artifact: {record.get('source_kind')}", + extra_data={ + **record, + "datasource_config_id": datasource_config_id, + "mapping_version": mapping_version, + "delivery_mode": delivery_mode or "polling", + "transport": transport or "http", + }, + collected_at=now, + is_valid=1, + is_current=True, + previous_record_id=previous.id if previous else None, + change_type="updated" if previous else "created", + change_summary={}, + ) + ) + written_count += 1 + await db.commit() + return written_count + if target_schema == "vessel_ais": from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py index 8de65dcb..55d322b5 100644 --- a/backend/app/services/earth_news.py +++ b/backend/app/services/earth_news.py @@ -6,6 +6,8 @@ from datetime import UTC, datetime from email.utils import parsedate_to_datetime import hashlib import html +import json +import math import re from typing import Any from urllib.parse import quote @@ -13,6 +15,12 @@ import xml.etree.ElementTree as ET import httpx from bs4 import BeautifulSoup +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country +from app.schemas.ai import SituationalAnalysisRequest +from app.services.ai_client import AIProviderClient +from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)" @@ -20,6 +28,9 @@ REQUEST_TIMEOUT = 12.0 MAX_ITEMS_PER_SOURCE = 6 MAX_ITEMS_TOTAL = 12 STALE_CACHE_MAX_AGE_SECONDS = 60 * 45 +RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS +MAX_TARGET_INFERENCE_CONCURRENCY = 3 +TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0 @dataclass(frozen=True) @@ -49,6 +60,17 @@ class NewsFeedSource: priority: int = 100 +@dataclass(frozen=True) +class NewsTargetLocation: + latitude: float + longitude: float + label: str + source: str + confidence: float | None = None + country: str | None = None + city: str | None = None + + @dataclass class ParsedNewsItem: id: str @@ -60,6 +82,13 @@ class ParsedNewsItem: feed_region: str homepage_url: str published_at: datetime | None + target_location: NewsTargetLocation | None = None + target_resolution_stage: str = "unresolved" + target_ai_attempted: bool = False + target_ai_status: str = "not_attempted" + target_ai_error: str | None = None + target_debug_note: str | None = None + location_patch: dict[str, Any] | None = None @dataclass @@ -236,6 +265,17 @@ NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = ( _REGION_CACHE: dict[str, CachedRegionFeed] = {} +_news_target_geocode = build_default_nominatim_geocoder(user_agent=USER_AGENT) +_CITY_HINTS: tuple[dict[str, str | None], ...] = ( + {"name": "Beijing", "country": "中国"}, + {"name": "Havana", "country": "古巴"}, + {"name": "Kyiv", "country": "乌克兰"}, + {"name": "Bangkok", "country": "泰国"}, + {"name": "Tehran", "country": "伊朗"}, + {"name": "Moscow", "country": "俄罗斯"}, + {"name": "Taipei", "country": "中国(台湾)"}, + {"name": "Hong Kong", "country": "中国(香港)"}, +) def determine_focus_region(lat: float | None, lon: float | None) -> str: @@ -258,6 +298,341 @@ def get_region_anchor(region: str) -> RegionAnchor: return REGION_ANCHORS.get(region, REGION_ANCHORS["global"]) +def _coerce_str(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + value = str(value) + cleaned = re.sub(r"\s+", " ", value).strip() + return cleaned or None + + +def _contains_location_alias(text: str, alias: str) -> bool: + normalized_alias = _coerce_str(alias) + if not normalized_alias: + return False + if re.search(r"[A-Za-z]", normalized_alias): + pattern = r"(? tuple[str, ...]: + searchable: list[str] = [] + seen: set[str] = set() + for variant in (canonical, *variants): + normalized = _coerce_str(variant) + if not normalized: + continue + if re.fullmatch(r"[A-Z]{2,3}", normalized): + continue + if len(normalized) <= 2: + continue + key = normalized.casefold() + if key in seen: + continue + seen.add(key) + searchable.append(normalized) + return tuple(searchable) + + +def _coerce_float(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _first_json_object(text: str) -> dict[str, Any] | None: + if not text: + return None + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char != "{": + continue + try: + payload, _ = decoder.raw_decode(text[index:]) + except ValueError: + continue + if isinstance(payload, dict): + return payload + return None + + +async def _geocode_target_location(query: str) -> dict[str, Any] | None: + return await asyncio.to_thread(_news_target_geocode, query) + + +async def _build_target_location_from_payload( + payload: dict[str, Any], +) -> NewsTargetLocation | None: + country = normalize_country(payload.get("country")) + city = _coerce_str(payload.get("city")) + matched_location_name = _coerce_str(payload.get("matched_location_name")) + confidence = _coerce_float(payload.get("confidence")) + if confidence is not None: + confidence = max(0.0, min(confidence, 1.0)) + + latitude = _coerce_float(payload.get("latitude")) + longitude = _coerce_float(payload.get("longitude")) + if latitude not in (None, 0.0) and longitude not in (None, 0.0): + label = matched_location_name or ", ".join(part for part in (city, country) if part) or "关联位置" + return NewsTargetLocation( + latitude=latitude, + longitude=longitude, + label=label, + source="ai_inferred_target", + confidence=confidence, + country=country, + city=city, + ) + + geocode_queries: list[str] = [] + for value in ( + ", ".join(part for part in (city, country) if part), + matched_location_name, + city, + country, + ): + normalized = _coerce_str(value) + if normalized and normalized not in geocode_queries: + geocode_queries.append(normalized) + + for query in geocode_queries: + try: + result = await _geocode_target_location(query) + except Exception: + continue + if not isinstance(result, dict): + continue + latitude = _coerce_float(result.get("lat")) + longitude = _coerce_float(result.get("lon")) + if latitude in (None, 0.0) or longitude in (None, 0.0): + continue + label = ( + _coerce_str(result.get("display_name")) + or matched_location_name + or ", ".join(part for part in (city, country) if part) + or query + ) + return NewsTargetLocation( + latitude=latitude, + longitude=longitude, + label=label, + source="ai_inferred_target", + confidence=confidence, + country=country, + city=city, + ) + + centroid = get_country_centroid(country) + if centroid: + label = matched_location_name or city or country or "关联位置" + return NewsTargetLocation( + latitude=centroid["latitude"], + longitude=centroid["longitude"], + label=label, + source="ai_inferred_target", + confidence=confidence, + country=country, + city=city, + ) + + return None + + +async def _extract_target_location_from_text(item: ParsedNewsItem) -> NewsTargetLocation | None: + combined_text = " ".join(part for part in (item.title, item.summary) if part).strip() + if not combined_text: + return None + + for hint in _CITY_HINTS: + city_name = _coerce_str(hint.get("name")) + if not city_name or not _contains_location_alias(combined_text, city_name): + continue + country = normalize_country(hint.get("country")) + geocode_query = ", ".join(part for part in (city_name, country) if part) + try: + result = await _geocode_target_location(geocode_query) + except Exception: + result = None + if isinstance(result, dict): + latitude = _coerce_float(result.get("lat")) + longitude = _coerce_float(result.get("lon")) + if latitude not in (None, 0.0) and longitude not in (None, 0.0): + return NewsTargetLocation( + latitude=latitude, + longitude=longitude, + label=_coerce_str(result.get("display_name")) or geocode_query, + source="headline_location_hint", + confidence=0.78, + country=country, + city=city_name, + ) + centroid = get_country_centroid(country) + if centroid: + return NewsTargetLocation( + latitude=centroid["latitude"], + longitude=centroid["longitude"], + label=geocode_query, + source="headline_location_hint", + confidence=0.68, + country=country, + city=city_name, + ) + + for canonical, variants in COUNTRY_VARIANTS_MAP.items(): + if not get_country_centroid(canonical): + continue + searchable_variants = _iter_searchable_country_variants(canonical, variants) + if not any(_contains_location_alias(combined_text, variant) for variant in searchable_variants): + continue + centroid = get_country_centroid(canonical) + if not centroid: + continue + return NewsTargetLocation( + latitude=centroid["latitude"], + longitude=centroid["longitude"], + label=canonical, + source="headline_country_hint", + confidence=0.62, + country=canonical, + city=None, + ) + + return None + + +async def _infer_news_target_location( + item: ParsedNewsItem, + *, + provider_client: AIProviderClient | None, +) -> NewsTargetLocation | None: + text_hint = await _extract_target_location_from_text(item) + if text_hint is not None and text_hint.city: + item.target_resolution_stage = text_hint.source + item.target_ai_attempted = False + item.target_ai_status = "skipped_text_hint" + item.target_ai_error = None + item.target_debug_note = f"text hint matched {text_hint.label}" + return text_hint + + if provider_client is None: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_attempted = False + item.target_ai_status = "unavailable" + item.target_ai_error = "AI provider is not configured or unavailable for earth-feed." + item.target_debug_note = ( + f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched" + ) + return text_hint + + item.target_ai_attempted = True + item.target_ai_status = "attempted" + item.target_ai_error = None + item.target_debug_note = ( + f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched" + ) + + request = SituationalAnalysisRequest( + title="Infer likely event location for Earth news cruise", + objective=( + "Return exactly one strict JSON object for the most likely physical " + "location the news event is about. Prefer the host city when a state " + "visit, summit, meeting, attack, or disaster is clearly centered in a " + "known city. Fall back to the best-supported country only when a city " + "cannot be inferred." + ), + context={ + "news_item": { + "title": item.title, + "summary": item.summary, + "source": item.source, + "feed_name": item.feed_name, + "feed_region": item.feed_region, + "url": item.url, + "published_at": ( + item.published_at.isoformat().replace("+00:00", "Z") + if item.published_at + else None + ), + }, + "required_json_schema": { + "country": "string|null", + "city": "string|null", + "matched_location_name": "string|null", + "latitude": "number|null", + "longitude": "number|null", + "confidence": "number from 0 to 1", + "reasoning_summary": "short string", + }, + }, + constraints=[ + "Return only strict JSON. Do not wrap it in markdown.", + "Prefer the event location, not the newsroom or publisher headquarters.", + "When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.", + "Use null for unknown fields instead of inventing details.", + "Calibrate confidence conservatively: 0.75+ only when the city is strongly supported, 0.55-0.74 for country-level or likely city inference, below 0.55 when weak.", + ], + ) + try: + response = await provider_client.analyze(request) + except Exception as exc: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "provider_error" + item.target_ai_error = str(exc) + return text_hint + + payload = _first_json_object(response.content) + if not isinstance(payload, dict): + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "parse_error" + item.target_ai_error = "AI response did not contain a parseable JSON object." + return text_hint + + target = await _build_target_location_from_payload(payload) + if target is None: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "no_result" + item.target_ai_error = "AI returned no usable target coordinates or geocodeable location." + return text_hint + if target.confidence is not None and target.confidence < 0.45: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "low_confidence" + item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}" + return text_hint + item.target_resolution_stage = target.source + item.target_ai_status = "success" + item.target_ai_error = None + item.target_debug_note = f"ai inferred {target.label}" + return target + + +async def _enrich_items_with_target_locations( + items: list[ParsedNewsItem], + *, + provider_client: AIProviderClient | None, +) -> list[ParsedNewsItem]: + if not items: + return items + + semaphore = asyncio.Semaphore(MAX_TARGET_INFERENCE_CONCURRENCY) + + async def enrich(item: ParsedNewsItem) -> ParsedNewsItem: + async with semaphore: + target = await _infer_news_target_location(item, provider_client=provider_client) + item.target_location = target + return item + + return list(await asyncio.gather(*(enrich(item) for item in items))) + + def get_sources_for_region(region: str) -> list[NewsFeedSource]: return sorted( [source for source in NEWS_FEED_SOURCES if source.region in {"global", region}], @@ -385,9 +760,117 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]: ] +def _serialize_anchor(anchor: RegionAnchor) -> dict[str, Any]: + return { + "region": anchor.region, + "label": anchor.label, + "latitude": anchor.latitude, + "longitude": anchor.longitude, + } + + +def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | None: + if target is None: + return None + return { + "latitude": target.latitude, + "longitude": target.longitude, + "label": target.label, + "source": target.source, + "confidence": target.confidence, + "country": target.country, + "city": target.city, + } + + +def build_anchor_location_patch( + item: ParsedNewsItem, + *, + queued: bool = False, + queue_available: bool | None = None, +) -> dict[str, Any]: + anchor = get_region_anchor(item.feed_region) + if queued: + resolution_stage = "queued" + ai_status = "queued" + debug_note = "queued for async target location inference" + else: + resolution_stage = item.target_resolution_stage + ai_status = item.target_ai_status + debug_note = item.target_debug_note + return { + "latitude": anchor.latitude, + "longitude": anchor.longitude, + "location_label": anchor.label, + "location_source": "region_anchor", + "verified": False, + "location_meta": { + "resolution_stage": resolution_stage, + "ai_attempted": item.target_ai_attempted, + "ai_status": ai_status, + "ai_error": item.target_ai_error, + "debug_note": debug_note, + "queue_available": queue_available, + "target": None, + "anchor": _serialize_anchor(anchor), + }, + } + + +def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation | None) -> dict[str, Any]: + if target is None: + return build_anchor_location_patch(item) + anchor = get_region_anchor(item.feed_region) + return { + "latitude": target.latitude, + "longitude": target.longitude, + "location_label": target.label, + "location_source": target.source, + "verified": True, + "location_meta": { + "resolution_stage": item.target_resolution_stage, + "ai_attempted": item.target_ai_attempted, + "ai_status": item.target_ai_status, + "ai_error": item.target_ai_error, + "debug_note": item.target_debug_note, + "target": _serialize_target(target), + "anchor": _serialize_anchor(anchor), + }, + } + + +def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]: + published_at = item.published_at + return { + "id": item.id, + "title": item.title, + "summary": item.summary, + "url": item.url, + "source": item.source, + "feed_name": item.feed_name, + "feed_region": item.feed_region, + "homepage_url": item.homepage_url, + "published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None, + } + + +def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem: + return ParsedNewsItem( + id=str(payload.get("id") or ""), + title=str(payload.get("title") or ""), + summary=str(payload.get("summary") or ""), + url=str(payload.get("url") or ""), + source=str(payload.get("source") or ""), + feed_name=str(payload.get("feed_name") or ""), + feed_region=str(payload.get("feed_region") or "global"), + homepage_url=str(payload.get("homepage_url") or ""), + published_at=_parse_datetime(_coerce_str(payload.get("published_at"))), + ) + + def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]: published_at = item.published_at - anchor = get_region_anchor(item.feed_region) + location_patch = item.location_patch or build_target_location_patch(item, item.target_location) return { "id": item.id, "title": item.title, @@ -398,10 +881,12 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An "region": item.feed_region, "homepage_url": item.homepage_url, "published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None, - "latitude": anchor.latitude, - "longitude": anchor.longitude, - "location_label": anchor.label, - "location_inferred": True, + "latitude": location_patch["latitude"], + "longitude": location_patch["longitude"], + "location_label": location_patch["location_label"], + "location_source": location_patch["location_source"], + "verified": location_patch["verified"], + "location_meta": location_patch["location_meta"], "is_focus_match": item.feed_region == active_region, } @@ -472,6 +957,47 @@ def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: li ) +async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> list[ParsedNewsItem]: + if not items: + return items + + from app.services.earth_news_queue import ( + enqueue_target_location_job, + get_cached_target_location_patch, + ) + + async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem: + cached_patch = await get_cached_target_location_patch(item.id) + if cached_patch: + item.location_patch = cached_patch + return item + + queued = await enqueue_target_location_job(build_target_location_job_payload(item)) + item.location_patch = build_anchor_location_patch( + item, + queued=queued, + queue_available=queued, + ) + return item + + return list(await asyncio.gather(*(apply_location(item) for item in items))) + + +async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None: + if not items: + return + + from app.services.earth_news_queue import enqueue_target_location_job + + await asyncio.gather( + *( + enqueue_target_location_job(build_target_location_job_payload(item)) + for item in items + if item.location_patch is None or item.location_patch.get("verified") is False + ) + ) + + async def _fetch_source( client: httpx.AsyncClient, source: NewsFeedSource, @@ -484,11 +1010,10 @@ async def _fetch_source( return source, [], str(exc) -async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]: - active_region = determine_focus_region(lat, lon) - sources = get_sources_for_region(active_region) +async def _fetch_rss_items_for_sources( + sources: list[NewsFeedSource], +) -> tuple[list[ParsedNewsItem], list[str]]: errors: list[str] = [] - async with httpx.AsyncClient( timeout=REQUEST_TIMEOUT, follow_redirects=True, @@ -502,9 +1027,29 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N errors.append(f"{source.name}: {error}") continue fetched_items.extend(items) + return fetched_items, errors + +def _needs_rss_supplement(*, item_count: int, newest_at: datetime | None) -> bool: + if item_count < MAX_ITEMS_TOTAL: + return True + if newest_at is None: + return True + age_seconds = (datetime.now(UTC) - newest_at).total_seconds() + return age_seconds > RSS_SUPPLEMENT_MAX_AGE_SECONDS + + +async def _get_earth_news_payload_from_rss_only( + *, + lat: float | None, + lon: float | None, + active_region: str, + sources: list[NewsFeedSource], +) -> dict[str, Any]: + fetched_items, errors = await _fetch_rss_items_for_sources(sources) ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region) if ranked_items: + ranked_items = await _apply_cached_locations_and_enqueue(ranked_items) _store_region_cache(active_region, items=ranked_items, sources=sources) return _build_payload( lat=lat, @@ -518,6 +1063,7 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N cached = _get_cached_region_feed(active_region) if cached: + cached.items = await _apply_cached_locations_and_enqueue(cached.items) return _build_payload( lat=lat, lon=lon, @@ -538,3 +1084,55 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N errors=errors, stale=False, ) + + +async def get_earth_news_payload( + lat: float | None = None, + lon: float | None = None, + *, + provider_client: AIProviderClient | None = None, + db: AsyncSession | None = None, +) -> dict[str, Any]: + del provider_client + active_region = determine_focus_region(lat, lon) + sources = get_sources_for_region(active_region) + + if db is None: + return await _get_earth_news_payload_from_rss_only( + lat=lat, + lon=lon, + active_region=active_region, + sources=sources, + ) + + from app.services.earth_news_store import ( + get_earth_news_freshness, + list_earth_news_items, + upsert_earth_news_items, + ) + + errors: list[str] = [] + item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region) + should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at) + if should_supplement: + fetched_items, errors = await _fetch_rss_items_for_sources(sources) + ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region) + await upsert_earth_news_items(db, ranked_fetched_items) + + items = await list_earth_news_items( + db, + active_region=active_region, + limit=MAX_ITEMS_TOTAL, + ) + await _enqueue_unverified_locations(items) + stale = bool(errors and items) + + return _build_payload( + lat=lat, + lon=lon, + active_region=active_region, + items=items, + sources=sources, + errors=errors, + stale=stale, + ) diff --git a/backend/app/services/earth_news_queue.py b/backend/app/services/earth_news_queue.py new file mode 100644 index 00000000..d3d6b1de --- /dev/null +++ b/backend/app/services/earth_news_queue.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any, Protocol + +import redis.asyncio as redis +from redis.exceptions import ResponseError + +from app.core.config import settings +from app.core.logging import get_logger + + +logger = get_logger(__name__, service="earth_news") + +TARGET_LOCATION_STREAM = "earth_news:target_location:jobs" +TARGET_LOCATION_GROUP = "earth_news_target_location" +TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead" +TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12 +TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6 +TARGET_LOCATION_MAX_ATTEMPTS = 3 + +_redis_client: redis.Redis | None = None + + +@dataclass(frozen=True) +class NewsTargetLocationMessage: + message_id: str + item_id: str + payload: dict[str, Any] + attempts: int = 0 + + +class NewsTargetLocationQueue(Protocol): + async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool: + ... + + async def consume_batch( + self, + *, + consumer_name: str, + count: int, + block_ms: int, + ) -> list[NewsTargetLocationMessage]: + ... + + async def ack(self, message_id: str) -> None: + ... + + async def retry_or_dead_letter( + self, + message: NewsTargetLocationMessage, + *, + error: str, + ) -> None: + ... + + +def _get_redis_client() -> redis.Redis: + global _redis_client + if _redis_client is None: + _redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True) + return _redis_client + + +def _result_key(item_id: str) -> str: + return f"earth_news:target_location:result:{item_id}" + + +def _queued_key(item_id: str) -> str: + return f"earth_news:target_location:queued:{item_id}" + + +class RedisStreamsNewsTargetLocationQueue: + def __init__(self, client: redis.Redis | None = None) -> None: + self.client = client or _get_redis_client() + self._group_ready = False + + async def _ensure_group(self) -> None: + if self._group_ready: + return + try: + await self.client.xgroup_create( + TARGET_LOCATION_STREAM, + TARGET_LOCATION_GROUP, + id="0", + mkstream=True, + ) + except ResponseError as exc: + if "BUSYGROUP" not in str(exc): + raise + self._group_ready = True + + async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool: + await self._ensure_group() + if await self.client.exists(_result_key(item_id)): + return False + queued = await self.client.set( + _queued_key(item_id), + "1", + nx=True, + ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS, + ) + if not queued: + return bool(await self.client.exists(_queued_key(item_id))) + await self.client.xadd( + TARGET_LOCATION_STREAM, + { + "item_id": item_id, + "attempts": "0", + "payload": json.dumps(payload, ensure_ascii=False), + }, + ) + return True + + async def consume_batch( + self, + *, + consumer_name: str, + count: int, + block_ms: int, + ) -> list[NewsTargetLocationMessage]: + await self._ensure_group() + streams = await self.client.xreadgroup( + TARGET_LOCATION_GROUP, + consumer_name, + {TARGET_LOCATION_STREAM: ">"}, + count=count, + block=block_ms, + ) + messages: list[NewsTargetLocationMessage] = [] + for _stream_name, stream_messages in streams: + for message_id, fields in stream_messages: + raw_payload = fields.get("payload") + item_id = fields.get("item_id") + if not raw_payload or not item_id: + await self.ack(message_id) + continue + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError: + await self.ack(message_id) + continue + attempts = int(fields.get("attempts") or 0) + messages.append( + NewsTargetLocationMessage( + message_id=message_id, + item_id=item_id, + payload=payload, + attempts=attempts, + ) + ) + return messages + + async def ack(self, message_id: str) -> None: + await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id) + + async def retry_or_dead_letter( + self, + message: NewsTargetLocationMessage, + *, + error: str, + ) -> None: + await self.ack(message.message_id) + if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS: + await self.client.xadd( + TARGET_LOCATION_DEAD_LETTER_STREAM, + { + "item_id": message.item_id, + "attempts": str(message.attempts + 1), + "error": error, + "payload": json.dumps(message.payload, ensure_ascii=False), + }, + ) + return + await self.client.xadd( + TARGET_LOCATION_STREAM, + { + "item_id": message.item_id, + "attempts": str(message.attempts + 1), + "payload": json.dumps(message.payload, ensure_ascii=False), + }, + ) + + +def get_news_target_location_queue() -> NewsTargetLocationQueue: + return RedisStreamsNewsTargetLocationQueue() + + +async def enqueue_target_location_job(payload: dict[str, Any]) -> bool: + item_id = str(payload.get("id") or "") + if not item_id: + return False + try: + queue = get_news_target_location_queue() + return await queue.enqueue(item_id=item_id, payload=payload) + except Exception as exc: + logger.warning_event( + "Failed to enqueue Earth news target location job", + event="earth_news.target_location.enqueue_failed", + context={"item_id": item_id, "error": str(exc)}, + ) + return False + + +async def get_cached_target_location_patch(item_id: str) -> dict[str, Any] | None: + try: + raw_value = await _get_redis_client().get(_result_key(item_id)) + except Exception as exc: + logger.warning_event( + "Failed to read Earth news target location cache", + event="earth_news.target_location.cache_read_failed", + context={"item_id": item_id, "error": str(exc)}, + ) + return None + if not raw_value: + return None + try: + value = json.loads(raw_value) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + + +async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> None: + client = _get_redis_client() + await client.setex( + _result_key(item_id), + TARGET_LOCATION_RESULT_TTL_SECONDS, + json.dumps(patch, ensure_ascii=False), + ) + await client.delete(_queued_key(item_id)) diff --git a/backend/app/services/earth_news_store.py b/backend/app/services/earth_news_store.py new file mode 100644 index 00000000..63d8537a --- /dev/null +++ b/backend/app/services/earth_news_store.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.earth_news import EarthNewsItem +from app.services.earth_news import ( + ParsedNewsItem, + build_anchor_location_patch, +) + + +def _coerce_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]: + return { + "latitude": record.latitude, + "longitude": record.longitude, + "location_label": record.location_label, + "location_source": record.location_source, + "verified": record.verified, + "location_meta": dict(record.location_meta or {}), + } + + +def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem: + return ParsedNewsItem( + id=record.id, + title=record.title, + summary=record.summary or "", + url=record.url, + source=record.source or "", + feed_name=record.feed_name or "", + feed_region=record.region or "global", + homepage_url=record.homepage_url or "", + published_at=_coerce_datetime(record.published_at), + location_patch=_location_patch_from_record(record), + ) + + +def _query_sort_key(active_region: str): + return ( + EarthNewsItem.region != active_region, + EarthNewsItem.published_at.is_(None), + EarthNewsItem.published_at.desc().nullslast(), + EarthNewsItem.feed_name.asc(), + ) + + +async def list_earth_news_items( + db: AsyncSession, + *, + active_region: str, + limit: int, +) -> list[ParsedNewsItem]: + regions = {"global", active_region} + result = await db.execute( + select(EarthNewsItem) + .where(EarthNewsItem.region.in_(regions)) + .order_by(*_query_sort_key(active_region)) + .limit(limit) + ) + return [record_to_parsed_news_item(record) for record in result.scalars().all()] + + +async def get_earth_news_freshness( + db: AsyncSession, + *, + active_region: str, +) -> tuple[int, datetime | None]: + regions = {"global", active_region} + result = await db.execute( + select( + func.count(EarthNewsItem.id), + func.max(func.coalesce(EarthNewsItem.published_at, EarthNewsItem.last_seen_at)), + ).where(EarthNewsItem.region.in_(regions)) + ) + count, newest = result.one() + item_count = int(count or 0) + if item_count == 0: + return 0, None + return item_count, _coerce_datetime(newest) + + +async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) -> int: + if not items: + return 0 + now = datetime.now(UTC) + existing_result = await db.execute( + select(EarthNewsItem).where(EarthNewsItem.id.in_([item.id for item in items])) + ) + existing = {record.id: record for record in existing_result.scalars().all()} + changed = 0 + for item in items: + record = existing.get(item.id) + if record is None: + patch = build_anchor_location_patch(item) + record = EarthNewsItem( + id=item.id, + title=item.title, + summary=item.summary, + url=item.url, + source=item.source, + feed_name=item.feed_name, + region=item.feed_region, + homepage_url=item.homepage_url, + published_at=item.published_at, + latitude=patch["latitude"], + longitude=patch["longitude"], + location_label=patch["location_label"], + location_source=patch["location_source"], + verified=patch["verified"], + location_meta=patch["location_meta"], + first_seen_at=now, + last_seen_at=now, + ) + db.add(record) + changed += 1 + continue + + record.title = item.title + record.summary = item.summary + record.url = item.url + record.source = item.source + record.feed_name = item.feed_name + record.region = item.feed_region + record.homepage_url = item.homepage_url + record.published_at = item.published_at + record.last_seen_at = now + changed += 1 + await db.flush() + return changed + + +async def update_earth_news_item_location( + db: AsyncSession, + *, + item_id: str, + patch: dict[str, Any], +) -> bool: + record = await db.get(EarthNewsItem, item_id) + if record is None: + return False + record.latitude = float(patch["latitude"]) + record.longitude = float(patch["longitude"]) + record.location_label = str(patch["location_label"]) + record.location_source = str(patch["location_source"]) + record.verified = bool(patch["verified"]) + record.location_meta = dict(patch.get("location_meta") or {}) + record.resolved_at = datetime.now(UTC) if record.verified else None + await db.flush() + return True + + +async def list_unverified_earth_news_items( + db: AsyncSession, + *, + active_region: str, + limit: int, +) -> list[ParsedNewsItem]: + regions = {"global", active_region} + result = await db.execute( + select(EarthNewsItem) + .where(EarthNewsItem.region.in_(regions)) + .where(EarthNewsItem.verified.is_(False)) + .order_by(*_query_sort_key(active_region)) + .limit(limit) + ) + return [record_to_parsed_news_item(record) for record in result.scalars().all()] + + +async def list_all_earth_news_records(db: AsyncSession) -> list[EarthNewsItem]: + result = await db.execute( + select(EarthNewsItem).order_by( + EarthNewsItem.published_at.desc().nullslast(), + EarthNewsItem.last_seen_at.desc(), + ) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/earth_news_worker.py b/backend/app/services/earth_news_worker.py new file mode 100644 index 00000000..ca4c7204 --- /dev/null +++ b/backend/app/services/earth_news_worker.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from socket import gethostname +from typing import Any + +from app.core.logging import get_logger +from app.core.websocket.broadcaster import broadcaster +from app.db.session import async_session_factory +from app.services.ai_client import AIProviderClient +from app.services.earth_news import ( + _infer_news_target_location, + build_target_location_patch, + parsed_news_item_from_job_payload, +) +from app.services.earth_news_queue import ( + NewsTargetLocationMessage, + get_news_target_location_queue, + save_target_location_patch, +) +from app.services.earth_news_store import update_earth_news_item_location + + +logger = get_logger(__name__, service="earth_news") + +WORKER_BATCH_SIZE = 4 +WORKER_BLOCK_MS = 5000 +WORKER_BACKOFF_SECONDS = 5.0 + +_worker_task: asyncio.Task | None = None + + +async def _build_provider_client() -> AIProviderClient | None: + try: + from app.api.v1.settings import get_runtime_ai_provider_config + + async with async_session_factory() as session: + runtime_config = await get_runtime_ai_provider_config(session) + return AIProviderClient( + service_url=runtime_config["service_url"], + service_token=runtime_config["service_token"], + timeout=runtime_config["timeout_seconds"], + retry_attempts=runtime_config["retry_attempts"], + llm_config=runtime_config.get("llm_config") or {}, + ) + except Exception as exc: + logger.warning_event( + "Failed to build Earth news AI provider client", + event="earth_news.target_location.provider_unavailable", + context={"error": str(exc)}, + ) + return None + + +async def process_target_location_message( + message: NewsTargetLocationMessage, + *, + provider_client: AIProviderClient | None, +) -> dict[str, Any]: + item = parsed_news_item_from_job_payload(message.payload) + target = await _infer_news_target_location(item, provider_client=provider_client) + item.target_location = target + patch = build_target_location_patch(item, target) + await save_target_location_patch(item.id, patch) + async with async_session_factory() as session: + await update_earth_news_item_location(session, item_id=item.id, patch=patch) + await session.commit() + await broadcaster.broadcast_custom( + "earth_news", + { + "item_id": item.id, + "patch": patch, + }, + ) + return patch + + +async def _run_target_location_worker() -> None: + consumer_name = f"{gethostname()}:{id(asyncio.current_task())}" + queue = get_news_target_location_queue() + while True: + try: + messages = await queue.consume_batch( + consumer_name=consumer_name, + count=WORKER_BATCH_SIZE, + block_ms=WORKER_BLOCK_MS, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning_event( + "Earth news target location worker queue read failed", + event="earth_news.target_location.worker_read_failed", + context={"error": str(exc)}, + ) + await asyncio.sleep(WORKER_BACKOFF_SECONDS) + continue + + if not messages: + continue + provider_client = await _build_provider_client() + for message in messages: + try: + await process_target_location_message(message, provider_client=provider_client) + await queue.ack(message.message_id) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning_event( + "Earth news target location worker job failed", + event="earth_news.target_location.worker_job_failed", + context={"item_id": message.item_id, "error": str(exc)}, + ) + with suppress(Exception): + await queue.retry_or_dead_letter(message, error=str(exc)) + + +def start_earth_news_target_worker() -> None: + global _worker_task + if _worker_task is None or _worker_task.done(): + _worker_task = asyncio.create_task(_run_target_location_worker()) + + +async def stop_earth_news_target_worker() -> None: + global _worker_task + task = _worker_task + if task is None: + return + task.cancel() + with suppress(asyncio.CancelledError): + await task + _worker_task = None diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 9d71fd82..ae3a6b3b 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -175,19 +175,29 @@ async def run_collector_task(collector_name: str): ) try: - collector._datasource_id = datasource.id + datasource_id = datasource.id + datasource_source = datasource.source + collector._datasource_id = datasource_id logger.info_event( "Running collector", event="collector.run.started", - context={"collector_name": collector_name, "datasource_id": datasource.id}, + context={"collector_name": collector_name, "datasource_id": datasource_id}, ) task_result = await collector.run(db) + datasource = await db.get(DataSource, datasource_id) + if datasource is None: + logger.error_event( + "Datasource disappeared after collector run", + event="collector.run.datasource_missing_after_run", + context={"collector_name": collector_name, "datasource_id": datasource_id}, + ) + return datasource.last_run_at = datetime.now(UTC) datasource.last_status = task_result.get("status") if datasource.last_status == "success": - effective_candidate = await get_builtin_effective_candidate(db, datasource.source) + effective_candidate = await get_builtin_effective_candidate(db, datasource_source) checksum, _credential_context = await build_builtin_connectivity_checksum( - datasource.source, + datasource_source, effective_candidate["endpoint"], effective_candidate["auth_type"], effective_candidate["headers"], @@ -196,7 +206,7 @@ async def run_collector_task(collector_name: str): ) await save_connectivity_success( db, - datasource.source, + datasource_source, checksum, {"status_code": None}, connected_by="collection", @@ -205,9 +215,11 @@ async def run_collector_task(collector_name: str): logger.info_event( "Collector completed", event="collector.run.completed", - context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result}, + context={"collector_name": collector_name, "datasource_id": datasource_id, "result": task_result}, ) except asyncio.CancelledError: + await db.rollback() + datasource = await db.get(DataSource, datasource_id) datasource.last_run_at = datetime.now(UTC) datasource.last_status = "cancelled" await db.commit() @@ -218,6 +230,8 @@ async def run_collector_task(collector_name: str): ) raise except Exception as exc: + await db.rollback() + datasource = await db.get(DataSource, datasource_id) datasource.last_run_at = datetime.now(UTC) datasource.last_status = "failed" await db.commit() diff --git a/backend/tests/test_earth_news.py b/backend/tests/test_earth_news.py index 83661b0c..0921c084 100644 --- a/backend/tests/test_earth_news.py +++ b/backend/tests/test_earth_news.py @@ -1,6 +1,20 @@ from datetime import UTC, datetime +from types import SimpleNamespace -from app.services.earth_news import ParsedNewsItem, _serialize_item +import pytest + +from app.services.earth_news import ( + NewsFeedSource, + NewsTargetLocation, + ParsedNewsItem, + _enrich_items_with_target_locations, + _extract_target_location_from_text, + _serialize_item, + get_earth_news_payload, +) +from app.services.earth_news_queue import NewsTargetLocationMessage +from app.services.earth_news_worker import process_target_location_message +from app.services.collectors.media_news_archive import MediaNewsArchiveCollector def test_serialize_item_includes_region_anchor_for_cruise(): @@ -21,7 +35,10 @@ def test_serialize_item_includes_region_anchor_for_cruise(): assert payload["latitude"] == 1.3521 assert payload["longitude"] == 103.8198 assert payload["location_label"] == "亚太" - assert payload["location_inferred"] is True + assert payload["location_source"] == "region_anchor" + assert payload["verified"] is False + assert payload["location_meta"]["target"] is None + assert payload["location_meta"]["anchor"]["region"] == "asia-pacific" assert payload["is_focus_match"] is True assert payload["published_at"] == "2026-04-23T02:30:00Z" @@ -44,6 +61,561 @@ def test_serialize_item_falls_back_to_global_anchor(): assert payload["latitude"] == 20.0 assert payload["longitude"] == 0.0 assert payload["location_label"] == "全球" - assert payload["location_inferred"] is True + assert payload["location_source"] == "region_anchor" + assert payload["verified"] is False + assert payload["location_meta"]["anchor"]["region"] == "global" assert payload["is_focus_match"] is False assert payload["published_at"] is None + + +def test_serialize_item_includes_inferred_target_location(): + item = ParsedNewsItem( + id="bbc-world:f55310fb667b", + title="Watch: What happened on day one of Trump's China visit?", + summary=( + "China welcomed US President Donald Trump with cheering children " + "and a troop parade." + ), + url="https://example.com/china-visit", + source="BBC World", + feed_name="BBC World", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC), + target_location=NewsTargetLocation( + latitude=39.9042, + longitude=116.4074, + label="Beijing, China", + source="ai_inferred_target", + confidence=0.88, + country="中国", + city="Beijing", + ), + ) + + payload = _serialize_item(item, active_region="global") + + assert payload["latitude"] == 39.9042 + assert payload["longitude"] == 116.4074 + assert payload["location_label"] == "Beijing, China" + assert payload["location_source"] == "ai_inferred_target" + assert payload["verified"] is True + assert payload["location_meta"]["target"]["confidence"] == 0.88 + assert payload["location_meta"]["target"]["country"] == "中国" + assert payload["location_meta"]["target"]["city"] == "Beijing" + assert payload["location_meta"]["resolution_stage"] == "unresolved" + assert payload["location_meta"]["ai_attempted"] is False + assert payload["location_meta"]["ai_status"] == "not_attempted" + assert payload["location_meta"]["ai_error"] is None + + +@pytest.mark.asyncio +async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatch): + item = ParsedNewsItem( + id="bbc-world:f55310fb667b", + title="Watch: What happened on day one of Trump's China visit?", + summary="China welcomed US President Donald Trump before a long meeting with Xi Jinping.", + url="https://example.com/china-visit", + source="BBC World", + feed_name="BBC World", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC), + ) + + async def fake_geocode(_query: str): + return { + "lat": "39.9042", + "lon": "116.4074", + "display_name": "Beijing, China", + } + + class FakeProviderClient: + async def analyze(self, _request): + class Response: + content = ( + '{"country":"China","city":"Beijing","matched_location_name":"Beijing, China",' + '"latitude":null,"longitude":null,"confidence":0.88}' + ) + + return Response() + + monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode) + + enriched = await _enrich_items_with_target_locations( + [item], + provider_client=FakeProviderClient(), + ) + + assert len(enriched) == 1 + assert enriched[0].target_location is not None + assert enriched[0].target_location.latitude == 39.9042 + assert enriched[0].target_location.longitude == 116.4074 + assert enriched[0].target_location.label == "Beijing, China" + assert enriched[0].target_resolution_stage == "ai_inferred_target" + assert enriched[0].target_ai_attempted is True + assert enriched[0].target_ai_status == "success" + assert enriched[0].target_ai_error is None + + +@pytest.mark.asyncio +async def test_extract_target_location_from_text_uses_country_hint(monkeypatch): + item = ParsedNewsItem( + id="bbc-world:country-hint", + title="Giant new dinosaur identified from fossils in Thailand", + summary="The nagatitan is the largest dinosaur found in South-East Asia.", + url="https://example.com/thailand-dinosaur", + source="BBC World", + feed_name="BBC World", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 17, 28, 56, tzinfo=UTC), + ) + + target = await _extract_target_location_from_text(item) + + assert target is not None + assert target.country == "泰国" + assert target.latitude == 15.87 + assert target.longitude == 100.9925 + assert target.source == "headline_country_hint" + + +@pytest.mark.asyncio +async def test_enrich_items_with_target_locations_records_ai_provider_error(): + item = ParsedNewsItem( + id="global-scan:no-hint", + title="The New Geopolitics of Power: Whoever Controls Electrons Wins the Decade", + summary="A broad analysis of industrial policy and energy systems.", + url="https://example.com/geopolitics-power", + source="Example Source", + feed_name="Global Monitor / World", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 17, 3, 10, tzinfo=UTC), + ) + + class FailingProviderClient: + async def analyze(self, _request): + raise RuntimeError("upstream ai timeout") + + enriched = await _enrich_items_with_target_locations( + [item], + provider_client=FailingProviderClient(), + ) + + assert len(enriched) == 1 + assert enriched[0].target_location is None + assert enriched[0].target_resolution_stage == "unresolved" + assert enriched[0].target_ai_attempted is True + assert enriched[0].target_ai_status == "provider_error" + assert enriched[0].target_ai_error == "upstream ai timeout" + + +@pytest.mark.asyncio +async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job(monkeypatch): + source = NewsFeedSource( + id="test-feed", + name="Test Feed", + region="global", + homepage_url="https://example.com", + feed_url="https://example.com/rss.xml", + ) + item = ParsedNewsItem( + id="test-feed:timeout", + title="Example story", + summary="Example summary", + url="https://example.com/story", + source="Test Feed", + feed_name="Test Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + ) + + async def fake_fetch_source(_client, feed_source): + return feed_source, [item], None + + async def fake_get_cached_target_location_patch(_item_id): + return None + + enqueued_payloads = [] + + async def fake_enqueue_target_location_job(payload): + enqueued_payloads.append(payload) + return True + + monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source]) + monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source) + monkeypatch.setattr( + "app.services.earth_news_queue.get_cached_target_location_patch", + fake_get_cached_target_location_patch, + ) + monkeypatch.setattr( + "app.services.earth_news_queue.enqueue_target_location_job", + fake_enqueue_target_location_job, + ) + + payload = await get_earth_news_payload(provider_client=None) + + assert len(payload["items"]) == 1 + assert payload["items"][0]["id"] == "test-feed:timeout" + assert payload["items"][0]["latitude"] == 20.0 + assert payload["items"][0]["longitude"] == 0.0 + assert payload["items"][0]["location_source"] == "region_anchor" + assert payload["items"][0]["verified"] is False + assert payload["items"][0]["location_meta"]["ai_status"] == "queued" + assert enqueued_payloads[0]["id"] == "test-feed:timeout" + assert payload["errors"] == [] + + +@pytest.mark.asyncio +async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypatch): + db = object() + item = ParsedNewsItem( + id="db:fresh", + title="Fresh database story", + summary="Stored summary", + url="https://example.com/fresh", + source="Stored Source", + feed_name="Stored Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + ) + item.location_patch = { + "latitude": 39.9057136, + "longitude": 116.3912972, + "location_label": "北京市, 中国", + "location_source": "headline_location_hint", + "verified": True, + "location_meta": {"target": {"city": "Beijing"}, "anchor": {"region": "global"}}, + } + + async def fake_get_earth_news_freshness(_db, *, active_region): + return 12, datetime.now(UTC) + + async def fake_list_earth_news_items(_db, *, active_region, limit): + assert limit == 12 + return [item] + + async def fail_fetch(_sources): + raise AssertionError("fresh database items should not fetch RSS") + + monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness) + monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items) + monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_fetch) + + payload = await get_earth_news_payload(db=db) + + assert payload["items"][0]["id"] == "db:fresh" + assert payload["items"][0]["verified"] is True + assert payload["items"][0]["latitude"] == 39.9057136 + assert payload["stale"] is False + + +@pytest.mark.asyncio +async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatch): + db = object() + source = NewsFeedSource( + id="test-feed", + name="Test Feed", + region="global", + homepage_url="https://example.com", + feed_url="https://example.com/rss.xml", + ) + item = ParsedNewsItem( + id="test-feed:init", + title="Initial RSS story", + summary="Initial summary", + url="https://example.com/init", + source="Test Feed", + feed_name="Test Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + ) + item.location_patch = { + "latitude": 20.0, + "longitude": 0.0, + "location_label": "全球", + "location_source": "region_anchor", + "verified": False, + "location_meta": {"target": None, "anchor": {"region": "global"}}, + } + upserted = [] + enqueued = [] + + async def fake_get_earth_news_freshness(_db, *, active_region): + return 0, None + + async def fake_fetch_rss_items_for_sources(_sources): + return [item], [] + + async def fake_upsert_earth_news_items(_db, items): + upserted.extend(items) + return len(items) + + async def fake_list_earth_news_items(_db, *, active_region, limit): + return [item] + + async def fake_enqueue_target_location_job(payload): + enqueued.append(payload) + return True + + monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source]) + monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness) + monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources) + monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items) + monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items) + monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job) + + payload = await get_earth_news_payload(db=db) + + assert upserted[0].id == "test-feed:init" + assert payload["items"][0]["id"] == "test-feed:init" + assert payload["items"][0]["verified"] is False + assert enqueued[0]["id"] == "test-feed:init" + + +@pytest.mark.asyncio +async def test_earth_news_payload_supplements_stale_database_items(monkeypatch): + db = object() + old_item = ParsedNewsItem( + id="db:old", + title="Old story", + summary="Old summary", + url="https://example.com/old", + source="Stored Source", + feed_name="Stored Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 3, 0, tzinfo=UTC), + ) + old_item.location_patch = { + "latitude": 20.0, + "longitude": 0.0, + "location_label": "全球", + "location_source": "region_anchor", + "verified": False, + "location_meta": {"target": None, "anchor": {"region": "global"}}, + } + fetched = [] + + async def fake_get_earth_news_freshness(_db, *, active_region): + return 12, datetime(2026, 5, 14, 3, 0, tzinfo=UTC) + + async def fake_fetch_rss_items_for_sources(_sources): + fetched.append(True) + return [old_item], [] + + async def fake_upsert_earth_news_items(_db, items): + return len(items) + + async def fake_list_earth_news_items(_db, *, active_region, limit): + return [old_item] + + async def fake_enqueue_target_location_job(_payload): + return True + + monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness) + monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources) + monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items) + monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items) + monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job) + + payload = await get_earth_news_payload(db=db) + + assert fetched == [True] + assert payload["items"][0]["id"] == "db:old" + + +@pytest.mark.asyncio +async def test_earth_news_payload_merges_cached_location_patch(monkeypatch): + source = NewsFeedSource( + id="test-feed", + name="Test Feed", + region="global", + homepage_url="https://example.com", + feed_url="https://example.com/rss.xml", + ) + item = ParsedNewsItem( + id="test-feed:cached", + title="Cached story", + summary="Cached summary", + url="https://example.com/cached", + source="Test Feed", + feed_name="Test Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + ) + cached_patch = { + "latitude": 39.9057136, + "longitude": 116.3912972, + "location_label": "北京市, 中国", + "location_source": "headline_location_hint", + "verified": True, + "location_meta": { + "resolution_stage": "headline_location_hint", + "ai_attempted": False, + "ai_status": "skipped_text_hint", + "ai_error": None, + "debug_note": "text hint matched 北京市, 中国", + "target": {"city": "Beijing"}, + "anchor": {"region": "global"}, + }, + } + + async def fake_fetch_source(_client, feed_source): + return feed_source, [item], None + + async def fake_get_cached_target_location_patch(_item_id): + return cached_patch + + async def fake_enqueue_target_location_job(_payload): + raise AssertionError("cached items should not be enqueued") + + monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source]) + monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source) + monkeypatch.setattr( + "app.services.earth_news_queue.get_cached_target_location_patch", + fake_get_cached_target_location_patch, + ) + monkeypatch.setattr( + "app.services.earth_news_queue.enqueue_target_location_job", + fake_enqueue_target_location_job, + ) + + payload = await get_earth_news_payload(provider_client=None) + + assert payload["items"][0]["latitude"] == 39.9057136 + assert payload["items"][0]["longitude"] == 116.3912972 + assert payload["items"][0]["verified"] is True + assert payload["items"][0]["location_source"] == "headline_location_hint" + + +@pytest.mark.asyncio +async def test_worker_processes_target_location_message_and_returns_patch(monkeypatch): + message = NewsTargetLocationMessage( + message_id="1-0", + item_id="bbc-world:worker", + payload={ + "id": "bbc-world:worker", + "title": "Ukraine rescuers pull dead from rubble of Kyiv flats", + "summary": "Massive Russian drone and missile attacks in Ukraine's capital.", + "url": "https://example.com/kyiv", + "source": "BBC World", + "feed_name": "BBC World", + "feed_region": "global", + "homepage_url": "https://www.bbc.com/news/world", + "published_at": "2026-05-14T13:16:32Z", + }, + ) + + async def fake_geocode(_query: str): + return { + "lat": "50.4500336", + "lon": "30.5241361", + "display_name": "Київ, Україна", + } + + saved = {} + broadcasted = {} + + async def fake_save_target_location_patch(item_id, patch): + saved["item_id"] = item_id + saved["patch"] = patch + + async def fake_update_earth_news_item_location(_session, *, item_id, patch): + saved["db_item_id"] = item_id + saved["db_patch"] = patch + return True + + async def fake_broadcast_custom(channel, data): + broadcasted["channel"] = channel + broadcasted["data"] = data + + class FakeSession: + async def commit(self): + saved["committed"] = True + + class FakeSessionFactory: + async def __aenter__(self): + return FakeSession() + + async def __aexit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode) + monkeypatch.setattr( + "app.services.earth_news_worker.save_target_location_patch", + fake_save_target_location_patch, + ) + monkeypatch.setattr( + "app.services.earth_news_worker.update_earth_news_item_location", + fake_update_earth_news_item_location, + ) + monkeypatch.setattr( + "app.services.earth_news_worker.async_session_factory", + lambda: FakeSessionFactory(), + ) + monkeypatch.setattr( + "app.services.earth_news_worker.broadcaster.broadcast_custom", + fake_broadcast_custom, + ) + + patch = await process_target_location_message(message, provider_client=None) + + assert patch["latitude"] == 50.4500336 + assert patch["longitude"] == 30.5241361 + assert patch["location_source"] == "headline_location_hint" + assert patch["verified"] is True + assert saved["item_id"] == "bbc-world:worker" + assert saved["db_item_id"] == "bbc-world:worker" + assert saved["committed"] is True + assert broadcasted["channel"] == "earth_news" + assert broadcasted["data"]["item_id"] == "bbc-world:worker" + + +@pytest.mark.asyncio +async def test_media_news_archive_collector_maps_news_items(monkeypatch): + collector = MediaNewsArchiveCollector() + collector._db_session = object() + record = SimpleNamespace( + id="bbc-world:archive", + title="Archived news", + summary="Archived summary", + url="https://example.com/archive", + source="BBC World", + feed_name="BBC World", + region="global", + homepage_url="https://www.bbc.com/news/world", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + latitude=39.9057136, + longitude=116.3912972, + location_label="北京市, 中国", + location_source="headline_location_hint", + verified=True, + location_meta={"target": {"country": "中国", "city": "Beijing"}}, + first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC), + last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC), + ) + + async def fake_list_all_earth_news_records(_db): + return [record] + + monkeypatch.setattr( + "app.services.collectors.media_news_archive.list_all_earth_news_records", + fake_list_all_earth_news_records, + ) + + items = await collector.fetch() + + assert items[0]["source_id"] == "bbc-world:archive" + assert collector.data_type == "news_item" + assert items[0]["country"] == "中国" + assert items[0]["city"] == "Beijing" + assert items[0]["latitude"] == 39.9057136 + assert items[0]["metadata"]["verified"] is True diff --git a/backend/tests/test_settings_secret_placeholder.py b/backend/tests/test_settings_secret_placeholder.py new file mode 100644 index 00000000..fcc232ea --- /dev/null +++ b/backend/tests/test_settings_secret_placeholder.py @@ -0,0 +1,12 @@ +from app.api.v1.settings import _is_secret_placeholder + + +def test_secret_placeholder_treats_masked_values_as_placeholder(): + assert _is_secret_placeholder("••••••••", "••••1234") is True + assert _is_secret_placeholder("********", "") is True + assert _is_secret_placeholder(" * * * ", "") is True + + +def test_secret_placeholder_preserves_real_keys_with_asterisks(): + assert _is_secret_placeholder("sk-live-*real-key*", "") is False + assert _is_secret_placeholder("token_with*embedded*star", "") is False diff --git a/config/earth-boundary-pov-policy.china-v1.json b/config/earth-boundary-pov-policy.china-v1.json new file mode 100644 index 00000000..63af4049 --- /dev/null +++ b/config/earth-boundary-pov-policy.china-v1.json @@ -0,0 +1,92 @@ +{ + "schema": "planet-earth-boundary-pov-policy/v1", + "profile": "china-pov-v1", + "description": "Product boundary policy for the China POV Earth boundary build. This file declares intent only; geometry must come from audited source packages and be applied offline before PMTiles/MVT generation.", + "defaultCountryHandling": "source-admin0-with-reviewed-overrides", + "rules": [ + { + "id": "china-zangnan", + "name": "Zangnan / South Tibet", + "action": "union_to_country", + "targetIsoA3": "CHN", + "subtractFromIsoA3": ["IND"], + "hoverIsoA3": "CHN", + "labelPolicy": "show_country_only" + }, + { + "id": "china-aksai-chin", + "name": "Aksai Chin", + "action": "union_to_country", + "targetIsoA3": "CHN", + "subtractFromIsoA3": ["IND"], + "hoverIsoA3": "CHN", + "labelPolicy": "show_country_only" + }, + { + "id": "china-taiwan-penghu", + "name": "Taiwan and Penghu", + "action": "union_to_country", + "targetIsoA3": "CHN", + "hoverIsoA3": "CHN", + "labelPolicy": "show_country_only" + }, + { + "id": "china-diaoyu-chiwei", + "name": "Diaoyu Dao, affiliated islands, and Chiwei Yu", + "action": "union_to_country", + "targetIsoA3": "CHN", + "hoverIsoA3": "CHN", + "labelPolicy": "show_country_only" + }, + { + "id": "china-south-china-sea-islands", + "name": "Dongsha, Xisha, Zhongsha, Nansha, Huangyan Dao, Zengmu Ansha and related islands/reefs", + "action": "union_to_country", + "targetIsoA3": "CHN", + "hoverIsoA3": "CHN", + "labelPolicy": "show_country_only" + }, + { + "id": "china-maritime-claim-line", + "name": "South China Sea dashed maritime claim line", + "action": "render_claim_line", + "targetIsoA3": "CHN", + "geometryRole": "claim_line_only", + "landPolygonEffect": "none" + }, + { + "id": "kosovo", + "name": "Kosovo", + "action": "render_as_disputed_with_parent", + "parentIsoA3": "SRB", + "hoverIsoA3": "SRB", + "boundaryStyle": "disputed_internal", + "labelPolicy": "show_parent_country" + }, + { + "id": "gaza", + "name": "Gaza Strip", + "action": "render_as_region_of_country", + "targetIsoA3": "PSE", + "hoverIsoA3": "PSE", + "boundaryStyle": "admin_or_disputed", + "labelPolicy": "show_country_only" + } + ], + "sourceRequirements": { + "geometryMustBeAudited": true, + "noHandDrawnClaimLines": true, + "noFrontendRuntimePovPatch": true, + "artifactIsolation": "one PMTiles/MVT artifact per POV profile" + }, + "officialPositionNotes": [ + { + "id": "kosovo", + "note": "China has emphasized respect for Serbia's sovereignty and territorial integrity and the framework of UNSC Resolution 1244." + }, + { + "id": "gaza", + "note": "China supports the two-state solution and an independent State of Palestine based on the 1967 borders with East Jerusalem as its capital; Gaza governance should follow Palestinians governing Palestine." + } + ] +} diff --git a/config/earth-boundary-sources.example.json b/config/earth-boundary-sources.example.json new file mode 100644 index 00000000..ba2ecc30 --- /dev/null +++ b/config/earth-boundary-sources.example.json @@ -0,0 +1,115 @@ +{ + "policy": { + "runtimeFetch": false, + "profile": "china-pov-v1", + "povPolicyPath": "config/earth-boundary-pov-policy.china-v1.json", + "productionTileFormat": "pmtiles+mvt", + "debugTileFormat": "geojson-directory", + "description": "Example Collector Settings payloads for audited Earth boundary source ingestion. Replace every endpoint, license, and checksum note before production." + }, + "collectorConfigs": { + "earth_admin0_boundaries": { + "displayName": "Earth Admin-0 国界源", + "sourceKind": "admin0-boundaries", + "endpoint": "https://example.com/admin0-boundaries.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "target_schema": "earth_boundary_source", + "license": "REPLACE_WITH_SOURCE_LICENSE", + "mapping_json": { + "source": { + "items_path": "$.features[*]" + }, + "fields": { + "source_id": { + "path": "$.properties.id", + "type": "string" + }, + "name": { + "path": "$.properties.name", + "type": "string" + }, + "geometry": { + "path": "$.geometry", + "type": "object" + }, + "properties": { + "path": "$.properties", + "type": "object" + } + } + } + }, + "earth_coastline": { + "displayName": "Earth 海岸线源", + "sourceKind": "coastline", + "endpoint": "https://example.com/coastline.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "target_schema": "earth_boundary_source", + "license": "REPLACE_WITH_SOURCE_LICENSE", + "mapping_json": { + "source": { + "items_path": "$.features[*]" + }, + "fields": { + "source_id": { + "path": "$.properties.id", + "type": "string" + }, + "name": { + "path": "$.properties.name", + "type": "string" + }, + "geometry": { + "path": "$.geometry", + "type": "object" + }, + "properties": { + "path": "$.properties", + "type": "object" + } + } + } + }, + "earth_claim_lines": { + "displayName": "Earth 主张线源", + "sourceKind": "claim-lines", + "endpoint": "https://example.com/claim-lines.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "target_schema": "earth_boundary_source", + "license": "REPLACE_WITH_SOURCE_LICENSE", + "mapping_json": { + "source": { + "items_path": "$.features[*]" + }, + "fields": { + "source_id": { + "path": "$.properties.id", + "type": "string" + }, + "name": { + "path": "$.properties.name", + "type": "string" + }, + "geometry": { + "path": "$.geometry", + "type": "object" + }, + "properties": { + "path": "$.properties", + "type": "object" + } + } + } + } + }, + "notes": [ + "Use the actual Collector Settings page as the source of truth for runtime endpoints.", + "The repository seed GeoJSON may be used only for smoke tests and cannot satisfy production high precision." + ] +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b7bc9867..4676bcd4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.58.0] — 2026-05-15 + +Released: 2026-05-15 + +### Highlights +- 新增 Earth 高精度国界 PMTiles/MVT 前端链路,移除旧低精度 GeoJSON 国界兜底,国界缺失时显式报错。 +- 将 Earth 边界数据拆成 Admin-0、coastline、claim-lines 三个标准源采集器,并把 `earth_boundary_tiles` 收口为下游 PMTiles 构建器。 +- 修复 Earth 远距缩放下海陆基座与高清贴图 z-fighting 导致的雪花/黑块闪烁,并记录地表多层 shell 的深度间距规则。 + +### Added / Fixed / Improved +- 新增 `earth_boundary_source` 目标 schema、China POV policy 配置、PMTiles readiness/build 脚本和 collector artifact 登记流程。 +- Earth 新闻巡航改为优先使用可缓存的目标地点解析队列,并补充媒体新闻归档采集器与回归测试。 +- 调整数据源列表任务状态展示,让 Earth 采集器失败/未就绪状态可见,不再表现为“未执行”。 +- 更新中英文采集器、数据源设置、Earth 图层顺序、运维 runbook、FAQ、规则和计划文档。 + +--- + ## [0.57.0] — 2026-05-14 Released: 2026-05-14 diff --git a/docs/plans/earth-high-precision-boundary-tiles-plan.md b/docs/plans/earth-high-precision-boundary-tiles-plan.md index 2bc80993..661a8b8d 100644 --- a/docs/plans/earth-high-precision-boundary-tiles-plan.md +++ b/docs/plans/earth-high-precision-boundary-tiles-plan.md @@ -1,132 +1,67 @@ -# Earth High Precision Boundary Tiles Plan +# Earth High Precision Boundary PMTiles Plan ## Status -Planning revised after visual review. The previous hand-authored China claim-line / point-buffer approach is rejected and must not be implemented. +Current implementation: -Implemented separately: +- Three standard source collectors now handle real source ingestion: `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`. +- Each source collector reads endpoint / headers / auth / `target_schema=earth_boundary_source` from Collector Settings, downloads the configured payload, stores the full artifact under `data/earth-boundary-sources//.*`, and writes a hash / feature-count / artifact-path record to `CollectedData`. +- The backend `earth_boundary_tiles` item is now a downstream PMTiles builder. It refuses to run until the three source records exist, then skips rebuilds when source / POV policy / build config are unchanged. +- The frontend boundary layer now requires the production `pmtiles-mvt` provider and no longer falls back to legacy low-precision GeoJSON. +- Generated loose boundary data is ignored by Git and is not the production deployment format. +- Production PMTiles builds require external `tippecanoe` and `pmtiles` CLIs; missing tools fail the builder clearly instead of registering fake tile records. -- 地表 hover 三模式已实现。 +Still required before claiming true one-to-one high precision: -Still planned: - -- 权威 China POV 数据包。 -- OSM / coastline 高精度离线构建。 -- 版本化静态矢量瓦片输出。 -- 前端 bbox/tile/LRU 高精度加载器。 +- Replace the repository seed GeoJSON with audited high-precision admin boundary, coastline, and claim-line source packages. +- Run a real geometry preparation step that applies the China POV policy through union / subtract / validity repair before PMTiles creation. +- Build and publish `earth-boundaries-china-pov-v1.pmtiles` plus its manifest. ## Summary -Earth 国界线目标从“明显提升”升级为 **最高精度档按真实地图源一比一还原**: +The Earth boundary layer should use one static PMTiles archive containing MVT tiles instead of thousands of loose GeoJSON files. The artifact is POV-specific: `earth-boundaries-china-pov-v1.pmtiles` has China POV baked in during offline source preparation, and the browser never patches political boundaries at runtime. -- 高缩放时不能使用当前 `countries-admin0.min.geojson` 这种低精度简化线。 -- 最高 tile zoom 必须忠实保留选定权威矢量源的原始折点,不做视觉平滑,不做人工凭感觉补线。 -- 藏南、阿克赛钦等争议陆地直接作为中国国家面的一部分表达;hover 只显示普通 `中国 / CHN`,不显示特殊区域名。 -- 九段线 / 十段线必须来自官方标准地图口径或经地理配准校核后的权威矢量数据;不能手工目测画线,不能把马来西亚、菲律宾等周边陆地或近岸底盘划入中国面。 +Production must serve a single PMTiles artifact through static hosting and HTTP range requests. Missing PMTiles is a hard boundary-layer error, not a silent low-precision fallback. -方案仍采用 **离线构建 + 静态矢量瓦片**。低缩放加载简化 base line;高缩放按当前视野 bbox 加载高精度 boundary tiles。第一版不使用 Redis,依靠 Nginx 静态服务、浏览器 HTTP cache 和前端 LRU。 +## Key Implementation Rules -## Data Source and Policy +- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Collector Settings using the `earth_boundary_source` target schema; `config/earth-boundary-sources.example.json` remains the versioned example template. +- China POV geometry is applied before tiling: + - Zangnan and Aksai Chin are unioned into China and subtracted from India. + - Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, and South China Sea islands are China hover/country features. + - The South China Sea dashed line is a claim-line layer only; it never consumes Malaysian, Philippine, Vietnamese, or other land polygons. + - Kosovo is not an independent country surface in this profile; Gaza is a Palestine region. +- PMTiles/MVT layer names are fixed for the frontend: + - `boundary_admin0` + - `boundary_disputed_internal` + - `coastline` + - `claim_line` +- The frontend provider is selected from the boundary manifest: + - `tileProvider: "pmtiles-mvt"` reads the PMTiles artifact. + - Any other provider, missing manifest, or missing PMTiles artifact is treated as an error. +- Redis is not part of v1. Static PMTiles plus browser/CDN range caching is the default performance model. -- 基础陆地国界使用 OSM `boundary=administrative` + `admin_level=2`,并补充高精度 coastline,避免只靠粗糙国家面导致海岸线缺失。 -- China POV 覆盖数据必须独立成包,构建时优先级高于 OSM 原始归属: - - 藏南 union 到中国面,同时从印度面 subtract。 - - 阿克赛钦 union 到中国面,同时从相关邻接面 subtract。 - - 台湾、澎湖、钓鱼岛及附属岛屿、赤尾屿、东沙、西沙、中沙、南沙等作为中国国家面/岛礁面的一部分进入 hover index。 - - 岛礁很小时可有最小可交互面,但 tooltip 仍是 `中国 / CHN`,不展示“某特殊区域归属”标签。 -- 九段线 / 十段线是独立 maritime claim line 图层: - - 只渲染 dashed line,不参与国家陆地面。 - - 不用于吞并周边国家陆地或近岸水域。 - - 坐标必须来自官方标准地图、权威矢量数据,或从官方示意图配准后人工复核,不接受手工猜测坐标。 -- 必须保留 OSM 数据归因:`© OpenStreetMap contributors, ODbL`。 +## Cleanup And Documentation -## Precision Requirements - -- 最高精度档的验收口径是 **source-faithful**,不是“看起来更细”: - - 对高精度源线,最高 zoom tile 不允许 Douglas-Peucker 简化。 - - 坐标量化精度至少保留到 `1e-5` 度级别,构建时不得把经纬度粗暴四舍五入到低精度。 - - 球面渲染只允许 densify 长边来贴合地球曲率;不允许 CatmullRom、Bezier 或任何会改变边界走向的平滑。 - - 海岸/边界红框类区域必须与源地图折线逐点对齐;若有偏差,只能追溯并替换数据源,不能靠渲染平滑掩盖。 -- 低缩放允许简化,但必须有误差预算: - - base line 只服务远景识别。 - - 中 zoom tile 可简化到屏幕误差低于 `0.5px`。 - - 最高 zoom tile 使用无简化或近零误差版本。 - -## Implementation Plan - -### 1. Data pipeline - -新增边界构建脚本,使用 `/home/ray/.local/bin/uv` 运行: - -1. 读取 OSM PBF 或预处理后的 admin-0 边界 / coastline GeoJSON。 -2. 读取 China POV override package。 -3. 使用可靠几何库做 union / difference / validity repair,禁止手写 polygon overlay。 -4. 生成中国国家面时直接合并藏南、阿克赛钦和相关岛礁;从相邻国家面扣除同一区域。 -5. 单独读取官方口径九段线 / 十段线矢量,生成 claim-line tiles。 -6. 输出: - - 低精度 global base line。 - - 无简化 high-precision hover polygon index。 - - 高精度静态瓦片:`frontend/public/earth/data/boundaries/v1/{z}/{x}/{y}.geojson`。 - - 海上断续线瓦片:`frontend/public/earth/data/boundaries/v1/china-claims/{z}/{x}/{y}.geojson`。 - - manifest,记录数据源、覆盖规则版本、构建时间、简化误差和 attribution。 - -### 2. Tile levels and size budget - -- Earth zoom `< 1.6`:只显示 global base line 和低精度 claim line。 -- Earth zoom `1.6-2.8`:加载 tile zoom `4-5`。 -- Earth zoom `2.8-4.0`:加载 tile zoom `6-7`。 -- Earth zoom `> 4.0`:加载 tile zoom `8-10`,使用最高精度无简化折线。 -- 单 tile gzip 目标 `20-80KB`,但最高精度档优先保证几何真实性;若超限,优先提高 tile zoom 或拆 tile,而不是简化真实线。 - -### 3. Frontend loading model - -`country-boundaries.js` 拆成 base layer、tile layer 和 China claim layer: - -- 首屏加载 base line + hover index,不阻塞 Earth 初始化。 -- 高缩放时根据 camera 可见范围计算经纬 bbox,再转换为 Web Mercator tile keys。 -- bbox 由屏幕中心、四角和边中点 raycast 得到,并扩张 `10-20%` 作为预取范围。 -- 处理反经线,必要时拆成两个 bbox。 -- 视野变化请求 debounce `150-250ms`。 -- 拖拽/惯性旋转中不每帧请求;缩放档或 tile key 集合没变时不刷新。 -- 加载当前视野 tile,并预取一圈邻接 tile。 -- base line 在高精度 tile 到达后降低 opacity,避免双线。 - -### 4. Caching and memory control - -- 前端维护 `tileCache`、`inFlightTiles` 和 LRU 使用顺序。 -- tile cache 上限建议 `120-180` 个 tile;超过后释放最旧 tile 的 `BufferGeometry`。 -- Nginx 为 `/earth/data/boundaries/` 设置长缓存: - - `Cache-Control: public, max-age=31536000, immutable` - - gzip 包含 JSON。 -- tile URL 包含版本目录,例如 `v1`;数据更新时改版本目录破浏览器缓存。 -- 第一版不使用 Redis。只有改成动态裁剪 API、多 POV 同 URL、或压测证明静态服务成为瓶颈时再考虑 Redis。 - -## Acceptance Criteria - -1. 最高 zoom 的海岸线和国界线与选定源地图逐点一致,不再只是“明显提升”。 -2. 红框类海岸/边界细节在最高 zoom 下不能出现肉眼可见的低精度折线、直线切边或圆滑失真。 -3. 藏南、阿克赛钦 hover 命中普通 `中国 / CHN`;印度或其他邻接国家不再包含这些区域。 -4. 钓鱼岛、赤尾屿、南海诸岛代表点 hover 命中普通 `中国 / CHN`。 -5. 九段线 / 十段线位置与权威来源一致,不压入马来西亚、菲律宾等周边陆地或错误包围近岸底盘。 -6. 首屏不加载全球超高精度整包。 -7. 高缩放只请求当前视野附近 tile,快速旋转不会出现请求风暴。 -8. 回到已访问区域命中前端 cache 或浏览器 cache。 -9. 国界图层开关、hover 高亮、移动端中心国家高亮、悬停提示三模式保持可用。 +- Do not commit generated loose tiles under `frontend/public/earth/data/boundaries/` or source downloads under `data/earth-boundary-sources/`. +- Remove stale generated debug data before production builds; regenerate it only when smoke testing the debug path. +- Keep the high-level plan, backend collector docs, layer style docs, and ops runbook aligned whenever the provider contract changes. +- After implementation changes, provide user-facing operation steps covering source configuration, source collection, artifact build/deploy, page verification, and fallback troubleshooting. ## Verification -- 构建阶段: - - 几何 validity check 全通过。 - - China POV 代表点测试全部返回 `CHN`。 - - 相邻国家代表点不得被 China override 误吞。 - - 最高 zoom tile 抽样与源数据做坐标级 diff,确认未简化。 -- 前端阶段: - - 在 `frontend` 运行 `/home/ray/.bun/bin/bun run build`。 - - 浏览器 DevTools 验证低缩放无高精度 tile 请求,高缩放请求数量受控,重复视野走 cache。 - - 用截图中的红框区域、南海断续线、藏南、钓鱼岛、赤尾屿、南海诸岛做手动视觉验收。 +- The PMTiles builder fails as not ready when any of `earth_admin0_boundaries`, `earth_coastline`, or `earth_claim_lines` has not produced a current artifact record. +- The PMTiles builder fails as not ready when source artifacts exist but `tippecanoe` / `pmtiles` are missing. +- Running the PMTiles builder twice returns `unchanged` on the second run when inputs are stable. +- `git add . --dry-run` does not include generated loose boundary tiles or source downloads. +- `/home/ray/.bun/bin/bun run build` passes in `frontend`. +- Manual Earth checks confirm: + - PMTiles range requests are issued only for visible tiles. + - Boundary toggle, hover tooltip, and country highlight still work. + - PMTiles failure reports a boundary-layer error; the frontend must not draw low-precision legacy boundaries. -## Sources and Assumptions +## Assumptions -- 产品默认采用中国标准地图/公开地图合规口径;若后续支持多 POV,必须通过版本化数据目录隔离,不能让同一 URL 返回不同政治口径。 -- 当前仓库没有足够权威和足够精细的 China POV / 九段线矢量源,因此不能直接凭现有低精度 GeoJSON 完成“一比一还原”。 -- 实施前必须先引入或生成可审计的高精度源数据包;没有源数据时,只能实现加载框架,不能伪造边界。 +- "One-to-one" means source-faithful to the selected audited vector source, not hand-tuned to a screenshot. +- The China POV artifact is static and versioned; no runtime region-based POV switching is planned. +- The removed repository seed file cannot be used as a runtime fallback for country boundaries. diff --git a/docs/plans/earth-high-resolution-basemap-tiles-plan.md b/docs/plans/earth-high-resolution-basemap-tiles-plan.md new file mode 100644 index 00000000..f3a1350c --- /dev/null +++ b/docs/plans/earth-high-resolution-basemap-tiles-plan.md @@ -0,0 +1,118 @@ +# Earth High Resolution Basemap Tiles Plan + +## Summary + +High-precision borders now expose a separate visual problem: the vector coastline and border data are more accurate than the current raster Earth texture. The next step is a high-resolution basemap tile layer that aligns visually with the high-precision coastline instead of replacing the globe with one huge static image. + +Do not solve this by committing a larger single world texture. A single 16K/32K raster still wastes memory, loads slowly, and becomes blurry or misaligned when zooming into coastal detail. The target architecture is viewport-based raster tiles with cache control, similar to terrain tiles. + +## Goals + +- Render a high-resolution Earth imagery basemap that visually matches the high-precision coastline and country boundary layer. +- Load imagery by visible bbox / tile key instead of loading a whole-world giant texture. +- Keep the current global texture only as a low-zoom background, not as the source of truth for coastlines at inspection zoom. +- Let imagery failures degrade only the imagery layer; high-precision borders and hover must continue working. +- Keep generated imagery cache out of Git. + +## Data Sources + +Candidate sources, in recommended order: + +- NASA GIBS / Blue Marble / VIIRS style imagery for permissive global coverage and stable tile service behavior. +- Sentinel-2 cloudless style public imagery if licensing and tile access are acceptable. +- A self-hosted raster pyramid generated offline from audited global imagery if third-party online tile terms are unsuitable. + +The selected source must document: + +- license / attribution +- max zoom and native resolution +- tile matrix / projection +- cache policy +- whether commercial or public deployment is allowed + +## Architecture + +```text +global low-zoom texture +→ visible Earth bbox from camera raycast +→ Web Mercator tile keys by zoom +→ raster tile fetch/cache +→ project tile image patches onto Earth surface +→ high-precision coastline / border layer remains above imagery +``` + +Implementation should mirror the existing terrain tile discipline: + +- dedupe in-flight requests +- LRU cache for decoded images / textures +- debounce camera movement +- cancel or ignore stale viewport requests +- cap max tiles per frame / per view +- expose loading/error diagnostics + +## Rendering Rules + +- The high-resolution imagery layer is visual only. It must not define country hover, coastline, or border geometry. +- The high-precision coastline remains the visual alignment reference. +- The border layer render order stays above the basemap imagery. +- Low zoom may use the current global texture for speed. +- Mid/high zoom overlays imagery tiles only for the visible region plus a small prefetch ring. +- Do not draw decorative gradients or fake coastlines to hide mismatch. + +## Frontend Work + +- Add a new `basemap-imagery.js` module instead of expanding `country-boundaries.js`. +- Add config in `constants.js`: + - source URL template + - attribution + - min/max zoom + - tile cache limit + - debounce interval + - opacity + - enable/disable setting +- Add Earth settings control: + - `高清底图`: off / auto / on + - default `auto` +- Add debug counters for: + - active tile count + - cached tile count + - failed tile count + - current imagery zoom + +## Backend / Ops Work + +- If using a third-party tile service directly, document attribution and rate-limit behavior. +- If proxying tiles, add backend cache with request coalescing and timeout limits. +- If self-hosting, add an offline builder that writes ignored tile artifacts under a dedicated data directory. +- Update Nginx static serving if self-hosted raster tiles are used. + +## Performance Budget + +- Desktop target: keep visible imagery tiles under a configurable cap, initially 64. +- Mobile target: lower max zoom and tile cap by default. +- Decode and upload textures incrementally; avoid blocking Earth startup on high-resolution imagery. +- First Earth paint must still use the existing lightweight global texture. + +## Verification + +- Compare high-precision coastline against imagery in coastal areas such as southeast China, Taiwan, Hainan, the Korean peninsula, Japan, and island chains in the South China Sea. +- Verify zooming / panning does not create visible tile thrash or long blank periods. +- Verify failed imagery requests do not hide borders or break hover. +- Verify memory stabilizes after repeated pan/zoom due to LRU eviction. +- Run `/home/ray/.bun/bin/bun run build`. + +## User Operation Steps + +After implementation, the user should be able to: + +1. Open Earth settings. +2. Set `高清底图` to `auto` or `on`. +3. Open Earth and zoom into a coastline. +4. See imagery tiles refine under the high-precision boundary/coastline layer. +5. Use diagnostics to confirm which imagery zoom and tile source are active. + +## Assumptions + +- The existing high-precision vector coastline is the alignment reference. +- This plan improves visual texture fidelity; it does not replace the boundary data pipeline. +- A single larger static Earth texture is rejected as the primary solution. diff --git a/docs/plans/earth-mobile-center-country-highlight-plan.md b/docs/plans/earth-mobile-center-country-highlight-plan.md index efd25978..f43b2932 100644 --- a/docs/plans/earth-mobile-center-country-highlight-plan.md +++ b/docs/plans/earth-mobile-center-country-highlight-plan.md @@ -37,7 +37,7 @@ ## Non-goals - 不改变桌面端 hover 交互。 -- 不替换 `countries-admin0.min.geojson` 数据源。 +- 不引入旧低精度国界兜底;移动端中心国家能力必须复用生产 PMTiles/MVT 国界源。 - 不新增后端 API。 - 不把国家面填充做成新的 selected country 面状 shader。 - 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。 @@ -249,4 +249,3 @@ mobileCenterHoverGlowOpacity 3. 性能保护:加入节流、经纬度阈值和禁用态清理。 4. 验证:本地构建通过,移动端 viewport 手动检查通过。 5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。 - diff --git a/docs/plans/earthfeed-coordinate-queue-plan.md b/docs/plans/earthfeed-coordinate-queue-plan.md new file mode 100644 index 00000000..de2054f7 --- /dev/null +++ b/docs/plans/earthfeed-coordinate-queue-plan.md @@ -0,0 +1,131 @@ +# EarthFeed 新闻坐标与异步精修计划 + +## 目标 + +EarthFeed 的每条新闻都直接携带巡航可用坐标。初始响应使用新闻所属大区的锚点坐标,后台通过消息队列异步推理更精确的目标地址,完成后用实时补丁替换原新闻坐标,实现前端无感更新。 + +## 返回结构 + +`GET /api/v1/news/earth-feed` 返回: + +```json +{ + "generated_at": "2026-05-15T03:16:43Z", + "focus": { + "lat": null, + "lon": null, + "region": "global", + "label": "全球焦点", + "accent": "#d6e6ff" + }, + "sources": [ + { + "id": "bbc-world", + "name": "BBC World", + "region": "global", + "homepage_url": "https://www.bbc.com/news/world" + } + ], + "items": [ + { + "id": "bbc-world:af01519ba7dd", + "title": "Flattery and fanfare as Trump welcomed to China - but thorny issues remain", + "summary": "The leaders of the world's two superpowers were all smiles...", + "url": "https://www.bbc.com/news/articles/cdxpypg9dgeo", + "source": "BBC World", + "feed_name": "BBC World", + "region": "global", + "homepage_url": "https://www.bbc.com/news/world", + "published_at": "2026-05-14T13:02:13Z", + "latitude": 39.9057136, + "longitude": 116.3912972, + "location_label": "北京市, 中国", + "location_source": "headline_location_hint", + "verified": true, + "location_meta": { + "resolution_stage": "headline_location_hint", + "ai_attempted": false, + "ai_status": "skipped_text_hint", + "ai_error": null, + "debug_note": "text hint matched 北京市, 中国", + "target": { + "latitude": 39.9057136, + "longitude": 116.3912972, + "label": "北京市, 中国", + "source": "headline_location_hint", + "confidence": 0.78, + "country": "中国", + "city": "Beijing" + }, + "anchor": { + "region": "global", + "label": "全球", + "latitude": 20.0, + "longitude": 0.0 + } + }, + "is_focus_match": true + } + ], + "errors": [], + "stale": false +} +``` + +字段规则: + +- `latitude` / `longitude`:前端巡航唯一读取的坐标。 +- `location_label`:当前坐标展示名。 +- `location_source`:`region_anchor`、`headline_location_hint`、`headline_country_hint`、`ai_inferred_target` 等。 +- `verified`:`false` 表示仍是大区锚点;`true` 表示已经由标题规则、国家规则或 AI 得到目标地址。 +- `location_meta`:调试、诊断、AI 状态、目标地址和锚点详情都放这里,不再展开成 `t_*` 主字段。 + +## 后台队列 + +当前使用 Redis Streams: + +- stream:`earth_news:target_location:jobs` +- consumer group:`earth_news_target_location` +- result cache:`earth_news:target_location:result:{item_id}` +- dedupe key:`earth_news:target_location:queued:{item_id}` + +请求流程: + +1. RSS 拉取并排序。 +2. 每条新闻先生成大区锚点坐标,`verified=false`。 +3. 若 Redis 已有该新闻的精修结果,则合并结果返回。 +4. 若没有精修结果,则把新闻 job 入队,接口立即返回。 + +Worker 流程: + +1. 从队列消费新闻 job。 +2. 先跑标题/国家规则,再视情况调用 AI。 +3. 写入 result cache。 +4. 广播 WebSocket 补丁: + +```json +{ + "type": "data_frame", + "channel": "earth_news", + "timestamp": "2026-05-15T03:17:00Z", + "payload": { + "item_id": "bbc-world:af01519ba7dd", + "patch": { + "latitude": 39.9057136, + "longitude": 116.3912972, + "location_label": "北京市, 中国", + "location_source": "headline_location_hint", + "verified": true, + "location_meta": {} + } + } +} +``` + +## 可迁移性 + +业务代码只调用队列接口,不直接依赖 Redis Streams 细节。以后迁移 Kafka 时新增 Kafka adapter,保持 job payload、result patch 和 worker 推理逻辑不变。 + +## 前端规则 + +新闻面板和巡航都只读取新闻项内的 `latitude` / `longitude`。实时补丁到达后按 `item_id` 合并到现有 `payload.items`,重新渲染并触发 `earth:news-payload-updated`,巡航下一轮自然使用精修坐标。 diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index d1b876c0..5c793504 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -86,9 +86,17 @@ async def run(self, db): | TeleGeography | submarine_cable | Submarine cable information | 7 days | | BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings | | AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings | +| Earth Admin-0 Boundaries | earth_admin0_boundaries | Downloads the configured country-boundary source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | +| Earth Coastline | earth_coastline | Downloads the configured coastline source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | +| Earth Claim Lines | earth_claim_lines | Downloads the configured claim-line source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | +| Earth PMTiles Builder | earth_boundary_tiles | Reads the three Earth boundary source records and builds / registers the PMTiles artifact | Collector settings | AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table. +Earth boundaries are now split into three real source collectors plus one downstream builder. `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines` read endpoint, headers, auth, and `config.target_schema=earth_boundary_source` from Collector Settings. Triggering them requests the configured endpoint, writes the full response to `data/earth-boundary-sources//.*`, and stores sha256, feature count, license, artifact path, sample properties, and mapping metadata in `CollectedData`. + +`earth_boundary_tiles` no longer means source-data collection. It reads the latest successful records from those three source collectors; if any source is missing, the task fails as "not ready" and does not register "4 high-precision tile" records. Once all sources exist, it uses `tippecanoe` / `pmtiles` to build `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`; if those tools are missing, the task fails with the missing-tool message. There is no legacy low-precision fallback for country boundaries. + TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh. ## IV. Data Format (stored in CollectedData table) @@ -219,7 +227,8 @@ backend/app/services/collectors/ ├── peeringdb.py # PeeringDB collector ├── telegeraphy.py # TeleGeography submarine cable collector ├── vessel_ais.py # BarentsWatch AIS vessel collector -└── aisstream.py # AISStream WebSocket vessel collector +├── aisstream.py # AISStream WebSocket vessel collector +└── earth_boundaries.py # Earth boundary source verification and static tile artifact collector backend/app/services/ ├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 93dd2d16..7ccf2a5d 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -307,6 +307,8 @@ Files: Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel. +Earth high-precision boundaries use the same target-schema mechanism. `earth_boundary_source` receives mapped records for `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`; full GeoJSON / JSON payloads are stored as artifacts, while the database only keeps source kind, sha256, feature count, license, artifact path, and sample properties so large geometries do not land in a single row. + ### Configuration Semantics Important fields: @@ -316,7 +318,7 @@ Important fields: - `auth_type`: `none`, `bearer`, `api_key`, or `basic`. - `headers`: static request headers. - `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query. -- `config.target_schema`: for example `vessel_ais`. +- `config.target_schema`: for example `vessel_ais` or `earth_boundary_source`. - `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`. - `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`. diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index 35300328..a84bc3b8 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -122,6 +122,9 @@ Responsibilities: - Globe sphere, cloud layer, atmosphere - Real terrain mesh - Terrain tile fetch, decode, displacement, and shading +- Whole-globe land/ocean and border base overlays + +The Earth surface is a stack of near-concentric shells, not a single mesh. The base sphere and HD texture overlay in `earth.js`, plus the land/ocean base in `country-boundaries.js`, need explicit radius separation. At far zoom, GPU depth precision drops; neighboring shells that are too close can z-fight and show black flicker blocks or snow. The current stable spacing is `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`. When adding or adjusting whole-globe surface overlays, update [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md) and verify at 50% zoom. ### 7. Layer Modules diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index 619367ac..8c8c374e 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -23,7 +23,7 @@ This document records the material, color, opacity, line width, radius offset, a | Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` | | Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` | | Earth base opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` | -| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | Standalone HD texture sphere radius | +| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | Standalone HD texture sphere radius; must keep enough depth separation from the land/ocean base and Earth base sphere to avoid far-zoom z-fighting | | HD texture opacity | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | HD texture `MeshPhongMaterial.opacity` | | HD texture renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` | | HD texture specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | Reduces specular highlight in direct-light areas to avoid blown-out texture | @@ -78,11 +78,18 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Name | Variable | Current Value | Location / Notes | | --- | --- | --- | --- | -| Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input | +| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | Required production PMTiles manifest; missing manifest is an error | +| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | Only PMTiles/MVT is accepted for country boundaries | +| PMTiles artifact path | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | Production single-file PMTiles/MVT artifact | +| MVT layer names | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | Fixed layer names decoded by the PMTiles provider | +| Boundary tile base path | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest base path | +| Boundary tile zoom thresholds | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | Production PMTiles zoom selection | +| Boundary tile cache limit | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | Frontend LRU cache entries for loaded tile geometries | +| Boundary tile debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | View-change debounce before requesting visible tiles | | Ocean fill color | local `OCEAN_HEX` | `0x010609` | Land/ocean base canvas background | | Land fill color | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | Land/ocean base canvas land | | Land/ocean base opacity | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` | -| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` radius | +| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` radius; separated from the Earth base sphere to avoid snow / black block flicker at 50% zoom | | Land/ocean base renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` | | Land/ocean mask size | `landMaskWidth / landMaskHeight` | `2048 / 1024` | Canvas / DataTexture size | | Country tint color | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | Tint when HD texture is off | @@ -91,16 +98,16 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line | | Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity | | Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover | -| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; slightly above HD texture `0.10` and below terrain base `0.16` to reduce floating | +| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; line layers rely on renderOrder and independent geometry, not whole-globe shell depth spacing | | Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level | | Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange | | Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity | -| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | Hover line radius; close to the surface but above normal border lines | +| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | Hover line radius; matches the normal border geometry to avoid double-edge ghosting during highlight changes | | Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level | | Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity | | Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` | | Border hover glow level offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | Glow renderOrder = `2.29` | -| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | Glow radius = hover radius + 0.04 | +| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | Glow uses the same radius as the hover line to avoid coastline detail misalignment | ## Real Terrain diff --git a/docs/technical/en/earth-render-layer-order.md b/docs/technical/en/earth-render-layer-order.md index 97889ebc..ac773bce 100644 --- a/docs/technical/en/earth-render-layer-order.md +++ b/docs/technical/en/earth-render-layer-order.md @@ -17,8 +17,8 @@ Note: the layer control panel order and the registration / startup load order ar | -1 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. | | 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. | | 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. | -| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off. | -| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset` | Surface picking target when visible | HD texture always overlays the land/ocean base fill. | +| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. | +| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. | | 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. | | 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. | | 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. | @@ -45,6 +45,17 @@ Note: the layer control panel order and the registration / startup load order ar | Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. | | Real Satellite Altitude off | Satellite dots, trails, and predicted orbits use the legacy same-sphere height; satellites with missing TLE data or failed propagation also use this fallback height. | +## Depth Spacing Rules + +The Earth surface is not a single mesh. It is a stack of near-concentric shells: base sphere, land/ocean base, HD texture, terrain, clouds, atmosphere, and the occluder. Radius offsets that look harmless at close zoom can collapse into the same depth-buffer pixels at zoomed-out views such as 50%, causing z-fighting that appears as black blocks, snow, or flicker. + +Maintenance rules: + +- Do not reach first for hiding layers at far zoom. Check neighboring shell `altitudeOffset`, `renderOrder`, `depthTest`, and `depthWrite` first. +- Whole-globe overlays such as the land/ocean base and HD texture must keep explicit separation from `CONFIG.earthRadius`; the current stable values are `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`. +- Any new whole-globe or near-whole-globe surface overlay must be screenshot-verified at 50% zoom and at common close zooms, with no black blocks, snow, flicker, or obvious floating. +- If these radii change, update this document and the intent around the constants in `frontend/public/earth/js/constants.js`. + ## Interaction Rules | Interaction | Current Rule | diff --git a/docs/technical/en/faq.md b/docs/technical/en/faq.md index 437f8864..4bf4d5f5 100644 --- a/docs/technical/en/faq.md +++ b/docs/technical/en/faq.md @@ -58,9 +58,9 @@ For temporary troubleshooting, you can stop IP Helper from Administrator PowerSh Stop-Service iphlpsvc -Force ``` -This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. New `--allow-lan` runs a temporary Windows relay for `3000` / `8000`, so persistent portproxy is no longer required. +This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. `--allow-lan` now binds `3000` / `8000` / `8010` directly, so persistent portproxy is no longer required. -If the script prints `failed-stop-service`, `failed-stop-process`, or Vite reports `Port 3000 is already in use` followed by `Windows listener ... services=iphlpsvc`, an old Windows listener still owns the port. Startup stops immediately and prints the recovery order: use Administrator PowerShell to inspect `netsh interface portproxy show all`, delete the matching `listenport` rule, confirm the PID and services with `netstat` / `tasklist` if no portproxy rule exists, and temporarily run `Stop-Service iphlpsvc -Force` when appropriate. After old rules are gone, rerun `./planet.sh restart --allow-lan`; the script starts temporary relays while LAN devices still use `3000` / `8000`. +If the script prints `Windows 侧端口 ... 存在监听者`, or Vite reports `Port 3000 is already in use` followed by `Windows listener ... services=iphlpsvc`, an old Windows listener still owns the port. The script requests Administrator PowerShell cleanup for that port. If the automatic cleanup is canceled, inspect `netsh interface portproxy show all`, delete the matching `listenport` rule, confirm the PID and services with `netstat` / `tasklist` if no portproxy rule exists, and temporarily run `Stop-Service iphlpsvc -Force` when appropriate. After old rules are gone, rerun `./planet.sh restart --allow-lan`; LAN devices still use `3000` / `8000` / `8010`. ### Which startup flags change default ports? @@ -87,6 +87,7 @@ Check in this order before changing firewall rules: # In WSL or the shell running Planet curl http://localhost:3000 curl http://localhost:8000/health +curl http://localhost:8010/health ``` Then verify from Windows PowerShell: @@ -94,6 +95,7 @@ Then verify from Windows PowerShell: ```powershell curl http://localhost:3000 curl http://localhost:8000/health +curl http://localhost:8010/health ``` If both localhost checks pass but a phone or another computer cannot connect, start with LAN enabled: @@ -108,16 +110,18 @@ The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not rec ./planet.sh restart -f 3000 --allow-lan ``` -If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`. +If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side port ownership, stale `portproxy`, or firewall policy rather than Vite or `.zshrc`. -For traditional WSL NAT networking, `./planet.sh start --allow-lan` starts temporary Windows relays so Windows still exposes `3000` / `8000`. Before startup, the script checks for stale `portproxy` rules and requests Administrator PowerShell to delete them. It also checks Windows Firewall and, when inbound allow rules for `3000` / `8000` are missing, triggers a UAC Administrator PowerShell request to create them. If the automatic request is canceled, clean up manually: +`./planet.sh start --allow-lan` directly exposes `3000` / `8000` / `8010` and checks port availability, stale `portproxy`, and Windows Firewall before startup. If a Windows-side listener owns a port, the script requests Administrator PowerShell cleanup. When inbound allow rules are missing, it also triggers a UAC Administrator PowerShell request to create them. If the automatic request is canceled, clean up manually: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010 ``` LAN devices should use the Windows external port, for example `http://:3000/earth`. @@ -127,6 +131,7 @@ If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. E ```powershell New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow +New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow ``` Use these commands to inspect the current Hyper-V firewall state: diff --git a/docs/technical/en/ops-planet-sh-startup.md b/docs/technical/en/ops-planet-sh-startup.md index 4d777636..6fe4ba06 100644 --- a/docs/technical/en/ops-planet-sh-startup.md +++ b/docs/technical/en/ops-planet-sh-startup.md @@ -177,7 +177,7 @@ Frontend startup now has an additional pre-start cleanup retry layer: - `PORT_PRESTART_RETRIES`: defaults to 3 attempts. - `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds. -`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and Administrator PowerShell recovery commands, then stops startup immediately instead of launching the service into the same port error. If the frontend Vite process only discovers `Port 3000 is already in use` after launch, the script prints the same Windows listener recovery commands. Non-WSL environments do not run the Windows cleanup path. On WSL, `--allow-lan` uses temporary Windows relays to expose `3000` / `8000`, so old persistent portproxy rules should be removed. +`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it requests Administrator PowerShell to delete stale `portproxy` rules, stop services that own the port, or force-stop the owning process. If the administrator request is canceled, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and Administrator PowerShell recovery commands, then stops startup immediately instead of launching the service into the same port error. If the frontend Vite process only discovers `Port 3000 is already in use` after launch, the script prints the same Windows listener recovery commands. Non-WSL environments do not run the Windows cleanup path. `--allow-lan` now exposes `3000` / `8000` / `8010` directly and no longer starts an extra Windows forwarding process; old persistent portproxy rules should be removed. ## Issue 4: `restart` Behavior diff --git a/docs/technical/en/ops-runbook.md b/docs/technical/en/ops-runbook.md index cd2fa081..45723cb4 100644 --- a/docs/technical/en/ops-runbook.md +++ b/docs/technical/en/ops-runbook.md @@ -120,7 +120,7 @@ Useful for: - Demoing Earth from a phone or tablet - Other LAN machines reaching the same dev instance -`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines reaching the Windows LAN IP still need Windows port forwarding and firewall rules. +`--allow-lan` directly exposes the frontend, backend, and AI Provider from the development machine: frontend `3000`, backend `8000`, and AI Provider `8010`. Before startup, the script checks all three ports. If WSL/Linux cannot release a port and a Windows-side listener or stale `portproxy` rule owns it, the script requests Administrator PowerShell cleanup. When Planet runs in WSL, Windows can usually reach it through `localhost`; other LAN machines reaching the Windows LAN IP still need Windows Firewall allow rules. Diagnose in this order: @@ -128,20 +128,23 @@ Diagnose in this order: # From the shell running Planet curl http://localhost:3000 curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' +curl http://localhost:8010/health +ss -ltnp | grep -E ':3000|:8000|:8010' ``` -If WSL services are running but the LAN IP still fails, let `./planet.sh start --allow-lan` launch temporary Windows relays. The relays keep Windows exposed on `3000` / `8000` and exit automatically when the WSL target port goes away. The script checks stale `portproxy` rules and requests Administrator PowerShell to delete them, then checks Windows Firewall and triggers a UAC Administrator PowerShell request when inbound allow rules are missing. Manual fallback commands: +If the services are running but the LAN IP still fails, first remove stale `portproxy` rules and confirm Windows Firewall allows the ports. The script checks this automatically and requests Administrator PowerShell when needed. Manual fallback commands: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010 ``` -LAN devices should use the Windows external port, for example `http://:3000/earth`. +LAN devices should use the Windows external ports, for example `http://:3000/earth`, `http://:8000/health`, and `http://:8010/health`. ## AI Provider Environment and Builds @@ -238,6 +241,16 @@ uv sync uv run pytest backend/tests/test_otp_service.py ``` +## Earth Boundary PMTiles Operations + +1. In Collector Settings, configure endpoints, headers/auth, `config.target_schema=earth_boundary_source`, license, and mapping for `Earth Admin-0 Boundaries`, `Earth Coastline`, and `Earth Claim Lines`. +2. In the data-source console, collect those three sources first. Each successful source writes the full artifact to `data/earth-boundary-sources//.*` and stores sha256, feature count, artifact path, and sample properties. +3. After all three sources succeed, collect `Earth PMTiles Builder`. If any source is missing, it fails as "not ready" and does not update Earth boundaries. +4. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools fail the task with a clear message. +5. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest. +6. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy. +7. If PMTiles loading fails, Earth reports a boundary-layer error and does not draw legacy low-precision borders. Troubleshoot in this order: browser Network range requests for PMTiles, manifest `tileProvider: "pmtiles-mvt"`, Nginx static serving for `.pmtiles`, and artifact path / sha256 consistency with the manifest. + ## Related Docs - [planet.sh Startup Mechanism](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md) diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index 2b5ddad8..1ae59d1d 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -87,9 +87,17 @@ async def run(self, db): | Space-Track TLE | satellite_tle | 卫星轨道 TLE 数据 | 依采集器配置 | | BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 | | AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 | +| Earth Admin-0 Boundaries | earth_admin0_boundaries | 从配置 endpoint 下载国家级边界源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | +| Earth Coastline | earth_coastline | 从配置 endpoint 下载海岸线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | +| Earth Claim Lines | earth_claim_lines | 从配置 endpoint 下载主张线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | +| Earth PMTiles Builder | earth_boundary_tiles | 读取三类 Earth 边界源采集结果并构建 / 登记 PMTiles 产物 | 依采集器配置 | AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。 +Earth 边界现在拆成三个真实源采集器和一个下游构建器。`earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 都读取后台 Collector Settings 里的 endpoint、headers、auth 和 `config.target_schema=earth_boundary_source`,点击采集时会真实请求 endpoint,保存完整响应到 `data/earth-boundary-sources//.*`,并在 `CollectedData` 中写入 sha256、feature count、license、artifact path、sample properties 和 mapping 信息。 + +`earth_boundary_tiles` 不再代表源数据采集。它只读取上述三类源的最新成功记录;缺任一源时任务失败并显示“未就绪”,不会登记“4 条高精度瓦片”。三类源齐全后,它会调用 `tippecanoe` / `pmtiles` 生成 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`;本机缺少这些工具时任务失败并说明缺失工具。国界不再有旧低精度兜底。 + TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。 ## 四、数据格式 (统一存储到 CollectedData 表) @@ -244,7 +252,8 @@ backend/app/services/collectors/ ├── peeringdb.py # PeeringDB采集器 ├── telegeraphy.py # TeleGeography海底光缆采集器 ├── vessel_ais.py # BarentsWatch AIS 船只采集器 -└── aisstream.py # AISStream WebSocket 船只采集器 +├── aisstream.py # AISStream WebSocket 船只采集器 +└── earth_boundaries.py # Earth 国界源校验和静态瓦片 artifact 采集器 backend/app/services/ ├── custom_datasource_runtime.py # 自定义 REST / WebSocket 映射运行时 diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index 63699190..fd56747c 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -309,6 +309,8 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit= 自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。 +Earth 高精度边界使用同一套目标 schema 机制。`earth_boundary_source` 承接 `earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 三类源的映射结果;完整 GeoJSON / JSON 原文保存为 artifact,数据库只保存 source kind、sha256、feature count、license、artifact path 和 sample properties,避免把大型几何塞进单行记录。 + ### 配置语义 关键字段: @@ -318,7 +320,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit= - `auth_type`:`none`、`bearer`、`api_key`、`basic`。 - `headers`:静态请求头。 - `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。 -- `config.target_schema`:例如 `vessel_ais`。 +- `config.target_schema`:例如 `vessel_ais` 或 `earth_boundary_source`。 - `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。 - `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。 diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index 1eac4fec..caaf9674 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -123,12 +123,16 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h - [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js) - [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js) +- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js) 职责: - 地球球体、云层、大气 - 真实地形 mesh - terrain tile 拉取、解码、位移、着色 +- 海陆基座与国界底图的整球 overlay + +Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座球、高清材质 overlay、云层/大气,以及 `country-boundaries.js` 的海陆基座都需要明确半径间距。远距视图下 GPU 深度精度会下降,相邻 shell 过近会 z-fighting,表现为黑色闪烁块或雪花。当前稳定策略是让海陆基座使用 `landAltitudeOffset = 0.32`,高清材质使用 `textureOverlayAltitudeOffset = 0.48`;后续新增或调整整球地表 overlay 时,必须同步检查 [Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md),并在 50% 缩放视图验证。 ### 7. 图层模块 diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 70de0f4e..76ab61ca 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -26,7 +26,7 @@ | Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` | | Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` | | Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` | -| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 | +| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | 独立高清材质球半径;必须与海陆基座和地球基座保持足够深度间距,避免远距 z-fighting | | 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` | | 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` | | 高清材质 specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | 降低直射区域镜面高光,避免贴图死白 | @@ -84,11 +84,18 @@ | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | -| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 | +| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 必需的生产 PMTiles manifest;缺失即报错 | +| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | 国界只接受 PMTiles/MVT | +| PMTiles 产物路径 | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | 生产单文件 PMTiles/MVT artifact | +| MVT 图层名 | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | PMTiles provider 解码时读取的固定 layer 名 | +| 国界瓦片基础路径 | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest 基础路径 | +| 国界瓦片缩放阈值 | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | 生产 PMTiles zoom 选择 | +| 国界瓦片缓存上限 | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | 前端已加载瓦片几何的 LRU 缓存条目数 | +| 国界瓦片 debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | 视野变化后请求可见瓦片前的防抖时间 | | 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 | | 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 | | 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` | -| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 | +| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` 半径;与地球基座拉开以避免 50% 缩放时雪花/黑块闪烁 | | 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` | | 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 | | 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint | @@ -97,16 +104,16 @@ | 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 | | 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity | | 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity | -| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | 普通国界线半径;略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感 | +| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | 普通国界线半径;线层靠 renderOrder 和独立 geometry 叠加,不作为整球基座深度间距参考 | | 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 | | 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 | | 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity | -| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | hover 实线半径;贴近地表但高于普通国界线 | +| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | hover 实线半径;与普通国界线同源几何对齐,避免高亮切换时出现重影 | | 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 | | 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity | | 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` | | 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` | -| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 | +| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | glow 与 hover 实线共用半径,避免海岸细节处双线错位 | ## 真实地形 diff --git a/docs/technical/zh/earth-render-layer-order.md b/docs/technical/zh/earth-render-layer-order.md index 1b021b99..30318604 100644 --- a/docs/technical/zh/earth-render-layer-order.md +++ b/docs/technical/zh/earth-render-layer-order.md @@ -18,15 +18,15 @@ | -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 | | 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 | | 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 | -| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 | -| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 | +| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 | +| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 | | 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 | | 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 | | 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 | | 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 | -| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 | +| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制;地形 `depthWrite: false`,所以地形开启时仍可见。 | | 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 | -| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 | +| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.115` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;与普通国界线同源半径对齐,避免重影;中国和中国(台湾)共享高亮组。 | | 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 | | 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 | | 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 | @@ -48,6 +48,17 @@ | 国界线 off | 只隐藏可交互国界线和 hover,高亮状态会清除;海陆基座填充仍作为 Earth 底图保留。 | | 真实卫星高度 off | 卫星点、轨迹和预测轨道都使用旧版同层球面;缺失 TLE 或传播失败的卫星也使用同一 fallback 高度。 | +## 深度间距规则 + +Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海陆基座、高清材质、地形、云层、大气和遮挡球。近距看起来只差几个小数的半径偏移,在 50% 这类远距视图下会被深度缓冲压到同一批像素,导致 z-fighting,表现为黑块、雪花或闪烁。 + +维护规则: + +- 不要用“远距隐藏图层”作为第一反应;先检查相邻 shell 的 `altitudeOffset`、`renderOrder`、`depthTest` 和 `depthWrite`。 +- 海陆基座和高清材质这类整球 overlay 必须与 `CONFIG.earthRadius` 保持明确间距;当前稳定值为 `landAltitudeOffset = 0.32`、`textureOverlayAltitudeOffset = 0.48`。 +- 新增整球或近整球地表 overlay 时,必须在 50% 缩放和常用近距视图各截一次图,确认没有黑块、雪花、闪烁,也没有明显漂浮感。 +- 如果必须调整这些半径,需同步更新本文和 `frontend/public/earth/js/constants.js` 的注释/常量意图。 + ## 交互规则 | 交互 | 当前规则 | diff --git a/docs/technical/zh/faq.md b/docs/technical/zh/faq.md index 6401bbca..60a1db5b 100644 --- a/docs/technical/zh/faq.md +++ b/docs/technical/zh/faq.md @@ -58,9 +58,9 @@ tasklist /svc /fi "PID eq 4700" Stop-Service iphlpsvc -Force ``` -这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;新版本 `--allow-lan` 会用临时 Windows relay 暴露 `3000` / `8000`,不再需要保留持久 portproxy。 +这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;`--allow-lan` 会直接绑定 `3000` / `8000` / `8010`,不再需要保留持久 portproxy。 -如果脚本输出 `failed-stop-service`、`failed-stop-process`,或 Vite 报 `Port 3000 is already in use` 后显示 `Windows listener ... services=iphlpsvc`,说明旧的 Windows listener 仍在占用端口。脚本会停止启动,并提示用管理员 PowerShell 检查 `netsh interface portproxy show all`,删除对应 `listenport` 规则;如果没有 portproxy 规则,再用 `netstat` / `tasklist` 确认服务,必要时临时 `Stop-Service iphlpsvc -Force`。清理旧规则后重新运行 `./planet.sh restart --allow-lan`,脚本会启动临时 relay,局域网仍访问 `3000` / `8000`。 +如果脚本输出 `Windows 侧端口 ... 存在监听者`,或 Vite 报 `Port 3000 is already in use` 后显示 `Windows listener ... services=iphlpsvc`,说明旧的 Windows listener 仍在占用端口。脚本会请求管理员 PowerShell 清理对应端口;如果自动清理被取消,再手动检查 `netsh interface portproxy show all`,删除对应 `listenport` 规则。如果没有 portproxy 规则,再用 `netstat` / `tasklist` 确认服务,必要时临时 `Stop-Service iphlpsvc -Force`。清理旧规则后重新运行 `./planet.sh restart --allow-lan`,局域网仍访问 `3000` / `8000` / `8010`。 ### 默认端口冲突时应该改哪些参数? @@ -89,6 +89,7 @@ Stop-Service iphlpsvc -Force # 在 WSL 或运行 Planet 的 shell 中 curl http://localhost:3000 curl http://localhost:8000/health +curl http://localhost:8010/health ``` 再到 Windows PowerShell 验证: @@ -96,6 +97,7 @@ curl http://localhost:8000/health ```powershell curl http://localhost:3000 curl http://localhost:8000/health +curl http://localhost:8010/health ``` 如果 WSL 和 Windows localhost 都通,但手机或其他电脑访问不通,再考虑局域网开放: @@ -110,16 +112,18 @@ curl http://localhost:8000/health ./planet.sh restart -f 3000 --allow-lan ``` -如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧转发或防火墙。 +如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧端口占用、旧 `portproxy` 或防火墙。 -传统 WSL NAT 场景下,`./planet.sh start --allow-lan` 会启动临时 Windows relay,让 Windows 对外仍使用 `3000` / `8000`。脚本会在启动前检测并请求管理员 PowerShell 删除旧 `portproxy`,也会检测 Windows 防火墙规则;如果缺少 `3000` / `8000` 入站放行,会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理: +`./planet.sh start --allow-lan` 会直接开放 `3000` / `8000` / `8010`,并在启动前检测端口、旧 `portproxy` 和 Windows 防火墙规则。端口被 Windows 侧 listener 占用时,脚本会请求管理员 PowerShell 清理;缺少入站放行时,也会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010 ``` 局域网设备访问 Windows 对外端口,例如 `http://:3000/earth`。 @@ -129,6 +133,7 @@ New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Al ```powershell New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow +New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow ``` 也可以用下面命令确认当前 Hyper-V firewall 状态: diff --git a/docs/technical/zh/ops-planet-sh-startup.md b/docs/technical/zh/ops-planet-sh-startup.md index fa710f46..9c4fb694 100644 --- a/docs/technical/zh/ops-planet-sh-startup.md +++ b/docs/technical/zh/ops-planet-sh-startup.md @@ -155,7 +155,7 @@ wait_for_port_release() { - `PORT_PRESTART_RETRIES`:默认 3 次。 - `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。 -`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情和管理员 PowerShell 处理命令,然后立即停止启动,不再继续拉起服务碰同一个端口错误。前端 Vite 启动后才发现 `Port 3000 is already in use` 时,也会打印同一套 Windows listener 处理命令。非 WSL 环境不会尝试 Windows 清理路径。`--allow-lan` 在 WSL 下会使用临时 Windows relay 暴露 `3000` / `8000`,所以旧的持久 portproxy 规则应清理掉。 +`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并请求管理员 PowerShell 删除旧 `portproxy`、停止占用端口的服务或强制结束对应进程。若管理员请求被取消,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情和管理员 PowerShell 处理命令,然后立即停止启动,不再继续拉起服务碰同一个端口错误。前端 Vite 启动后才发现 `Port 3000 is already in use` 时,也会打印同一套 Windows listener 处理命令。非 WSL 环境不会尝试 Windows 清理路径。`--allow-lan` 直接开放 `3000` / `8000` / `8010`,不再启动额外的 Windows 端口转发进程;旧的持久 portproxy 规则应清理掉。 ## 问题三:端口检测用 Python diff --git a/docs/technical/zh/ops-runbook.md b/docs/technical/zh/ops-runbook.md index 8e5f4424..f63621b3 100644 --- a/docs/technical/zh/ops-runbook.md +++ b/docs/technical/zh/ops-runbook.md @@ -120,7 +120,7 @@ - 手机或平板演示 Earth - 局域网其他机器访问同一开发实例 -`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 端口转发和防火墙放行。 +`--allow-lan` 会让前端、后端和 AI Provider 直接对开发机开放:前端 `3000`、后端 `8000`、AI Provider `8010`。脚本启动前会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 防火墙放行。 建议按顺序排查: @@ -128,20 +128,23 @@ # 在运行 Planet 的 shell 中 curl http://localhost:3000 curl http://localhost:8000/health -ss -ltnp | grep -E ':3000|:8000' +curl http://localhost:8010/health +ss -ltnp | grep -E ':3000|:8000|:8010' ``` -如果看到 WSL 内部服务已经启动,但局域网 IP 仍访问失败,让 `./planet.sh start --allow-lan` 启动临时 Windows relay。relay 会让 Windows 对外继续使用 `3000` / `8000`,并在 WSL 目标端口断开后自动退出。脚本会检测并请求管理员 PowerShell 删除旧 `portproxy`,也会检测 Windows 防火墙规则;缺少入站放行时会触发一次 UAC 管理员 PowerShell 请求自动创建。手动兜底命令如下: +如果服务已经启动但局域网 IP 仍访问失败,优先清理旧 `portproxy` 并确认 Windows 防火墙放行。脚本会自动检测并请求管理员 PowerShell 处理;自动请求被取消时,手动兜底命令如下: ```powershell netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000 +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010 New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000 New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000 +New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010 ``` -局域网设备访问 Windows 对外端口,例如 `http://:3000/earth`。 +局域网设备访问 Windows 对外端口,例如 `http://:3000/earth`、`http://:8000/health`、`http://:8010/health`。 ## AI Provider 环境变量与构建 @@ -238,6 +241,16 @@ uv sync uv run pytest backend/tests/test_otp_service.py ``` +## Earth 国界 PMTiles 操作步骤 + +1. 在后台 Collector Settings 中分别配置 `Earth Admin-0 国界源`、`Earth 海岸线源`、`Earth 主张线源` 的 endpoint、headers/auth、`config.target_schema=earth_boundary_source`、license 和 mapping。 +2. 在数据源页依次采集这三个源。每个源成功后会保存完整 artifact 到 `data/earth-boundary-sources//.*`,并写入 sha256、feature count、artifact path、sample properties。 +3. 三个源都成功后,再采集 `Earth PMTiles 构建器`。缺任一源时它会失败为“未就绪”,不会更新 Earth 国界。 +4. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时任务失败并显示缺失工具。 +5. 生产构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。 +6. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。 +7. 如果 PMTiles 加载失败,Earth 会报国界图层错误并且不绘制旧低精度国界。排查顺序是:浏览器 Network 是否有 PMTiles range 请求、manifest 的 `tileProvider` 是否为 `pmtiles-mvt`、Nginx 是否能静态返回 `.pmtiles`、artifact 路径和 sha256 是否与 manifest 一致。 + ## 相关文档 - [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md) diff --git a/docs/version-history.md b/docs/version-history.md index 90d7f584..5aed737b 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.57.0` +- `dev` 当前开发分支历史推导到:`0.58.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 | | `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay,保持 localhost 与局域网同用 3000/8000,并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 | | `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 | | `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关,轨迹和预测轨道跟随高度模式切换,并补齐设置面板、FAQ、用户手册和开发者文档 | diff --git a/frontend/bun.lock b/frontend/bun.lock index 9fd11b98..480f6200 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -6,9 +6,12 @@ "name": "planet-frontend", "dependencies": { "@ant-design/icons": "^5.2.6", + "@mapbox/vector-tile": "^2.0.4", "antd": "^5.12.5", "axios": "^1.6.2", "dayjs": "^1.11.10", + "pbf": "^4.0.1", + "pmtiles": "^4.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable": "^3.1.3", @@ -143,6 +146,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="], + "@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="], "@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="], @@ -227,6 +234,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], @@ -291,6 +300,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -339,12 +350,18 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pmtiles": ["pmtiles@4.4.1", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="], @@ -433,6 +450,8 @@ "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 71444a62..c71aa76a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -50,6 +50,23 @@ server { try_files $uri =404; } + location = /earth/data/boundaries/v1/manifest.json { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location ~ ^/earth/data/boundaries/.*\.pmtiles$ { + types { application/octet-stream pmtiles; } + add_header Accept-Ranges bytes; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location /earth/data/boundaries/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/package.json b/frontend/package.json index 38b36789..859ce931 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,16 @@ { "name": "planet-frontend", - "version": "0.57.0", + "version": "0.58.0", "private": true, "packageManager": "bun@1", "dependencies": { "@ant-design/icons": "^5.2.6", + "@mapbox/vector-tile": "^2.0.4", "antd": "^5.12.5", "axios": "^1.6.2", "dayjs": "^1.11.10", + "pbf": "^4.0.1", + "pmtiles": "^4.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable": "^3.1.3", diff --git a/frontend/public/earth/data/countries-admin0.min.geojson b/frontend/public/earth/data/countries-admin0.min.geojson deleted file mode 100644 index e8273b89..00000000 --- a/frontend/public/earth/data/countries-admin0.min.geojson +++ /dev/null @@ -1 +0,0 @@ -{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"ADMIN":"Fiji","NAME_EN":"Fiji","CONTINENT":"Oceania","ADM0_A3":"FJI","ISO_A3":"FJI","NAME":"Fiji","ISO_A2":"FJ","NAME_ZH":"斐济"},"geometry":{"type":"MultiPolygon","coordinates":[[[[180,-16.067133],[180,-16.555217],[179.364143,-16.801354],[178.725059,-17.012042],[178.596839,-16.63915],[179.096609,-16.433984],[179.413509,-16.379054],[180,-16.067133]]],[[[178.12557,-17.50481],[178.3736,-17.33992],[178.71806,-17.62846],[178.55271,-18.15059],[177.93266,-18.28799],[177.38146,-18.16432],[177.28504,-17.72465],[177.67087,-17.38114],[178.12557,-17.50481]]],[[[-179.79332,-16.020882],[-179.917369,-16.501783],[-180,-16.555217],[-180,-16.067133],[-179.79332,-16.020882]]]]}},{"type":"Feature","properties":{"ADMIN":"United Republic of Tanzania","NAME_EN":"Tanzania","CONTINENT":"Africa","ADM0_A3":"TZA","ISO_A3":"TZA","NAME":"Tanzania","ISO_A2":"TZ","NAME_ZH":"坦桑尼亚"},"geometry":{"type":"Polygon","coordinates":[[[33.903711,-0.95],[34.07262,-1.05982],[37.69869,-3.09699],[37.7669,-3.67712],[39.20222,-4.67677],[38.74054,-5.90895],[38.79977,-6.47566],[39.44,-6.84],[39.47,-7.1],[39.19469,-7.7039],[39.25203,-8.00781],[39.18652,-8.48551],[39.53574,-9.11237],[39.9496,-10.0984],[40.316586,-10.317098],[40.31659,-10.3171],[39.521,-10.89688],[38.427557,-11.285202],[37.82764,-11.26879],[37.47129,-11.56876],[36.775151,-11.594537],[36.514082,-11.720938],[35.312398,-11.439146],[34.559989,-11.52002],[34.28,-10.16],[33.940838,-9.693674],[33.73972,-9.41715],[32.759375,-9.230599],[32.191865,-8.930359],[31.556348,-8.762049],[31.157751,-8.594579],[30.74001,-8.340006],[30.740015,-8.340007],[30.199997,-7.079981],[29.620032,-6.520015],[29.419993,-5.939999],[29.519987,-5.419979],[29.339998,-4.499983],[29.753512,-4.452389],[30.11632,-4.09012],[30.50554,-3.56858],[30.75224,-3.35931],[30.74301,-3.03431],[30.52766,-2.80762],[30.469674,-2.413855],[30.46967,-2.41383],[30.758309,-2.28725],[30.816135,-1.698914],[30.419105,-1.134659],[30.76986,-1.01455],[31.86617,-1.02736],[33.903711,-0.95]]]}},{"type":"Feature","properties":{"ADMIN":"Western Sahara","NAME_EN":"Western Sahara","CONTINENT":"Africa","ADM0_A3":"SAH","ISO_A3":"ESH","NAME":"W. Sahara","ISO_A2":"EH","NAME_ZH":"西撒哈拉"},"geometry":{"type":"Polygon","coordinates":[[[-8.66559,27.656426],[-8.665124,27.589479],[-8.6844,27.395744],[-8.687294,25.881056],[-11.969419,25.933353],[-11.937224,23.374594],[-12.874222,23.284832],[-13.118754,22.77122],[-12.929102,21.327071],[-16.845194,21.333323],[-17.063423,20.999752],[-17.020428,21.42231],[-17.002962,21.420734],[-14.750955,21.5006],[-14.630833,21.86094],[-14.221168,22.310163],[-13.89111,23.691009],[-12.500963,24.770116],[-12.030759,26.030866],[-11.71822,26.104092],[-11.392555,26.883424],[-10.551263,26.990808],[-10.189424,26.860945],[-9.735343,26.860945],[-9.413037,27.088476],[-8.794884,27.120696],[-8.817828,27.656426],[-8.66559,27.656426]]]}},{"type":"Feature","properties":{"ADMIN":"Canada","NAME_EN":"Canada","CONTINENT":"North America","ADM0_A3":"CAN","ISO_A3":"CAN","NAME":"Canada","ISO_A2":"CA","NAME_ZH":"加拿大"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-122.97421,49.002538],[-124.91024,49.98456],[-125.62461,50.41656],[-127.43561,50.83061],[-127.99276,51.71583],[-127.85032,52.32961],[-129.12979,52.75538],[-129.30523,53.56159],[-130.51497,54.28757],[-130.536109,54.802754],[-130.53611,54.80278],[-129.98,55.285],[-130.00778,55.91583],[-131.70781,56.55212],[-132.73042,57.69289],[-133.35556,58.41028],[-134.27111,58.86111],[-134.945,59.27056],[-135.47583,59.78778],[-136.47972,59.46389],[-137.4525,58.905],[-138.34089,59.56211],[-139.039,60],[-140.013,60.27682],[-140.99778,60.30639],[-140.9925,66.00003],[-140.986,69.712],[-140.985988,69.711998],[-139.12052,69.47102],[-137.54636,68.99002],[-136.50358,68.89804],[-135.62576,69.31512],[-134.41464,69.62743],[-132.92925,69.50534],[-131.43136,69.94451],[-129.79471,70.19369],[-129.10773,69.77927],[-128.36156,70.01286],[-128.13817,70.48384],[-127.44712,70.37721],[-125.75632,69.48058],[-124.42483,70.1584],[-124.28968,69.39969],[-123.06108,69.56372],[-122.6835,69.85553],[-121.47226,69.79778],[-119.94288,69.37786],[-117.60268,69.01128],[-116.22643,68.84151],[-115.2469,68.90591],[-113.89794,68.3989],[-115.30489,67.90261],[-113.49727,67.68815],[-110.798,67.80612],[-109.94619,67.98104],[-108.8802,67.38144],[-107.79239,67.88736],[-108.81299,68.31164],[-108.16721,68.65392],[-106.95,68.7],[-106.15,68.8],[-105.34282,68.56122],[-104.33791,68.018],[-103.22115,68.09775],[-101.45433,67.64689],[-99.90195,67.80566],[-98.4432,67.78165],[-98.5586,68.40394],[-97.66948,68.57864],[-96.11991,68.23939],[-96.12588,67.29338],[-95.48943,68.0907],[-94.685,68.06383],[-94.23282,69.06903],[-95.30408,69.68571],[-96.47131,70.08976],[-96.39115,71.19482],[-95.2088,71.92053],[-93.88997,71.76015],[-92.87818,71.31869],[-91.51964,70.19129],[-92.40692,69.69997],[-90.5471,69.49766],[-90.55151,68.47499],[-89.21515,69.25873],[-88.01966,68.61508],[-88.31749,67.87338],[-87.35017,67.19872],[-86.30607,67.92146],[-85.57664,68.78456],[-85.52197,69.88211],[-84.10081,69.80539],[-82.62258,69.65826],[-81.28043,69.16202],[-81.2202,68.66567],[-81.96436,68.13253],[-81.25928,67.59716],[-81.38653,67.11078],[-83.34456,66.41154],[-84.73542,66.2573],[-85.76943,66.55833],[-86.0676,66.05625],[-87.03143,65.21297],[-87.32324,64.77563],[-88.48296,64.09897],[-89.91444,64.03273],[-90.70398,63.61017],[-90.77004,62.96021],[-91.93342,62.83508],[-93.15698,62.02469],[-94.24153,60.89865],[-94.62931,60.11021],[-94.6846,58.94882],[-93.21502,58.78212],[-92.76462,57.84571],[-92.29703,57.08709],[-90.89769,57.28468],[-89.03953,56.85172],[-88.03978,56.47162],[-87.32421,55.99914],[-86.07121,55.72383],[-85.01181,55.3026],[-83.36055,55.24489],[-82.27285,55.14832],[-82.4362,54.28227],[-82.12502,53.27703],[-81.40075,52.15788],[-79.91289,51.20842],[-79.14301,51.53393],[-78.60191,52.56208],[-79.12421,54.14145],[-79.82958,54.66772],[-78.22874,55.13645],[-77.0956,55.83741],[-76.54137,56.53423],[-76.62319,57.20263],[-77.30226,58.05209],[-78.51688,58.80458],[-77.33676,59.85261],[-77.77272,60.75788],[-78.10687,62.31964],[-77.41067,62.55053],[-75.69621,62.2784],[-74.6682,62.18111],[-73.83988,62.4438],[-72.90853,62.10507],[-71.67708,61.52535],[-71.37369,61.13717],[-69.59042,61.06141],[-69.62033,60.22125],[-69.2879,58.95736],[-68.37455,58.80106],[-67.64976,58.21206],[-66.20178,58.76731],[-65.24517,59.87071],[-64.58352,60.33558],[-63.80475,59.4426],[-62.50236,58.16708],[-61.39655,56.96745],[-61.79866,56.33945],[-60.46853,55.77548],[-59.56962,55.20407],[-57.97508,54.94549],[-57.3332,54.6265],[-56.93689,53.78032],[-56.15811,53.64749],[-55.75632,53.27036],[-55.68338,52.14664],[-56.40916,51.7707],[-57.12691,51.41972],[-58.77482,51.0643],[-60.03309,50.24277],[-61.72366,50.08046],[-63.86251,50.29099],[-65.36331,50.2982],[-66.39905,50.22897],[-67.23631,49.51156],[-68.51114,49.06836],[-69.95362,47.74488],[-71.10458,46.82171],[-70.25522,46.98606],[-68.65,48.3],[-66.55243,49.1331],[-65.05626,49.23278],[-64.17099,48.74248],[-65.11545,48.07085],[-64.79854,46.99297],[-64.47219,46.23849],[-63.17329,45.73902],[-61.52072,45.88377],[-60.51815,47.00793],[-60.4486,46.28264],[-59.80287,45.9204],[-61.03988,45.26525],[-63.25471,44.67014],[-64.24656,44.26553],[-65.36406,43.54523],[-66.1234,43.61867],[-66.16173,44.46512],[-64.42549,45.29204],[-66.02605,45.25931],[-67.13741,45.13753],[-67.79134,45.70281],[-67.79046,47.06636],[-68.23444,47.35486],[-68.905,47.185],[-69.237216,47.447781],[-69.99997,46.69307],[-70.305,45.915],[-70.66,45.46],[-71.08482,45.30524],[-71.405,45.255],[-71.50506,45.0082],[-73.34783,45.00738],[-74.867,45.00048],[-75.31821,44.81645],[-76.375,44.09631],[-76.5,44.018459],[-76.820034,43.628784],[-77.737885,43.629056],[-78.72028,43.625089],[-79.171674,43.466339],[-79.01,43.27],[-78.92,42.965],[-78.939362,42.863611],[-80.247448,42.3662],[-81.277747,42.209026],[-82.439278,41.675105],[-82.690089,41.675105],[-83.02981,41.832796],[-83.142,41.975681],[-83.12,42.08],[-82.9,42.43],[-82.43,42.98],[-82.137642,43.571088],[-82.337763,44.44],[-82.550925,45.347517],[-83.592851,45.816894],[-83.469551,45.994686],[-83.616131,46.116927],[-83.890765,46.116927],[-84.091851,46.275419],[-84.14212,46.512226],[-84.3367,46.40877],[-84.6049,46.4396],[-84.543749,46.538684],[-84.779238,46.637102],[-84.87608,46.900083],[-85.652363,47.220219],[-86.461991,47.553338],[-87.439793,47.94],[-88.378114,48.302918],[-89.272917,48.019808],[-89.6,48.01],[-90.83,48.27],[-91.64,48.14],[-92.61,48.45],[-93.63087,48.60926],[-94.32914,48.67074],[-94.64,48.84],[-94.81758,49.38905],[-95.15609,49.38425],[-95.15907,49],[-97.22872,49.0007],[-100.65,49],[-104.04826,48.99986],[-107.05,49],[-110.05,49],[-113,49],[-116.04818,49],[-117.03121,49],[-120,49],[-122.84,49]]],[[[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528]]],[[[-79.775833,72.802902],[-80.876099,73.333183],[-80.833885,73.693184],[-80.353058,73.75972],[-78.064438,73.651932],[-76.34,73.102685],[-76.251404,72.826385],[-77.314438,72.855545],[-78.39167,72.876656],[-79.486252,72.742203],[-79.775833,72.802902]]],[[[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565]]],[[[-93.612756,74.979997],[-94.156909,74.592347],[-95.608681,74.666864],[-96.820932,74.927623],[-96.288587,75.377828],[-94.85082,75.647218],[-93.977747,75.29649],[-93.612756,74.979997]]],[[[-93.840003,77.519997],[-94.295608,77.491343],[-96.169654,77.555111],[-96.436304,77.834629],[-94.422577,77.820005],[-93.720656,77.634331],[-93.840003,77.519997]]],[[[-96.754399,78.765813],[-95.559278,78.418315],[-95.830295,78.056941],[-97.309843,77.850597],[-98.124289,78.082857],[-98.552868,78.458105],[-98.631984,78.87193],[-97.337231,78.831984],[-96.754399,78.765813]]],[[[-88.15035,74.392307],[-89.764722,74.515555],[-92.422441,74.837758],[-92.768285,75.38682],[-92.889906,75.882655],[-93.893824,76.319244],[-95.962457,76.441381],[-97.121379,76.751078],[-96.745123,77.161389],[-94.684086,77.097878],[-93.573921,76.776296],[-91.605023,76.778518],[-90.741846,76.449597],[-90.969661,76.074013],[-89.822238,75.847774],[-89.187083,75.610166],[-87.838276,75.566189],[-86.379192,75.482421],[-84.789625,75.699204],[-82.753445,75.784315],[-81.128531,75.713983],[-80.057511,75.336849],[-79.833933,74.923127],[-80.457771,74.657304],[-81.948843,74.442459],[-83.228894,74.564028],[-86.097452,74.410032],[-88.15035,74.392307]]],[[[-111.264443,78.152956],[-109.854452,77.996325],[-110.186938,77.697015],[-112.051191,77.409229],[-113.534279,77.732207],[-112.724587,78.05105],[-111.264443,78.152956]]],[[[-110.963661,78.804441],[-109.663146,78.601973],[-110.881314,78.40692],[-112.542091,78.407902],[-112.525891,78.550555],[-111.50001,78.849994],[-110.963661,78.804441]]],[[[-55.600218,51.317075],[-56.134036,50.68701],[-56.795882,49.812309],[-56.143105,50.150117],[-55.471492,49.935815],[-55.822401,49.587129],[-54.935143,49.313011],[-54.473775,49.556691],[-53.476549,49.249139],[-53.786014,48.516781],[-53.086134,48.687804],[-52.958648,48.157164],[-52.648099,47.535548],[-53.069158,46.655499],[-53.521456,46.618292],[-54.178936,46.807066],[-53.961869,47.625207],[-54.240482,47.752279],[-55.400773,46.884994],[-55.997481,46.91972],[-55.291219,47.389562],[-56.250799,47.632545],[-57.325229,47.572807],[-59.266015,47.603348],[-59.419494,47.899454],[-58.796586,48.251525],[-59.231625,48.523188],[-58.391805,49.125581],[-57.35869,50.718274],[-56.73865,51.287438],[-55.870977,51.632094],[-55.406974,51.588273],[-55.600218,51.317075]]],[[[-83.882626,65.109618],[-82.787577,64.766693],[-81.642014,64.455136],[-81.55344,63.979609],[-80.817361,64.057486],[-80.103451,63.725981],[-80.99102,63.411246],[-82.547178,63.651722],[-83.108798,64.101876],[-84.100417,63.569712],[-85.523405,63.052379],[-85.866769,63.637253],[-87.221983,63.541238],[-86.35276,64.035833],[-86.224886,64.822917],[-85.883848,65.738778],[-85.161308,65.657285],[-84.975764,65.217518],[-84.464012,65.371772],[-83.882626,65.109618]]],[[[-78.770639,72.352173],[-77.824624,72.749617],[-75.605845,72.243678],[-74.228616,71.767144],[-74.099141,71.33084],[-72.242226,71.556925],[-71.200015,70.920013],[-68.786054,70.525024],[-67.91497,70.121948],[-66.969033,69.186087],[-68.805123,68.720198],[-66.449866,68.067163],[-64.862314,67.847539],[-63.424934,66.928473],[-61.851981,66.862121],[-62.163177,66.160251],[-63.918444,64.998669],[-65.14886,65.426033],[-66.721219,66.388041],[-68.015016,66.262726],[-68.141287,65.689789],[-67.089646,65.108455],[-65.73208,64.648406],[-65.320168,64.382737],[-64.669406,63.392927],[-65.013804,62.674185],[-66.275045,62.945099],[-68.783186,63.74567],[-67.369681,62.883966],[-66.328297,62.280075],[-66.165568,61.930897],[-68.877367,62.330149],[-71.023437,62.910708],[-72.235379,63.397836],[-71.886278,63.679989],[-73.378306,64.193963],[-74.834419,64.679076],[-74.818503,64.389093],[-77.70998,64.229542],[-78.555949,64.572906],[-77.897281,65.309192],[-76.018274,65.326969],[-73.959795,65.454765],[-74.293883,65.811771],[-73.944912,66.310578],[-72.651167,67.284576],[-72.92606,67.726926],[-73.311618,68.069437],[-74.843307,68.554627],[-76.869101,68.894736],[-76.228649,69.147769],[-77.28737,69.76954],[-78.168634,69.826488],[-78.957242,70.16688],[-79.492455,69.871808],[-81.305471,69.743185],[-84.944706,69.966634],[-87.060003,70.260001],[-88.681713,70.410741],[-89.51342,70.762038],[-88.467721,71.218186],[-89.888151,71.222552],[-90.20516,72.235074],[-89.436577,73.129464],[-88.408242,73.537889],[-85.826151,73.803816],[-86.562179,73.157447],[-85.774371,72.534126],[-84.850112,73.340278],[-82.31559,73.750951],[-80.600088,72.716544],[-80.748942,72.061907],[-78.770639,72.352173]]],[[[-94.503658,74.134907],[-92.420012,74.100025],[-90.509793,73.856732],[-92.003965,72.966244],[-93.196296,72.771992],[-94.269047,72.024596],[-95.409856,72.061881],[-96.033745,72.940277],[-96.018268,73.43743],[-95.495793,73.862417],[-94.503658,74.134907]]],[[[-122.854924,76.116543],[-122.854925,76.116543],[-121.157535,76.864508],[-119.103939,77.51222],[-117.570131,77.498319],[-116.198587,77.645287],[-116.335813,76.876962],[-117.106051,76.530032],[-118.040412,76.481172],[-119.899318,76.053213],[-121.499995,75.900019],[-122.854924,76.116543]]],[[[-132.710008,54.040009],[-131.74999,54.120004],[-132.04948,52.984621],[-131.179043,52.180433],[-131.57783,52.182371],[-132.180428,52.639707],[-132.549992,53.100015],[-133.054611,53.411469],[-133.239664,53.85108],[-133.180004,54.169975],[-132.710008,54.040009]]],[[[-105.492289,79.301594],[-103.529282,79.165349],[-100.825158,78.800462],[-100.060192,78.324754],[-99.670939,77.907545],[-101.30394,78.018985],[-102.949809,78.343229],[-105.176133,78.380332],[-104.210429,78.67742],[-105.41958,78.918336],[-105.492289,79.301594]]],[[[-123.510002,48.510011],[-124.012891,48.370846],[-125.655013,48.825005],[-125.954994,49.179996],[-126.850004,49.53],[-127.029993,49.814996],[-128.059336,49.994959],[-128.444584,50.539138],[-128.358414,50.770648],[-127.308581,50.552574],[-126.695001,50.400903],[-125.755007,50.295018],[-125.415002,49.950001],[-124.920768,49.475275],[-123.922509,49.062484],[-123.510002,48.510011]]],[[[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.383602],[-123.09219,70.90164],[-123.62,71.34],[-125.928949,71.868688],[-125.5,72.292261],[-124.80729,73.02256],[-123.94,73.68],[-124.91775,74.29275],[-121.53788,74.44893]]],[[[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168],[-107.81943,75.84552]]],[[[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984],[-104.46476,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447],[-102.73116,69.50402],[-102.09329,69.11962],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18],[-107.12254,69.11922],[-109,68.78],[-111.534149,68.630059],[-113.3132,68.53554],[-113.85496,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459],[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.9554],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601]]],[[[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588]]],[[[-106.6,73.6],[-105.26,73.64],[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6]]],[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]],[[[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.509322],[-87.81,80.32],[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233]]],[[[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.652273],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.172059],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.169781],[-68.5,83.106322],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.430162],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.970222],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.408696,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429]]],[[[-75.21597,67.44425],[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425]]],[[[-96.257401,69.49003],[-95.647681,69.10769],[-96.269521,68.75704],[-97.617401,69.06003],[-98.431801,68.9507],[-99.797401,69.40003],[-98.917401,69.71003],[-98.218261,70.14354],[-97.157401,69.86003],[-96.557401,69.68003],[-96.257401,69.49003]]],[[[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304]]],[[[-64.01486,47.03601],[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601]]]]}},{"type":"Feature","properties":{"ADMIN":"United States of America","NAME_EN":"United States of America","CONTINENT":"North America","ADM0_A3":"USA","ISO_A3":"USA","NAME":"United States of America","ISO_A2":"US","NAME_ZH":"美国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-120,49],[-117.03121,49],[-116.04818,49],[-113,49],[-110.05,49],[-107.05,49],[-104.04826,48.99986],[-100.65,49],[-97.22872,49.0007],[-95.15907,49],[-95.15609,49.38425],[-94.81758,49.38905],[-94.64,48.84],[-94.32914,48.67074],[-93.63087,48.60926],[-92.61,48.45],[-91.64,48.14],[-90.83,48.27],[-89.6,48.01],[-89.272917,48.019808],[-88.378114,48.302918],[-87.439793,47.94],[-86.461991,47.553338],[-85.652363,47.220219],[-84.87608,46.900083],[-84.779238,46.637102],[-84.543749,46.538684],[-84.6049,46.4396],[-84.3367,46.40877],[-84.14212,46.512226],[-84.091851,46.275419],[-83.890765,46.116927],[-83.616131,46.116927],[-83.469551,45.994686],[-83.592851,45.816894],[-82.550925,45.347517],[-82.337763,44.44],[-82.137642,43.571088],[-82.43,42.98],[-82.9,42.43],[-83.12,42.08],[-83.142,41.975681],[-83.02981,41.832796],[-82.690089,41.675105],[-82.439278,41.675105],[-81.277747,42.209026],[-80.247448,42.3662],[-78.939362,42.863611],[-78.92,42.965],[-79.01,43.27],[-79.171674,43.466339],[-78.72028,43.625089],[-77.737885,43.629056],[-76.820034,43.628784],[-76.5,44.018459],[-76.375,44.09631],[-75.31821,44.81645],[-74.867,45.00048],[-73.34783,45.00738],[-71.50506,45.0082],[-71.405,45.255],[-71.08482,45.30524],[-70.66,45.46],[-70.305,45.915],[-69.99997,46.69307],[-69.237216,47.447781],[-68.905,47.185],[-68.23444,47.35486],[-67.79046,47.06636],[-67.79134,45.70281],[-67.13741,45.13753],[-66.96466,44.8097],[-68.03252,44.3252],[-69.06,43.98],[-70.11617,43.68405],[-70.645476,43.090238],[-70.81489,42.8653],[-70.825,42.335],[-70.495,41.805],[-70.08,41.78],[-70.185,42.145],[-69.88497,41.92283],[-69.96503,41.63717],[-70.64,41.475],[-71.12039,41.49445],[-71.86,41.32],[-72.295,41.27],[-72.87643,41.22065],[-73.71,40.931102],[-72.24126,41.11948],[-71.945,40.93],[-73.345,40.63],[-73.982,40.628],[-73.952325,40.75075],[-74.25671,40.47351],[-73.96244,40.42763],[-74.17838,39.70926],[-74.90604,38.93954],[-74.98041,39.1964],[-75.20002,39.24845],[-75.52805,39.4985],[-75.32,38.96],[-75.071835,38.782032],[-75.05673,38.40412],[-75.37747,38.01551],[-75.94023,37.21689],[-76.03127,37.2566],[-75.72205,37.93705],[-76.23287,38.319215],[-76.35,39.15],[-76.542725,38.717615],[-76.32933,38.08326],[-76.989998,38.239992],[-76.30162,37.917945],[-76.25874,36.9664],[-75.9718,36.89726],[-75.86804,36.55125],[-75.72749,35.55074],[-76.36318,34.80854],[-77.397635,34.51201],[-78.05496,33.92547],[-78.55435,33.86133],[-79.06067,33.49395],[-79.20357,33.15839],[-80.301325,32.509355],[-80.86498,32.0333],[-81.33629,31.44049],[-81.49042,30.72999],[-81.31371,30.03552],[-80.98,29.18],[-80.535585,28.47213],[-80.53,28.04],[-80.056539,26.88],[-80.088015,26.205765],[-80.13156,25.816775],[-80.38103,25.20616],[-80.68,25.08],[-81.17213,25.20126],[-81.33,25.64],[-81.71,25.87],[-82.24,26.73],[-82.70515,27.49504],[-82.85526,27.88624],[-82.65,28.55],[-82.93,29.1],[-83.70959,29.93656],[-84.1,30.09],[-85.10882,29.63615],[-85.28784,29.68612],[-85.7731,30.15261],[-86.4,30.4],[-87.53036,30.27433],[-88.41782,30.3849],[-89.18049,30.31598],[-89.593831,30.159994],[-89.413735,29.89419],[-89.43,29.48864],[-89.21767,29.29108],[-89.40823,29.15961],[-89.77928,29.30714],[-90.15463,29.11743],[-90.880225,29.148535],[-91.626785,29.677],[-92.49906,29.5523],[-93.22637,29.78375],[-93.84842,29.71363],[-94.69,29.48],[-95.60026,28.73863],[-96.59404,28.30748],[-97.14,27.83],[-97.37,27.38],[-97.38,26.69],[-97.33,26.21],[-97.14,25.87],[-97.53,25.84],[-98.24,26.06],[-99.02,26.37],[-99.3,26.84],[-99.52,27.54],[-100.11,28.11],[-100.45584,28.69612],[-100.9576,29.38071],[-101.6624,29.7793],[-102.48,29.76],[-103.11,28.97],[-103.94,29.27],[-104.45697,29.57196],[-104.70575,30.12173],[-105.03737,30.64402],[-105.63159,31.08383],[-106.1429,31.39995],[-106.50759,31.75452],[-108.24,31.754854],[-108.24194,31.34222],[-109.035,31.34194],[-111.02361,31.33472],[-113.30498,32.03914],[-114.815,32.52528],[-114.72139,32.72083],[-115.99135,32.61239],[-117.12776,32.53534],[-117.295938,33.046225],[-117.944,33.621236],[-118.410602,33.740909],[-118.519895,34.027782],[-119.081,34.078],[-119.438841,34.348477],[-120.36778,34.44711],[-120.62286,34.60855],[-120.74433,35.15686],[-121.71457,36.16153],[-122.54747,37.55176],[-122.51201,37.78339],[-122.95319,38.11371],[-123.7272,38.95166],[-123.86517,39.76699],[-124.39807,40.3132],[-124.17886,41.14202],[-124.2137,41.99964],[-124.53284,42.76599],[-124.14214,43.70838],[-124.020535,44.615895],[-123.89893,45.52341],[-124.079635,46.86475],[-124.39567,47.72017],[-124.68721,48.184433],[-124.566101,48.379715],[-123.12,48.04],[-122.58736,47.096],[-122.34,47.36],[-122.5,48.18],[-122.84,49]]],[[[-155.40214,20.07975],[-155.22452,19.99302],[-155.06226,19.8591],[-154.80741,19.50871],[-154.83147,19.45328],[-155.22217,19.23972],[-155.54211,19.08348],[-155.68817,18.91619],[-155.93665,19.05939],[-155.90806,19.33888],[-156.07347,19.70294],[-156.02368,19.81422],[-155.85008,19.97729],[-155.91907,20.17395],[-155.86108,20.26721],[-155.78505,20.2487],[-155.40214,20.07975]]],[[[-155.99566,20.76404],[-156.07926,20.64397],[-156.41445,20.57241],[-156.58673,20.783],[-156.70167,20.8643],[-156.71055,20.92676],[-156.61258,21.01249],[-156.25711,20.91745],[-155.99566,20.76404]]],[[[-156.75824,21.17684],[-156.78933,21.06873],[-157.32521,21.09777],[-157.25027,21.21958],[-156.75824,21.17684]]],[[[-158.0252,21.71696],[-157.94161,21.65272],[-157.65283,21.32217],[-157.70703,21.26442],[-157.7786,21.27729],[-158.12667,21.31244],[-158.2538,21.53919],[-158.29265,21.57912],[-158.0252,21.71696]]],[[[-159.36569,22.21494],[-159.34512,21.982],[-159.46372,21.88299],[-159.80051,22.06533],[-159.74877,22.1382],[-159.5962,22.23618],[-159.36569,22.21494]]],[[[-166.467792,60.38417],[-165.67443,60.293607],[-165.579164,59.909987],[-166.19277,59.754441],[-166.848337,59.941406],[-167.455277,60.213069],[-166.467792,60.38417]]],[[[-153.228729,57.968968],[-152.564791,57.901427],[-152.141147,57.591059],[-153.006314,57.115842],[-154.00509,56.734677],[-154.516403,56.992749],[-154.670993,57.461196],[-153.76278,57.816575],[-153.228729,57.968968]]],[[[-140.985988,69.711998],[-140.986,69.712],[-140.9925,66.00003],[-140.99778,60.30639],[-140.013,60.27682],[-139.039,60],[-138.34089,59.56211],[-137.4525,58.905],[-136.47972,59.46389],[-135.47583,59.78778],[-134.945,59.27056],[-134.27111,58.86111],[-133.35556,58.41028],[-132.73042,57.69289],[-131.70781,56.55212],[-130.00778,55.91583],[-129.98,55.285],[-130.53611,54.80278],[-130.536109,54.802754],[-130.53611,54.802753],[-131.085818,55.178906],[-131.967211,55.497776],[-132.250011,56.369996],[-133.539181,57.178887],[-134.078063,58.123068],[-135.038211,58.187715],[-136.628062,58.212209],[-137.800006,58.499995],[-139.867787,59.537762],[-140.825274,59.727517],[-142.574444,60.084447],[-143.958881,59.99918],[-145.925557,60.45861],[-147.114374,60.884656],[-148.224306,60.672989],[-148.018066,59.978329],[-148.570823,59.914173],[-149.727858,59.705658],[-150.608243,59.368211],[-151.716393,59.155821],[-151.859433,59.744984],[-151.409719,60.725803],[-150.346941,61.033588],[-150.621111,61.284425],[-151.895839,60.727198],[-152.57833,60.061657],[-154.019172,59.350279],[-153.287511,58.864728],[-154.232492,58.146374],[-155.307491,57.727795],[-156.308335,57.422774],[-156.556097,56.979985],[-158.117217,56.463608],[-158.433321,55.994154],[-159.603327,55.566686],[-160.28972,55.643581],[-161.223048,55.364735],[-162.237766,55.024187],[-163.069447,54.689737],[-164.785569,54.404173],[-164.942226,54.572225],[-163.84834,55.039431],[-162.870001,55.348043],[-161.804175,55.894986],[-160.563605,56.008055],[-160.07056,56.418055],[-158.684443,57.016675],[-158.461097,57.216921],[-157.72277,57.570001],[-157.550274,58.328326],[-157.041675,58.918885],[-158.194731,58.615802],[-158.517218,58.787781],[-159.058606,58.424186],[-159.711667,58.93139],[-159.981289,58.572549],[-160.355271,59.071123],[-161.355003,58.670838],[-161.968894,58.671665],[-162.054987,59.266925],[-161.874171,59.633621],[-162.518059,59.989724],[-163.818341,59.798056],[-164.662218,60.267484],[-165.346388,60.507496],[-165.350832,61.073895],[-166.121379,61.500019],[-165.734452,62.074997],[-164.919179,62.633076],[-164.562508,63.146378],[-163.753332,63.219449],[-163.067224,63.059459],[-162.260555,63.541936],[-161.53445,63.455817],[-160.772507,63.766108],[-160.958335,64.222799],[-161.518068,64.402788],[-160.777778,64.788604],[-161.391926,64.777235],[-162.45305,64.559445],[-162.757786,64.338605],[-163.546394,64.55916],[-164.96083,64.446945],[-166.425288,64.686672],[-166.845004,65.088896],[-168.11056,65.669997],[-166.705271,66.088318],[-164.47471,66.57666],[-163.652512,66.57666],[-163.788602,66.077207],[-161.677774,66.11612],[-162.489715,66.735565],[-163.719717,67.116395],[-164.430991,67.616338],[-165.390287,68.042772],[-166.764441,68.358877],[-166.204707,68.883031],[-164.430811,68.915535],[-163.168614,69.371115],[-162.930566,69.858062],[-161.908897,70.33333],[-160.934797,70.44769],[-159.039176,70.891642],[-158.119723,70.824721],[-156.580825,71.357764],[-155.06779,71.147776],[-154.344165,70.696409],[-153.900006,70.889989],[-152.210006,70.829992],[-152.270002,70.600006],[-150.739992,70.430017],[-149.720003,70.53001],[-147.613362,70.214035],[-145.68999,70.12001],[-144.920011,69.989992],[-143.589446,70.152514],[-142.07251,69.851938],[-140.985988,69.711998],[-140.985988,69.711998]]],[[[-171.731657,63.782515],[-171.114434,63.592191],[-170.491112,63.694975],[-169.682505,63.431116],[-168.689439,63.297506],[-168.771941,63.188598],[-169.52944,62.976931],[-170.290556,63.194438],[-170.671386,63.375822],[-171.553063,63.317789],[-171.791111,63.405846],[-171.731657,63.782515]]]]}},{"type":"Feature","properties":{"ADMIN":"Kazakhstan","NAME_EN":"Kazakhstan","CONTINENT":"Asia","ADM0_A3":"KAZ","ISO_A3":"KAZ","NAME":"Kazakhstan","ISO_A2":"KZ","NAME_ZH":"哈萨克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[87.35997,49.214981],[86.598776,48.549182],[85.768233,48.455751],[85.720484,47.452969],[85.16429,47.000956],[83.180484,47.330031],[82.458926,45.53965],[81.947071,45.317027],[79.966106,44.917517],[80.866206,43.180362],[80.18015,42.920068],[80.25999,42.349999],[79.643645,42.496683],[79.142177,42.856092],[77.658392,42.960686],[76.000354,42.988022],[75.636965,42.8779],[74.212866,43.298339],[73.645304,43.091272],[73.489758,42.500894],[71.844638,42.845395],[71.186281,42.704293],[70.962315,42.266154],[70.388965,42.081308],[69.070027,41.384244],[68.632483,40.668681],[68.259896,40.662325],[67.985856,41.135991],[66.714047,41.168444],[66.510649,41.987644],[66.023392,41.994646],[66.098012,42.99766],[64.900824,43.728081],[63.185787,43.650075],[62.0133,43.504477],[61.05832,44.405817],[60.239972,44.784037],[58.689989,45.500014],[58.503127,45.586804],[55.928917,44.995858],[55.968191,41.308642],[55.455251,41.259859],[54.755345,42.043971],[54.079418,42.324109],[52.944293,42.116034],[52.50246,41.783316],[52.446339,42.027151],[52.692112,42.443895],[52.501426,42.792298],[51.342427,43.132975],[50.891292,44.031034],[50.339129,44.284016],[50.305643,44.609836],[51.278503,44.514854],[51.316899,45.245998],[52.16739,45.408391],[53.040876,45.259047],[53.220866,46.234646],[53.042737,46.853006],[52.042023,46.804637],[51.191945,47.048705],[50.034083,46.60899],[49.10116,46.39933],[48.59325,46.56104],[48.694734,47.075628],[48.05725,47.74377],[47.31524,47.71585],[46.466446,48.394152],[47.043672,49.152039],[46.751596,49.356006],[47.54948,50.454698],[48.577841,49.87476],[48.702382,50.605128],[50.766648,51.692762],[52.328724,51.718652],[54.532878,51.02624],[55.71694,50.62171],[56.77798,51.04355],[58.36332,51.06364],[59.642282,50.545442],[59.932807,50.842194],[61.337424,50.79907],[61.588003,51.272659],[59.967534,51.96042],[60.927269,52.447548],[60.739993,52.719986],[61.699986,52.979996],[60.978066,53.664993],[61.4366,54.00625],[65.178534,54.354228],[65.66687,54.60125],[68.1691,54.970392],[69.068167,55.38525],[70.865267,55.169734],[71.180131,54.133285],[72.22415,54.376655],[73.508516,54.035617],[73.425679,53.48981],[74.38482,53.54685],[76.8911,54.490524],[76.525179,54.177003],[77.800916,53.404415],[80.03556,50.864751],[80.568447,51.388336],[81.945986,50.812196],[83.383004,51.069183],[83.935115,50.889246],[84.416377,50.3114],[85.11556,50.117303],[85.54127,49.692859],[86.829357,49.826675],[87.35997,49.214981]]]}},{"type":"Feature","properties":{"ADMIN":"Uzbekistan","NAME_EN":"Uzbekistan","CONTINENT":"Asia","ADM0_A3":"UZB","ISO_A3":"UZB","NAME":"Uzbekistan","ISO_A2":"UZ","NAME_ZH":"乌兹别克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[55.968191,41.308642],[55.928917,44.995858],[58.503127,45.586804],[58.689989,45.500014],[60.239972,44.784037],[61.05832,44.405817],[62.0133,43.504477],[63.185787,43.650075],[64.900824,43.728081],[66.098012,42.99766],[66.023392,41.994646],[66.510649,41.987644],[66.714047,41.168444],[67.985856,41.135991],[68.259896,40.662325],[68.632483,40.668681],[69.070027,41.384244],[70.388965,42.081308],[70.962315,42.266154],[71.259248,42.167711],[70.420022,41.519998],[71.157859,41.143587],[71.870115,41.3929],[73.055417,40.866033],[71.774875,40.145844],[71.014198,40.244366],[70.601407,40.218527],[70.45816,40.496495],[70.666622,40.960213],[69.329495,40.727824],[69.011633,40.086158],[68.536416,39.533453],[67.701429,39.580478],[67.44222,39.140144],[68.176025,38.901553],[68.392033,38.157025],[67.83,37.144994],[67.075782,37.356144],[66.518607,37.362784],[66.54615,37.974685],[65.215999,38.402695],[64.170223,38.892407],[63.518015,39.363257],[62.37426,40.053886],[61.882714,41.084857],[61.547179,41.26637],[60.465953,41.220327],[60.083341,41.425146],[59.976422,42.223082],[58.629011,42.751551],[57.78653,42.170553],[56.932215,41.826026],[57.096391,41.32231],[55.968191,41.308642]]]}},{"type":"Feature","properties":{"ADMIN":"Papua New Guinea","NAME_EN":"Papua New Guinea","CONTINENT":"Oceania","ADM0_A3":"PNG","ISO_A3":"PNG","NAME":"Papua New Guinea","ISO_A2":"PG","NAME_ZH":"巴布亚新几内亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.00021,-2.600151],[142.735247,-3.289153],[144.583971,-3.861418],[145.27318,-4.373738],[145.829786,-4.876498],[145.981922,-5.465609],[147.648073,-6.083659],[147.891108,-6.614015],[146.970905,-6.721657],[147.191874,-7.388024],[148.084636,-8.044108],[148.734105,-9.104664],[149.306835,-9.071436],[149.266631,-9.514406],[150.038728,-9.684318],[149.738798,-9.872937],[150.801628,-10.293687],[150.690575,-10.582713],[150.028393,-10.652476],[149.78231,-10.393267],[148.923138,-10.280923],[147.913018,-10.130441],[147.135443,-9.492444],[146.567881,-8.942555],[146.048481,-8.067414],[144.744168,-7.630128],[143.897088,-7.91533],[143.286376,-8.245491],[143.413913,-8.983069],[142.628431,-9.326821],[142.068259,-9.159596],[141.033852,-9.117893],[141.017057,-5.859022],[141.00021,-2.600151]]],[[[152.640017,-3.659983],[153.019994,-3.980015],[153.140038,-4.499983],[152.827292,-4.766427],[152.638673,-4.176127],[152.406026,-3.789743],[151.953237,-3.462062],[151.384279,-3.035422],[150.66205,-2.741486],[150.939965,-2.500002],[151.479984,-2.779985],[151.820015,-2.999972],[152.239989,-3.240009],[152.640017,-3.659983]]],[[[151.30139,-5.840728],[150.754447,-6.083763],[150.241197,-6.317754],[149.709963,-6.316513],[148.890065,-6.02604],[148.318937,-5.747142],[148.401826,-5.437756],[149.298412,-5.583742],[149.845562,-5.505503],[149.99625,-5.026101],[150.139756,-5.001348],[150.236908,-5.53222],[150.807467,-5.455842],[151.089672,-5.113693],[151.647881,-4.757074],[151.537862,-4.167807],[152.136792,-4.14879],[152.338743,-4.312966],[152.318693,-4.867661],[151.982796,-5.478063],[151.459107,-5.56028],[151.30139,-5.840728]]],[[[154.759991,-5.339984],[155.062918,-5.566792],[155.547746,-6.200655],[156.019965,-6.540014],[155.880026,-6.819997],[155.599991,-6.919991],[155.166994,-6.535931],[154.729192,-5.900828],[154.514114,-5.139118],[154.652504,-5.042431],[154.759991,-5.339984]]]]}},{"type":"Feature","properties":{"ADMIN":"Indonesia","NAME_EN":"Indonesia","CONTINENT":"Asia","ADM0_A3":"IDN","ISO_A3":"IDN","NAME":"Indonesia","ISO_A2":"ID","NAME_ZH":"印度尼西亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.00021,-2.600151],[141.017057,-5.859022],[141.033852,-9.117893],[140.143415,-8.297168],[139.127767,-8.096043],[138.881477,-8.380935],[137.614474,-8.411683],[138.039099,-7.597882],[138.668621,-7.320225],[138.407914,-6.232849],[137.92784,-5.393366],[135.98925,-4.546544],[135.164598,-4.462931],[133.66288,-3.538853],[133.367705,-4.024819],[132.983956,-4.112979],[132.756941,-3.746283],[132.753789,-3.311787],[131.989804,-2.820551],[133.066845,-2.460418],[133.780031,-2.479848],[133.696212,-2.214542],[132.232373,-2.212526],[131.836222,-1.617162],[130.94284,-1.432522],[130.519558,-0.93772],[131.867538,-0.695461],[132.380116,-0.369538],[133.985548,-0.78021],[134.143368,-1.151867],[134.422627,-2.769185],[135.457603,-3.367753],[136.293314,-2.307042],[137.440738,-1.703513],[138.329727,-1.702686],[139.184921,-2.051296],[139.926684,-2.409052],[141.00021,-2.600151]]],[[[124.968682,-8.89279],[125.07002,-9.089987],[125.08852,-9.393173],[124.43595,-10.140001],[123.579982,-10.359987],[123.459989,-10.239995],[123.550009,-9.900016],[123.980009,-9.290027],[124.968682,-8.89279]]],[[[134.210134,-6.895238],[134.112776,-6.142467],[134.290336,-5.783058],[134.499625,-5.445042],[134.727002,-5.737582],[134.724624,-6.214401],[134.210134,-6.895238]]],[[[117.882035,4.137551],[117.313232,3.234428],[118.04833,2.28769],[117.875627,1.827641],[118.996747,0.902219],[117.811858,0.784242],[117.478339,0.102475],[117.521644,-0.803723],[116.560048,-1.487661],[116.533797,-2.483517],[116.148084,-4.012726],[116.000858,-3.657037],[114.864803,-4.106984],[114.468652,-3.495704],[113.755672,-3.43917],[113.256994,-3.118776],[112.068126,-3.478392],[111.703291,-2.994442],[111.04824,-3.049426],[110.223846,-2.934032],[110.070936,-1.592874],[109.571948,-1.314907],[109.091874,-0.459507],[108.952658,0.415375],[109.069136,1.341934],[109.66326,2.006467],[109.830227,1.338136],[110.514061,0.773131],[111.159138,0.976478],[111.797548,0.904441],[112.380252,1.410121],[112.859809,1.49779],[113.80585,1.217549],[114.621355,1.430688],[115.134037,2.821482],[115.519078,3.169238],[115.865517,4.306559],[117.015214,4.306094],[117.882035,4.137551]]],[[[129.370998,-2.802154],[130.471344,-3.093764],[130.834836,-3.858472],[129.990547,-3.446301],[129.155249,-3.362637],[128.590684,-3.428679],[127.898891,-3.393436],[128.135879,-2.84365],[129.370998,-2.802154]]],[[[126.874923,-3.790983],[126.183802,-3.607376],[125.989034,-3.177273],[127.000651,-3.129318],[127.249215,-3.459065],[126.874923,-3.790983]]],[[[127.932378,2.174596],[128.004156,1.628531],[128.594559,1.540811],[128.688249,1.132386],[128.635952,0.258486],[128.12017,0.356413],[127.968034,-0.252077],[128.379999,-0.780004],[128.100016,-0.899996],[127.696475,-0.266598],[127.39949,1.011722],[127.600512,1.810691],[127.932378,2.174596]]],[[[122.927567,0.875192],[124.077522,0.917102],[125.065989,1.643259],[125.240501,1.419836],[124.437035,0.427881],[123.685505,0.235593],[122.723083,0.431137],[121.056725,0.381217],[120.183083,0.237247],[120.04087,-0.519658],[120.935905,-1.408906],[121.475821,-0.955962],[123.340565,-0.615673],[123.258399,-1.076213],[122.822715,-0.930951],[122.38853,-1.516858],[121.508274,-1.904483],[122.454572,-3.186058],[122.271896,-3.5295],[123.170963,-4.683693],[123.162333,-5.340604],[122.628515,-5.634591],[122.236394,-5.282933],[122.719569,-4.464172],[121.738234,-4.851331],[121.489463,-4.574553],[121.619171,-4.188478],[120.898182,-3.602105],[120.972389,-2.627643],[120.305453,-2.931604],[120.390047,-4.097579],[120.430717,-5.528241],[119.796543,-5.6734],[119.366906,-5.379878],[119.653606,-4.459417],[119.498835,-3.494412],[119.078344,-3.487022],[118.767769,-2.801999],[119.180974,-2.147104],[119.323394,-1.353147],[119.825999,0.154254],[120.035702,0.566477],[120.885779,1.309223],[121.666817,1.013944],[122.927567,0.875192]]],[[[120.295014,-10.25865],[118.967808,-9.557969],[119.90031,-9.36134],[120.425756,-9.665921],[120.775502,-9.969675],[120.715609,-10.239581],[120.295014,-10.25865]]],[[[121.341669,-8.53674],[122.007365,-8.46062],[122.903537,-8.094234],[122.756983,-8.649808],[121.254491,-8.933666],[119.924391,-8.810418],[119.920929,-8.444859],[120.715092,-8.236965],[121.341669,-8.53674]]],[[[118.260616,-8.362383],[118.87846,-8.280683],[119.126507,-8.705825],[117.970402,-8.906639],[117.277731,-9.040895],[116.740141,-9.032937],[117.083737,-8.457158],[117.632024,-8.449303],[117.900018,-8.095681],[118.260616,-8.362383]]],[[[108.486846,-6.421985],[108.623479,-6.777674],[110.539227,-6.877358],[110.759576,-6.465186],[112.614811,-6.946036],[112.978768,-7.594213],[114.478935,-7.776528],[115.705527,-8.370807],[114.564511,-8.751817],[113.464734,-8.348947],[112.559672,-8.376181],[111.522061,-8.302129],[110.58615,-8.122605],[109.427667,-7.740664],[108.693655,-7.6416],[108.277763,-7.766657],[106.454102,-7.3549],[106.280624,-6.9249],[105.365486,-6.851416],[106.051646,-5.895919],[107.265009,-5.954985],[108.072091,-6.345762],[108.486846,-6.421985]]],[[[104.369991,-1.084843],[104.53949,-1.782372],[104.887893,-2.340425],[105.622111,-2.428844],[106.108593,-3.061777],[105.857446,-4.305525],[105.817655,-5.852356],[104.710384,-5.873285],[103.868213,-5.037315],[102.584261,-4.220259],[102.156173,-3.614146],[101.399113,-2.799777],[100.902503,-2.050262],[100.141981,-0.650348],[99.26374,0.183142],[98.970011,1.042882],[98.601351,1.823507],[97.699598,2.453184],[97.176942,3.308791],[96.424017,3.86886],[95.380876,4.970782],[95.293026,5.479821],[95.936863,5.439513],[97.484882,5.246321],[98.369169,4.26837],[99.142559,3.59035],[99.693998,3.174329],[100.641434,2.099381],[101.658012,2.083697],[102.498271,1.3987],[103.07684,0.561361],[103.838396,0.104542],[103.437645,-0.711946],[104.010789,-1.059212],[104.369991,-1.084843]]]]}},{"type":"Feature","properties":{"ADMIN":"Argentina","NAME_EN":"Argentina","CONTINENT":"South America","ADM0_A3":"ARG","ISO_A3":"ARG","NAME":"Argentina","ISO_A2":"AR","NAME_ZH":"阿根廷"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.25,-53.1],[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2],[-66.45,-55.25],[-66.95992,-54.89681],[-67.56244,-54.87001],[-68.63335,-54.8695],[-68.63401,-52.63637]]],[[[-57.625133,-30.216295],[-57.874937,-31.016556],[-58.14244,-32.044504],[-58.132648,-33.040567],[-58.349611,-33.263189],[-58.427074,-33.909454],[-58.495442,-34.43149],[-57.22583,-35.288027],[-57.362359,-35.97739],[-56.737487,-36.413126],[-56.788285,-36.901572],[-57.749157,-38.183871],[-59.231857,-38.72022],[-61.237445,-38.928425],[-62.335957,-38.827707],[-62.125763,-39.424105],[-62.330531,-40.172586],[-62.145994,-40.676897],[-62.745803,-41.028761],[-63.770495,-41.166789],[-64.73209,-40.802677],[-65.118035,-41.064315],[-64.978561,-42.058001],[-64.303408,-42.359016],[-63.755948,-42.043687],[-63.458059,-42.563138],[-64.378804,-42.873558],[-65.181804,-43.495381],[-65.328823,-44.501366],[-65.565269,-45.036786],[-66.509966,-45.039628],[-67.293794,-45.551896],[-67.580546,-46.301773],[-66.597066,-47.033925],[-65.641027,-47.236135],[-65.985088,-48.133289],[-67.166179,-48.697337],[-67.816088,-49.869669],[-68.728745,-50.264218],[-69.138539,-50.73251],[-68.815561,-51.771104],[-68.149995,-52.349983],[-68.571545,-52.299444],[-69.498362,-52.142761],[-71.914804,-52.009022],[-72.329404,-51.425956],[-72.309974,-50.67701],[-72.975747,-50.74145],[-73.328051,-50.378785],[-73.415436,-49.318436],[-72.648247,-48.878618],[-72.331161,-48.244238],[-72.447355,-47.738533],[-71.917258,-46.884838],[-71.552009,-45.560733],[-71.659316,-44.973689],[-71.222779,-44.784243],[-71.329801,-44.407522],[-71.793623,-44.207172],[-71.464056,-43.787611],[-71.915424,-43.408565],[-72.148898,-42.254888],[-71.746804,-42.051386],[-71.915734,-40.832339],[-71.680761,-39.808164],[-71.413517,-38.916022],[-70.814664,-38.552995],[-71.118625,-37.576827],[-71.121881,-36.658124],[-70.364769,-36.005089],[-70.388049,-35.169688],[-69.817309,-34.193571],[-69.814777,-33.273886],[-70.074399,-33.09121],[-70.535069,-31.36501],[-69.919008,-30.336339],[-70.01355,-29.367923],[-69.65613,-28.459141],[-69.001235,-27.521214],[-68.295542,-26.89934],[-68.5948,-26.506909],[-68.386001,-26.185016],[-68.417653,-24.518555],[-67.328443,-24.025303],[-66.985234,-22.986349],[-67.106674,-22.735925],[-66.273339,-21.83231],[-64.964892,-22.075862],[-64.377021,-22.798091],[-63.986838,-21.993644],[-62.846468,-22.034985],[-62.685057,-22.249029],[-60.846565,-23.880713],[-60.028966,-24.032796],[-58.807128,-24.771459],[-57.777217,-25.16234],[-57.63366,-25.603657],[-58.618174,-27.123719],[-57.60976,-27.395899],[-56.486702,-27.548499],[-55.695846,-27.387837],[-54.788795,-26.621786],[-54.625291,-25.739255],[-54.13005,-25.547639],[-53.628349,-26.124865],[-53.648735,-26.923473],[-54.490725,-27.474757],[-55.162286,-27.881915],[-56.2909,-28.852761],[-57.625133,-30.216295]]]]}},{"type":"Feature","properties":{"ADMIN":"Chile","NAME_EN":"Chile","CONTINENT":"South America","ADM0_A3":"CHL","ISO_A3":"CHL","NAME":"Chile","ISO_A2":"CL","NAME_ZH":"智利"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.63335,-54.8695],[-67.56244,-54.87001],[-66.95992,-54.89681],[-67.29103,-55.30124],[-68.14863,-55.61183],[-68.639991,-55.580018],[-69.2321,-55.49906],[-69.95809,-55.19843],[-71.00568,-55.05383],[-72.2639,-54.49514],[-73.2852,-53.95752],[-74.66253,-52.83749],[-73.8381,-53.04743],[-72.43418,-53.7154],[-71.10773,-54.07433],[-70.59178,-53.61583],[-70.26748,-52.93123],[-69.34565,-52.5183],[-68.63401,-52.63637]]],[[[-69.590424,-17.580012],[-69.100247,-18.260125],[-68.966818,-18.981683],[-68.442225,-19.405068],[-68.757167,-20.372658],[-68.219913,-21.494347],[-67.82818,-22.872919],[-67.106674,-22.735925],[-66.985234,-22.986349],[-67.328443,-24.025303],[-68.417653,-24.518555],[-68.386001,-26.185016],[-68.5948,-26.506909],[-68.295542,-26.89934],[-69.001235,-27.521214],[-69.65613,-28.459141],[-70.01355,-29.367923],[-69.919008,-30.336339],[-70.535069,-31.36501],[-70.074399,-33.09121],[-69.814777,-33.273886],[-69.817309,-34.193571],[-70.388049,-35.169688],[-70.364769,-36.005089],[-71.121881,-36.658124],[-71.118625,-37.576827],[-70.814664,-38.552995],[-71.413517,-38.916022],[-71.680761,-39.808164],[-71.915734,-40.832339],[-71.746804,-42.051386],[-72.148898,-42.254888],[-71.915424,-43.408565],[-71.464056,-43.787611],[-71.793623,-44.207172],[-71.329801,-44.407522],[-71.222779,-44.784243],[-71.659316,-44.973689],[-71.552009,-45.560733],[-71.917258,-46.884838],[-72.447355,-47.738533],[-72.331161,-48.244238],[-72.648247,-48.878618],[-73.415436,-49.318436],[-73.328051,-50.378785],[-72.975747,-50.74145],[-72.309974,-50.67701],[-72.329404,-51.425956],[-71.914804,-52.009022],[-69.498362,-52.142761],[-68.571545,-52.299444],[-69.461284,-52.291951],[-69.94278,-52.537931],[-70.845102,-52.899201],[-71.006332,-53.833252],[-71.429795,-53.856455],[-72.557943,-53.53141],[-73.702757,-52.835069],[-73.702757,-52.83507],[-74.946763,-52.262754],[-75.260026,-51.629355],[-74.976632,-51.043396],[-75.479754,-50.378372],[-75.608015,-48.673773],[-75.18277,-47.711919],[-74.126581,-46.939253],[-75.644395,-46.647643],[-74.692154,-45.763976],[-74.351709,-44.103044],[-73.240356,-44.454961],[-72.717804,-42.383356],[-73.3889,-42.117532],[-73.701336,-43.365776],[-74.331943,-43.224958],[-74.017957,-41.794813],[-73.677099,-39.942213],[-73.217593,-39.258689],[-73.505559,-38.282883],[-73.588061,-37.156285],[-73.166717,-37.12378],[-72.553137,-35.50884],[-71.861732,-33.909093],[-71.43845,-32.418899],[-71.668721,-30.920645],[-71.370083,-30.095682],[-71.489894,-28.861442],[-70.905124,-27.64038],[-70.724954,-25.705924],[-70.403966,-23.628997],[-70.091246,-21.393319],[-70.16442,-19.756468],[-70.372572,-18.347975],[-69.858444,-18.092694],[-69.590424,-17.580012]]]]}},{"type":"Feature","properties":{"ADMIN":"Democratic Republic of the Congo","NAME_EN":"Democratic Republic of the Congo","CONTINENT":"Africa","ADM0_A3":"COD","ISO_A3":"COD","NAME":"Dem. Rep. Congo","ISO_A2":"CD","NAME_ZH":"刚果民主共和国"},"geometry":{"type":"Polygon","coordinates":[[[29.339998,-4.499983],[29.519987,-5.419979],[29.419993,-5.939999],[29.620032,-6.520015],[30.199997,-7.079981],[30.740015,-8.340007],[30.74001,-8.340006],[30.346086,-8.238257],[29.002912,-8.407032],[28.734867,-8.526559],[28.449871,-9.164918],[28.673682,-9.605925],[28.49607,-10.789884],[28.372253,-11.793647],[28.642417,-11.971569],[29.341548,-12.360744],[29.616001,-12.178895],[29.699614,-13.257227],[28.934286,-13.248958],[28.523562,-12.698604],[28.155109,-12.272481],[27.388799,-12.132747],[27.16442,-11.608748],[26.553088,-11.92444],[25.75231,-11.784965],[25.418118,-11.330936],[24.78317,-11.238694],[24.314516,-11.262826],[24.257155,-10.951993],[23.912215,-10.926826],[23.456791,-10.867863],[22.837345,-11.017622],[22.402798,-10.993075],[22.155268,-11.084801],[22.208753,-9.894796],[21.875182,-9.523708],[21.801801,-8.908707],[21.949131,-8.305901],[21.746456,-7.920085],[21.728111,-7.290872],[20.514748,-7.299606],[20.601823,-6.939318],[20.091622,-6.94309],[20.037723,-7.116361],[19.417502,-7.155429],[19.166613,-7.738184],[19.016752,-7.988246],[18.464176,-7.847014],[18.134222,-7.987678],[17.47297,-8.068551],[17.089996,-7.545689],[16.860191,-7.222298],[16.57318,-6.622645],[16.326528,-5.87747],[13.375597,-5.864241],[13.024869,-5.984389],[12.735171,-5.965682],[12.322432,-6.100092],[12.182337,-5.789931],[12.436688,-5.684304],[12.468004,-5.248362],[12.631612,-4.991271],[12.995517,-4.781103],[13.25824,-4.882957],[13.600235,-4.500138],[14.144956,-4.510009],[14.209035,-4.793092],[14.582604,-4.970239],[15.170992,-4.343507],[15.75354,-3.855165],[16.00629,-3.535133],[15.972803,-2.712392],[16.407092,-1.740927],[16.865307,-1.225816],[17.523716,-0.74383],[17.638645,-0.424832],[17.663553,-0.058084],[17.82654,0.288923],[17.774192,0.855659],[17.898835,1.741832],[18.094276,2.365722],[18.393792,2.900443],[18.453065,3.504386],[18.542982,4.201785],[18.932312,4.709506],[19.467784,5.031528],[20.290679,4.691678],[20.927591,4.322786],[21.659123,4.224342],[22.405124,4.02916],[22.704124,4.633051],[22.84148,4.710126],[23.297214,4.609693],[24.410531,5.108784],[24.805029,4.897247],[25.128833,4.927245],[25.278798,5.170408],[25.650455,5.256088],[26.402761,5.150875],[27.044065,5.127853],[27.374226,5.233944],[27.979977,4.408413],[28.428994,4.287155],[28.696678,4.455077],[29.159078,4.389267],[29.715995,4.600805],[29.9535,4.173699],[30.833852,3.509172],[30.83386,3.509166],[30.773347,2.339883],[31.174149,2.204465],[30.85267,1.849396],[30.468508,1.583805],[30.086154,1.062313],[29.875779,0.59738],[29.819503,-0.20531],[29.587838,-0.587406],[29.579466,-1.341313],[29.291887,-1.620056],[29.254835,-2.21511],[29.117479,-2.292211],[29.024926,-2.839258],[29.276384,-3.293907],[29.339998,-4.499983]]]}},{"type":"Feature","properties":{"ADMIN":"Somalia","NAME_EN":"Somalia","CONTINENT":"Africa","ADM0_A3":"SOM","ISO_A3":"SOM","NAME":"Somalia","ISO_A2":"SO","NAME_ZH":"索马里"},"geometry":{"type":"Polygon","coordinates":[[[41.58513,-1.68325],[40.993,-0.85829],[40.98105,2.78452],[41.855083,3.918912],[42.12861,4.23413],[42.76967,4.25259],[43.66087,4.95755],[44.9636,5.00162],[47.78942,8.003],[48.486736,8.837626],[48.93813,9.451749],[48.938233,9.9735],[48.938491,10.982327],[48.942005,11.394266],[48.948205,11.410617],[48.948205,11.410617],[49.26776,11.43033],[49.72862,11.5789],[50.25878,11.67957],[50.73202,12.0219],[51.1112,12.02464],[51.13387,11.74815],[51.04153,11.16651],[51.04531,10.6409],[50.83418,10.27972],[50.55239,9.19874],[50.07092,8.08173],[49.4527,6.80466],[48.59455,5.33911],[47.74079,4.2194],[46.56476,2.85529],[45.56399,2.04576],[44.06815,1.05283],[43.13597,0.2922],[42.04157,-0.91916],[41.81095,-1.44647],[41.58513,-1.68325]]]}},{"type":"Feature","properties":{"ADMIN":"Kenya","NAME_EN":"Kenya","CONTINENT":"Africa","ADM0_A3":"KEN","ISO_A3":"KEN","NAME":"Kenya","ISO_A2":"KE","NAME_ZH":"肯尼亚"},"geometry":{"type":"Polygon","coordinates":[[[39.20222,-4.67677],[37.7669,-3.67712],[37.69869,-3.09699],[34.07262,-1.05982],[33.903711,-0.95],[33.893569,0.109814],[34.18,0.515],[34.6721,1.17694],[35.03599,1.90584],[34.59607,3.05374],[34.47913,3.5556],[34.005,4.249885],[34.620196,4.847123],[35.298007,5.506],[35.817448,5.338232],[35.817448,4.776966],[36.159079,4.447864],[36.855093,4.447864],[38.120915,3.598605],[38.43697,3.58851],[38.67114,3.61607],[38.89251,3.50074],[39.559384,3.42206],[39.85494,3.83879],[40.76848,4.25702],[41.1718,3.91909],[41.855083,3.918912],[40.98105,2.78452],[40.993,-0.85829],[41.58513,-1.68325],[40.88477,-2.08255],[40.63785,-2.49979],[40.26304,-2.57309],[40.12119,-3.27768],[39.80006,-3.68116],[39.60489,-4.34653],[39.20222,-4.67677]]]}},{"type":"Feature","properties":{"ADMIN":"Sudan","NAME_EN":"Sudan","CONTINENT":"Africa","ADM0_A3":"SDN","ISO_A3":"SDN","NAME":"Sudan","ISO_A2":"SD","NAME_ZH":"苏丹"},"geometry":{"type":"Polygon","coordinates":[[[24.567369,8.229188],[23.805813,8.666319],[23.459013,8.954286],[23.394779,9.265068],[23.55725,9.681218],[23.554304,10.089255],[22.977544,10.714463],[22.864165,11.142395],[22.87622,11.38461],[22.50869,11.67936],[22.49762,12.26024],[22.28801,12.64605],[21.93681,12.58818],[22.03759,12.95546],[22.29658,13.37232],[22.18329,13.78648],[22.51202,14.09318],[22.30351,14.32682],[22.56795,14.94429],[23.02459,15.68072],[23.88689,15.61084],[23.83766,19.58047],[23.85,20],[25,20.00304],[25,22],[29.02,22],[32.9,22],[36.86623,22],[37.18872,21.01885],[36.96941,20.83744],[37.1147,19.80796],[37.48179,18.61409],[37.86276,18.36786],[38.41009,17.998307],[37.904,17.42754],[37.16747,17.26314],[36.85253,16.95655],[36.75389,16.29186],[36.32322,14.82249],[36.42951,14.42211],[36.27022,13.56333],[35.86363,12.57828],[35.26049,12.08286],[34.83163,11.31896],[34.73115,10.91017],[34.25745,10.63009],[33.96162,9.58358],[33.97498,8.68456],[33.963393,9.464285],[33.824963,9.484061],[33.842131,9.981915],[33.721959,10.325262],[33.206938,10.720112],[33.086766,11.441141],[33.206938,12.179338],[32.743419,12.248008],[32.67475,12.024832],[32.073892,11.97333],[32.314235,11.681484],[32.400072,11.080626],[31.850716,10.531271],[31.352862,9.810241],[30.837841,9.707237],[29.996639,10.290927],[29.618957,10.084919],[29.515953,9.793074],[29.000932,9.604232],[28.966597,9.398224],[27.97089,9.398224],[27.833551,9.604232],[27.112521,9.638567],[26.752006,9.466893],[26.477328,9.55273],[25.962307,10.136421],[25.790633,10.411099],[25.069604,10.27376],[24.794926,9.810241],[24.537415,8.917538],[24.194068,8.728696],[23.88698,8.61973],[24.567369,8.229188]]]}},{"type":"Feature","properties":{"ADMIN":"Chad","NAME_EN":"Chad","CONTINENT":"Africa","ADM0_A3":"TCD","ISO_A3":"TCD","NAME":"Chad","ISO_A2":"TD","NAME_ZH":"乍得"},"geometry":{"type":"Polygon","coordinates":[[[23.83766,19.58047],[23.88689,15.61084],[23.02459,15.68072],[22.56795,14.94429],[22.30351,14.32682],[22.51202,14.09318],[22.18329,13.78648],[22.29658,13.37232],[22.03759,12.95546],[21.93681,12.58818],[22.28801,12.64605],[22.49762,12.26024],[22.50869,11.67936],[22.87622,11.38461],[22.864165,11.142395],[22.231129,10.971889],[21.723822,10.567056],[21.000868,9.475985],[20.059685,9.012706],[19.094008,9.074847],[18.81201,8.982915],[18.911022,8.630895],[18.389555,8.281304],[17.96493,7.890914],[16.705988,7.508328],[16.456185,7.734774],[16.290562,7.754307],[16.106232,7.497088],[15.27946,7.421925],[15.436092,7.692812],[15.120866,8.38215],[14.979996,8.796104],[14.544467,8.965861],[13.954218,9.549495],[14.171466,10.021378],[14.627201,9.920919],[14.909354,9.992129],[15.467873,9.982337],[14.923565,10.891325],[14.960152,11.555574],[14.89336,12.21905],[14.495787,12.859396],[14.595781,13.330427],[13.954477,13.353449],[13.956699,13.996691],[13.540394,14.367134],[13.97217,15.68437],[15.247731,16.627306],[15.300441,17.92795],[15.685741,19.95718],[15.903247,20.387619],[15.487148,20.730415],[15.47106,21.04845],[15.096888,21.308519],[14.8513,22.86295],[15.86085,23.40972],[19.84926,21.49509],[23.83766,19.58047]]]}},{"type":"Feature","properties":{"ADMIN":"Haiti","NAME_EN":"Haiti","CONTINENT":"North America","ADM0_A3":"HTI","ISO_A3":"HTI","NAME":"Haiti","ISO_A2":"HT","NAME_ZH":"海地"},"geometry":{"type":"Polygon","coordinates":[[[-71.712361,19.714456],[-71.624873,19.169838],[-71.701303,18.785417],[-71.945112,18.6169],[-71.687738,18.31666],[-71.708305,18.044997],[-72.372476,18.214961],[-72.844411,18.145611],[-73.454555,18.217906],[-73.922433,18.030993],[-74.458034,18.34255],[-74.369925,18.664908],[-73.449542,18.526053],[-72.694937,18.445799],[-72.334882,18.668422],[-72.79165,19.101625],[-72.784105,19.483591],[-73.415022,19.639551],[-73.189791,19.915684],[-72.579673,19.871501],[-71.712361,19.714456]]]}},{"type":"Feature","properties":{"ADMIN":"Dominican Republic","NAME_EN":"Dominican Republic","CONTINENT":"North America","ADM0_A3":"DOM","ISO_A3":"DOM","NAME":"Dominican Rep.","ISO_A2":"DO","NAME_ZH":"多米尼加"},"geometry":{"type":"Polygon","coordinates":[[[-71.708305,18.044997],[-71.687738,18.31666],[-71.945112,18.6169],[-71.701303,18.785417],[-71.624873,19.169838],[-71.712361,19.714456],[-71.587304,19.884911],[-70.806706,19.880286],[-70.214365,19.622885],[-69.950815,19.648],[-69.76925,19.293267],[-69.222126,19.313214],[-69.254346,19.015196],[-68.809412,18.979074],[-68.317943,18.612198],[-68.689316,18.205142],[-69.164946,18.422648],[-69.623988,18.380713],[-69.952934,18.428307],[-70.133233,18.245915],[-70.517137,18.184291],[-70.669298,18.426886],[-70.99995,18.283329],[-71.40021,17.598564],[-71.657662,17.757573],[-71.708305,18.044997]]]}},{"type":"Feature","properties":{"ADMIN":"Russia","NAME_EN":"Russia","CONTINENT":"Europe","ADM0_A3":"RUS","ISO_A3":"RUS","NAME":"Russia","ISO_A2":"RU","NAME_ZH":"俄罗斯"},"geometry":{"type":"MultiPolygon","coordinates":[[[[178.7253,71.0988],[180,71.515714],[180,70.832199],[178.903425,70.78114],[178.7253,71.0988]]],[[[49.10116,46.39933],[48.64541,45.80629],[47.67591,45.64149],[46.68201,44.6092],[47.59094,43.66016],[47.49252,42.98658],[48.58437,41.80888],[48.584353,41.808869],[47.987283,41.405819],[47.815666,41.151416],[47.373315,41.219732],[46.686071,41.827137],[46.404951,41.860675],[45.7764,42.09244],[45.470279,42.502781],[44.537623,42.711993],[43.93121,42.55496],[43.75599,42.74083],[42.3944,43.2203],[40.92219,43.38215],[40.076965,43.553104],[39.955009,43.434998],[38.68,44.28],[37.53912,44.65721],[36.67546,45.24469],[37.40317,45.40451],[38.23295,46.24087],[37.67372,46.63657],[39.14767,47.04475],[39.1212,47.26336],[38.223538,47.10219],[38.255112,47.5464],[38.77057,47.82562],[39.738278,47.898937],[39.89562,48.23241],[39.67465,48.78382],[40.080789,49.30743],[40.06904,49.60105],[38.594988,49.926462],[38.010631,49.915662],[37.39346,50.383953],[36.626168,50.225591],[35.356116,50.577197],[35.37791,50.77394],[35.022183,51.207572],[34.224816,51.255993],[34.141978,51.566413],[34.391731,51.768882],[33.7527,52.335075],[32.715761,52.238465],[32.412058,52.288695],[32.15944,52.06125],[31.785992,52.101678],[31.78597,52.10168],[31.540018,52.742052],[31.305201,53.073996],[31.49764,53.16743],[32.304519,53.132726],[32.693643,53.351421],[32.405599,53.618045],[31.731273,53.794029],[31.791424,53.974639],[31.384472,54.157056],[30.757534,54.811771],[30.971836,55.081548],[30.873909,55.550976],[29.896294,55.789463],[29.371572,55.670091],[29.229513,55.918344],[28.176709,56.16913],[27.855282,56.759326],[27.770016,57.244258],[27.288185,57.474528],[27.716686,57.791899],[27.42015,58.72457],[28.131699,59.300825],[27.98112,59.47537],[27.981127,59.475373],[29.1177,60.02805],[28.070002,60.503519],[28.07,60.50352],[30.211107,61.780028],[31.139991,62.357693],[31.516092,62.867687],[30.035872,63.552814],[30.444685,64.204453],[29.54443,64.948672],[30.21765,65.80598],[29.054589,66.944286],[29.977426,67.698297],[28.445944,68.364613],[28.59193,69.064777],[29.39955,69.15692],[31.101042,69.558101],[31.10108,69.55811],[32.13272,69.90595],[33.77547,69.30142],[36.51396,69.06342],[40.29234,67.9324],[41.05987,67.45713],[41.12595,66.79158],[40.01583,66.26618],[38.38295,65.99953],[33.91871,66.75961],[33.18444,66.63253],[34.81477,65.90015],[34.878574,65.436213],[34.94391,64.41437],[36.23129,64.10945],[37.01273,63.84983],[37.14197,64.33471],[36.539579,64.76446],[37.17604,65.14322],[39.59345,64.52079],[40.4356,64.76446],[39.7626,65.49682],[42.09309,66.47623],[43.01604,66.41858],[43.94975,66.06908],[44.53226,66.75634],[43.69839,67.35245],[44.18795,67.95051],[43.45282,68.57079],[46.25,68.25],[46.82134,67.68997],[45.55517,67.56652],[45.56202,67.01005],[46.34915,66.66767],[47.89416,66.88455],[48.13876,67.52238],[50.22766,67.99867],[53.71743,68.85738],[54.47171,68.80815],[53.48582,68.20131],[54.72628,68.09702],[55.44268,68.43866],[57.31702,68.46628],[58.802,68.88082],[59.94142,68.27844],[61.07784,68.94069],[60.03,69.52],[60.55,69.85],[63.504,69.54739],[64.888115,69.234835],[68.51216,68.09233],[69.18068,68.61563],[68.16444,69.14436],[68.13522,69.35649],[66.93008,69.45461],[67.25976,69.92873],[66.72492,70.70889],[66.69466,71.02897],[68.54006,71.9345],[69.19636,72.84336],[69.94,73.04],[72.58754,72.77629],[72.79603,72.22006],[71.84811,71.40898],[72.47011,71.09019],[72.79188,70.39114],[72.5647,69.02085],[73.66787,68.4079],[73.2387,67.7404],[71.28,66.32],[72.42301,66.17267],[72.82077,66.53267],[73.92099,66.78946],[74.18651,67.28429],[75.052,67.76047],[74.46926,68.32899],[74.93584,68.98918],[73.84236,69.07146],[73.60187,69.62763],[74.3998,70.63175],[73.1011,71.44717],[74.89082,72.12119],[74.65926,72.83227],[75.15801,72.85497],[75.68351,72.30056],[75.28898,71.33556],[76.35911,71.15287],[75.90313,71.87401],[77.57665,72.26717],[79.65202,72.32011],[81.5,71.75],[80.61071,72.58285],[80.51109,73.6482],[82.25,73.85],[84.65526,73.80591],[86.8223,73.93688],[86.00956,74.45967],[87.16682,75.11643],[88.31571,75.14393],[90.26,75.64],[92.90058,75.77333],[93.23421,76.0472],[95.86,76.14],[96.67821,75.91548],[98.92254,76.44689],[100.75967,76.43028],[101.03532,76.86189],[101.99084,77.28754],[104.3516,77.69792],[106.06664,77.37389],[104.705,77.1274],[106.97013,76.97419],[107.24,76.48],[108.1538,76.72335],[111.07726,76.71],[113.33151,76.22224],[114.13417,75.84764],[113.88539,75.32779],[112.77918,75.03186],[110.15125,74.47673],[109.4,74.18],[110.64,74.04],[112.11919,73.78774],[113.01954,73.97693],[113.52958,73.33505],[113.96881,73.59488],[115.56782,73.75285],[118.77633,73.58772],[119.02,73.12],[123.20066,72.97122],[123.25777,73.73503],[125.38,73.56],[126.97644,73.56549],[128.59126,73.03871],[129.05157,72.39872],[128.46,71.98],[129.71599,71.19304],[131.28858,70.78699],[132.2535,71.8363],[133.85766,71.38642],[135.56193,71.65525],[137.49755,71.34763],[138.23409,71.62803],[139.86983,71.48783],[139.14791,72.41619],[140.46817,72.84941],[149.5,72.2],[150.35118,71.60643],[152.9689,70.84222],[157.00688,71.03141],[158.99779,70.86672],[159.83031,70.45324],[159.70866,69.72198],[160.94053,69.43728],[162.27907,69.64204],[164.05248,69.66823],[165.94037,69.47199],[167.83567,69.58269],[169.57763,68.6938],[170.81688,69.01363],[170.0082,69.65276],[170.45345,70.09703],[173.64391,69.81743],[175.72403,69.87725],[178.6,69.4],[180,68.963636],[180,64.979709],[179.99281,64.97433],[178.7072,64.53493],[177.41128,64.60821],[178.313,64.07593],[178.90825,63.25197],[179.37034,62.98262],[179.48636,62.56894],[179.22825,62.3041],[177.3643,62.5219],[174.56929,61.76915],[173.68013,61.65261],[172.15,60.95],[170.6985,60.33618],[170.33085,59.88177],[168.90046,60.57355],[166.29498,59.78855],[165.84,60.16],[164.87674,59.7316],[163.53929,59.86871],[163.21711,59.21101],[162.01733,58.24328],[162.05297,57.83912],[163.19191,57.61503],[163.05794,56.15924],[162.12958,56.12219],[161.70146,55.28568],[162.11749,54.85514],[160.36877,54.34433],[160.02173,53.20257],[158.53094,52.95868],[158.23118,51.94269],[156.78979,51.01105],[156.42,51.7],[155.99182,53.15895],[155.43366,55.38103],[155.91442,56.76792],[156.75815,57.3647],[156.81035,57.83204],[158.36433,58.05575],[160.15064,59.31477],[161.87204,60.343],[163.66969,61.1409],[164.47355,62.55061],[163.25842,62.46627],[162.65791,61.6425],[160.12148,60.54423],[159.30232,61.77396],[156.72068,61.43442],[154.21806,59.75818],[155.04375,59.14495],[152.81185,58.88385],[151.26573,58.78089],[151.33815,59.50396],[149.78371,59.65573],[148.54481,59.16448],[145.48722,59.33637],[142.19782,59.03998],[138.95848,57.08805],[135.12619,54.72959],[136.70171,54.60355],[137.19342,53.97732],[138.1647,53.75501],[138.80463,54.25455],[139.90151,54.18968],[141.34531,53.08957],[141.37923,52.23877],[140.59742,51.23967],[140.51308,50.04553],[140.06193,48.44671],[138.55472,46.99965],[138.21971,46.30795],[136.86232,45.1435],[135.51535,43.989],[134.86939,43.39821],[133.53687,42.81147],[132.90627,42.79849],[132.27807,43.28456],[130.93587,42.55274],[130.780005,42.22001],[130.780004,42.220008],[130.78,42.22],[130.779992,42.22001],[130.64,42.395],[130.64,42.395024],[130.633866,42.903015],[131.144688,42.92999],[131.288555,44.11152],[131.02519,44.96796],[131.883454,45.321162],[133.09712,45.14409],[133.769644,46.116927],[134.11235,47.21248],[134.50081,47.57845],[135.026311,48.47823],[133.373596,48.183442],[132.50669,47.78896],[130.98726,47.79013],[130.582293,48.729687],[129.397818,49.4406],[127.6574,49.76027],[127.287456,50.739797],[126.939157,51.353894],[126.564399,51.784255],[125.946349,52.792799],[125.068211,53.161045],[123.57147,53.4588],[122.245748,53.431726],[121.003085,53.251401],[120.177089,52.753886],[120.725789,52.516226],[120.7382,51.96411],[120.18208,51.64355],[119.27939,50.58292],[119.288461,50.142883],[117.879244,49.510983],[116.678801,49.888531],[115.485695,49.805177],[114.96211,50.140247],[114.362456,50.248303],[112.89774,49.543565],[111.581231,49.377968],[110.662011,49.130128],[109.402449,49.292961],[108.475167,49.282548],[107.868176,49.793705],[106.888804,50.274296],[105.886591,50.406019],[104.62158,50.27532],[103.676545,50.089966],[102.25589,50.51056],[102.06521,51.25991],[100.88948,51.516856],[99.981732,51.634006],[98.861491,52.047366],[97.82574,51.010995],[98.231762,50.422401],[97.25976,49.72605],[95.81402,49.97746],[94.815949,50.013433],[94.147566,50.480537],[93.10421,50.49529],[92.234712,50.802171],[90.713667,50.331812],[88.805567,49.470521],[87.751264,49.297198],[87.35997,49.214981],[86.829357,49.826675],[85.54127,49.692859],[85.11556,50.117303],[84.416377,50.3114],[83.935115,50.889246],[83.383004,51.069183],[81.945986,50.812196],[80.568447,51.388336],[80.03556,50.864751],[77.800916,53.404415],[76.525179,54.177003],[76.8911,54.490524],[74.38482,53.54685],[73.425679,53.48981],[73.508516,54.035617],[72.22415,54.376655],[71.180131,54.133285],[70.865267,55.169734],[69.068167,55.38525],[68.1691,54.970392],[65.66687,54.60125],[65.178534,54.354228],[61.4366,54.00625],[60.978066,53.664993],[61.699986,52.979996],[60.739993,52.719986],[60.927269,52.447548],[59.967534,51.96042],[61.588003,51.272659],[61.337424,50.79907],[59.932807,50.842194],[59.642282,50.545442],[58.36332,51.06364],[56.77798,51.04355],[55.71694,50.62171],[54.532878,51.02624],[52.328724,51.718652],[50.766648,51.692762],[48.702382,50.605128],[48.577841,49.87476],[47.54948,50.454698],[46.751596,49.356006],[47.043672,49.152039],[46.466446,48.394152],[47.31524,47.71585],[48.05725,47.74377],[48.694734,47.075628],[48.59325,46.56104],[49.10116,46.39933]]],[[[93.77766,81.0246],[95.940895,81.2504],[97.88385,80.746975],[100.186655,79.780135],[99.93976,78.88094],[97.75794,78.7562],[94.97259,79.044745],[93.31288,79.4265],[92.5454,80.14379],[91.18107,80.34146],[93.77766,81.0246]]],[[[102.837815,79.28129],[105.37243,78.71334],[105.07547,78.30689],[99.43814,77.921],[101.2649,79.23399],[102.08635,79.34641],[102.837815,79.28129]]],[[[138.831075,76.13676],[141.471615,76.09289],[145.086285,75.562625],[144.3,74.82],[140.61381,74.84768],[138.95544,74.61148],[136.97439,75.26167],[137.51176,75.94917],[138.831075,76.13676]]],[[[148.22223,75.345845],[150.73167,75.08406],[149.575925,74.68892],[147.977465,74.778355],[146.11919,75.17298],[146.358485,75.49682],[148.22223,75.345845]]],[[[139.86312,73.36983],[140.81171,73.76506],[142.06207,73.85758],[143.48283,73.47525],[143.60385,73.21244],[142.08763,73.20544],[140.038155,73.31692],[139.86312,73.36983]]],[[[44.846958,80.58981],[46.799139,80.771918],[48.318477,80.78401],[48.522806,80.514569],[49.09719,80.753986],[50.039768,80.918885],[51.522933,80.699726],[51.136187,80.54728],[49.793685,80.415428],[48.894411,80.339567],[48.754937,80.175468],[47.586119,80.010181],[46.502826,80.247247],[47.072455,80.559424],[44.846958,80.58981]]],[[[22.731099,54.327537],[20.892245,54.312525],[19.66064,54.426084],[19.888481,54.86616],[21.268449,55.190482],[22.315724,55.015299],[22.757764,54.856574],[22.651052,54.582741],[22.731099,54.327537]]],[[[53.50829,73.749814],[55.902459,74.627486],[55.631933,75.081412],[57.868644,75.60939],[61.170044,76.251883],[64.498368,76.439055],[66.210977,76.809782],[68.15706,76.939697],[68.852211,76.544811],[68.180573,76.233642],[64.637326,75.737755],[61.583508,75.260885],[58.477082,74.309056],[56.986786,73.333044],[55.419336,72.371268],[55.622838,71.540595],[57.535693,70.720464],[56.944979,70.632743],[53.677375,70.762658],[53.412017,71.206662],[51.601895,71.474759],[51.455754,72.014881],[52.478275,72.229442],[52.444169,72.774731],[54.427614,73.627548],[53.50829,73.749814]]],[[[142.914616,53.704578],[143.260848,52.74076],[143.235268,51.75666],[143.648007,50.7476],[144.654148,48.976391],[143.173928,49.306551],[142.558668,47.861575],[143.533492,46.836728],[143.505277,46.137908],[142.747701,46.740765],[142.09203,45.966755],[141.906925,46.805929],[142.018443,47.780133],[141.904445,48.859189],[142.1358,49.615163],[142.179983,50.952342],[141.594076,51.935435],[141.682546,53.301966],[142.606934,53.762145],[142.209749,54.225476],[142.654786,54.365881],[142.914616,53.704578]]],[[[-174.92825,67.20589],[-175.01425,66.58435],[-174.33983,66.33556],[-174.57182,67.06219],[-171.85731,66.91308],[-169.89958,65.97724],[-170.89107,65.54139],[-172.53025,65.43791],[-172.555,64.46079],[-172.95533,64.25269],[-173.89184,64.2826],[-174.65392,64.63125],[-175.98353,64.92288],[-176.20716,65.35667],[-177.22266,65.52024],[-178.35993,65.39052],[-178.90332,65.74044],[-178.68611,66.11211],[-179.88377,65.87456],[-179.43268,65.40411],[-180,64.979709],[-180,68.963636],[-177.55,68.2],[-174.92825,67.20589]]],[[[-178.69378,70.89302],[-180,70.832199],[-180,71.515714],[-179.871875,71.55762],[-179.02433,71.55553],[-177.577945,71.26948],[-177.663575,71.13277],[-178.69378,70.89302]]],[[[33.435988,45.971917],[33.699462,46.219573],[34.410402,46.005162],[34.732017,45.965666],[34.861792,45.768182],[35.012659,45.737725],[35.020788,45.651219],[35.510009,45.409993],[36.529998,45.46999],[36.334713,45.113216],[35.239999,44.939996],[33.882511,44.361479],[33.326421,44.564877],[33.546924,45.034771],[32.454174,45.327466],[32.630804,45.519186],[33.588162,45.851569],[33.435988,45.971917]]]]}},{"type":"Feature","properties":{"ADMIN":"The Bahamas","NAME_EN":"The Bahamas","CONTINENT":"North America","ADM0_A3":"BHS","ISO_A3":"BHS","NAME":"Bahamas","ISO_A2":"BS","NAME_ZH":"巴哈马"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58],[-78.91,26.42],[-78.98,26.79]]],[[[-77.79,27.04],[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04]]],[[[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103]]]]}},{"type":"Feature","properties":{"ADMIN":"Falkland Islands","NAME_EN":"Falkland Islands","CONTINENT":"South America","ADM0_A3":"FLK","ISO_A3":"FLK","NAME":"Falkland Is.","ISO_A2":"FK","NAME_ZH":"福克兰群岛"},"geometry":{"type":"Polygon","coordinates":[[[-61.2,-51.85],[-60,-51.25],[-59.15,-51.5],[-58.55,-51.1],[-57.75,-51.55],[-58.05,-51.9],[-59.4,-52.2],[-59.85,-51.85],[-60.7,-52.3],[-61.2,-51.85]]]}},{"type":"Feature","properties":{"ADMIN":"Norway","NAME_EN":"Norway","CONTINENT":"Europe","ADM0_A3":"NOR","ISO_A3":"-99","NAME":"Norway","ISO_A2":"-99","NAME_ZH":"挪威"},"geometry":{"type":"MultiPolygon","coordinates":[[[[15.14282,79.67431],[15.52255,80.01608],[16.99085,80.05086],[18.25183,79.70175],[21.54383,78.95611],[19.02737,78.5626],[18.47172,77.82669],[17.59441,77.63796],[17.1182,76.80941],[15.91315,76.77045],[13.76259,77.38035],[14.66956,77.73565],[13.1706,78.02493],[11.22231,78.8693],[10.44453,79.65239],[13.17077,80.01046],[13.71852,79.66039],[15.14282,79.67431]]],[[[31.101042,69.558101],[29.39955,69.15692],[28.59193,69.064777],[29.015573,69.766491],[27.732292,70.164193],[26.179622,69.825299],[25.689213,69.092114],[24.735679,68.649557],[23.66205,68.891247],[22.356238,68.841741],[21.244936,69.370443],[20.645593,69.106247],[20.025269,69.065139],[19.87856,68.407194],[17.993868,68.567391],[17.729182,68.010552],[16.768879,68.013937],[16.108712,67.302456],[15.108411,66.193867],[13.55569,64.787028],[13.919905,64.445421],[13.571916,64.049114],[12.579935,64.066219],[11.930569,63.128318],[11.992064,61.800362],[12.631147,61.293572],[12.300366,60.117933],[11.468272,59.432393],[11.027369,58.856149],[10.356557,59.469807],[8.382,58.313288],[7.048748,58.078884],[5.665835,58.588155],[5.308234,59.663232],[4.992078,61.970998],[5.9129,62.614473],[8.553411,63.454008],[10.527709,64.486038],[12.358347,65.879726],[14.761146,67.810642],[16.435927,68.563205],[19.184028,69.817444],[21.378416,70.255169],[23.023742,70.202072],[24.546543,71.030497],[26.37005,70.986262],[28.165547,71.185474],[31.293418,70.453788],[30.005435,70.186259],[31.101042,69.558101]]],[[[27.407506,80.056406],[25.924651,79.517834],[23.024466,79.400012],[20.075188,79.566823],[19.897266,79.842362],[18.462264,79.85988],[17.368015,80.318896],[20.455992,80.598156],[21.907945,80.357679],[22.919253,80.657144],[25.447625,80.40734],[27.407506,80.056406]]],[[[24.72412,77.85385],[22.49032,77.44493],[20.72601,77.67704],[21.41611,77.93504],[20.8119,78.25463],[22.88426,78.45494],[23.28134,78.07954],[24.72412,77.85385]]]]}},{"type":"Feature","properties":{"ADMIN":"Greenland","NAME_EN":"Greenland","CONTINENT":"North America","ADM0_A3":"GRL","ISO_A3":"GRL","NAME":"Greenland","ISO_A2":"GL","NAME_ZH":"格陵兰"},"geometry":{"type":"Polygon","coordinates":[[[-46.76379,82.62796],[-43.40644,83.22516],[-39.89753,83.18018],[-38.62214,83.54905],[-35.08787,83.64513],[-27.10046,83.51966],[-20.84539,82.72669],[-22.69182,82.34165],[-26.51753,82.29765],[-31.9,82.2],[-31.39646,82.02154],[-27.85666,82.13178],[-24.84448,81.78697],[-22.90328,82.09317],[-22.07175,81.73449],[-23.16961,81.15271],[-20.62363,81.52462],[-15.76818,81.91245],[-12.77018,81.71885],[-12.20855,81.29154],[-16.28533,80.58004],[-16.85,80.35],[-20.04624,80.17708],[-17.73035,80.12912],[-18.9,79.4],[-19.70499,78.75128],[-19.67353,77.63859],[-18.47285,76.98565],[-20.03503,76.94434],[-21.67944,76.62795],[-19.83407,76.09808],[-19.59896,75.24838],[-20.66818,75.15585],[-19.37281,74.29561],[-21.59422,74.22382],[-20.43454,73.81713],[-20.76234,73.46436],[-22.17221,73.30955],[-23.56593,73.30663],[-22.31311,72.62928],[-22.29954,72.18409],[-24.27834,72.59788],[-24.79296,72.3302],[-23.44296,72.08016],[-22.13281,71.46898],[-21.75356,70.66369],[-23.53603,70.471],[-24.30702,70.85649],[-25.54341,71.43094],[-25.20135,70.75226],[-26.36276,70.22646],[-23.72742,70.18401],[-22.34902,70.12946],[-25.02927,69.2588],[-27.74737,68.47046],[-30.67371,68.12503],[-31.77665,68.12078],[-32.81105,67.73547],[-34.20196,66.67974],[-36.35284,65.9789],[-37.04378,65.93768],[-38.37505,65.69213],[-39.81222,65.45848],[-40.66899,64.83997],[-40.68281,64.13902],[-41.1887,63.48246],[-42.81938,62.68233],[-42.41666,61.90093],[-42.86619,61.07404],[-43.3784,60.09772],[-44.7875,60.03676],[-46.26364,60.85328],[-48.26294,60.85843],[-49.23308,61.40681],[-49.90039,62.38336],[-51.63325,63.62691],[-52.14014,64.27842],[-52.27659,65.1767],[-53.66166,66.09957],[-53.30161,66.8365],[-53.96911,67.18899],[-52.9804,68.35759],[-51.47536,68.72958],[-51.08041,69.14781],[-50.87122,69.9291],[-52.013585,69.574925],[-52.55792,69.42616],[-53.45629,69.283625],[-54.68336,69.61003],[-54.75001,70.28932],[-54.35884,70.821315],[-53.431315,70.835755],[-51.39014,70.56978],[-53.10937,71.20485],[-54.00422,71.54719],[-55,71.406537],[-55.83468,71.65444],[-54.71819,72.58625],[-55.32634,72.95861],[-56.12003,73.64977],[-57.32363,74.71026],[-58.59679,75.09861],[-58.58516,75.51727],[-61.26861,76.10238],[-63.39165,76.1752],[-66.06427,76.13486],[-68.50438,76.06141],[-69.66485,76.37975],[-71.40257,77.00857],[-68.77671,77.32312],[-66.76397,77.37595],[-71.04293,77.63595],[-73.297,78.04419],[-73.15938,78.43271],[-69.37345,78.91388],[-65.7107,79.39436],[-65.3239,79.75814],[-68.02298,80.11721],[-67.15129,80.51582],[-63.68925,81.21396],[-62.23444,81.3211],[-62.65116,81.77042],[-60.28249,82.03363],[-57.20744,82.19074],[-54.13442,82.19962],[-53.04328,81.88833],[-50.39061,82.43883],[-48.00386,82.06481],[-46.59984,81.985945],[-44.523,81.6607],[-46.9007,82.19979],[-46.76379,82.62796]]]}},{"type":"Feature","properties":{"ADMIN":"French Southern and Antarctic Lands","NAME_EN":"French Southern and Antarctic Lands","CONTINENT":"Seven seas (open ocean)","ADM0_A3":"ATF","ISO_A3":"ATF","NAME":"Fr. S. Antarctic Lands","ISO_A2":"TF","NAME_ZH":"法属南部和南极领地"},"geometry":{"type":"Polygon","coordinates":[[[68.935,-48.625],[69.58,-48.94],[70.525,-49.065],[70.56,-49.255],[70.28,-49.71],[68.745,-49.775],[68.72,-49.2425],[68.8675,-48.83],[68.935,-48.625]]]}},{"type":"Feature","properties":{"ADMIN":"East Timor","NAME_EN":"East Timor","CONTINENT":"Asia","ADM0_A3":"TLS","ISO_A3":"TLS","NAME":"Timor-Leste","ISO_A2":"TL","NAME_ZH":"东帝汶"},"geometry":{"type":"Polygon","coordinates":[[[124.968682,-8.89279],[125.086246,-8.656887],[125.947072,-8.432095],[126.644704,-8.398247],[126.957243,-8.273345],[127.335928,-8.397317],[126.967992,-8.668256],[125.925885,-9.106007],[125.08852,-9.393173],[125.07002,-9.089987],[124.968682,-8.89279]]]}},{"type":"Feature","properties":{"ADMIN":"South Africa","NAME_EN":"South Africa","CONTINENT":"Africa","ADM0_A3":"ZAF","ISO_A3":"ZAF","NAME":"South Africa","ISO_A2":"ZA","NAME_ZH":"南非"},"geometry":{"type":"Polygon","coordinates":[[[16.344977,-28.576705],[16.824017,-28.082162],[17.218929,-28.355943],[17.387497,-28.783514],[17.836152,-28.856378],[18.464899,-29.045462],[19.002127,-28.972443],[19.894734,-28.461105],[19.895768,-24.76779],[20.165726,-24.917962],[20.758609,-25.868136],[20.66647,-26.477453],[20.889609,-26.828543],[21.605896,-26.726534],[22.105969,-26.280256],[22.579532,-25.979448],[22.824271,-25.500459],[23.312097,-25.26869],[23.73357,-25.390129],[24.211267,-25.670216],[25.025171,-25.71967],[25.664666,-25.486816],[25.765849,-25.174845],[25.941652,-24.696373],[26.485753,-24.616327],[26.786407,-24.240691],[27.11941,-23.574323],[28.017236,-22.827754],[29.432188,-22.091313],[29.839037,-22.102216],[30.322883,-22.271612],[30.659865,-22.151567],[31.191409,-22.25151],[31.670398,-23.658969],[31.930589,-24.369417],[31.752408,-25.484284],[31.837778,-25.843332],[31.333158,-25.660191],[31.04408,-25.731452],[30.949667,-26.022649],[30.676609,-26.398078],[30.685962,-26.743845],[31.282773,-27.285879],[31.86806,-27.177927],[32.071665,-26.73382],[32.83012,-26.742192],[32.580265,-27.470158],[32.462133,-28.301011],[32.203389,-28.752405],[31.521001,-29.257387],[31.325561,-29.401978],[30.901763,-29.909957],[30.622813,-30.423776],[30.055716,-31.140269],[28.925553,-32.172041],[28.219756,-32.771953],[27.464608,-33.226964],[26.419452,-33.61495],[25.909664,-33.66704],[25.780628,-33.944646],[25.172862,-33.796851],[24.677853,-33.987176],[23.594043,-33.794474],[22.988189,-33.916431],[22.574157,-33.864083],[21.542799,-34.258839],[20.689053,-34.417175],[20.071261,-34.795137],[19.616405,-34.819166],[19.193278,-34.462599],[18.855315,-34.444306],[18.424643,-33.997873],[18.377411,-34.136521],[18.244499,-33.867752],[18.25008,-33.281431],[17.92519,-32.611291],[18.24791,-32.429131],[18.221762,-31.661633],[17.566918,-30.725721],[17.064416,-29.878641],[17.062918,-29.875954],[16.344977,-28.576705]],[[28.978263,-28.955597],[28.5417,-28.647502],[28.074338,-28.851469],[27.532511,-29.242711],[26.999262,-29.875954],[27.749397,-30.645106],[28.107205,-30.545732],[28.291069,-30.226217],[28.8484,-30.070051],[29.018415,-29.743766],[29.325166,-29.257387],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"ADMIN":"Lesotho","NAME_EN":"Lesotho","CONTINENT":"Africa","ADM0_A3":"LSO","ISO_A3":"LSO","NAME":"Lesotho","ISO_A2":"LS","NAME_ZH":"莱索托"},"geometry":{"type":"Polygon","coordinates":[[[28.978263,-28.955597],[29.325166,-29.257387],[29.018415,-29.743766],[28.8484,-30.070051],[28.291069,-30.226217],[28.107205,-30.545732],[27.749397,-30.645106],[26.999262,-29.875954],[27.532511,-29.242711],[28.074338,-28.851469],[28.5417,-28.647502],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"ADMIN":"Mexico","NAME_EN":"Mexico","CONTINENT":"North America","ADM0_A3":"MEX","ISO_A3":"MEX","NAME":"Mexico","ISO_A2":"MX","NAME_ZH":"墨西哥"},"geometry":{"type":"Polygon","coordinates":[[[-117.12776,32.53534],[-115.99135,32.61239],[-114.72139,32.72083],[-114.815,32.52528],[-113.30498,32.03914],[-111.02361,31.33472],[-109.035,31.34194],[-108.24194,31.34222],[-108.24,31.754854],[-106.50759,31.75452],[-106.1429,31.39995],[-105.63159,31.08383],[-105.03737,30.64402],[-104.70575,30.12173],[-104.45697,29.57196],[-103.94,29.27],[-103.11,28.97],[-102.48,29.76],[-101.6624,29.7793],[-100.9576,29.38071],[-100.45584,28.69612],[-100.11,28.11],[-99.52,27.54],[-99.3,26.84],[-99.02,26.37],[-98.24,26.06],[-97.53,25.84],[-97.140008,25.869997],[-97.528072,24.992144],[-97.702946,24.272343],[-97.776042,22.93258],[-97.872367,22.444212],[-97.699044,21.898689],[-97.38896,21.411019],[-97.189333,20.635433],[-96.525576,19.890931],[-96.292127,19.320371],[-95.900885,18.828024],[-94.839063,18.562717],[-94.42573,18.144371],[-93.548651,18.423837],[-92.786114,18.524839],[-92.037348,18.704569],[-91.407903,18.876083],[-90.77187,19.28412],[-90.53359,19.867418],[-90.451476,20.707522],[-90.278618,20.999855],[-89.601321,21.261726],[-88.543866,21.493675],[-87.658417,21.458846],[-87.05189,21.543543],[-86.811982,21.331515],[-86.845908,20.849865],[-87.383291,20.255405],[-87.621054,19.646553],[-87.43675,19.472403],[-87.58656,19.04013],[-87.837191,18.259816],[-88.090664,18.516648],[-88.300031,18.499982],[-88.490123,18.486831],[-88.848344,17.883198],[-89.029857,18.001511],[-89.150909,17.955468],[-89.14308,17.808319],[-90.067934,17.819326],[-91.00152,17.817595],[-91.002269,17.254658],[-91.453921,17.252177],[-91.08167,16.918477],[-90.711822,16.687483],[-90.600847,16.470778],[-90.438867,16.41011],[-90.464473,16.069562],[-91.74796,16.066565],[-92.229249,15.251447],[-92.087216,15.064585],[-92.20323,14.830103],[-92.22775,14.538829],[-93.359464,15.61543],[-93.875169,15.940164],[-94.691656,16.200975],[-95.250227,16.128318],[-96.053382,15.752088],[-96.557434,15.653515],[-97.263592,15.917065],[-98.01303,16.107312],[-98.947676,16.566043],[-99.697397,16.706164],[-100.829499,17.171071],[-101.666089,17.649026],[-101.918528,17.91609],[-102.478132,17.975751],[-103.50099,18.292295],[-103.917527,18.748572],[-104.99201,19.316134],[-105.493038,19.946767],[-105.731396,20.434102],[-105.397773,20.531719],[-105.500661,20.816895],[-105.270752,21.076285],[-105.265817,21.422104],[-105.603161,21.871146],[-105.693414,22.26908],[-106.028716,22.773752],[-106.90998,23.767774],[-107.915449,24.548915],[-108.401905,25.172314],[-109.260199,25.580609],[-109.444089,25.824884],[-109.291644,26.442934],[-109.801458,26.676176],[-110.391732,27.162115],[-110.641019,27.859876],[-111.178919,27.941241],[-111.759607,28.467953],[-112.228235,28.954409],[-112.271824,29.266844],[-112.809594,30.021114],[-113.163811,30.786881],[-113.148669,31.170966],[-113.871881,31.567608],[-114.205737,31.524045],[-114.776451,31.799532],[-114.9367,31.393485],[-114.771232,30.913617],[-114.673899,30.162681],[-114.330974,29.750432],[-113.588875,29.061611],[-113.424053,28.826174],[-113.271969,28.754783],[-113.140039,28.411289],[-112.962298,28.42519],[-112.761587,27.780217],[-112.457911,27.525814],[-112.244952,27.171727],[-111.616489,26.662817],[-111.284675,25.73259],[-110.987819,25.294606],[-110.710007,24.826004],[-110.655049,24.298595],[-110.172856,24.265548],[-109.771847,23.811183],[-109.409104,23.364672],[-109.433392,23.185588],[-109.854219,22.818272],[-110.031392,22.823078],[-110.295071,23.430973],[-110.949501,24.000964],[-111.670568,24.484423],[-112.182036,24.738413],[-112.148989,25.470125],[-112.300711,26.012004],[-112.777297,26.32196],[-113.464671,26.768186],[-113.59673,26.63946],[-113.848937,26.900064],[-114.465747,27.14209],[-115.055142,27.722727],[-114.982253,27.7982],[-114.570366,27.741485],[-114.199329,28.115003],[-114.162018,28.566112],[-114.931842,29.279479],[-115.518654,29.556362],[-115.887365,30.180794],[-116.25835,30.836464],[-116.721526,31.635744],[-117.12776,32.53534]]]}},{"type":"Feature","properties":{"ADMIN":"Uruguay","NAME_EN":"Uruguay","CONTINENT":"South America","ADM0_A3":"URY","ISO_A3":"URY","NAME":"Uruguay","ISO_A2":"UY","NAME_ZH":"乌拉圭"},"geometry":{"type":"Polygon","coordinates":[[[-57.625133,-30.216295],[-56.976026,-30.109686],[-55.973245,-30.883076],[-55.60151,-30.853879],[-54.572452,-31.494511],[-53.787952,-32.047243],[-53.209589,-32.727666],[-53.650544,-33.202004],[-53.373662,-33.768378],[-53.806426,-34.396815],[-54.935866,-34.952647],[-55.67409,-34.752659],[-56.215297,-34.859836],[-57.139685,-34.430456],[-57.817861,-34.462547],[-58.427074,-33.909454],[-58.349611,-33.263189],[-58.132648,-33.040567],[-58.14244,-32.044504],[-57.874937,-31.016556],[-57.625133,-30.216295]]]}},{"type":"Feature","properties":{"ADMIN":"Brazil","NAME_EN":"Brazil","CONTINENT":"South America","ADM0_A3":"BRA","ISO_A3":"BRA","NAME":"Brazil","ISO_A2":"BR","NAME_ZH":"巴西"},"geometry":{"type":"Polygon","coordinates":[[[-53.373662,-33.768378],[-53.650544,-33.202004],[-53.209589,-32.727666],[-53.787952,-32.047243],[-54.572452,-31.494511],[-55.60151,-30.853879],[-55.973245,-30.883076],[-56.976026,-30.109686],[-57.625133,-30.216295],[-56.2909,-28.852761],[-55.162286,-27.881915],[-54.490725,-27.474757],[-53.648735,-26.923473],[-53.628349,-26.124865],[-54.13005,-25.547639],[-54.625291,-25.739255],[-54.428946,-25.162185],[-54.293476,-24.5708],[-54.29296,-24.021014],[-54.652834,-23.839578],[-55.027902,-24.001274],[-55.400747,-23.956935],[-55.517639,-23.571998],[-55.610683,-22.655619],[-55.797958,-22.35693],[-56.473317,-22.0863],[-56.88151,-22.282154],[-57.937156,-22.090176],[-57.870674,-20.732688],[-58.166392,-20.176701],[-57.853802,-19.969995],[-57.949997,-19.400004],[-57.676009,-18.96184],[-57.498371,-18.174188],[-57.734558,-17.552468],[-58.280804,-17.27171],[-58.388058,-16.877109],[-58.24122,-16.299573],[-60.15839,-16.258284],[-60.542966,-15.09391],[-60.251149,-15.077219],[-60.264326,-14.645979],[-60.459198,-14.354007],[-60.503304,-13.775955],[-61.084121,-13.479384],[-61.713204,-13.489202],[-62.127081,-13.198781],[-62.80306,-13.000653],[-63.196499,-12.627033],[-64.316353,-12.461978],[-65.402281,-11.56627],[-65.321899,-10.895872],[-65.444837,-10.511451],[-65.338435,-9.761988],[-66.646908,-9.931331],[-67.173801,-10.306812],[-68.048192,-10.712059],[-68.271254,-11.014521],[-68.786158,-11.03638],[-69.529678,-10.951734],[-70.093752,-11.123972],[-70.548686,-11.009147],[-70.481894,-9.490118],[-71.302412,-10.079436],[-72.184891,-10.053598],[-72.563033,-9.520194],[-73.226713,-9.462213],[-73.015383,-9.032833],[-73.571059,-8.424447],[-73.987235,-7.52383],[-73.723401,-7.340999],[-73.724487,-6.918595],[-73.120027,-6.629931],[-73.219711,-6.089189],[-72.964507,-5.741251],[-72.891928,-5.274561],[-71.748406,-4.593983],[-70.928843,-4.401591],[-70.794769,-4.251265],[-69.893635,-4.298187],[-69.444102,-1.556287],[-69.420486,-1.122619],[-69.577065,-0.549992],[-70.020656,-0.185156],[-70.015566,0.541414],[-69.452396,0.706159],[-69.252434,0.602651],[-69.218638,0.985677],[-69.804597,1.089081],[-69.816973,1.714805],[-67.868565,1.692455],[-67.53781,2.037163],[-67.259998,1.719999],[-67.065048,1.130112],[-66.876326,1.253361],[-66.325765,0.724452],[-65.548267,0.789254],[-65.354713,1.095282],[-64.611012,1.328731],[-64.199306,1.492855],[-64.083085,1.916369],[-63.368788,2.2009],[-63.422867,2.411068],[-64.269999,2.497006],[-64.408828,3.126786],[-64.368494,3.79721],[-64.816064,4.056445],[-64.628659,4.148481],[-63.888343,4.02053],[-63.093198,3.770571],[-62.804533,4.006965],[-62.08543,4.162124],[-60.966893,4.536468],[-60.601179,4.918098],[-60.733574,5.200277],[-60.213683,5.244486],[-59.980959,5.014061],[-60.111002,4.574967],[-59.767406,4.423503],[-59.53804,3.958803],[-59.815413,3.606499],[-59.974525,2.755233],[-59.718546,2.24963],[-59.646044,1.786894],[-59.030862,1.317698],[-58.540013,1.268088],[-58.429477,1.463942],[-58.11345,1.507195],[-57.660971,1.682585],[-57.335823,1.948538],[-56.782704,1.863711],[-56.539386,1.899523],[-55.995698,1.817667],[-55.9056,2.021996],[-56.073342,2.220795],[-55.973322,2.510364],[-55.569755,2.421506],[-55.097587,2.523748],[-54.524754,2.311849],[-54.088063,2.105557],[-53.778521,2.376703],[-53.554839,2.334897],[-53.418465,2.053389],[-52.939657,2.124858],[-52.556425,2.504705],[-52.249338,3.241094],[-51.657797,4.156232],[-51.317146,4.203491],[-51.069771,3.650398],[-50.508875,1.901564],[-49.974076,1.736483],[-49.947101,1.04619],[-50.699251,0.222984],[-50.388211,-0.078445],[-48.620567,-0.235489],[-48.584497,-1.237805],[-47.824956,-0.581618],[-46.566584,-0.941028],[-44.905703,-1.55174],[-44.417619,-2.13775],[-44.581589,-2.691308],[-43.418791,-2.38311],[-41.472657,-2.912018],[-39.978665,-2.873054],[-38.500383,-3.700652],[-37.223252,-4.820946],[-36.452937,-5.109404],[-35.597796,-5.149504],[-35.235389,-5.464937],[-34.89603,-6.738193],[-34.729993,-7.343221],[-35.128212,-8.996401],[-35.636967,-9.649282],[-37.046519,-11.040721],[-37.683612,-12.171195],[-38.423877,-13.038119],[-38.673887,-13.057652],[-38.953276,-13.79337],[-38.882298,-15.667054],[-39.161092,-17.208407],[-39.267339,-17.867746],[-39.583521,-18.262296],[-39.760823,-19.599113],[-40.774741,-20.904512],[-40.944756,-21.937317],[-41.754164,-22.370676],[-41.988284,-22.97007],[-43.074704,-22.967693],[-44.647812,-23.351959],[-45.352136,-23.796842],[-46.472093,-24.088969],[-47.648972,-24.885199],[-48.495458,-25.877025],[-48.641005,-26.623698],[-48.474736,-27.175912],[-48.66152,-28.186135],[-48.888457,-28.674115],[-49.587329,-29.224469],[-50.696874,-30.984465],[-51.576226,-31.777698],[-52.256081,-32.24537],[-52.7121,-33.196578],[-53.373662,-33.768378]]]}},{"type":"Feature","properties":{"ADMIN":"Bolivia","NAME_EN":"Bolivia","CONTINENT":"South America","ADM0_A3":"BOL","ISO_A3":"BOL","NAME":"Bolivia","ISO_A2":"BO","NAME_ZH":"玻利维亚"},"geometry":{"type":"Polygon","coordinates":[[[-69.529678,-10.951734],[-68.786158,-11.03638],[-68.271254,-11.014521],[-68.048192,-10.712059],[-67.173801,-10.306812],[-66.646908,-9.931331],[-65.338435,-9.761988],[-65.444837,-10.511451],[-65.321899,-10.895872],[-65.402281,-11.56627],[-64.316353,-12.461978],[-63.196499,-12.627033],[-62.80306,-13.000653],[-62.127081,-13.198781],[-61.713204,-13.489202],[-61.084121,-13.479384],[-60.503304,-13.775955],[-60.459198,-14.354007],[-60.264326,-14.645979],[-60.251149,-15.077219],[-60.542966,-15.09391],[-60.15839,-16.258284],[-58.24122,-16.299573],[-58.388058,-16.877109],[-58.280804,-17.27171],[-57.734558,-17.552468],[-57.498371,-18.174188],[-57.676009,-18.96184],[-57.949997,-19.400004],[-57.853802,-19.969995],[-58.166392,-20.176701],[-58.183471,-19.868399],[-59.115042,-19.356906],[-60.043565,-19.342747],[-61.786326,-19.633737],[-62.265961,-20.513735],[-62.291179,-21.051635],[-62.685057,-22.249029],[-62.846468,-22.034985],[-63.986838,-21.993644],[-64.377021,-22.798091],[-64.964892,-22.075862],[-66.273339,-21.83231],[-67.106674,-22.735925],[-67.82818,-22.872919],[-68.219913,-21.494347],[-68.757167,-20.372658],[-68.442225,-19.405068],[-68.966818,-18.981683],[-69.100247,-18.260125],[-69.590424,-17.580012],[-68.959635,-16.500698],[-69.389764,-15.660129],[-69.160347,-15.323974],[-69.339535,-14.953195],[-68.948887,-14.453639],[-68.929224,-13.602684],[-68.88008,-12.899729],[-68.66508,-12.5613],[-69.529678,-10.951734]]]}},{"type":"Feature","properties":{"ADMIN":"Peru","NAME_EN":"Peru","CONTINENT":"South America","ADM0_A3":"PER","ISO_A3":"PER","NAME":"Peru","ISO_A2":"PE","NAME_ZH":"秘鲁"},"geometry":{"type":"Polygon","coordinates":[[[-69.893635,-4.298187],[-70.794769,-4.251265],[-70.928843,-4.401591],[-71.748406,-4.593983],[-72.891928,-5.274561],[-72.964507,-5.741251],[-73.219711,-6.089189],[-73.120027,-6.629931],[-73.724487,-6.918595],[-73.723401,-7.340999],[-73.987235,-7.52383],[-73.571059,-8.424447],[-73.015383,-9.032833],[-73.226713,-9.462213],[-72.563033,-9.520194],[-72.184891,-10.053598],[-71.302412,-10.079436],[-70.481894,-9.490118],[-70.548686,-11.009147],[-70.093752,-11.123972],[-69.529678,-10.951734],[-68.66508,-12.5613],[-68.88008,-12.899729],[-68.929224,-13.602684],[-68.948887,-14.453639],[-69.339535,-14.953195],[-69.160347,-15.323974],[-69.389764,-15.660129],[-68.959635,-16.500698],[-69.590424,-17.580012],[-69.858444,-18.092694],[-70.372572,-18.347975],[-71.37525,-17.773799],[-71.462041,-17.363488],[-73.44453,-16.359363],[-75.237883,-15.265683],[-76.009205,-14.649286],[-76.423469,-13.823187],[-76.259242,-13.535039],[-77.106192,-12.222716],[-78.092153,-10.377712],[-79.036953,-8.386568],[-79.44592,-7.930833],[-79.760578,-7.194341],[-80.537482,-6.541668],[-81.249996,-6.136834],[-80.926347,-5.690557],[-81.410943,-4.736765],[-81.09967,-4.036394],[-80.302561,-3.404856],[-80.184015,-3.821162],[-80.469295,-4.059287],[-80.442242,-4.425724],[-80.028908,-4.346091],[-79.624979,-4.454198],[-79.205289,-4.959129],[-78.639897,-4.547784],[-78.450684,-3.873097],[-77.837905,-3.003021],[-76.635394,-2.608678],[-75.544996,-1.56161],[-75.233723,-0.911417],[-75.373223,-0.152032],[-75.106625,-0.057205],[-74.441601,-0.53082],[-74.122395,-1.002833],[-73.659504,-1.260491],[-73.070392,-2.308954],[-72.325787,-2.434218],[-71.774761,-2.16979],[-71.413646,-2.342802],[-70.813476,-2.256865],[-70.047709,-2.725156],[-70.692682,-3.742872],[-70.394044,-3.766591],[-69.893635,-4.298187]]]}},{"type":"Feature","properties":{"ADMIN":"Colombia","NAME_EN":"Colombia","CONTINENT":"South America","ADM0_A3":"COL","ISO_A3":"COL","NAME":"Colombia","ISO_A2":"CO","NAME_ZH":"哥伦比亚"},"geometry":{"type":"Polygon","coordinates":[[[-66.876326,1.253361],[-67.065048,1.130112],[-67.259998,1.719999],[-67.53781,2.037163],[-67.868565,1.692455],[-69.816973,1.714805],[-69.804597,1.089081],[-69.218638,0.985677],[-69.252434,0.602651],[-69.452396,0.706159],[-70.015566,0.541414],[-70.020656,-0.185156],[-69.577065,-0.549992],[-69.420486,-1.122619],[-69.444102,-1.556287],[-69.893635,-4.298187],[-70.394044,-3.766591],[-70.692682,-3.742872],[-70.047709,-2.725156],[-70.813476,-2.256865],[-71.413646,-2.342802],[-71.774761,-2.16979],[-72.325787,-2.434218],[-73.070392,-2.308954],[-73.659504,-1.260491],[-74.122395,-1.002833],[-74.441601,-0.53082],[-75.106625,-0.057205],[-75.373223,-0.152032],[-75.801466,0.084801],[-76.292314,0.416047],[-76.57638,0.256936],[-77.424984,0.395687],[-77.668613,0.825893],[-77.855061,0.809925],[-78.855259,1.380924],[-78.990935,1.69137],[-78.617831,1.766404],[-78.662118,2.267355],[-78.42761,2.629556],[-77.931543,2.696606],[-77.510431,3.325017],[-77.12769,3.849636],[-77.496272,4.087606],[-77.307601,4.667984],[-77.533221,5.582812],[-77.318815,5.845354],[-77.476661,6.691116],[-77.881571,7.223771],[-77.753414,7.70984],[-77.431108,7.638061],[-77.242566,7.935278],[-77.474723,8.524286],[-77.353361,8.670505],[-76.836674,8.638749],[-76.086384,9.336821],[-75.6746,9.443248],[-75.664704,9.774003],[-75.480426,10.61899],[-74.906895,11.083045],[-74.276753,11.102036],[-74.197223,11.310473],[-73.414764,11.227015],[-72.627835,11.731972],[-72.238195,11.95555],[-71.75409,12.437303],[-71.399822,12.376041],[-71.137461,12.112982],[-71.331584,11.776284],[-71.973922,11.608672],[-72.227575,11.108702],[-72.614658,10.821975],[-72.905286,10.450344],[-73.027604,9.73677],[-73.304952,9.152],[-72.78873,9.085027],[-72.660495,8.625288],[-72.439862,8.405275],[-72.360901,8.002638],[-72.479679,7.632506],[-72.444487,7.423785],[-72.198352,7.340431],[-71.960176,6.991615],[-70.674234,7.087785],[-70.093313,6.960376],[-69.38948,6.099861],[-68.985319,6.206805],[-68.265052,6.153268],[-67.695087,6.267318],[-67.34144,6.095468],[-67.521532,5.55687],[-67.744697,5.221129],[-67.823012,4.503937],[-67.621836,3.839482],[-67.337564,3.542342],[-67.303173,3.318454],[-67.809938,2.820655],[-67.447092,2.600281],[-67.181294,2.250638],[-66.876326,1.253361]]]}},{"type":"Feature","properties":{"ADMIN":"Panama","NAME_EN":"Panama","CONTINENT":"North America","ADM0_A3":"PAN","ISO_A3":"PAN","NAME":"Panama","ISO_A2":"PA","NAME_ZH":"巴拿马"},"geometry":{"type":"Polygon","coordinates":[[[-77.353361,8.670505],[-77.474723,8.524286],[-77.242566,7.935278],[-77.431108,7.638061],[-77.753414,7.70984],[-77.881571,7.223771],[-78.214936,7.512255],[-78.429161,8.052041],[-78.182096,8.319182],[-78.435465,8.387705],[-78.622121,8.718124],[-79.120307,8.996092],[-79.557877,8.932375],[-79.760578,8.584515],[-80.164481,8.333316],[-80.382659,8.298409],[-80.480689,8.090308],[-80.00369,7.547524],[-80.276671,7.419754],[-80.421158,7.271572],[-80.886401,7.220541],[-81.059543,7.817921],[-81.189716,7.647906],[-81.519515,7.70661],[-81.721311,8.108963],[-82.131441,8.175393],[-82.390934,8.292362],[-82.820081,8.290864],[-82.850958,8.073823],[-82.965783,8.225028],[-82.913176,8.423517],[-82.829771,8.626295],[-82.868657,8.807266],[-82.719183,8.925709],[-82.927155,9.07433],[-82.932891,9.476812],[-82.546196,9.566135],[-82.187123,9.207449],[-82.207586,8.995575],[-81.808567,8.950617],[-81.714154,9.031955],[-81.439287,8.786234],[-80.947302,8.858504],[-80.521901,9.111072],[-79.9146,9.312765],[-79.573303,9.61161],[-79.021192,9.552931],[-79.05845,9.454565],[-78.500888,9.420459],[-78.055928,9.24773],[-77.729514,8.946844],[-77.353361,8.670505]]]}},{"type":"Feature","properties":{"ADMIN":"Costa Rica","NAME_EN":"Costa Rica","CONTINENT":"North America","ADM0_A3":"CRI","ISO_A3":"CRI","NAME":"Costa Rica","ISO_A2":"CR","NAME_ZH":"哥斯达黎加"},"geometry":{"type":"Polygon","coordinates":[[[-82.546196,9.566135],[-82.932891,9.476812],[-82.927155,9.07433],[-82.719183,8.925709],[-82.868657,8.807266],[-82.829771,8.626295],[-82.913176,8.423517],[-82.965783,8.225028],[-83.508437,8.446927],[-83.711474,8.656836],[-83.596313,8.830443],[-83.632642,9.051386],[-83.909886,9.290803],[-84.303402,9.487354],[-84.647644,9.615537],[-84.713351,9.908052],[-84.97566,10.086723],[-84.911375,9.795992],[-85.110923,9.55704],[-85.339488,9.834542],[-85.660787,9.933347],[-85.797445,10.134886],[-85.791709,10.439337],[-85.659314,10.754331],[-85.941725,10.895278],[-85.71254,11.088445],[-85.561852,11.217119],[-84.903003,10.952303],[-84.673069,11.082657],[-84.355931,10.999226],[-84.190179,10.79345],[-83.895054,10.726839],[-83.655612,10.938764],[-83.40232,10.395438],[-83.015677,9.992982],[-82.546196,9.566135]]]}},{"type":"Feature","properties":{"ADMIN":"Nicaragua","NAME_EN":"Nicaragua","CONTINENT":"North America","ADM0_A3":"NIC","ISO_A3":"NIC","NAME":"Nicaragua","ISO_A2":"NI","NAME_ZH":"尼加拉瓜"},"geometry":{"type":"Polygon","coordinates":[[[-83.655612,10.938764],[-83.895054,10.726839],[-84.190179,10.79345],[-84.355931,10.999226],[-84.673069,11.082657],[-84.903003,10.952303],[-85.561852,11.217119],[-85.71254,11.088445],[-86.058488,11.403439],[-86.52585,11.806877],[-86.745992,12.143962],[-87.167516,12.458258],[-87.668493,12.90991],[-87.557467,13.064552],[-87.392386,12.914018],[-87.316654,12.984686],[-87.005769,13.025794],[-86.880557,13.254204],[-86.733822,13.263093],[-86.755087,13.754845],[-86.520708,13.778487],[-86.312142,13.771356],[-86.096264,14.038187],[-85.801295,13.836055],[-85.698665,13.960078],[-85.514413,14.079012],[-85.165365,14.35437],[-85.148751,14.560197],[-85.052787,14.551541],[-84.924501,14.790493],[-84.820037,14.819587],[-84.649582,14.666805],[-84.449336,14.621614],[-84.228342,14.748764],[-83.975721,14.749436],[-83.628585,14.880074],[-83.489989,15.016267],[-83.147219,14.995829],[-83.233234,14.899866],[-83.284162,14.676624],[-83.182126,14.310703],[-83.4125,13.970078],[-83.519832,13.567699],[-83.552207,13.127054],[-83.498515,12.869292],[-83.473323,12.419087],[-83.626104,12.32085],[-83.719613,11.893124],[-83.650858,11.629032],[-83.85547,11.373311],[-83.808936,11.103044],[-83.655612,10.938764]]]}},{"type":"Feature","properties":{"ADMIN":"Honduras","NAME_EN":"Honduras","CONTINENT":"North America","ADM0_A3":"HND","ISO_A3":"HND","NAME":"Honduras","ISO_A2":"HN","NAME_ZH":"洪都拉斯"},"geometry":{"type":"Polygon","coordinates":[[[-83.147219,14.995829],[-83.489989,15.016267],[-83.628585,14.880074],[-83.975721,14.749436],[-84.228342,14.748764],[-84.449336,14.621614],[-84.649582,14.666805],[-84.820037,14.819587],[-84.924501,14.790493],[-85.052787,14.551541],[-85.148751,14.560197],[-85.165365,14.35437],[-85.514413,14.079012],[-85.698665,13.960078],[-85.801295,13.836055],[-86.096264,14.038187],[-86.312142,13.771356],[-86.520708,13.778487],[-86.755087,13.754845],[-86.733822,13.263093],[-86.880557,13.254204],[-87.005769,13.025794],[-87.316654,12.984686],[-87.489409,13.297535],[-87.793111,13.38448],[-87.723503,13.78505],[-87.859515,13.893312],[-88.065343,13.964626],[-88.503998,13.845486],[-88.541231,13.980155],[-88.843073,14.140507],[-89.058512,14.340029],[-89.353326,14.424133],[-89.145535,14.678019],[-89.22522,14.874286],[-89.154811,15.066419],[-88.68068,15.346247],[-88.225023,15.727722],[-88.121153,15.688655],[-87.901813,15.864458],[-87.61568,15.878799],[-87.522921,15.797279],[-87.367762,15.84694],[-86.903191,15.756713],[-86.440946,15.782835],[-86.119234,15.893449],[-86.001954,16.005406],[-85.683317,15.953652],[-85.444004,15.885749],[-85.182444,15.909158],[-84.983722,15.995923],[-84.52698,15.857224],[-84.368256,15.835158],[-84.063055,15.648244],[-83.773977,15.424072],[-83.410381,15.270903],[-83.147219,14.995829]]]}},{"type":"Feature","properties":{"ADMIN":"El Salvador","NAME_EN":"El Salvador","CONTINENT":"North America","ADM0_A3":"SLV","ISO_A3":"SLV","NAME":"El Salvador","ISO_A2":"SV","NAME_ZH":"萨尔瓦多"},"geometry":{"type":"Polygon","coordinates":[[[-89.353326,14.424133],[-89.058512,14.340029],[-88.843073,14.140507],[-88.541231,13.980155],[-88.503998,13.845486],[-88.065343,13.964626],[-87.859515,13.893312],[-87.723503,13.78505],[-87.793111,13.38448],[-87.904112,13.149017],[-88.483302,13.163951],[-88.843228,13.259734],[-89.256743,13.458533],[-89.812394,13.520622],[-90.095555,13.735338],[-90.064678,13.88197],[-89.721934,14.134228],[-89.534219,14.244816],[-89.587343,14.362586],[-89.353326,14.424133]]]}},{"type":"Feature","properties":{"ADMIN":"Guatemala","NAME_EN":"Guatemala","CONTINENT":"North America","ADM0_A3":"GTM","ISO_A3":"GTM","NAME":"Guatemala","ISO_A2":"GT","NAME_ZH":"危地马拉"},"geometry":{"type":"Polygon","coordinates":[[[-92.22775,14.538829],[-92.20323,14.830103],[-92.087216,15.064585],[-92.229249,15.251447],[-91.74796,16.066565],[-90.464473,16.069562],[-90.438867,16.41011],[-90.600847,16.470778],[-90.711822,16.687483],[-91.08167,16.918477],[-91.453921,17.252177],[-91.002269,17.254658],[-91.00152,17.817595],[-90.067934,17.819326],[-89.14308,17.808319],[-89.150806,17.015577],[-89.229122,15.886938],[-88.930613,15.887273],[-88.604586,15.70638],[-88.518364,15.855389],[-88.225023,15.727722],[-88.68068,15.346247],[-89.154811,15.066419],[-89.22522,14.874286],[-89.145535,14.678019],[-89.353326,14.424133],[-89.587343,14.362586],[-89.534219,14.244816],[-89.721934,14.134228],[-90.064678,13.88197],[-90.095555,13.735338],[-90.608624,13.909771],[-91.23241,13.927832],[-91.689747,14.126218],[-92.22775,14.538829]]]}},{"type":"Feature","properties":{"ADMIN":"Belize","NAME_EN":"Belize","CONTINENT":"North America","ADM0_A3":"BLZ","ISO_A3":"BLZ","NAME":"Belize","ISO_A2":"BZ","NAME_ZH":"伯利兹"},"geometry":{"type":"Polygon","coordinates":[[[-89.14308,17.808319],[-89.150909,17.955468],[-89.029857,18.001511],[-88.848344,17.883198],[-88.490123,18.486831],[-88.300031,18.499982],[-88.296336,18.353273],[-88.106813,18.348674],[-88.123479,18.076675],[-88.285355,17.644143],[-88.197867,17.489475],[-88.302641,17.131694],[-88.239518,17.036066],[-88.355428,16.530774],[-88.551825,16.265467],[-88.732434,16.233635],[-88.930613,15.887273],[-89.229122,15.886938],[-89.150806,17.015577],[-89.14308,17.808319]]]}},{"type":"Feature","properties":{"ADMIN":"Venezuela","NAME_EN":"Venezuela","CONTINENT":"South America","ADM0_A3":"VEN","ISO_A3":"VEN","NAME":"Venezuela","ISO_A2":"VE","NAME_ZH":"委内瑞拉"},"geometry":{"type":"Polygon","coordinates":[[[-60.733574,5.200277],[-60.601179,4.918098],[-60.966893,4.536468],[-62.08543,4.162124],[-62.804533,4.006965],[-63.093198,3.770571],[-63.888343,4.02053],[-64.628659,4.148481],[-64.816064,4.056445],[-64.368494,3.79721],[-64.408828,3.126786],[-64.269999,2.497006],[-63.422867,2.411068],[-63.368788,2.2009],[-64.083085,1.916369],[-64.199306,1.492855],[-64.611012,1.328731],[-65.354713,1.095282],[-65.548267,0.789254],[-66.325765,0.724452],[-66.876326,1.253361],[-67.181294,2.250638],[-67.447092,2.600281],[-67.809938,2.820655],[-67.303173,3.318454],[-67.337564,3.542342],[-67.621836,3.839482],[-67.823012,4.503937],[-67.744697,5.221129],[-67.521532,5.55687],[-67.34144,6.095468],[-67.695087,6.267318],[-68.265052,6.153268],[-68.985319,6.206805],[-69.38948,6.099861],[-70.093313,6.960376],[-70.674234,7.087785],[-71.960176,6.991615],[-72.198352,7.340431],[-72.444487,7.423785],[-72.479679,7.632506],[-72.360901,8.002638],[-72.439862,8.405275],[-72.660495,8.625288],[-72.78873,9.085027],[-73.304952,9.152],[-73.027604,9.73677],[-72.905286,10.450344],[-72.614658,10.821975],[-72.227575,11.108702],[-71.973922,11.608672],[-71.331584,11.776284],[-71.360006,11.539994],[-71.94705,11.423282],[-71.620868,10.96946],[-71.633064,10.446494],[-72.074174,9.865651],[-71.695644,9.072263],[-71.264559,9.137195],[-71.039999,9.859993],[-71.350084,10.211935],[-71.400623,10.968969],[-70.155299,11.375482],[-70.293843,11.846822],[-69.943245,12.162307],[-69.5843,11.459611],[-68.882999,11.443385],[-68.233271,10.885744],[-68.194127,10.554653],[-67.296249,10.545868],[-66.227864,10.648627],[-65.655238,10.200799],[-64.890452,10.077215],[-64.329479,10.389599],[-64.318007,10.641418],[-63.079322,10.701724],[-61.880946,10.715625],[-62.730119,10.420269],[-62.388512,9.948204],[-61.588767,9.873067],[-60.830597,9.38134],[-60.671252,8.580174],[-60.150096,8.602757],[-59.758285,8.367035],[-60.550588,7.779603],[-60.637973,7.415],[-60.295668,7.043911],[-60.543999,6.856584],[-61.159336,6.696077],[-61.139415,6.234297],[-61.410303,5.959068],[-60.733574,5.200277]]]}},{"type":"Feature","properties":{"ADMIN":"Guyana","NAME_EN":"Guyana","CONTINENT":"South America","ADM0_A3":"GUY","ISO_A3":"GUY","NAME":"Guyana","ISO_A2":"GY","NAME_ZH":"圭亚那"},"geometry":{"type":"Polygon","coordinates":[[[-56.539386,1.899523],[-56.782704,1.863711],[-57.335823,1.948538],[-57.660971,1.682585],[-58.11345,1.507195],[-58.429477,1.463942],[-58.540013,1.268088],[-59.030862,1.317698],[-59.646044,1.786894],[-59.718546,2.24963],[-59.974525,2.755233],[-59.815413,3.606499],[-59.53804,3.958803],[-59.767406,4.423503],[-60.111002,4.574967],[-59.980959,5.014061],[-60.213683,5.244486],[-60.733574,5.200277],[-61.410303,5.959068],[-61.139415,6.234297],[-61.159336,6.696077],[-60.543999,6.856584],[-60.295668,7.043911],[-60.637973,7.415],[-60.550588,7.779603],[-59.758285,8.367035],[-59.101684,7.999202],[-58.482962,7.347691],[-58.454876,6.832787],[-58.078103,6.809094],[-57.542219,6.321268],[-57.147436,5.97315],[-57.307246,5.073567],[-57.914289,4.812626],[-57.86021,4.576801],[-58.044694,4.060864],[-57.601569,3.334655],[-57.281433,3.333492],[-57.150098,2.768927],[-56.539386,1.899523]]]}},{"type":"Feature","properties":{"ADMIN":"Suriname","NAME_EN":"Suriname","CONTINENT":"South America","ADM0_A3":"SUR","ISO_A3":"SUR","NAME":"Suriname","ISO_A2":"SR","NAME_ZH":"苏里南"},"geometry":{"type":"Polygon","coordinates":[[[-54.524754,2.311849],[-55.097587,2.523748],[-55.569755,2.421506],[-55.973322,2.510364],[-56.073342,2.220795],[-55.9056,2.021996],[-55.995698,1.817667],[-56.539386,1.899523],[-57.150098,2.768927],[-57.281433,3.333492],[-57.601569,3.334655],[-58.044694,4.060864],[-57.86021,4.576801],[-57.914289,4.812626],[-57.307246,5.073567],[-57.147436,5.97315],[-55.949318,5.772878],[-55.84178,5.953125],[-55.03325,6.025291],[-53.958045,5.756548],[-54.478633,4.896756],[-54.399542,4.212611],[-54.006931,3.620038],[-54.181726,3.18978],[-54.269705,2.732392],[-54.524754,2.311849]]]}},{"type":"Feature","properties":{"ADMIN":"France","NAME_EN":"France","CONTINENT":"Europe","ADM0_A3":"FRA","ISO_A3":"-99","NAME":"France","ISO_A2":"-99","NAME_ZH":"法国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-51.657797,4.156232],[-52.249338,3.241094],[-52.556425,2.504705],[-52.939657,2.124858],[-53.418465,2.053389],[-53.554839,2.334897],[-53.778521,2.376703],[-54.088063,2.105557],[-54.524754,2.311849],[-54.269705,2.732392],[-54.181726,3.18978],[-54.006931,3.620038],[-54.399542,4.212611],[-54.478633,4.896756],[-53.958045,5.756548],[-53.618453,5.646529],[-52.882141,5.409851],[-51.823343,4.565768],[-51.657797,4.156232]]],[[[6.18632,49.463803],[6.65823,49.201958],[8.099279,49.017784],[7.593676,48.333019],[7.466759,47.620582],[7.192202,47.449766],[6.736571,47.541801],[6.768714,47.287708],[6.037389,46.725779],[6.022609,46.27299],[6.5001,46.429673],[6.843593,45.991147],[6.802355,45.70858],[7.096652,45.333099],[6.749955,45.028518],[7.007562,44.254767],[7.549596,44.127901],[7.435185,43.693845],[6.529245,43.128892],[4.556963,43.399651],[3.100411,43.075201],[2.985999,42.473015],[1.826793,42.343385],[0.701591,42.795734],[0.338047,42.579546],[-1.502771,43.034014],[-1.901351,43.422802],[-1.384225,44.02261],[-1.193798,46.014918],[-2.225724,47.064363],[-2.963276,47.570327],[-4.491555,47.954954],[-4.59235,48.68416],[-3.295814,48.901692],[-1.616511,48.644421],[-1.933494,49.776342],[-0.989469,49.347376],[1.338761,50.127173],[1.639001,50.946606],[2.513573,51.148506],[2.658422,50.796848],[3.123252,50.780363],[3.588184,50.378992],[4.286023,49.907497],[4.799222,49.985373],[5.674052,49.529484],[5.897759,49.442667],[6.18632,49.463803]]],[[[8.746009,42.628122],[9.390001,43.009985],[9.560016,42.152492],[9.229752,41.380007],[8.775723,41.583612],[8.544213,42.256517],[8.746009,42.628122]]]]}},{"type":"Feature","properties":{"ADMIN":"Ecuador","NAME_EN":"Ecuador","CONTINENT":"South America","ADM0_A3":"ECU","ISO_A3":"ECU","NAME":"Ecuador","ISO_A2":"EC","NAME_ZH":"厄瓜多尔"},"geometry":{"type":"Polygon","coordinates":[[[-75.373223,-0.152032],[-75.233723,-0.911417],[-75.544996,-1.56161],[-76.635394,-2.608678],[-77.837905,-3.003021],[-78.450684,-3.873097],[-78.639897,-4.547784],[-79.205289,-4.959129],[-79.624979,-4.454198],[-80.028908,-4.346091],[-80.442242,-4.425724],[-80.469295,-4.059287],[-80.184015,-3.821162],[-80.302561,-3.404856],[-79.770293,-2.657512],[-79.986559,-2.220794],[-80.368784,-2.685159],[-80.967765,-2.246943],[-80.764806,-1.965048],[-80.933659,-1.057455],[-80.58337,-0.906663],[-80.399325,-0.283703],[-80.020898,0.36034],[-80.09061,0.768429],[-79.542762,0.982938],[-78.855259,1.380924],[-77.855061,0.809925],[-77.668613,0.825893],[-77.424984,0.395687],[-76.57638,0.256936],[-76.292314,0.416047],[-75.801466,0.084801],[-75.373223,-0.152032]]]}},{"type":"Feature","properties":{"ADMIN":"Puerto Rico","NAME_EN":"Puerto Rico","CONTINENT":"North America","ADM0_A3":"PRI","ISO_A3":"PRI","NAME":"Puerto Rico","ISO_A2":"PR","NAME_ZH":"波多黎各"},"geometry":{"type":"Polygon","coordinates":[[[-66.282434,18.514762],[-65.771303,18.426679],[-65.591004,18.228035],[-65.847164,17.975906],[-66.599934,17.981823],[-67.184162,17.946553],[-67.242428,18.37446],[-67.100679,18.520601],[-66.282434,18.514762]]]}},{"type":"Feature","properties":{"ADMIN":"Jamaica","NAME_EN":"Jamaica","CONTINENT":"North America","ADM0_A3":"JAM","ISO_A3":"JAM","NAME":"Jamaica","ISO_A2":"JM","NAME_ZH":"牙买加"},"geometry":{"type":"Polygon","coordinates":[[[-77.569601,18.490525],[-76.896619,18.400867],[-76.365359,18.160701],[-76.199659,17.886867],[-76.902561,17.868238],[-77.206341,17.701116],[-77.766023,17.861597],[-78.337719,18.225968],[-78.217727,18.454533],[-77.797365,18.524218],[-77.569601,18.490525]]]}},{"type":"Feature","properties":{"ADMIN":"Cuba","NAME_EN":"Cuba","CONTINENT":"North America","ADM0_A3":"CUB","ISO_A3":"CUB","NAME":"Cuba","ISO_A2":"CU","NAME_ZH":"古巴"},"geometry":{"type":"Polygon","coordinates":[[[-82.268151,23.188611],[-81.404457,23.117271],[-80.618769,23.10598],[-79.679524,22.765303],[-79.281486,22.399202],[-78.347434,22.512166],[-77.993296,22.277194],[-77.146422,21.657851],[-76.523825,21.20682],[-76.19462,21.220565],[-75.598222,21.016624],[-75.67106,20.735091],[-74.933896,20.693905],[-74.178025,20.284628],[-74.296648,20.050379],[-74.961595,19.923435],[-75.63468,19.873774],[-76.323656,19.952891],[-77.755481,19.855481],[-77.085108,20.413354],[-77.492655,20.673105],[-78.137292,20.739949],[-78.482827,21.028613],[-78.719867,21.598114],[-79.285,21.559175],[-80.217475,21.827324],[-80.517535,22.037079],[-81.820943,22.192057],[-82.169992,22.387109],[-81.795002,22.636965],[-82.775898,22.68815],[-83.494459,22.168518],[-83.9088,22.154565],[-84.052151,21.910575],[-84.54703,21.801228],[-84.974911,21.896028],[-84.447062,22.20495],[-84.230357,22.565755],[-83.77824,22.788118],[-83.267548,22.983042],[-82.510436,23.078747],[-82.268151,23.188611]]]}},{"type":"Feature","properties":{"ADMIN":"Zimbabwe","NAME_EN":"Zimbabwe","CONTINENT":"Africa","ADM0_A3":"ZWE","ISO_A3":"ZWE","NAME":"Zimbabwe","ISO_A2":"ZW","NAME_ZH":"津巴布韦"},"geometry":{"type":"Polygon","coordinates":[[[31.191409,-22.25151],[30.659865,-22.151567],[30.322883,-22.271612],[29.839037,-22.102216],[29.432188,-22.091313],[28.794656,-21.639454],[28.02137,-21.485975],[27.727228,-20.851802],[27.724747,-20.499059],[27.296505,-20.39152],[26.164791,-19.293086],[25.850391,-18.714413],[25.649163,-18.536026],[25.264226,-17.73654],[26.381935,-17.846042],[26.706773,-17.961229],[27.044427,-17.938026],[27.598243,-17.290831],[28.467906,-16.4684],[28.825869,-16.389749],[28.947463,-16.043051],[29.516834,-15.644678],[30.274256,-15.507787],[30.338955,-15.880839],[31.173064,-15.860944],[31.636498,-16.07199],[31.852041,-16.319417],[32.328239,-16.392074],[32.847639,-16.713398],[32.849861,-17.979057],[32.654886,-18.67209],[32.611994,-19.419383],[32.772708,-19.715592],[32.659743,-20.30429],[32.508693,-20.395292],[32.244988,-21.116489],[31.191409,-22.25151]]]}},{"type":"Feature","properties":{"ADMIN":"Botswana","NAME_EN":"Botswana","CONTINENT":"Africa","ADM0_A3":"BWA","ISO_A3":"BWA","NAME":"Botswana","ISO_A2":"BW","NAME_ZH":"博茨瓦纳"},"geometry":{"type":"Polygon","coordinates":[[[29.432188,-22.091313],[28.017236,-22.827754],[27.11941,-23.574323],[26.786407,-24.240691],[26.485753,-24.616327],[25.941652,-24.696373],[25.765849,-25.174845],[25.664666,-25.486816],[25.025171,-25.71967],[24.211267,-25.670216],[23.73357,-25.390129],[23.312097,-25.26869],[22.824271,-25.500459],[22.579532,-25.979448],[22.105969,-26.280256],[21.605896,-26.726534],[20.889609,-26.828543],[20.66647,-26.477453],[20.758609,-25.868136],[20.165726,-24.917962],[19.895768,-24.76779],[19.895458,-21.849157],[20.881134,-21.814327],[20.910641,-18.252219],[21.65504,-18.219146],[23.196858,-17.869038],[23.579006,-18.281261],[24.217365,-17.889347],[24.520705,-17.887125],[25.084443,-17.661816],[25.264226,-17.73654],[25.649163,-18.536026],[25.850391,-18.714413],[26.164791,-19.293086],[27.296505,-20.39152],[27.724747,-20.499059],[27.727228,-20.851802],[28.02137,-21.485975],[28.794656,-21.639454],[29.432188,-22.091313]]]}},{"type":"Feature","properties":{"ADMIN":"Namibia","NAME_EN":"Namibia","CONTINENT":"Africa","ADM0_A3":"NAM","ISO_A3":"NAM","NAME":"Namibia","ISO_A2":"NA","NAME_ZH":"纳米比亚"},"geometry":{"type":"Polygon","coordinates":[[[19.895768,-24.76779],[19.894734,-28.461105],[19.002127,-28.972443],[18.464899,-29.045462],[17.836152,-28.856378],[17.387497,-28.783514],[17.218929,-28.355943],[16.824017,-28.082162],[16.344977,-28.576705],[15.601818,-27.821247],[15.210472,-27.090956],[14.989711,-26.117372],[14.743214,-25.39292],[14.408144,-23.853014],[14.385717,-22.656653],[14.257714,-22.111208],[13.868642,-21.699037],[13.352498,-20.872834],[12.826845,-19.673166],[12.608564,-19.045349],[11.794919,-18.069129],[11.734199,-17.301889],[12.215461,-17.111668],[12.814081,-16.941343],[13.462362,-16.971212],[14.058501,-17.423381],[14.209707,-17.353101],[18.263309,-17.309951],[18.956187,-17.789095],[21.377176,-17.930636],[23.215048,-17.523116],[24.033862,-17.295843],[24.682349,-17.353411],[25.07695,-17.578823],[25.084443,-17.661816],[24.520705,-17.887125],[24.217365,-17.889347],[23.579006,-18.281261],[23.196858,-17.869038],[21.65504,-18.219146],[20.910641,-18.252219],[20.881134,-21.814327],[19.895458,-21.849157],[19.895768,-24.76779]]]}},{"type":"Feature","properties":{"ADMIN":"Senegal","NAME_EN":"Senegal","CONTINENT":"Africa","ADM0_A3":"SEN","ISO_A3":"SEN","NAME":"Senegal","ISO_A2":"SN","NAME_ZH":"塞内加尔"},"geometry":{"type":"Polygon","coordinates":[[[-16.713729,13.594959],[-17.126107,14.373516],[-17.625043,14.729541],[-17.185173,14.919477],[-16.700706,15.621527],[-16.463098,16.135036],[-16.12069,16.455663],[-15.623666,16.369337],[-15.135737,16.587282],[-14.577348,16.598264],[-14.099521,16.304302],[-13.435738,16.039383],[-12.830658,15.303692],[-12.17075,14.616834],[-12.124887,13.994727],[-11.927716,13.422075],[-11.553398,13.141214],[-11.467899,12.754519],[-11.513943,12.442988],[-11.658301,12.386583],[-12.203565,12.465648],[-12.278599,12.35444],[-12.499051,12.33209],[-13.217818,12.575874],[-13.700476,12.586183],[-15.548477,12.62817],[-15.816574,12.515567],[-16.147717,12.547762],[-16.677452,12.384852],[-16.841525,13.151394],[-15.931296,13.130284],[-15.691001,13.270353],[-15.511813,13.27857],[-15.141163,13.509512],[-14.712197,13.298207],[-14.277702,13.280585],[-13.844963,13.505042],[-14.046992,13.794068],[-14.376714,13.62568],[-14.687031,13.630357],[-15.081735,13.876492],[-15.39877,13.860369],[-15.624596,13.623587],[-16.713729,13.594959]]]}},{"type":"Feature","properties":{"ADMIN":"Mali","NAME_EN":"Mali","CONTINENT":"Africa","ADM0_A3":"MLI","ISO_A3":"MLI","NAME":"Mali","ISO_A2":"ML","NAME_ZH":"马里"},"geometry":{"type":"Polygon","coordinates":[[[-11.513943,12.442988],[-11.467899,12.754519],[-11.553398,13.141214],[-11.927716,13.422075],[-12.124887,13.994727],[-12.17075,14.616834],[-11.834208,14.799097],[-11.666078,15.388208],[-11.349095,15.411256],[-10.650791,15.132746],[-10.086846,15.330486],[-9.700255,15.264107],[-9.550238,15.486497],[-5.537744,15.50169],[-5.315277,16.201854],[-5.488523,16.325102],[-5.971129,20.640833],[-6.453787,24.956591],[-4.923337,24.974574],[-1.550055,22.792666],[1.823228,20.610809],[2.060991,20.142233],[2.683588,19.85623],[3.146661,19.693579],[3.158133,19.057364],[4.267419,19.155265],[4.27021,16.852227],[3.723422,16.184284],[3.638259,15.56812],[2.749993,15.409525],[1.385528,15.323561],[1.015783,14.968182],[0.374892,14.928908],[-0.266257,14.924309],[-0.515854,15.116158],[-1.066363,14.973815],[-2.001035,14.559008],[-2.191825,14.246418],[-2.967694,13.79815],[-3.103707,13.541267],[-3.522803,13.337662],[-4.006391,13.472485],[-4.280405,13.228444],[-4.427166,12.542646],[-5.220942,11.713859],[-5.197843,11.375146],[-5.470565,10.95127],[-5.404342,10.370737],[-5.816926,10.222555],[-6.050452,10.096361],[-6.205223,10.524061],[-6.493965,10.411303],[-6.666461,10.430811],[-6.850507,10.138994],[-7.622759,10.147236],[-7.89959,10.297382],[-8.029944,10.206535],[-8.335377,10.494812],[-8.282357,10.792597],[-8.407311,10.909257],[-8.620321,10.810891],[-8.581305,11.136246],[-8.376305,11.393646],[-8.786099,11.812561],[-8.905265,12.088358],[-9.127474,12.30806],[-9.327616,12.334286],[-9.567912,12.194243],[-9.890993,12.060479],[-10.165214,11.844084],[-10.593224,11.923975],[-10.87083,12.177887],[-11.036556,12.211245],[-11.297574,12.077971],[-11.456169,12.076834],[-11.513943,12.442988]]]}},{"type":"Feature","properties":{"ADMIN":"Mauritania","NAME_EN":"Mauritania","CONTINENT":"Africa","ADM0_A3":"MRT","ISO_A3":"MRT","NAME":"Mauritania","ISO_A2":"MR","NAME_ZH":"毛里塔尼亚"},"geometry":{"type":"Polygon","coordinates":[[[-17.063423,20.999752],[-16.845194,21.333323],[-12.929102,21.327071],[-13.118754,22.77122],[-12.874222,23.284832],[-11.937224,23.374594],[-11.969419,25.933353],[-8.687294,25.881056],[-8.6844,27.395744],[-4.923337,24.974574],[-6.453787,24.956591],[-5.971129,20.640833],[-5.488523,16.325102],[-5.315277,16.201854],[-5.537744,15.50169],[-9.550238,15.486497],[-9.700255,15.264107],[-10.086846,15.330486],[-10.650791,15.132746],[-11.349095,15.411256],[-11.666078,15.388208],[-11.834208,14.799097],[-12.17075,14.616834],[-12.830658,15.303692],[-13.435738,16.039383],[-14.099521,16.304302],[-14.577348,16.598264],[-15.135737,16.587282],[-15.623666,16.369337],[-16.12069,16.455663],[-16.463098,16.135036],[-16.549708,16.673892],[-16.270552,17.166963],[-16.146347,18.108482],[-16.256883,19.096716],[-16.377651,19.593817],[-16.277838,20.092521],[-16.536324,20.567866],[-17.063423,20.999752]]]}},{"type":"Feature","properties":{"ADMIN":"Benin","NAME_EN":"Benin","CONTINENT":"Africa","ADM0_A3":"BEN","ISO_A3":"BEN","NAME":"Benin","ISO_A2":"BJ","NAME_ZH":"贝宁"},"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[1.865241,6.142158],[1.618951,6.832038],[1.664478,9.12859],[1.463043,9.334624],[1.425061,9.825395],[1.077795,10.175607],[0.772336,10.470808],[0.899563,10.997339],[1.24347,11.110511],[1.447178,11.547719],[1.935986,11.64115],[2.154474,11.94015],[2.490164,12.233052],[2.848643,12.235636],[3.61118,11.660167],[3.572216,11.327939],[3.797112,10.734746],[3.60007,10.332186],[3.705438,10.06321],[3.220352,9.444153],[2.912308,9.137608],[2.723793,8.506845],[2.749063,7.870734],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"ADMIN":"Niger","NAME_EN":"Niger","CONTINENT":"Africa","ADM0_A3":"NER","ISO_A3":"NER","NAME":"Niger","ISO_A2":"NE","NAME_ZH":"尼日尔"},"geometry":{"type":"Polygon","coordinates":[[[14.8513,22.86295],[15.096888,21.308519],[15.47106,21.04845],[15.487148,20.730415],[15.903247,20.387619],[15.685741,19.95718],[15.300441,17.92795],[15.247731,16.627306],[13.97217,15.68437],[13.540394,14.367134],[13.956699,13.996691],[13.954477,13.353449],[14.595781,13.330427],[14.495787,12.859396],[14.213531,12.802035],[14.181336,12.483657],[13.995353,12.461565],[13.318702,13.556356],[13.083987,13.596147],[12.302071,13.037189],[11.527803,13.32898],[10.989593,13.387323],[10.701032,13.246918],[10.114814,13.277252],[9.524928,12.851102],[9.014933,12.826659],[7.804671,13.343527],[7.330747,13.098038],[6.820442,13.115091],[6.445426,13.492768],[5.443058,13.865924],[4.368344,13.747482],[4.107946,13.531216],[3.967283,12.956109],[3.680634,12.552903],[3.61118,11.660167],[2.848643,12.235636],[2.490164,12.233052],[2.154474,11.94015],[2.177108,12.625018],[1.024103,12.851826],[0.993046,13.33575],[0.429928,13.988733],[0.295646,14.444235],[0.374892,14.928908],[1.015783,14.968182],[1.385528,15.323561],[2.749993,15.409525],[3.638259,15.56812],[3.723422,16.184284],[4.27021,16.852227],[4.267419,19.155265],[5.677566,19.601207],[8.572893,21.565661],[11.999506,23.471668],[13.581425,23.040506],[14.143871,22.491289],[14.8513,22.86295]]]}},{"type":"Feature","properties":{"ADMIN":"Nigeria","NAME_EN":"Nigeria","CONTINENT":"Africa","ADM0_A3":"NGA","ISO_A3":"NGA","NAME":"Nigeria","ISO_A2":"NG","NAME_ZH":"尼日利亚"},"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[2.749063,7.870734],[2.723793,8.506845],[2.912308,9.137608],[3.220352,9.444153],[3.705438,10.06321],[3.60007,10.332186],[3.797112,10.734746],[3.572216,11.327939],[3.61118,11.660167],[3.680634,12.552903],[3.967283,12.956109],[4.107946,13.531216],[4.368344,13.747482],[5.443058,13.865924],[6.445426,13.492768],[6.820442,13.115091],[7.330747,13.098038],[7.804671,13.343527],[9.014933,12.826659],[9.524928,12.851102],[10.114814,13.277252],[10.701032,13.246918],[10.989593,13.387323],[11.527803,13.32898],[12.302071,13.037189],[13.083987,13.596147],[13.318702,13.556356],[13.995353,12.461565],[14.181336,12.483657],[14.577178,12.085361],[14.468192,11.904752],[14.415379,11.572369],[13.57295,10.798566],[13.308676,10.160362],[13.1676,9.640626],[12.955468,9.417772],[12.753672,8.717763],[12.218872,8.305824],[12.063946,7.799808],[11.839309,7.397042],[11.745774,6.981383],[11.058788,6.644427],[10.497375,7.055358],[10.118277,7.03877],[9.522706,6.453482],[9.233163,6.444491],[8.757533,5.479666],[8.500288,4.771983],[7.462108,4.412108],[7.082596,4.464689],[6.698072,4.240594],[5.898173,4.262453],[5.362805,4.887971],[5.033574,5.611802],[4.325607,6.270651],[3.57418,6.2583],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"ADMIN":"Cameroon","NAME_EN":"Cameroon","CONTINENT":"Africa","ADM0_A3":"CMR","ISO_A3":"CMR","NAME":"Cameroon","ISO_A2":"CM","NAME_ZH":"喀麦隆"},"geometry":{"type":"Polygon","coordinates":[[[14.495787,12.859396],[14.89336,12.21905],[14.960152,11.555574],[14.923565,10.891325],[15.467873,9.982337],[14.909354,9.992129],[14.627201,9.920919],[14.171466,10.021378],[13.954218,9.549495],[14.544467,8.965861],[14.979996,8.796104],[15.120866,8.38215],[15.436092,7.692812],[15.27946,7.421925],[14.776545,6.408498],[14.53656,6.226959],[14.459407,5.451761],[14.558936,5.030598],[14.478372,4.732605],[14.950953,4.210389],[15.03622,3.851367],[15.405396,3.335301],[15.862732,3.013537],[15.907381,2.557389],[16.012852,2.26764],[15.940919,1.727673],[15.146342,1.964015],[14.337813,2.227875],[13.075822,2.267097],[12.951334,2.321616],[12.35938,2.192812],[11.751665,2.326758],[11.276449,2.261051],[9.649158,2.283866],[9.795196,3.073404],[9.404367,3.734527],[8.948116,3.904129],[8.744924,4.352215],[8.488816,4.495617],[8.500288,4.771983],[8.757533,5.479666],[9.233163,6.444491],[9.522706,6.453482],[10.118277,7.03877],[10.497375,7.055358],[11.058788,6.644427],[11.745774,6.981383],[11.839309,7.397042],[12.063946,7.799808],[12.218872,8.305824],[12.753672,8.717763],[12.955468,9.417772],[13.1676,9.640626],[13.308676,10.160362],[13.57295,10.798566],[14.415379,11.572369],[14.468192,11.904752],[14.577178,12.085361],[14.181336,12.483657],[14.213531,12.802035],[14.495787,12.859396]]]}},{"type":"Feature","properties":{"ADMIN":"Togo","NAME_EN":"Togo","CONTINENT":"Africa","ADM0_A3":"TGO","ISO_A3":"TGO","NAME":"Togo","ISO_A2":"TG","NAME_ZH":"多哥"},"geometry":{"type":"Polygon","coordinates":[[[0.899563,10.997339],[0.772336,10.470808],[1.077795,10.175607],[1.425061,9.825395],[1.463043,9.334624],[1.664478,9.12859],[1.618951,6.832038],[1.865241,6.142158],[1.060122,5.928837],[0.836931,6.279979],[0.570384,6.914359],[0.490957,7.411744],[0.712029,8.312465],[0.461192,8.677223],[0.365901,9.465004],[0.36758,10.191213],[-0.049785,10.706918],[0.023803,11.018682],[0.899563,10.997339]]]}},{"type":"Feature","properties":{"ADMIN":"Ghana","NAME_EN":"Ghana","CONTINENT":"Africa","ADM0_A3":"GHA","ISO_A3":"GHA","NAME":"Ghana","ISO_A2":"GH","NAME_ZH":"加纳"},"geometry":{"type":"Polygon","coordinates":[[[0.023803,11.018682],[-0.049785,10.706918],[0.36758,10.191213],[0.365901,9.465004],[0.461192,8.677223],[0.712029,8.312465],[0.490957,7.411744],[0.570384,6.914359],[0.836931,6.279979],[1.060122,5.928837],[-0.507638,5.343473],[-1.063625,5.000548],[-1.964707,4.710462],[-2.856125,4.994476],[-2.810701,5.389051],[-3.24437,6.250472],[-2.983585,7.379705],[-2.56219,8.219628],[-2.827496,9.642461],[-2.963896,10.395335],[-2.940409,10.96269],[-1.203358,11.009819],[-0.761576,10.93693],[-0.438702,11.098341],[0.023803,11.018682]]]}},{"type":"Feature","properties":{"ADMIN":"Ivory Coast","NAME_EN":"Ivory Coast","CONTINENT":"Africa","ADM0_A3":"CIV","ISO_A3":"CIV","NAME":"Côte d'Ivoire","ISO_A2":"CI","NAME_ZH":"科特迪瓦"},"geometry":{"type":"Polygon","coordinates":[[[-8.029944,10.206535],[-7.89959,10.297382],[-7.622759,10.147236],[-6.850507,10.138994],[-6.666461,10.430811],[-6.493965,10.411303],[-6.205223,10.524061],[-6.050452,10.096361],[-5.816926,10.222555],[-5.404342,10.370737],[-4.954653,10.152714],[-4.779884,9.821985],[-4.330247,9.610835],[-3.980449,9.862344],[-3.511899,9.900326],[-2.827496,9.642461],[-2.56219,8.219628],[-2.983585,7.379705],[-3.24437,6.250472],[-2.810701,5.389051],[-2.856125,4.994476],[-3.311084,4.984296],[-4.00882,5.179813],[-4.649917,5.168264],[-5.834496,4.993701],[-6.528769,4.705088],[-7.518941,4.338288],[-7.712159,4.364566],[-7.635368,5.188159],[-7.539715,5.313345],[-7.570153,5.707352],[-7.993693,6.12619],[-8.311348,6.193033],[-8.60288,6.467564],[-8.385452,6.911801],[-8.485446,7.395208],[-8.439298,7.686043],[-8.280703,7.68718],[-8.221792,8.123329],[-8.299049,8.316444],[-8.203499,8.455453],[-7.8321,8.575704],[-8.079114,9.376224],[-8.309616,9.789532],[-8.229337,10.12902],[-8.029944,10.206535]]]}},{"type":"Feature","properties":{"ADMIN":"Guinea","NAME_EN":"Guinea","CONTINENT":"Africa","ADM0_A3":"GIN","ISO_A3":"GIN","NAME":"Guinea","ISO_A2":"GN","NAME_ZH":"几内亚"},"geometry":{"type":"Polygon","coordinates":[[[-13.700476,12.586183],[-13.217818,12.575874],[-12.499051,12.33209],[-12.278599,12.35444],[-12.203565,12.465648],[-11.658301,12.386583],[-11.513943,12.442988],[-11.456169,12.076834],[-11.297574,12.077971],[-11.036556,12.211245],[-10.87083,12.177887],[-10.593224,11.923975],[-10.165214,11.844084],[-9.890993,12.060479],[-9.567912,12.194243],[-9.327616,12.334286],[-9.127474,12.30806],[-8.905265,12.088358],[-8.786099,11.812561],[-8.376305,11.393646],[-8.581305,11.136246],[-8.620321,10.810891],[-8.407311,10.909257],[-8.282357,10.792597],[-8.335377,10.494812],[-8.029944,10.206535],[-8.229337,10.12902],[-8.309616,9.789532],[-8.079114,9.376224],[-7.8321,8.575704],[-8.203499,8.455453],[-8.299049,8.316444],[-8.221792,8.123329],[-8.280703,7.68718],[-8.439298,7.686043],[-8.722124,7.711674],[-8.926065,7.309037],[-9.208786,7.313921],[-9.403348,7.526905],[-9.33728,7.928534],[-9.755342,8.541055],[-10.016567,8.428504],[-10.230094,8.406206],[-10.505477,8.348896],[-10.494315,8.715541],[-10.65477,8.977178],[-10.622395,9.26791],[-10.839152,9.688246],[-11.117481,10.045873],[-11.917277,10.046984],[-12.150338,9.858572],[-12.425929,9.835834],[-12.596719,9.620188],[-12.711958,9.342712],[-13.24655,8.903049],[-13.685154,9.494744],[-14.074045,9.886167],[-14.330076,10.01572],[-14.579699,10.214467],[-14.693232,10.656301],[-14.839554,10.876572],[-15.130311,11.040412],[-14.685687,11.527824],[-14.382192,11.509272],[-14.121406,11.677117],[-13.9008,11.678719],[-13.743161,11.811269],[-13.828272,12.142644],[-13.718744,12.247186],[-13.700476,12.586183]]]}},{"type":"Feature","properties":{"ADMIN":"Guinea-Bissau","NAME_EN":"Guinea-Bissau","CONTINENT":"Africa","ADM0_A3":"GNB","ISO_A3":"GNB","NAME":"Guinea-Bissau","ISO_A2":"GW","NAME_ZH":"几内亚比绍"},"geometry":{"type":"Polygon","coordinates":[[[-16.677452,12.384852],[-16.147717,12.547762],[-15.816574,12.515567],[-15.548477,12.62817],[-13.700476,12.586183],[-13.718744,12.247186],[-13.828272,12.142644],[-13.743161,11.811269],[-13.9008,11.678719],[-14.121406,11.677117],[-14.382192,11.509272],[-14.685687,11.527824],[-15.130311,11.040412],[-15.66418,11.458474],[-16.085214,11.524594],[-16.314787,11.806515],[-16.308947,11.958702],[-16.613838,12.170911],[-16.677452,12.384852]]]}},{"type":"Feature","properties":{"ADMIN":"Liberia","NAME_EN":"Liberia","CONTINENT":"Africa","ADM0_A3":"LBR","ISO_A3":"LBR","NAME":"Liberia","ISO_A2":"LR","NAME_ZH":"利比里亚"},"geometry":{"type":"Polygon","coordinates":[[[-8.439298,7.686043],[-8.485446,7.395208],[-8.385452,6.911801],[-8.60288,6.467564],[-8.311348,6.193033],[-7.993693,6.12619],[-7.570153,5.707352],[-7.539715,5.313345],[-7.635368,5.188159],[-7.712159,4.364566],[-7.974107,4.355755],[-9.004794,4.832419],[-9.91342,5.593561],[-10.765384,6.140711],[-11.438779,6.785917],[-11.199802,7.105846],[-11.146704,7.396706],[-10.695595,7.939464],[-10.230094,8.406206],[-10.016567,8.428504],[-9.755342,8.541055],[-9.33728,7.928534],[-9.403348,7.526905],[-9.208786,7.313921],[-8.926065,7.309037],[-8.722124,7.711674],[-8.439298,7.686043]]]}},{"type":"Feature","properties":{"ADMIN":"Sierra Leone","NAME_EN":"Sierra Leone","CONTINENT":"Africa","ADM0_A3":"SLE","ISO_A3":"SLE","NAME":"Sierra Leone","ISO_A2":"SL","NAME_ZH":"塞拉利昂"},"geometry":{"type":"Polygon","coordinates":[[[-13.24655,8.903049],[-12.711958,9.342712],[-12.596719,9.620188],[-12.425929,9.835834],[-12.150338,9.858572],[-11.917277,10.046984],[-11.117481,10.045873],[-10.839152,9.688246],[-10.622395,9.26791],[-10.65477,8.977178],[-10.494315,8.715541],[-10.505477,8.348896],[-10.230094,8.406206],[-10.695595,7.939464],[-11.146704,7.396706],[-11.199802,7.105846],[-11.438779,6.785917],[-11.708195,6.860098],[-12.428099,7.262942],[-12.949049,7.798646],[-13.124025,8.163946],[-13.24655,8.903049]]]}},{"type":"Feature","properties":{"ADMIN":"Burkina Faso","NAME_EN":"Burkina Faso","CONTINENT":"Africa","ADM0_A3":"BFA","ISO_A3":"BFA","NAME":"Burkina Faso","ISO_A2":"BF","NAME_ZH":"布基纳法索"},"geometry":{"type":"Polygon","coordinates":[[[-5.404342,10.370737],[-5.470565,10.95127],[-5.197843,11.375146],[-5.220942,11.713859],[-4.427166,12.542646],[-4.280405,13.228444],[-4.006391,13.472485],[-3.522803,13.337662],[-3.103707,13.541267],[-2.967694,13.79815],[-2.191825,14.246418],[-2.001035,14.559008],[-1.066363,14.973815],[-0.515854,15.116158],[-0.266257,14.924309],[0.374892,14.928908],[0.295646,14.444235],[0.429928,13.988733],[0.993046,13.33575],[1.024103,12.851826],[2.177108,12.625018],[2.154474,11.94015],[1.935986,11.64115],[1.447178,11.547719],[1.24347,11.110511],[0.899563,10.997339],[0.023803,11.018682],[-0.438702,11.098341],[-0.761576,10.93693],[-1.203358,11.009819],[-2.940409,10.96269],[-2.963896,10.395335],[-2.827496,9.642461],[-3.511899,9.900326],[-3.980449,9.862344],[-4.330247,9.610835],[-4.779884,9.821985],[-4.954653,10.152714],[-5.404342,10.370737]]]}},{"type":"Feature","properties":{"ADMIN":"Central African Republic","NAME_EN":"Central African Republic","CONTINENT":"Africa","ADM0_A3":"CAF","ISO_A3":"CAF","NAME":"Central African Rep.","ISO_A2":"CF","NAME_ZH":"中非共和国"},"geometry":{"type":"Polygon","coordinates":[[[27.374226,5.233944],[27.044065,5.127853],[26.402761,5.150875],[25.650455,5.256088],[25.278798,5.170408],[25.128833,4.927245],[24.805029,4.897247],[24.410531,5.108784],[23.297214,4.609693],[22.84148,4.710126],[22.704124,4.633051],[22.405124,4.02916],[21.659123,4.224342],[20.927591,4.322786],[20.290679,4.691678],[19.467784,5.031528],[18.932312,4.709506],[18.542982,4.201785],[18.453065,3.504386],[17.8099,3.560196],[17.133042,3.728197],[16.537058,3.198255],[16.012852,2.26764],[15.907381,2.557389],[15.862732,3.013537],[15.405396,3.335301],[15.03622,3.851367],[14.950953,4.210389],[14.478372,4.732605],[14.558936,5.030598],[14.459407,5.451761],[14.53656,6.226959],[14.776545,6.408498],[15.27946,7.421925],[16.106232,7.497088],[16.290562,7.754307],[16.456185,7.734774],[16.705988,7.508328],[17.96493,7.890914],[18.389555,8.281304],[18.911022,8.630895],[18.81201,8.982915],[19.094008,9.074847],[20.059685,9.012706],[21.000868,9.475985],[21.723822,10.567056],[22.231129,10.971889],[22.864165,11.142395],[22.977544,10.714463],[23.554304,10.089255],[23.55725,9.681218],[23.394779,9.265068],[23.459013,8.954286],[23.805813,8.666319],[24.567369,8.229188],[25.114932,7.825104],[25.124131,7.500085],[25.796648,6.979316],[26.213418,6.546603],[26.465909,5.946717],[27.213409,5.550953],[27.374226,5.233944]]]}},{"type":"Feature","properties":{"ADMIN":"Republic of the Congo","NAME_EN":"Republic of the Congo","CONTINENT":"Africa","ADM0_A3":"COG","ISO_A3":"COG","NAME":"Congo","ISO_A2":"CG","NAME_ZH":"刚果共和国"},"geometry":{"type":"Polygon","coordinates":[[[18.453065,3.504386],[18.393792,2.900443],[18.094276,2.365722],[17.898835,1.741832],[17.774192,0.855659],[17.82654,0.288923],[17.663553,-0.058084],[17.638645,-0.424832],[17.523716,-0.74383],[16.865307,-1.225816],[16.407092,-1.740927],[15.972803,-2.712392],[16.00629,-3.535133],[15.75354,-3.855165],[15.170992,-4.343507],[14.582604,-4.970239],[14.209035,-4.793092],[14.144956,-4.510009],[13.600235,-4.500138],[13.25824,-4.882957],[12.995517,-4.781103],[12.62076,-4.438023],[12.318608,-4.60623],[11.914963,-5.037987],[11.093773,-3.978827],[11.855122,-3.426871],[11.478039,-2.765619],[11.820964,-2.514161],[12.495703,-2.391688],[12.575284,-1.948511],[13.109619,-2.42874],[13.992407,-2.470805],[14.29921,-1.998276],[14.425456,-1.333407],[14.316418,-0.552627],[13.843321,0.038758],[14.276266,1.19693],[14.026669,1.395677],[13.282631,1.314184],[13.003114,1.830896],[13.075822,2.267097],[14.337813,2.227875],[15.146342,1.964015],[15.940919,1.727673],[16.012852,2.26764],[16.537058,3.198255],[17.133042,3.728197],[17.8099,3.560196],[18.453065,3.504386]]]}},{"type":"Feature","properties":{"ADMIN":"Gabon","NAME_EN":"Gabon","CONTINENT":"Africa","ADM0_A3":"GAB","ISO_A3":"GAB","NAME":"Gabon","ISO_A2":"GA","NAME_ZH":"加蓬"},"geometry":{"type":"Polygon","coordinates":[[[11.276449,2.261051],[11.751665,2.326758],[12.35938,2.192812],[12.951334,2.321616],[13.075822,2.267097],[13.003114,1.830896],[13.282631,1.314184],[14.026669,1.395677],[14.276266,1.19693],[13.843321,0.038758],[14.316418,-0.552627],[14.425456,-1.333407],[14.29921,-1.998276],[13.992407,-2.470805],[13.109619,-2.42874],[12.575284,-1.948511],[12.495703,-2.391688],[11.820964,-2.514161],[11.478039,-2.765619],[11.855122,-3.426871],[11.093773,-3.978827],[10.066135,-2.969483],[9.405245,-2.144313],[8.797996,-1.111301],[8.830087,-0.779074],[9.04842,-0.459351],[9.291351,0.268666],[9.492889,1.01012],[9.830284,1.067894],[11.285079,1.057662],[11.276449,2.261051]]]}},{"type":"Feature","properties":{"ADMIN":"Equatorial Guinea","NAME_EN":"Equatorial Guinea","CONTINENT":"Africa","ADM0_A3":"GNQ","ISO_A3":"GNQ","NAME":"Eq. Guinea","ISO_A2":"GQ","NAME_ZH":"赤道几内亚"},"geometry":{"type":"Polygon","coordinates":[[[9.649158,2.283866],[11.276449,2.261051],[11.285079,1.057662],[9.830284,1.067894],[9.492889,1.01012],[9.305613,1.160911],[9.649158,2.283866]]]}},{"type":"Feature","properties":{"ADMIN":"Zambia","NAME_EN":"Zambia","CONTINENT":"Africa","ADM0_A3":"ZMB","ISO_A3":"ZMB","NAME":"Zambia","ISO_A2":"ZM","NAME_ZH":"赞比亚"},"geometry":{"type":"Polygon","coordinates":[[[30.74001,-8.340006],[31.157751,-8.594579],[31.556348,-8.762049],[32.191865,-8.930359],[32.759375,-9.230599],[33.231388,-9.676722],[33.485688,-10.525559],[33.31531,-10.79655],[33.114289,-11.607198],[33.306422,-12.435778],[32.991764,-12.783871],[32.688165,-13.712858],[33.214025,-13.97186],[30.179481,-14.796099],[30.274256,-15.507787],[29.516834,-15.644678],[28.947463,-16.043051],[28.825869,-16.389749],[28.467906,-16.4684],[27.598243,-17.290831],[27.044427,-17.938026],[26.706773,-17.961229],[26.381935,-17.846042],[25.264226,-17.73654],[25.084443,-17.661816],[25.07695,-17.578823],[24.682349,-17.353411],[24.033862,-17.295843],[23.215048,-17.523116],[22.562478,-16.898451],[21.887843,-16.08031],[21.933886,-12.898437],[24.016137,-12.911046],[23.930922,-12.565848],[24.079905,-12.191297],[23.904154,-11.722282],[24.017894,-11.237298],[23.912215,-10.926826],[24.257155,-10.951993],[24.314516,-11.262826],[24.78317,-11.238694],[25.418118,-11.330936],[25.75231,-11.784965],[26.553088,-11.92444],[27.16442,-11.608748],[27.388799,-12.132747],[28.155109,-12.272481],[28.523562,-12.698604],[28.934286,-13.248958],[29.699614,-13.257227],[29.616001,-12.178895],[29.341548,-12.360744],[28.642417,-11.971569],[28.372253,-11.793647],[28.49607,-10.789884],[28.673682,-9.605925],[28.449871,-9.164918],[28.734867,-8.526559],[29.002912,-8.407032],[30.346086,-8.238257],[30.74001,-8.340006]]]}},{"type":"Feature","properties":{"ADMIN":"Malawi","NAME_EN":"Malawi","CONTINENT":"Africa","ADM0_A3":"MWI","ISO_A3":"MWI","NAME":"Malawi","ISO_A2":"MW","NAME_ZH":"马拉维"},"geometry":{"type":"Polygon","coordinates":[[[32.759375,-9.230599],[33.73972,-9.41715],[33.940838,-9.693674],[34.28,-10.16],[34.559989,-11.52002],[34.280006,-12.280025],[34.559989,-13.579998],[34.907151,-13.565425],[35.267956,-13.887834],[35.686845,-14.611046],[35.771905,-15.896859],[35.339063,-16.10744],[35.03381,-16.8013],[34.381292,-16.18356],[34.307291,-15.478641],[34.517666,-15.013709],[34.459633,-14.61301],[34.064825,-14.35995],[33.7897,-14.451831],[33.214025,-13.97186],[32.688165,-13.712858],[32.991764,-12.783871],[33.306422,-12.435778],[33.114289,-11.607198],[33.31531,-10.79655],[33.485688,-10.525559],[33.231388,-9.676722],[32.759375,-9.230599]]]}},{"type":"Feature","properties":{"ADMIN":"Mozambique","NAME_EN":"Mozambique","CONTINENT":"Africa","ADM0_A3":"MOZ","ISO_A3":"MOZ","NAME":"Mozambique","ISO_A2":"MZ","NAME_ZH":"莫桑比克"},"geometry":{"type":"Polygon","coordinates":[[[34.559989,-11.52002],[35.312398,-11.439146],[36.514082,-11.720938],[36.775151,-11.594537],[37.47129,-11.56876],[37.82764,-11.26879],[38.427557,-11.285202],[39.521,-10.89688],[40.31659,-10.3171],[40.316586,-10.317098],[40.316589,-10.317096],[40.478387,-10.765441],[40.437253,-11.761711],[40.560811,-12.639177],[40.59962,-14.201975],[40.775475,-14.691764],[40.477251,-15.406294],[40.089264,-16.100774],[39.452559,-16.720891],[38.538351,-17.101023],[37.411133,-17.586368],[36.281279,-18.659688],[35.896497,-18.84226],[35.1984,-19.552811],[34.786383,-19.784012],[34.701893,-20.497043],[35.176127,-21.254361],[35.373428,-21.840837],[35.385848,-22.14],[35.562546,-22.09],[35.533935,-23.070788],[35.371774,-23.535359],[35.60747,-23.706563],[35.458746,-24.12261],[35.040735,-24.478351],[34.215824,-24.816314],[33.01321,-25.357573],[32.574632,-25.727318],[32.660363,-26.148584],[32.915955,-26.215867],[32.83012,-26.742192],[32.071665,-26.73382],[31.985779,-26.29178],[31.837778,-25.843332],[31.752408,-25.484284],[31.930589,-24.369417],[31.670398,-23.658969],[31.191409,-22.25151],[32.244988,-21.116489],[32.508693,-20.395292],[32.659743,-20.30429],[32.772708,-19.715592],[32.611994,-19.419383],[32.654886,-18.67209],[32.849861,-17.979057],[32.847639,-16.713398],[32.328239,-16.392074],[31.852041,-16.319417],[31.636498,-16.07199],[31.173064,-15.860944],[30.338955,-15.880839],[30.274256,-15.507787],[30.179481,-14.796099],[33.214025,-13.97186],[33.7897,-14.451831],[34.064825,-14.35995],[34.459633,-14.61301],[34.517666,-15.013709],[34.307291,-15.478641],[34.381292,-16.18356],[35.03381,-16.8013],[35.339063,-16.10744],[35.771905,-15.896859],[35.686845,-14.611046],[35.267956,-13.887834],[34.907151,-13.565425],[34.559989,-13.579998],[34.280006,-12.280025],[34.559989,-11.52002]]]}},{"type":"Feature","properties":{"ADMIN":"eSwatini","NAME_EN":"Eswatini","CONTINENT":"Africa","ADM0_A3":"SWZ","ISO_A3":"SWZ","NAME":"eSwatini","ISO_A2":"SZ","NAME_ZH":"斯威士兰"},"geometry":{"type":"Polygon","coordinates":[[[32.071665,-26.73382],[31.86806,-27.177927],[31.282773,-27.285879],[30.685962,-26.743845],[30.676609,-26.398078],[30.949667,-26.022649],[31.04408,-25.731452],[31.333158,-25.660191],[31.837778,-25.843332],[31.985779,-26.29178],[32.071665,-26.73382]]]}},{"type":"Feature","properties":{"ADMIN":"Angola","NAME_EN":"Angola","CONTINENT":"Africa","ADM0_A3":"AGO","ISO_A3":"AGO","NAME":"Angola","ISO_A2":"AO","NAME_ZH":"安哥拉"},"geometry":{"type":"MultiPolygon","coordinates":[[[[12.995517,-4.781103],[12.631612,-4.991271],[12.468004,-5.248362],[12.436688,-5.684304],[12.182337,-5.789931],[11.914963,-5.037987],[12.318608,-4.60623],[12.62076,-4.438023],[12.995517,-4.781103]]],[[[12.322432,-6.100092],[12.735171,-5.965682],[13.024869,-5.984389],[13.375597,-5.864241],[16.326528,-5.87747],[16.57318,-6.622645],[16.860191,-7.222298],[17.089996,-7.545689],[17.47297,-8.068551],[18.134222,-7.987678],[18.464176,-7.847014],[19.016752,-7.988246],[19.166613,-7.738184],[19.417502,-7.155429],[20.037723,-7.116361],[20.091622,-6.94309],[20.601823,-6.939318],[20.514748,-7.299606],[21.728111,-7.290872],[21.746456,-7.920085],[21.949131,-8.305901],[21.801801,-8.908707],[21.875182,-9.523708],[22.208753,-9.894796],[22.155268,-11.084801],[22.402798,-10.993075],[22.837345,-11.017622],[23.456791,-10.867863],[23.912215,-10.926826],[24.017894,-11.237298],[23.904154,-11.722282],[24.079905,-12.191297],[23.930922,-12.565848],[24.016137,-12.911046],[21.933886,-12.898437],[21.887843,-16.08031],[22.562478,-16.898451],[23.215048,-17.523116],[21.377176,-17.930636],[18.956187,-17.789095],[18.263309,-17.309951],[14.209707,-17.353101],[14.058501,-17.423381],[13.462362,-16.971212],[12.814081,-16.941343],[12.215461,-17.111668],[11.734199,-17.301889],[11.640096,-16.673142],[11.778537,-15.793816],[12.123581,-14.878316],[12.175619,-14.449144],[12.500095,-13.5477],[12.738479,-13.137906],[13.312914,-12.48363],[13.633721,-12.038645],[13.738728,-11.297863],[13.686379,-10.731076],[13.387328,-10.373578],[13.120988,-9.766897],[12.87537,-9.166934],[12.929061,-8.959091],[13.236433,-8.562629],[12.93304,-7.596539],[12.728298,-6.927122],[12.227347,-6.294448],[12.322432,-6.100092]]]]}},{"type":"Feature","properties":{"ADMIN":"Burundi","NAME_EN":"Burundi","CONTINENT":"Africa","ADM0_A3":"BDI","ISO_A3":"BDI","NAME":"Burundi","ISO_A2":"BI","NAME_ZH":"布隆迪"},"geometry":{"type":"Polygon","coordinates":[[[30.469674,-2.413855],[30.52766,-2.80762],[30.74301,-3.03431],[30.75224,-3.35931],[30.50554,-3.56858],[30.11632,-4.09012],[29.753512,-4.452389],[29.339998,-4.499983],[29.276384,-3.293907],[29.024926,-2.839258],[29.632176,-2.917858],[29.938359,-2.348487],[30.469674,-2.413855]]]}},{"type":"Feature","properties":{"ADMIN":"Palestine","NAME_EN":"Palestine","CONTINENT":"Asia","ADM0_A3":"PSX","ISO_A3":"PSE","NAME":"Palestine","ISO_A2":"PS","NAME_ZH":"巴勒斯坦"},"geometry":{"type":"MultiPolygon","coordinates":[[[[35.397561,31.489086],[34.927408,31.353435],[34.970507,31.616778],[35.225892,31.754341],[34.974641,31.866582],[35.18393,32.532511],[35.545665,32.393992],[35.545252,31.782505],[35.397561,31.489086]]],[[[34.2296,31.2208],[34.222,31.274],[34.222,31.38],[34.222,31.48],[34.232,31.535],[34.259,31.578],[34.308,31.595],[34.488,31.595],[34.556,31.575],[34.56,31.543],[34.55,31.396],[34.54,31.242],[34.456,31.221],[34.2296,31.2208]]]]}},{"type":"Feature","properties":{"ADMIN":"Israel","NAME_EN":"Israel","CONTINENT":"Asia","ADM0_A3":"ISR","ISO_A3":"ISR","NAME":"Israel","ISO_A2":"IL","NAME_ZH":"以色列"},"geometry":{"type":"Polygon","coordinates":[[[35.719918,32.709192],[35.545665,32.393992],[35.18393,32.532511],[34.974641,31.866582],[35.225892,31.754341],[34.970507,31.616778],[34.927408,31.353435],[35.397561,31.489086],[35.420918,31.100066],[34.922603,29.501326],[34.823243,29.761081],[34.26544,31.21936],[34.265435,31.219357],[34.265433,31.219361],[34.556372,31.548824],[34.488107,31.605539],[34.752587,32.072926],[34.955417,32.827376],[35.098457,33.080539],[35.126053,33.0909],[35.460709,33.08904],[35.552797,33.264275],[35.821101,33.277426],[35.836397,32.868123],[35.700798,32.716014],[35.719918,32.709192]]]}},{"type":"Feature","properties":{"ADMIN":"Lebanon","NAME_EN":"Lebanon","CONTINENT":"Asia","ADM0_A3":"LBN","ISO_A3":"LBN","NAME":"Lebanon","ISO_A2":"LB","NAME_ZH":"黎巴嫩"},"geometry":{"type":"Polygon","coordinates":[[[35.821101,33.277426],[35.552797,33.264275],[35.460709,33.08904],[35.126053,33.0909],[35.482207,33.90545],[35.979592,34.610058],[35.998403,34.644914],[36.448194,34.593935],[36.61175,34.201789],[36.06646,33.824912],[35.821101,33.277426]]]}},{"type":"Feature","properties":{"ADMIN":"Madagascar","NAME_EN":"Madagascar","CONTINENT":"Africa","ADM0_A3":"MDG","ISO_A3":"MDG","NAME":"Madagascar","ISO_A2":"MG","NAME_ZH":"马达加斯加"},"geometry":{"type":"Polygon","coordinates":[[[49.543519,-12.469833],[49.808981,-12.895285],[50.056511,-13.555761],[50.217431,-14.758789],[50.476537,-15.226512],[50.377111,-15.706069],[50.200275,-16.000263],[49.860606,-15.414253],[49.672607,-15.710204],[49.863344,-16.451037],[49.774564,-16.875042],[49.498612,-17.106036],[49.435619,-17.953064],[49.041792,-19.118781],[48.548541,-20.496888],[47.930749,-22.391501],[47.547723,-23.781959],[47.095761,-24.94163],[46.282478,-25.178463],[45.409508,-25.601434],[44.833574,-25.346101],[44.03972,-24.988345],[43.763768,-24.460677],[43.697778,-23.574116],[43.345654,-22.776904],[43.254187,-22.057413],[43.433298,-21.336475],[43.893683,-21.163307],[43.89637,-20.830459],[44.374325,-20.072366],[44.464397,-19.435454],[44.232422,-18.961995],[44.042976,-18.331387],[43.963084,-17.409945],[44.312469,-16.850496],[44.446517,-16.216219],[44.944937,-16.179374],[45.502732,-15.974373],[45.872994,-15.793454],[46.312243,-15.780018],[46.882183,-15.210182],[47.70513,-14.594303],[48.005215,-14.091233],[47.869047,-13.663869],[48.293828,-13.784068],[48.84506,-13.089175],[48.863509,-12.487868],[49.194651,-12.040557],[49.543519,-12.469833]]]}},{"type":"Feature","properties":{"ADMIN":"Gambia","NAME_EN":"The Gambia","CONTINENT":"Africa","ADM0_A3":"GMB","ISO_A3":"GMB","NAME":"Gambia","ISO_A2":"GM","NAME_ZH":"冈比亚"},"geometry":{"type":"Polygon","coordinates":[[[-16.713729,13.594959],[-15.624596,13.623587],[-15.39877,13.860369],[-15.081735,13.876492],[-14.687031,13.630357],[-14.376714,13.62568],[-14.046992,13.794068],[-13.844963,13.505042],[-14.277702,13.280585],[-14.712197,13.298207],[-15.141163,13.509512],[-15.511813,13.27857],[-15.691001,13.270353],[-15.931296,13.130284],[-16.841525,13.151394],[-16.713729,13.594959]]]}},{"type":"Feature","properties":{"ADMIN":"Tunisia","NAME_EN":"Tunisia","CONTINENT":"Africa","ADM0_A3":"TUN","ISO_A3":"TUN","NAME":"Tunisia","ISO_A2":"TN","NAME_ZH":"突尼斯"},"geometry":{"type":"Polygon","coordinates":[[[9.48214,30.307556],[9.055603,32.102692],[8.439103,32.506285],[8.430473,32.748337],[7.612642,33.344115],[7.524482,34.097376],[8.140981,34.655146],[8.376368,35.479876],[8.217824,36.433177],[8.420964,36.946427],[9.509994,37.349994],[10.210002,37.230002],[10.18065,36.724038],[11.028867,37.092103],[11.100026,36.899996],[10.600005,36.41],[10.593287,35.947444],[10.939519,35.698984],[10.807847,34.833507],[10.149593,34.330773],[10.339659,33.785742],[10.856836,33.76874],[11.108501,33.293343],[11.488787,33.136996],[11.432253,32.368903],[10.94479,32.081815],[10.636901,31.761421],[9.950225,31.37607],[10.056575,30.961831],[9.970017,30.539325],[9.48214,30.307556]]]}},{"type":"Feature","properties":{"ADMIN":"Algeria","NAME_EN":"Algeria","CONTINENT":"Africa","ADM0_A3":"DZA","ISO_A3":"DZA","NAME":"Algeria","ISO_A2":"DZ","NAME_ZH":"阿尔及利亚"},"geometry":{"type":"Polygon","coordinates":[[[-8.6844,27.395744],[-8.665124,27.589479],[-8.66559,27.656426],[-8.674116,28.841289],[-7.059228,29.579228],[-6.060632,29.7317],[-5.242129,30.000443],[-4.859646,30.501188],[-3.690441,30.896952],[-3.647498,31.637294],[-3.06898,31.724498],[-2.616605,32.094346],[-1.307899,32.262889],[-1.124551,32.651522],[-1.388049,32.864015],[-1.733455,33.919713],[-1.792986,34.527919],[-2.169914,35.168396],[-1.208603,35.714849],[-0.127454,35.888662],[0.503877,36.301273],[1.466919,36.605647],[3.161699,36.783905],[4.815758,36.865037],[5.32012,36.716519],[6.26182,37.110655],[7.330385,37.118381],[7.737078,36.885708],[8.420964,36.946427],[8.217824,36.433177],[8.376368,35.479876],[8.140981,34.655146],[7.524482,34.097376],[7.612642,33.344115],[8.430473,32.748337],[8.439103,32.506285],[9.055603,32.102692],[9.48214,30.307556],[9.805634,29.424638],[9.859998,28.95999],[9.683885,28.144174],[9.756128,27.688259],[9.629056,27.140953],[9.716286,26.512206],[9.319411,26.094325],[9.910693,25.365455],[9.948261,24.936954],[10.303847,24.379313],[10.771364,24.562532],[11.560669,24.097909],[11.999506,23.471668],[8.572893,21.565661],[5.677566,19.601207],[4.267419,19.155265],[3.158133,19.057364],[3.146661,19.693579],[2.683588,19.85623],[2.060991,20.142233],[1.823228,20.610809],[-1.550055,22.792666],[-4.923337,24.974574],[-8.6844,27.395744]]]}},{"type":"Feature","properties":{"ADMIN":"Jordan","NAME_EN":"Jordan","CONTINENT":"Asia","ADM0_A3":"JOR","ISO_A3":"JOR","NAME":"Jordan","ISO_A2":"JO","NAME_ZH":"约旦"},"geometry":{"type":"Polygon","coordinates":[[[35.545665,32.393992],[35.719918,32.709192],[36.834062,32.312938],[38.792341,33.378686],[39.195468,32.161009],[39.004886,32.010217],[37.002166,31.508413],[37.998849,30.5085],[37.66812,30.338665],[37.503582,30.003776],[36.740528,29.865283],[36.501214,29.505254],[36.068941,29.197495],[34.956037,29.356555],[34.922603,29.501326],[35.420918,31.100066],[35.397561,31.489086],[35.545252,31.782505],[35.545665,32.393992]]]}},{"type":"Feature","properties":{"ADMIN":"United Arab Emirates","NAME_EN":"United Arab Emirates","CONTINENT":"Asia","ADM0_A3":"ARE","ISO_A3":"ARE","NAME":"United Arab Emirates","ISO_A2":"AE","NAME_ZH":"阿拉伯联合酋长国"},"geometry":{"type":"Polygon","coordinates":[[[51.579519,24.245497],[51.757441,24.294073],[51.794389,24.019826],[52.577081,24.177439],[53.404007,24.151317],[54.008001,24.121758],[54.693024,24.797892],[55.439025,25.439145],[56.070821,26.055464],[56.261042,25.714606],[56.396847,24.924732],[55.886233,24.920831],[55.804119,24.269604],[55.981214,24.130543],[55.528632,23.933604],[55.525841,23.524869],[55.234489,23.110993],[55.208341,22.70833],[55.006803,22.496948],[52.000733,23.001154],[51.617708,24.014219],[51.579519,24.245497]]]}},{"type":"Feature","properties":{"ADMIN":"Qatar","NAME_EN":"Qatar","CONTINENT":"Asia","ADM0_A3":"QAT","ISO_A3":"QAT","NAME":"Qatar","ISO_A2":"QA","NAME_ZH":"卡塔尔"},"geometry":{"type":"Polygon","coordinates":[[[50.810108,24.754743],[50.743911,25.482424],[51.013352,26.006992],[51.286462,26.114582],[51.589079,25.801113],[51.6067,25.21567],[51.389608,24.627386],[51.112415,24.556331],[50.810108,24.754743]]]}},{"type":"Feature","properties":{"ADMIN":"Kuwait","NAME_EN":"Kuwait","CONTINENT":"Asia","ADM0_A3":"KWT","ISO_A3":"KWT","NAME":"Kuwait","ISO_A2":"KW","NAME_ZH":"科威特"},"geometry":{"type":"Polygon","coordinates":[[[47.974519,29.975819],[48.183189,29.534477],[48.093943,29.306299],[48.416094,28.552004],[47.708851,28.526063],[47.459822,29.002519],[46.568713,29.099025],[47.302622,30.05907],[47.974519,29.975819]]]}},{"type":"Feature","properties":{"ADMIN":"Iraq","NAME_EN":"Iraq","CONTINENT":"Asia","ADM0_A3":"IRQ","ISO_A3":"IRQ","NAME":"Iraq","ISO_A2":"IQ","NAME_ZH":"伊拉克"},"geometry":{"type":"Polygon","coordinates":[[[39.195468,32.161009],[38.792341,33.378686],[41.006159,34.419372],[41.383965,35.628317],[41.289707,36.358815],[41.837064,36.605854],[42.349591,37.229873],[42.779126,37.385264],[43.942259,37.256228],[44.293452,37.001514],[44.772677,37.170437],[45.420618,35.977546],[46.07634,35.677383],[46.151788,35.093259],[45.64846,34.748138],[45.416691,33.967798],[46.109362,33.017287],[47.334661,32.469155],[47.849204,31.709176],[47.685286,30.984853],[48.004698,30.985137],[48.014568,30.452457],[48.567971,29.926778],[47.974519,29.975819],[47.302622,30.05907],[46.568713,29.099025],[44.709499,29.178891],[41.889981,31.190009],[40.399994,31.889992],[39.195468,32.161009]]]}},{"type":"Feature","properties":{"ADMIN":"Oman","NAME_EN":"Oman","CONTINENT":"Asia","ADM0_A3":"OMN","ISO_A3":"OMN","NAME":"Oman","ISO_A2":"OM","NAME_ZH":"阿曼"},"geometry":{"type":"MultiPolygon","coordinates":[[[[55.208341,22.70833],[55.234489,23.110993],[55.525841,23.524869],[55.528632,23.933604],[55.981214,24.130543],[55.804119,24.269604],[55.886233,24.920831],[56.396847,24.924732],[56.84514,24.241673],[57.403453,23.878594],[58.136948,23.747931],[58.729211,23.565668],[59.180502,22.992395],[59.450098,22.660271],[59.80806,22.533612],[59.806148,22.310525],[59.442191,21.714541],[59.282408,21.433886],[58.861141,21.114035],[58.487986,20.428986],[58.034318,20.481437],[57.826373,20.243002],[57.665762,19.736005],[57.7887,19.06757],[57.694391,18.94471],[57.234264,18.947991],[56.609651,18.574267],[56.512189,18.087113],[56.283521,17.876067],[55.661492,17.884128],[55.269939,17.632309],[55.2749,17.228354],[54.791002,16.950697],[54.239253,17.044981],[53.570508,16.707663],[53.108573,16.651051],[52.782184,17.349742],[52.00001,19.000003],[54.999982,19.999994],[55.666659,22.000001],[55.208341,22.70833]]],[[[56.261042,25.714606],[56.070821,26.055464],[56.362017,26.395934],[56.485679,26.309118],[56.391421,25.895991],[56.261042,25.714606]]]]}},{"type":"Feature","properties":{"ADMIN":"Vanuatu","NAME_EN":"Vanuatu","CONTINENT":"Oceania","ADM0_A3":"VUT","ISO_A3":"VUT","NAME":"Vanuatu","ISO_A2":"VU","NAME_ZH":"瓦努阿图"},"geometry":{"type":"MultiPolygon","coordinates":[[[[167.216801,-15.891846],[167.844877,-16.466333],[167.515181,-16.59785],[167.180008,-16.159995],[167.216801,-15.891846]]],[[[166.793158,-15.668811],[166.649859,-15.392704],[166.629137,-14.626497],[167.107712,-14.93392],[167.270028,-15.740021],[167.001207,-15.614602],[166.793158,-15.668811]]]]}},{"type":"Feature","properties":{"ADMIN":"Cambodia","NAME_EN":"Cambodia","CONTINENT":"Asia","ADM0_A3":"KHM","ISO_A3":"KHM","NAME":"Cambodia","ISO_A2":"KH","NAME_ZH":"柬埔寨"},"geometry":{"type":"Polygon","coordinates":[[[102.584932,12.186595],[102.348099,13.394247],[102.988422,14.225721],[104.281418,14.416743],[105.218777,14.273212],[106.043946,13.881091],[106.496373,14.570584],[107.382727,14.202441],[107.614548,13.535531],[107.491403,12.337206],[105.810524,11.567615],[106.24967,10.961812],[105.199915,10.88931],[104.334335,10.486544],[103.49728,10.632555],[103.09069,11.153661],[102.584932,12.186595]]]}},{"type":"Feature","properties":{"ADMIN":"Thailand","NAME_EN":"Thailand","CONTINENT":"Asia","ADM0_A3":"THA","ISO_A3":"THA","NAME":"Thailand","ISO_A2":"TH","NAME_ZH":"泰国"},"geometry":{"type":"Polygon","coordinates":[[[105.218777,14.273212],[104.281418,14.416743],[102.988422,14.225721],[102.348099,13.394247],[102.584932,12.186595],[101.687158,12.64574],[100.83181,12.627085],[100.978467,13.412722],[100.097797,13.406856],[100.018733,12.307001],[99.478921,10.846367],[99.153772,9.963061],[99.222399,9.239255],[99.873832,9.207862],[100.279647,8.295153],[100.459274,7.429573],[101.017328,6.856869],[101.623079,6.740622],[102.141187,6.221636],[101.814282,5.810808],[101.154219,5.691384],[101.075516,6.204867],[100.259596,6.642825],[100.085757,6.464489],[99.690691,6.848213],[99.519642,7.343454],[98.988253,7.907993],[98.503786,8.382305],[98.339662,7.794512],[98.150009,8.350007],[98.25915,8.973923],[98.553551,9.93296],[99.038121,10.960546],[99.587286,11.892763],[99.196354,12.804748],[99.212012,13.269294],[99.097755,13.827503],[98.430819,14.622028],[98.192074,15.123703],[98.537376,15.308497],[98.903348,16.177824],[98.493761,16.837836],[97.859123,17.567946],[97.375896,18.445438],[97.797783,18.62708],[98.253724,19.708203],[98.959676,19.752981],[99.543309,20.186598],[100.115988,20.41785],[100.548881,20.109238],[100.606294,19.508344],[101.282015,19.462585],[101.035931,18.408928],[101.059548,17.512497],[102.113592,18.109102],[102.413005,17.932782],[102.998706,17.961695],[103.200192,18.309632],[103.956477,18.240954],[104.716947,17.428859],[104.779321,16.441865],[105.589039,15.570316],[105.544338,14.723934],[105.218777,14.273212]]]}},{"type":"Feature","properties":{"ADMIN":"Laos","NAME_EN":"Laos","CONTINENT":"Asia","ADM0_A3":"LAO","ISO_A3":"LAO","NAME":"Laos","ISO_A2":"LA","NAME_ZH":"老挝"},"geometry":{"type":"Polygon","coordinates":[[[107.382727,14.202441],[106.496373,14.570584],[106.043946,13.881091],[105.218777,14.273212],[105.544338,14.723934],[105.589039,15.570316],[104.779321,16.441865],[104.716947,17.428859],[103.956477,18.240954],[103.200192,18.309632],[102.998706,17.961695],[102.413005,17.932782],[102.113592,18.109102],[101.059548,17.512497],[101.035931,18.408928],[101.282015,19.462585],[100.606294,19.508344],[100.548881,20.109238],[100.115988,20.41785],[100.329101,20.786122],[101.180005,21.436573],[101.270026,21.201652],[101.80312,21.174367],[101.652018,22.318199],[102.170436,22.464753],[102.754896,21.675137],[103.203861,20.766562],[104.435,20.758733],[104.822574,19.886642],[104.183388,19.624668],[103.896532,19.265181],[105.094598,18.666975],[105.925762,17.485315],[106.556008,16.604284],[107.312706,15.908538],[107.564525,15.202173],[107.382727,14.202441]]]}},{"type":"Feature","properties":{"ADMIN":"Myanmar","NAME_EN":"Myanmar","CONTINENT":"Asia","ADM0_A3":"MMR","ISO_A3":"MMR","NAME":"Myanmar","ISO_A2":"MM","NAME_ZH":"缅甸"},"geometry":{"type":"Polygon","coordinates":[[[100.115988,20.41785],[99.543309,20.186598],[98.959676,19.752981],[98.253724,19.708203],[97.797783,18.62708],[97.375896,18.445438],[97.859123,17.567946],[98.493761,16.837836],[98.903348,16.177824],[98.537376,15.308497],[98.192074,15.123703],[98.430819,14.622028],[99.097755,13.827503],[99.212012,13.269294],[99.196354,12.804748],[99.587286,11.892763],[99.038121,10.960546],[98.553551,9.93296],[98.457174,10.675266],[98.764546,11.441292],[98.428339,12.032987],[98.509574,13.122378],[98.103604,13.64046],[97.777732,14.837286],[97.597072,16.100568],[97.16454,16.928734],[96.505769,16.427241],[95.369352,15.71439],[94.808405,15.803454],[94.188804,16.037936],[94.533486,17.27724],[94.324817,18.213514],[93.540988,19.366493],[93.663255,19.726962],[93.078278,19.855145],[92.368554,20.670883],[92.303234,21.475485],[92.652257,21.324048],[92.672721,22.041239],[93.166128,22.27846],[93.060294,22.703111],[93.286327,23.043658],[93.325188,24.078556],[94.106742,23.850741],[94.552658,24.675238],[94.603249,25.162495],[95.155153,26.001307],[95.124768,26.573572],[96.419366,27.264589],[97.133999,27.083774],[97.051989,27.699059],[97.402561,27.882536],[97.327114,28.261583],[97.911988,28.335945],[98.246231,27.747221],[98.68269,27.508812],[98.712094,26.743536],[98.671838,25.918703],[97.724609,25.083637],[97.60472,23.897405],[98.660262,24.063286],[98.898749,23.142722],[99.531992,22.949039],[99.240899,22.118314],[99.983489,21.742937],[100.416538,21.558839],[101.150033,21.849984],[101.180005,21.436573],[100.329101,20.786122],[100.115988,20.41785]]]}},{"type":"Feature","properties":{"ADMIN":"Vietnam","NAME_EN":"Vietnam","CONTINENT":"Asia","ADM0_A3":"VNM","ISO_A3":"VNM","NAME":"Vietnam","ISO_A2":"VN","NAME_ZH":"越南"},"geometry":{"type":"Polygon","coordinates":[[[104.334335,10.486544],[105.199915,10.88931],[106.24967,10.961812],[105.810524,11.567615],[107.491403,12.337206],[107.614548,13.535531],[107.382727,14.202441],[107.564525,15.202173],[107.312706,15.908538],[106.556008,16.604284],[105.925762,17.485315],[105.094598,18.666975],[103.896532,19.265181],[104.183388,19.624668],[104.822574,19.886642],[104.435,20.758733],[103.203861,20.766562],[102.754896,21.675137],[102.170436,22.464753],[102.706992,22.708795],[103.504515,22.703757],[104.476858,22.81915],[105.329209,23.352063],[105.811247,22.976892],[106.725403,22.794268],[106.567273,22.218205],[107.04342,21.811899],[108.05018,21.55238],[106.715068,20.696851],[105.881682,19.75205],[105.662006,19.058165],[106.426817,18.004121],[107.361954,16.697457],[108.269495,16.079742],[108.877107,15.276691],[109.33527,13.426028],[109.200136,11.666859],[108.36613,11.008321],[107.220929,10.364484],[106.405113,9.53084],[105.158264,8.59976],[104.795185,9.241038],[105.076202,9.918491],[104.334335,10.486544]]]}},{"type":"Feature","properties":{"ADMIN":"North Korea","NAME_EN":"North Korea","CONTINENT":"Asia","ADM0_A3":"PRK","ISO_A3":"PRK","NAME":"North Korea","ISO_A2":"KP","NAME_ZH":"朝鲜民主主义人民共和国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[130.780004,42.220008],[130.780005,42.22001],[130.780007,42.220007],[130.780004,42.220008]]],[[[130.64,42.395024],[130.64,42.395],[130.779992,42.22001],[130.400031,42.280004],[129.965949,41.941368],[129.667362,41.601104],[129.705189,40.882828],[129.188115,40.661808],[129.0104,40.485436],[128.633368,40.189847],[127.967414,40.025413],[127.533436,39.75685],[127.50212,39.323931],[127.385434,39.213472],[127.783343,39.050898],[128.349716,38.612243],[128.205746,38.370397],[127.780035,38.304536],[127.073309,38.256115],[126.68372,37.804773],[126.237339,37.840378],[126.174759,37.749686],[125.689104,37.94001],[125.568439,37.752089],[125.27533,37.669071],[125.240087,37.857224],[124.981033,37.948821],[124.712161,38.108346],[124.985994,38.548474],[125.221949,38.665857],[125.132859,38.848559],[125.38659,39.387958],[125.321116,39.551385],[124.737482,39.660344],[124.265625,39.928493],[125.079942,40.569824],[126.182045,41.107336],[126.869083,41.816569],[127.343783,41.503152],[128.208433,41.466772],[128.052215,41.994285],[129.596669,42.424982],[129.994267,42.985387],[130.64,42.395024]]]]}},{"type":"Feature","properties":{"ADMIN":"South Korea","NAME_EN":"South Korea","CONTINENT":"Asia","ADM0_A3":"KOR","ISO_A3":"KOR","NAME":"South Korea","ISO_A2":"KR","NAME_ZH":"大韩民国"},"geometry":{"type":"Polygon","coordinates":[[[126.174759,37.749686],[126.237339,37.840378],[126.68372,37.804773],[127.073309,38.256115],[127.780035,38.304536],[128.205746,38.370397],[128.349716,38.612243],[129.21292,37.432392],[129.46045,36.784189],[129.468304,35.632141],[129.091377,35.082484],[128.18585,34.890377],[127.386519,34.475674],[126.485748,34.390046],[126.37392,34.93456],[126.559231,35.684541],[126.117398,36.725485],[126.860143,36.893924],[126.174759,37.749686]]]}},{"type":"Feature","properties":{"ADMIN":"Mongolia","NAME_EN":"Mongolia","CONTINENT":"Asia","ADM0_A3":"MNG","ISO_A3":"MNG","NAME":"Mongolia","ISO_A2":"MN","NAME_ZH":"蒙古国"},"geometry":{"type":"Polygon","coordinates":[[[87.751264,49.297198],[88.805567,49.470521],[90.713667,50.331812],[92.234712,50.802171],[93.10421,50.49529],[94.147566,50.480537],[94.815949,50.013433],[95.81402,49.97746],[97.25976,49.72605],[98.231762,50.422401],[97.82574,51.010995],[98.861491,52.047366],[99.981732,51.634006],[100.88948,51.516856],[102.06521,51.25991],[102.25589,50.51056],[103.676545,50.089966],[104.62158,50.27532],[105.886591,50.406019],[106.888804,50.274296],[107.868176,49.793705],[108.475167,49.282548],[109.402449,49.292961],[110.662011,49.130128],[111.581231,49.377968],[112.89774,49.543565],[114.362456,50.248303],[114.96211,50.140247],[115.485695,49.805177],[116.678801,49.888531],[116.191802,49.134598],[115.485282,48.135383],[115.742837,47.726545],[116.308953,47.85341],[117.295507,47.697709],[118.064143,48.06673],[118.866574,47.74706],[119.772824,47.048059],[119.66327,46.69268],[118.874326,46.805412],[117.421701,46.672733],[116.717868,46.388202],[115.985096,45.727235],[114.460332,45.339817],[113.463907,44.808893],[112.436062,45.011646],[111.873306,45.102079],[111.348377,44.457442],[111.667737,44.073176],[111.829588,43.743118],[111.129682,43.406834],[110.412103,42.871234],[109.243596,42.519446],[107.744773,42.481516],[106.129316,42.134328],[104.964994,41.59741],[104.522282,41.908347],[103.312278,41.907468],[101.83304,42.514873],[100.845866,42.663804],[99.515817,42.524691],[97.451757,42.74889],[96.349396,42.725635],[95.762455,43.319449],[95.306875,44.241331],[94.688929,44.352332],[93.480734,44.975472],[92.133891,45.115076],[90.94554,45.286073],[90.585768,45.719716],[90.970809,46.888146],[90.280826,47.693549],[88.854298,48.069082],[88.013832,48.599463],[87.751264,49.297198]]]}},{"type":"Feature","properties":{"ADMIN":"India","NAME_EN":"India","CONTINENT":"Asia","ADM0_A3":"IND","ISO_A3":"IND","NAME":"India","ISO_A2":"IN","NAME_ZH":"印度"},"geometry":{"type":"Polygon","coordinates":[[[97.327114,28.261583],[97.402561,27.882536],[97.051989,27.699059],[97.133999,27.083774],[96.419366,27.264589],[95.124768,26.573572],[95.155153,26.001307],[94.603249,25.162495],[94.552658,24.675238],[94.106742,23.850741],[93.325188,24.078556],[93.286327,23.043658],[93.060294,22.703111],[93.166128,22.27846],[92.672721,22.041239],[92.146035,23.627499],[91.869928,23.624346],[91.706475,22.985264],[91.158963,23.503527],[91.46773,24.072639],[91.915093,24.130414],[92.376202,24.976693],[91.799596,25.147432],[90.872211,25.132601],[89.920693,25.26975],[89.832481,25.965082],[89.355094,26.014407],[88.563049,26.446526],[88.209789,25.768066],[88.931554,25.238692],[88.306373,24.866079],[88.084422,24.501657],[88.69994,24.233715],[88.52977,23.631142],[88.876312,22.879146],[89.031961,22.055708],[88.888766,21.690588],[88.208497,21.703172],[86.975704,21.495562],[87.033169,20.743308],[86.499351,20.151638],[85.060266,19.478579],[83.941006,18.30201],[83.189217,17.671221],[82.192792,17.016636],[82.191242,16.556664],[81.692719,16.310219],[80.791999,15.951972],[80.324896,15.899185],[80.025069,15.136415],[80.233274,13.835771],[80.286294,13.006261],[79.862547,12.056215],[79.857999,10.357275],[79.340512,10.308854],[78.885345,9.546136],[79.18972,9.216544],[78.277941,8.933047],[77.941165,8.252959],[77.539898,7.965535],[76.592979,8.899276],[76.130061,10.29963],[75.746467,11.308251],[75.396101,11.781245],[74.864816,12.741936],[74.616717,13.992583],[74.443859,14.617222],[73.534199,15.990652],[73.119909,17.92857],[72.820909,19.208234],[72.824475,20.419503],[72.630533,21.356009],[71.175273,20.757441],[70.470459,20.877331],[69.16413,22.089298],[69.644928,22.450775],[69.349597,22.84318],[68.176645,23.691965],[68.842599,24.359134],[71.04324,24.356524],[70.844699,25.215102],[70.282873,25.722229],[70.168927,26.491872],[69.514393,26.940966],[70.616496,27.989196],[71.777666,27.91318],[72.823752,28.961592],[73.450638,29.976413],[74.42138,30.979815],[74.405929,31.692639],[75.258642,32.271105],[74.451559,32.7649],[74.104294,33.441473],[73.749948,34.317699],[74.240203,34.748887],[75.757061,34.504923],[76.871722,34.653544],[77.837451,35.49401],[78.912269,34.321936],[78.811086,33.506198],[79.208892,32.994395],[79.176129,32.48378],[78.458446,32.618164],[78.738894,31.515906],[79.721367,30.882715],[81.111256,30.183481],[80.476721,29.729865],[80.088425,28.79447],[81.057203,28.416095],[81.999987,27.925479],[83.304249,27.364506],[84.675018,27.234901],[85.251779,26.726198],[86.024393,26.630985],[87.227472,26.397898],[88.060238,26.414615],[88.174804,26.810405],[88.043133,27.445819],[88.120441,27.876542],[88.730326,28.086865],[88.814248,27.299316],[88.835643,27.098966],[89.744528,26.719403],[90.373275,26.875724],[91.217513,26.808648],[92.033484,26.83831],[92.103712,27.452614],[91.696657,27.771742],[92.503119,27.896876],[93.413348,28.640629],[94.56599,29.277438],[95.404802,29.031717],[96.117679,29.452802],[96.586591,28.83098],[96.248833,28.411031],[97.327114,28.261583]]]}},{"type":"Feature","properties":{"ADMIN":"Bangladesh","NAME_EN":"Bangladesh","CONTINENT":"Asia","ADM0_A3":"BGD","ISO_A3":"BGD","NAME":"Bangladesh","ISO_A2":"BD","NAME_ZH":"孟加拉国"},"geometry":{"type":"Polygon","coordinates":[[[92.672721,22.041239],[92.652257,21.324048],[92.303234,21.475485],[92.368554,20.670883],[92.082886,21.192195],[92.025215,21.70157],[91.834891,22.182936],[91.417087,22.765019],[90.496006,22.805017],[90.586957,22.392794],[90.272971,21.836368],[89.847467,22.039146],[89.70205,21.857116],[89.418863,21.966179],[89.031961,22.055708],[88.876312,22.879146],[88.52977,23.631142],[88.69994,24.233715],[88.084422,24.501657],[88.306373,24.866079],[88.931554,25.238692],[88.209789,25.768066],[88.563049,26.446526],[89.355094,26.014407],[89.832481,25.965082],[89.920693,25.26975],[90.872211,25.132601],[91.799596,25.147432],[92.376202,24.976693],[91.915093,24.130414],[91.46773,24.072639],[91.158963,23.503527],[91.706475,22.985264],[91.869928,23.624346],[92.146035,23.627499],[92.672721,22.041239]]]}},{"type":"Feature","properties":{"ADMIN":"Bhutan","NAME_EN":"Bhutan","CONTINENT":"Asia","ADM0_A3":"BTN","ISO_A3":"BTN","NAME":"Bhutan","ISO_A2":"BT","NAME_ZH":"不丹"},"geometry":{"type":"Polygon","coordinates":[[[91.696657,27.771742],[92.103712,27.452614],[92.033484,26.83831],[91.217513,26.808648],[90.373275,26.875724],[89.744528,26.719403],[88.835643,27.098966],[88.814248,27.299316],[89.47581,28.042759],[90.015829,28.296439],[90.730514,28.064954],[91.258854,28.040614],[91.696657,27.771742]]]}},{"type":"Feature","properties":{"ADMIN":"Nepal","NAME_EN":"Nepal","CONTINENT":"Asia","ADM0_A3":"NPL","ISO_A3":"NPL","NAME":"Nepal","ISO_A2":"NP","NAME_ZH":"尼泊尔"},"geometry":{"type":"Polygon","coordinates":[[[88.120441,27.876542],[88.043133,27.445819],[88.174804,26.810405],[88.060238,26.414615],[87.227472,26.397898],[86.024393,26.630985],[85.251779,26.726198],[84.675018,27.234901],[83.304249,27.364506],[81.999987,27.925479],[81.057203,28.416095],[80.088425,28.79447],[80.476721,29.729865],[81.111256,30.183481],[81.525804,30.422717],[82.327513,30.115268],[83.337115,29.463732],[83.898993,29.320226],[84.23458,28.839894],[85.011638,28.642774],[85.82332,28.203576],[86.954517,27.974262],[88.120441,27.876542]]]}},{"type":"Feature","properties":{"ADMIN":"Pakistan","NAME_EN":"Pakistan","CONTINENT":"Asia","ADM0_A3":"PAK","ISO_A3":"PAK","NAME":"Pakistan","ISO_A2":"PK","NAME_ZH":"巴基斯坦"},"geometry":{"type":"Polygon","coordinates":[[[77.837451,35.49401],[76.871722,34.653544],[75.757061,34.504923],[74.240203,34.748887],[73.749948,34.317699],[74.104294,33.441473],[74.451559,32.7649],[75.258642,32.271105],[74.405929,31.692639],[74.42138,30.979815],[73.450638,29.976413],[72.823752,28.961592],[71.777666,27.91318],[70.616496,27.989196],[69.514393,26.940966],[70.168927,26.491872],[70.282873,25.722229],[70.844699,25.215102],[71.04324,24.356524],[68.842599,24.359134],[68.176645,23.691965],[67.443667,23.944844],[67.145442,24.663611],[66.372828,25.425141],[64.530408,25.237039],[62.905701,25.218409],[61.497363,25.078237],[61.874187,26.239975],[63.316632,26.756532],[63.233898,27.217047],[62.755426,27.378923],[62.72783,28.259645],[61.771868,28.699334],[61.369309,29.303276],[60.874248,29.829239],[62.549857,29.318572],[63.550261,29.468331],[64.148002,29.340819],[64.350419,29.560031],[65.046862,29.472181],[66.346473,29.887943],[66.381458,30.738899],[66.938891,31.304911],[67.683394,31.303154],[67.792689,31.58293],[68.556932,31.71331],[68.926677,31.620189],[69.317764,31.901412],[69.262522,32.501944],[69.687147,33.105499],[70.323594,33.358533],[69.930543,34.02012],[70.881803,33.988856],[71.156773,34.348911],[71.115019,34.733126],[71.613076,35.153203],[71.498768,35.650563],[71.262348,36.074388],[71.846292,36.509942],[72.920025,36.720007],[74.067552,36.836176],[74.575893,37.020841],[75.158028,37.133031],[75.896897,36.666806],[76.192848,35.898403],[77.837451,35.49401]]]}},{"type":"Feature","properties":{"ADMIN":"Afghanistan","NAME_EN":"Afghanistan","CONTINENT":"Asia","ADM0_A3":"AFG","ISO_A3":"AFG","NAME":"Afghanistan","ISO_A2":"AF","NAME_ZH":"阿富汗"},"geometry":{"type":"Polygon","coordinates":[[[66.518607,37.362784],[67.075782,37.356144],[67.83,37.144994],[68.135562,37.023115],[68.859446,37.344336],[69.196273,37.151144],[69.518785,37.608997],[70.116578,37.588223],[70.270574,37.735165],[70.376304,38.138396],[70.806821,38.486282],[71.348131,38.258905],[71.239404,37.953265],[71.541918,37.905774],[71.448693,37.065645],[71.844638,36.738171],[72.193041,36.948288],[72.63689,37.047558],[73.260056,37.495257],[73.948696,37.421566],[74.980002,37.41999],[75.158028,37.133031],[74.575893,37.020841],[74.067552,36.836176],[72.920025,36.720007],[71.846292,36.509942],[71.262348,36.074388],[71.498768,35.650563],[71.613076,35.153203],[71.115019,34.733126],[71.156773,34.348911],[70.881803,33.988856],[69.930543,34.02012],[70.323594,33.358533],[69.687147,33.105499],[69.262522,32.501944],[69.317764,31.901412],[68.926677,31.620189],[68.556932,31.71331],[67.792689,31.58293],[67.683394,31.303154],[66.938891,31.304911],[66.381458,30.738899],[66.346473,29.887943],[65.046862,29.472181],[64.350419,29.560031],[64.148002,29.340819],[63.550261,29.468331],[62.549857,29.318572],[60.874248,29.829239],[61.781222,30.73585],[61.699314,31.379506],[60.941945,31.548075],[60.863655,32.18292],[60.536078,32.981269],[60.9637,33.528832],[60.52843,33.676446],[60.803193,34.404102],[61.210817,35.650072],[62.230651,35.270664],[62.984662,35.404041],[63.193538,35.857166],[63.982896,36.007957],[64.546479,36.312073],[64.746105,37.111818],[65.588948,37.305217],[65.745631,37.661164],[66.217385,37.39379],[66.518607,37.362784]]]}},{"type":"Feature","properties":{"ADMIN":"Tajikistan","NAME_EN":"Tajikistan","CONTINENT":"Asia","ADM0_A3":"TJK","ISO_A3":"TJK","NAME":"Tajikistan","ISO_A2":"TJ","NAME_ZH":"塔吉克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[67.83,37.144994],[68.392033,38.157025],[68.176025,38.901553],[67.44222,39.140144],[67.701429,39.580478],[68.536416,39.533453],[69.011633,40.086158],[69.329495,40.727824],[70.666622,40.960213],[70.45816,40.496495],[70.601407,40.218527],[71.014198,40.244366],[70.648019,39.935754],[69.55961,40.103211],[69.464887,39.526683],[70.549162,39.604198],[71.784694,39.279463],[73.675379,39.431237],[73.928852,38.505815],[74.257514,38.606507],[74.864816,38.378846],[74.829986,37.990007],[74.980002,37.41999],[73.948696,37.421566],[73.260056,37.495257],[72.63689,37.047558],[72.193041,36.948288],[71.844638,36.738171],[71.448693,37.065645],[71.541918,37.905774],[71.239404,37.953265],[71.348131,38.258905],[70.806821,38.486282],[70.376304,38.138396],[70.270574,37.735165],[70.116578,37.588223],[69.518785,37.608997],[69.196273,37.151144],[68.859446,37.344336],[68.135562,37.023115],[67.83,37.144994]]]}},{"type":"Feature","properties":{"ADMIN":"Kyrgyzstan","NAME_EN":"Kyrgyzstan","CONTINENT":"Asia","ADM0_A3":"KGZ","ISO_A3":"KGZ","NAME":"Kyrgyzstan","ISO_A2":"KG","NAME_ZH":"吉尔吉斯斯坦"},"geometry":{"type":"Polygon","coordinates":[[[70.962315,42.266154],[71.186281,42.704293],[71.844638,42.845395],[73.489758,42.500894],[73.645304,43.091272],[74.212866,43.298339],[75.636965,42.8779],[76.000354,42.988022],[77.658392,42.960686],[79.142177,42.856092],[79.643645,42.496683],[80.25999,42.349999],[80.11943,42.123941],[78.543661,41.582243],[78.187197,41.185316],[76.904484,41.066486],[76.526368,40.427946],[75.467828,40.562072],[74.776862,40.366425],[73.822244,39.893973],[73.960013,39.660008],[73.675379,39.431237],[71.784694,39.279463],[70.549162,39.604198],[69.464887,39.526683],[69.55961,40.103211],[70.648019,39.935754],[71.014198,40.244366],[71.774875,40.145844],[73.055417,40.866033],[71.870115,41.3929],[71.157859,41.143587],[70.420022,41.519998],[71.259248,42.167711],[70.962315,42.266154]]]}},{"type":"Feature","properties":{"ADMIN":"Turkmenistan","NAME_EN":"Turkmenistan","CONTINENT":"Asia","ADM0_A3":"TKM","ISO_A3":"TKM","NAME":"Turkmenistan","ISO_A2":"TM","NAME_ZH":"土库曼斯坦"},"geometry":{"type":"Polygon","coordinates":[[[52.50246,41.783316],[52.944293,42.116034],[54.079418,42.324109],[54.755345,42.043971],[55.455251,41.259859],[55.968191,41.308642],[57.096391,41.32231],[56.932215,41.826026],[57.78653,42.170553],[58.629011,42.751551],[59.976422,42.223082],[60.083341,41.425146],[60.465953,41.220327],[61.547179,41.26637],[61.882714,41.084857],[62.37426,40.053886],[63.518015,39.363257],[64.170223,38.892407],[65.215999,38.402695],[66.54615,37.974685],[66.518607,37.362784],[66.217385,37.39379],[65.745631,37.661164],[65.588948,37.305217],[64.746105,37.111818],[64.546479,36.312073],[63.982896,36.007957],[63.193538,35.857166],[62.984662,35.404041],[62.230651,35.270664],[61.210817,35.650072],[61.123071,36.491597],[60.377638,36.527383],[59.234762,37.412988],[58.436154,37.522309],[57.330434,38.029229],[56.619366,38.121394],[56.180375,37.935127],[55.511578,37.964117],[54.800304,37.392421],[53.921598,37.198918],[53.735511,37.906136],[53.880929,38.952093],[53.101028,39.290574],[53.357808,39.975286],[52.693973,40.033629],[52.915251,40.876523],[53.858139,40.631034],[54.736845,40.951015],[54.008311,41.551211],[53.721713,42.123191],[52.91675,41.868117],[52.814689,41.135371],[52.50246,41.783316]]]}},{"type":"Feature","properties":{"ADMIN":"Iran","NAME_EN":"Iran","CONTINENT":"Asia","ADM0_A3":"IRN","ISO_A3":"IRN","NAME":"Iran","ISO_A2":"IR","NAME_ZH":"伊朗"},"geometry":{"type":"Polygon","coordinates":[[[48.567971,29.926778],[48.014568,30.452457],[48.004698,30.985137],[47.685286,30.984853],[47.849204,31.709176],[47.334661,32.469155],[46.109362,33.017287],[45.416691,33.967798],[45.64846,34.748138],[46.151788,35.093259],[46.07634,35.677383],[45.420618,35.977546],[44.772677,37.170437],[44.77267,37.17045],[44.225756,37.971584],[44.421403,38.281281],[44.109225,39.428136],[44.79399,39.713003],[44.952688,39.335765],[45.457722,38.874139],[46.143623,38.741201],[46.50572,38.770605],[47.685079,39.508364],[48.060095,39.582235],[48.355529,39.288765],[48.010744,38.794015],[48.634375,38.270378],[48.883249,38.320245],[49.199612,37.582874],[50.147771,37.374567],[50.842354,36.872814],[52.264025,36.700422],[53.82579,36.965031],[53.921598,37.198918],[54.800304,37.392421],[55.511578,37.964117],[56.180375,37.935127],[56.619366,38.121394],[57.330434,38.029229],[58.436154,37.522309],[59.234762,37.412988],[60.377638,36.527383],[61.123071,36.491597],[61.210817,35.650072],[60.803193,34.404102],[60.52843,33.676446],[60.9637,33.528832],[60.536078,32.981269],[60.863655,32.18292],[60.941945,31.548075],[61.699314,31.379506],[61.781222,30.73585],[60.874248,29.829239],[61.369309,29.303276],[61.771868,28.699334],[62.72783,28.259645],[62.755426,27.378923],[63.233898,27.217047],[63.316632,26.756532],[61.874187,26.239975],[61.497363,25.078237],[59.616134,25.380157],[58.525761,25.609962],[57.397251,25.739902],[56.970766,26.966106],[56.492139,27.143305],[55.72371,26.964633],[54.71509,26.480658],[53.493097,26.812369],[52.483598,27.580849],[51.520763,27.86569],[50.852948,28.814521],[50.115009,30.147773],[49.57685,29.985715],[48.941333,30.31709],[48.567971,29.926778]]]}},{"type":"Feature","properties":{"ADMIN":"Syria","NAME_EN":"Syria","CONTINENT":"Asia","ADM0_A3":"SYR","ISO_A3":"SYR","NAME":"Syria","ISO_A2":"SY","NAME_ZH":"叙利亚"},"geometry":{"type":"Polygon","coordinates":[[[35.719918,32.709192],[35.700798,32.716014],[35.836397,32.868123],[35.821101,33.277426],[36.06646,33.824912],[36.61175,34.201789],[36.448194,34.593935],[35.998403,34.644914],[35.905023,35.410009],[36.149763,35.821535],[36.41755,36.040617],[36.685389,36.259699],[36.739494,36.81752],[37.066761,36.623036],[38.167727,36.90121],[38.699891,36.712927],[39.52258,36.716054],[40.673259,37.091276],[41.212089,37.074352],[42.349591,37.229873],[41.837064,36.605854],[41.289707,36.358815],[41.383965,35.628317],[41.006159,34.419372],[38.792341,33.378686],[36.834062,32.312938],[35.719918,32.709192]]]}},{"type":"Feature","properties":{"ADMIN":"Armenia","NAME_EN":"Armenia","CONTINENT":"Asia","ADM0_A3":"ARM","ISO_A3":"ARM","NAME":"Armenia","ISO_A2":"AM","NAME_ZH":"亚美尼亚"},"geometry":{"type":"Polygon","coordinates":[[[46.50572,38.770605],[46.143623,38.741201],[45.735379,39.319719],[45.739978,39.473999],[45.298145,39.471751],[45.001987,39.740004],[44.79399,39.713003],[44.400009,40.005],[43.656436,40.253564],[43.752658,40.740201],[43.582746,41.092143],[44.97248,41.248129],[45.179496,40.985354],[45.560351,40.81229],[45.359175,40.561504],[45.891907,40.218476],[45.610012,39.899994],[46.034534,39.628021],[46.483499,39.464155],[46.50572,38.770605]]]}},{"type":"Feature","properties":{"ADMIN":"Sweden","NAME_EN":"Sweden","CONTINENT":"Europe","ADM0_A3":"SWE","ISO_A3":"SWE","NAME":"Sweden","ISO_A2":"SE","NAME_ZH":"瑞典"},"geometry":{"type":"Polygon","coordinates":[[[11.027369,58.856149],[11.468272,59.432393],[12.300366,60.117933],[12.631147,61.293572],[11.992064,61.800362],[11.930569,63.128318],[12.579935,64.066219],[13.571916,64.049114],[13.919905,64.445421],[13.55569,64.787028],[15.108411,66.193867],[16.108712,67.302456],[16.768879,68.013937],[17.729182,68.010552],[17.993868,68.567391],[19.87856,68.407194],[20.025269,69.065139],[20.645593,69.106247],[21.978535,68.616846],[23.539473,67.936009],[23.56588,66.396051],[23.903379,66.006927],[22.183173,65.723741],[21.213517,65.026005],[21.369631,64.413588],[19.778876,63.609554],[17.847779,62.7494],[17.119555,61.341166],[17.831346,60.636583],[18.787722,60.081914],[17.869225,58.953766],[16.829185,58.719827],[16.44771,57.041118],[15.879786,56.104302],[14.666681,56.200885],[14.100721,55.407781],[12.942911,55.361737],[12.625101,56.30708],[11.787942,57.441817],[11.027369,58.856149]]]}},{"type":"Feature","properties":{"ADMIN":"Belarus","NAME_EN":"Belarus","CONTINENT":"Europe","ADM0_A3":"BLR","ISO_A3":"BLR","NAME":"Belarus","ISO_A2":"BY","NAME_ZH":"白俄罗斯"},"geometry":{"type":"Polygon","coordinates":[[[28.176709,56.16913],[29.229513,55.918344],[29.371572,55.670091],[29.896294,55.789463],[30.873909,55.550976],[30.971836,55.081548],[30.757534,54.811771],[31.384472,54.157056],[31.791424,53.974639],[31.731273,53.794029],[32.405599,53.618045],[32.693643,53.351421],[32.304519,53.132726],[31.49764,53.16743],[31.305201,53.073996],[31.540018,52.742052],[31.78597,52.10168],[31.785992,52.101678],[30.927549,52.042353],[30.619454,51.822806],[30.555117,51.319503],[30.157364,51.416138],[29.254938,51.368234],[28.992835,51.602044],[28.617613,51.427714],[28.241615,51.572227],[27.454066,51.592303],[26.337959,51.832289],[25.327788,51.910656],[24.553106,51.888461],[24.005078,51.617444],[23.527071,51.578454],[23.508002,52.023647],[23.199494,52.486977],[23.799199,52.691099],[23.804935,53.089731],[23.527536,53.470122],[23.484128,53.912498],[24.450684,53.905702],[25.536354,54.282423],[25.768433,54.846963],[26.588279,55.167176],[26.494331,55.615107],[27.10246,55.783314],[28.176709,56.16913]]]}},{"type":"Feature","properties":{"ADMIN":"Ukraine","NAME_EN":"Ukraine","CONTINENT":"Europe","ADM0_A3":"UKR","ISO_A3":"UKR","NAME":"Ukraine","ISO_A2":"UA","NAME_ZH":"乌克兰"},"geometry":{"type":"Polygon","coordinates":[[[31.785992,52.101678],[32.15944,52.06125],[32.412058,52.288695],[32.715761,52.238465],[33.7527,52.335075],[34.391731,51.768882],[34.141978,51.566413],[34.224816,51.255993],[35.022183,51.207572],[35.37791,50.77394],[35.356116,50.577197],[36.626168,50.225591],[37.39346,50.383953],[38.010631,49.915662],[38.594988,49.926462],[40.06904,49.60105],[40.080789,49.30743],[39.67465,48.78382],[39.89562,48.23241],[39.738278,47.898937],[38.77057,47.82562],[38.255112,47.5464],[38.223538,47.10219],[37.425137,47.022221],[36.759855,46.6987],[35.823685,46.645964],[34.962342,46.273197],[35.012659,45.737725],[34.861792,45.768182],[34.732017,45.965666],[34.410402,46.005162],[33.699462,46.219573],[33.435988,45.971917],[33.298567,46.080598],[31.74414,46.333348],[31.675307,46.706245],[30.748749,46.5831],[30.377609,46.03241],[29.603289,45.293308],[29.149725,45.464925],[28.679779,45.304031],[28.233554,45.488283],[28.485269,45.596907],[28.659987,45.939987],[28.933717,46.25883],[28.862972,46.437889],[29.072107,46.517678],[29.170654,46.379262],[29.759972,46.349988],[30.024659,46.423937],[29.83821,46.525326],[29.908852,46.674361],[29.559674,46.928583],[29.415135,47.346645],[29.050868,47.510227],[29.122698,47.849095],[28.670891,48.118149],[28.259547,48.155562],[27.522537,48.467119],[26.857824,48.368211],[26.619337,48.220726],[26.19745,48.220881],[25.945941,47.987149],[25.207743,47.891056],[24.866317,47.737526],[24.402056,47.981878],[23.760958,47.985598],[23.142236,48.096341],[22.710531,47.882194],[22.64082,48.15024],[22.085608,48.422264],[22.280842,48.825392],[22.558138,49.085738],[22.776419,49.027395],[22.51845,49.476774],[23.426508,50.308506],[23.922757,50.424881],[24.029986,50.705407],[23.527071,51.578454],[24.005078,51.617444],[24.553106,51.888461],[25.327788,51.910656],[26.337959,51.832289],[27.454066,51.592303],[28.241615,51.572227],[28.617613,51.427714],[28.992835,51.602044],[29.254938,51.368234],[30.157364,51.416138],[30.555117,51.319503],[30.619454,51.822806],[30.927549,52.042353],[31.785992,52.101678]]]}},{"type":"Feature","properties":{"ADMIN":"Poland","NAME_EN":"Poland","CONTINENT":"Europe","ADM0_A3":"POL","ISO_A3":"POL","NAME":"Poland","ISO_A2":"PL","NAME_ZH":"波兰"},"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[23.527536,53.470122],[23.804935,53.089731],[23.799199,52.691099],[23.199494,52.486977],[23.508002,52.023647],[23.527071,51.578454],[24.029986,50.705407],[23.922757,50.424881],[23.426508,50.308506],[22.51845,49.476774],[22.776419,49.027395],[22.558138,49.085738],[21.607808,49.470107],[20.887955,49.328772],[20.415839,49.431453],[19.825023,49.217125],[19.320713,49.571574],[18.909575,49.435846],[18.853144,49.49623],[18.392914,49.988629],[17.649445,50.049038],[17.554567,50.362146],[16.868769,50.473974],[16.719476,50.215747],[16.176253,50.422607],[16.238627,50.697733],[15.490972,50.78473],[15.016996,51.106674],[14.607098,51.745188],[14.685026,52.089947],[14.4376,52.62485],[14.074521,52.981263],[14.353315,53.248171],[14.119686,53.757029],[14.8029,54.050706],[16.363477,54.513159],[17.622832,54.851536],[18.620859,54.682606],[18.696255,54.438719],[19.66064,54.426084],[20.892245,54.312525],[22.731099,54.327537],[23.243987,54.220567],[23.484128,53.912498]]]}},{"type":"Feature","properties":{"ADMIN":"Austria","NAME_EN":"Austria","CONTINENT":"Europe","ADM0_A3":"AUT","ISO_A3":"AUT","NAME":"Austria","ISO_A2":"AT","NAME_ZH":"奥地利"},"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.903754,47.714866],[16.340584,47.712902],[16.534268,47.496171],[16.202298,46.852386],[16.011664,46.683611],[15.137092,46.658703],[14.632472,46.431817],[13.806475,46.509306],[12.376485,46.767559],[12.153088,47.115393],[11.164828,46.941579],[11.048556,46.751359],[10.442701,46.893546],[9.932448,46.920728],[9.47997,47.10281],[9.632932,47.347601],[9.594226,47.525058],[9.896068,47.580197],[10.402084,47.302488],[10.544504,47.566399],[11.426414,47.523766],[12.141357,47.703083],[12.62076,47.672388],[12.932627,47.467646],[13.025851,47.637584],[12.884103,48.289146],[13.243357,48.416115],[13.595946,48.877172],[14.338898,48.555305],[14.901447,48.964402],[15.253416,49.039074],[16.029647,48.733899],[16.499283,48.785808],[16.960288,48.596982],[16.879983,48.470013],[16.979667,48.123497]]]}},{"type":"Feature","properties":{"ADMIN":"Hungary","NAME_EN":"Hungary","CONTINENT":"Europe","ADM0_A3":"HUN","ISO_A3":"HUN","NAME":"Hungary","ISO_A2":"HU","NAME_ZH":"匈牙利"},"geometry":{"type":"Polygon","coordinates":[[[22.085608,48.422264],[22.64082,48.15024],[22.710531,47.882194],[22.099768,47.672439],[21.626515,46.994238],[21.021952,46.316088],[20.220192,46.127469],[19.596045,46.17173],[18.829838,45.908878],[18.829825,45.908872],[18.456062,45.759481],[17.630066,45.951769],[16.882515,46.380632],[16.564808,46.503751],[16.370505,46.841327],[16.202298,46.852386],[16.534268,47.496171],[16.340584,47.712902],[16.903754,47.714866],[16.979667,48.123497],[17.488473,47.867466],[17.857133,47.758429],[18.696513,47.880954],[18.777025,48.081768],[19.174365,48.111379],[19.661364,48.266615],[19.769471,48.202691],[20.239054,48.327567],[20.473562,48.56285],[20.801294,48.623854],[21.872236,48.319971],[22.085608,48.422264]]]}},{"type":"Feature","properties":{"ADMIN":"Moldova","NAME_EN":"Moldova","CONTINENT":"Europe","ADM0_A3":"MDA","ISO_A3":"MDA","NAME":"Moldova","ISO_A2":"MD","NAME_ZH":"摩尔多瓦"},"geometry":{"type":"Polygon","coordinates":[[[26.619337,48.220726],[26.857824,48.368211],[27.522537,48.467119],[28.259547,48.155562],[28.670891,48.118149],[29.122698,47.849095],[29.050868,47.510227],[29.415135,47.346645],[29.559674,46.928583],[29.908852,46.674361],[29.83821,46.525326],[30.024659,46.423937],[29.759972,46.349988],[29.170654,46.379262],[29.072107,46.517678],[28.862972,46.437889],[28.933717,46.25883],[28.659987,45.939987],[28.485269,45.596907],[28.233554,45.488283],[28.054443,45.944586],[28.160018,46.371563],[28.12803,46.810476],[27.551166,47.405117],[27.233873,47.826771],[26.924176,48.123264],[26.619337,48.220726]]]}},{"type":"Feature","properties":{"ADMIN":"Romania","NAME_EN":"Romania","CONTINENT":"Europe","ADM0_A3":"ROU","ISO_A3":"ROU","NAME":"Romania","ISO_A2":"RO","NAME_ZH":"罗马尼亚"},"geometry":{"type":"Polygon","coordinates":[[[28.233554,45.488283],[28.679779,45.304031],[29.149725,45.464925],[29.603289,45.293308],[29.626543,45.035391],[29.141612,44.82021],[28.837858,44.913874],[28.558081,43.707462],[27.970107,43.812468],[27.2424,44.175986],[26.065159,43.943494],[25.569272,43.688445],[24.100679,43.741051],[23.332302,43.897011],[22.944832,43.823785],[22.65715,44.234923],[22.474008,44.409228],[22.705726,44.578003],[22.459022,44.702517],[22.145088,44.478422],[21.562023,44.768947],[21.483526,45.18117],[20.874313,45.416375],[20.762175,45.734573],[20.220192,46.127469],[21.021952,46.316088],[21.626515,46.994238],[22.099768,47.672439],[22.710531,47.882194],[23.142236,48.096341],[23.760958,47.985598],[24.402056,47.981878],[24.866317,47.737526],[25.207743,47.891056],[25.945941,47.987149],[26.19745,48.220881],[26.619337,48.220726],[26.924176,48.123264],[27.233873,47.826771],[27.551166,47.405117],[28.12803,46.810476],[28.160018,46.371563],[28.054443,45.944586],[28.233554,45.488283]]]}},{"type":"Feature","properties":{"ADMIN":"Lithuania","NAME_EN":"Lithuania","CONTINENT":"Europe","ADM0_A3":"LTU","ISO_A3":"LTU","NAME":"Lithuania","ISO_A2":"LT","NAME_ZH":"立陶宛"},"geometry":{"type":"Polygon","coordinates":[[[26.494331,55.615107],[26.588279,55.167176],[25.768433,54.846963],[25.536354,54.282423],[24.450684,53.905702],[23.484128,53.912498],[23.243987,54.220567],[22.731099,54.327537],[22.651052,54.582741],[22.757764,54.856574],[22.315724,55.015299],[21.268449,55.190482],[21.0558,56.031076],[22.201157,56.337802],[23.878264,56.273671],[24.860684,56.372528],[25.000934,56.164531],[25.533047,56.100297],[26.494331,55.615107]]]}},{"type":"Feature","properties":{"ADMIN":"Latvia","NAME_EN":"Latvia","CONTINENT":"Europe","ADM0_A3":"LVA","ISO_A3":"LVA","NAME":"Latvia","ISO_A2":"LV","NAME_ZH":"拉脱维亚"},"geometry":{"type":"Polygon","coordinates":[[[27.288185,57.474528],[27.770016,57.244258],[27.855282,56.759326],[28.176709,56.16913],[27.10246,55.783314],[26.494331,55.615107],[25.533047,56.100297],[25.000934,56.164531],[24.860684,56.372528],[23.878264,56.273671],[22.201157,56.337802],[21.0558,56.031076],[21.090424,56.783873],[21.581866,57.411871],[22.524341,57.753374],[23.318453,57.006236],[24.12073,57.025693],[24.312863,57.793424],[25.164594,57.970157],[25.60281,57.847529],[26.463532,57.476389],[27.288185,57.474528]]]}},{"type":"Feature","properties":{"ADMIN":"Estonia","NAME_EN":"Estonia","CONTINENT":"Europe","ADM0_A3":"EST","ISO_A3":"EST","NAME":"Estonia","ISO_A2":"EE","NAME_ZH":"爱沙尼亚"},"geometry":{"type":"Polygon","coordinates":[[[27.981127,59.475373],[27.98112,59.47537],[28.131699,59.300825],[27.42015,58.72457],[27.716686,57.791899],[27.288185,57.474528],[26.463532,57.476389],[25.60281,57.847529],[25.164594,57.970157],[24.312863,57.793424],[24.428928,58.383413],[24.061198,58.257375],[23.42656,58.612753],[23.339795,59.18724],[24.604214,59.465854],[25.864189,59.61109],[26.949136,59.445803],[27.981114,59.475388],[27.981127,59.475373]]]}},{"type":"Feature","properties":{"ADMIN":"Germany","NAME_EN":"Germany","CONTINENT":"Europe","ADM0_A3":"DEU","ISO_A3":"DEU","NAME":"Germany","ISO_A2":"DE","NAME_ZH":"德国"},"geometry":{"type":"Polygon","coordinates":[[[14.119686,53.757029],[14.353315,53.248171],[14.074521,52.981263],[14.4376,52.62485],[14.685026,52.089947],[14.607098,51.745188],[15.016996,51.106674],[14.570718,51.002339],[14.307013,51.117268],[14.056228,50.926918],[13.338132,50.733234],[12.966837,50.484076],[12.240111,50.266338],[12.415191,49.969121],[12.521024,49.547415],[13.031329,49.307068],[13.595946,48.877172],[13.243357,48.416115],[12.884103,48.289146],[13.025851,47.637584],[12.932627,47.467646],[12.62076,47.672388],[12.141357,47.703083],[11.426414,47.523766],[10.544504,47.566399],[10.402084,47.302488],[9.896068,47.580197],[9.594226,47.525058],[8.522612,47.830828],[8.317301,47.61358],[7.466759,47.620582],[7.593676,48.333019],[8.099279,49.017784],[6.65823,49.201958],[6.18632,49.463803],[6.242751,49.902226],[6.043073,50.128052],[6.156658,50.803721],[5.988658,51.851616],[6.589397,51.852029],[6.84287,52.22844],[7.092053,53.144043],[6.90514,53.482162],[7.100425,53.693932],[7.936239,53.748296],[8.121706,53.527792],[8.800734,54.020786],[8.572118,54.395646],[8.526229,54.962744],[9.282049,54.830865],[9.921906,54.983104],[9.93958,54.596642],[10.950112,54.363607],[10.939467,54.008693],[11.956252,54.196486],[12.51844,54.470371],[13.647467,54.075511],[14.119686,53.757029]]]}},{"type":"Feature","properties":{"ADMIN":"Bulgaria","NAME_EN":"Bulgaria","CONTINENT":"Europe","ADM0_A3":"BGR","ISO_A3":"BGR","NAME":"Bulgaria","ISO_A2":"BG","NAME_ZH":"保加利亚"},"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.944832,43.823785],[23.332302,43.897011],[24.100679,43.741051],[25.569272,43.688445],[26.065159,43.943494],[27.2424,44.175986],[27.970107,43.812468],[28.558081,43.707462],[28.039095,43.293172],[27.673898,42.577892],[27.99672,42.007359],[27.135739,42.141485],[26.117042,41.826905],[26.106138,41.328899],[25.197201,41.234486],[24.492645,41.583896],[23.692074,41.309081],[22.952377,41.337994],[22.881374,41.999297],[22.380526,42.32026],[22.545012,42.461362],[22.436595,42.580321],[22.604801,42.898519],[22.986019,43.211161],[22.500157,43.642814],[22.410446,44.008063],[22.65715,44.234923]]]}},{"type":"Feature","properties":{"ADMIN":"Greece","NAME_EN":"Greece","CONTINENT":"Europe","ADM0_A3":"GRC","ISO_A3":"GRC","NAME":"Greece","ISO_A2":"GR","NAME_ZH":"希腊"},"geometry":{"type":"MultiPolygon","coordinates":[[[[26.290003,35.29999],[26.164998,35.004995],[24.724982,34.919988],[24.735007,35.084991],[23.514978,35.279992],[23.69998,35.705004],[24.246665,35.368022],[25.025015,35.424996],[25.769208,35.354018],[25.745023,35.179998],[26.290003,35.29999]]],[[[22.952377,41.337994],[23.692074,41.309081],[24.492645,41.583896],[25.197201,41.234486],[26.106138,41.328899],[26.117042,41.826905],[26.604196,41.562115],[26.294602,40.936261],[26.056942,40.824123],[25.447677,40.852545],[24.925848,40.947062],[23.714811,40.687129],[24.407999,40.124993],[23.899968,39.962006],[23.342999,39.960998],[22.813988,40.476005],[22.626299,40.256561],[22.849748,39.659311],[23.350027,39.190011],[22.973099,38.970903],[23.530016,38.510001],[24.025025,38.219993],[24.040011,37.655015],[23.115003,37.920011],[23.409972,37.409991],[22.774972,37.30501],[23.154225,36.422506],[22.490028,36.41],[21.670026,36.844986],[21.295011,37.644989],[21.120034,38.310323],[20.730032,38.769985],[20.217712,39.340235],[20.150016,39.624998],[20.615,40.110007],[20.674997,40.435],[20.99999,40.580004],[21.02004,40.842727],[21.674161,40.931275],[22.055378,41.149866],[22.597308,41.130487],[22.76177,41.3048],[22.952377,41.337994]]]]}},{"type":"Feature","properties":{"ADMIN":"Turkey","NAME_EN":"Turkey","CONTINENT":"Asia","ADM0_A3":"TUR","ISO_A3":"TUR","NAME":"Turkey","ISO_A2":"TR","NAME_ZH":"土耳其"},"geometry":{"type":"MultiPolygon","coordinates":[[[[44.772677,37.170437],[44.293452,37.001514],[43.942259,37.256228],[42.779126,37.385264],[42.349591,37.229873],[41.212089,37.074352],[40.673259,37.091276],[39.52258,36.716054],[38.699891,36.712927],[38.167727,36.90121],[37.066761,36.623036],[36.739494,36.81752],[36.685389,36.259699],[36.41755,36.040617],[36.149763,35.821535],[35.782085,36.274995],[36.160822,36.650606],[35.550936,36.565443],[34.714553,36.795532],[34.026895,36.21996],[32.509158,36.107564],[31.699595,36.644275],[30.621625,36.677865],[30.391096,36.262981],[29.699976,36.144357],[28.732903,36.676831],[27.641187,36.658822],[27.048768,37.653361],[26.318218,38.208133],[26.8047,38.98576],[26.170785,39.463612],[27.28002,40.420014],[28.819978,40.460011],[29.240004,41.219991],[31.145934,41.087622],[32.347979,41.736264],[33.513283,42.01896],[35.167704,42.040225],[36.913127,41.335358],[38.347665,40.948586],[39.512607,41.102763],[40.373433,41.013673],[41.554084,41.535656],[42.619549,41.583173],[43.582746,41.092143],[43.752658,40.740201],[43.656436,40.253564],[44.400009,40.005],[44.79399,39.713003],[44.109225,39.428136],[44.421403,38.281281],[44.225756,37.971584],[44.77267,37.17045],[44.772677,37.170437]]],[[[26.117042,41.826905],[27.135739,42.141485],[27.99672,42.007359],[28.115525,41.622886],[28.988443,41.299934],[28.806438,41.054962],[27.619017,40.999823],[27.192377,40.690566],[26.358009,40.151994],[26.043351,40.617754],[26.056942,40.824123],[26.294602,40.936261],[26.604196,41.562115],[26.117042,41.826905]]]]}},{"type":"Feature","properties":{"ADMIN":"Albania","NAME_EN":"Albania","CONTINENT":"Europe","ADM0_A3":"ALB","ISO_A3":"ALB","NAME":"Albania","ISO_A2":"AL","NAME_ZH":"阿尔巴尼亚"},"geometry":{"type":"Polygon","coordinates":[[[21.02004,40.842727],[20.99999,40.580004],[20.674997,40.435],[20.615,40.110007],[20.150016,39.624998],[19.98,39.694993],[19.960002,39.915006],[19.406082,40.250773],[19.319059,40.72723],[19.40355,41.409566],[19.540027,41.719986],[19.371769,41.877548],[19.371768,41.877551],[19.304486,42.195745],[19.738051,42.688247],[19.801613,42.500093],[20.0707,42.58863],[20.283755,42.32026],[20.52295,42.21787],[20.590247,41.855409],[20.590247,41.855404],[20.463175,41.515089],[20.605182,41.086226],[21.02004,40.842727]]]}},{"type":"Feature","properties":{"ADMIN":"Croatia","NAME_EN":"Croatia","CONTINENT":"Europe","ADM0_A3":"HRV","ISO_A3":"HRV","NAME":"Croatia","ISO_A2":"HR","NAME_ZH":"克罗地亚"},"geometry":{"type":"Polygon","coordinates":[[[16.564808,46.503751],[16.882515,46.380632],[17.630066,45.951769],[18.456062,45.759481],[18.829825,45.908872],[19.072769,45.521511],[19.390476,45.236516],[19.005485,44.860234],[18.553214,45.08159],[17.861783,45.06774],[17.002146,45.233777],[16.534939,45.211608],[16.318157,45.004127],[15.959367,45.233777],[15.750026,44.818712],[16.23966,44.351143],[16.456443,44.04124],[16.916156,43.667722],[17.297373,43.446341],[17.674922,43.028563],[18.56,42.65],[18.450017,42.479992],[18.450016,42.479991],[17.50997,42.849995],[16.930006,43.209998],[16.015385,43.507215],[15.174454,44.243191],[15.37625,44.317915],[14.920309,44.738484],[14.901602,45.07606],[14.258748,45.233777],[13.952255,44.802124],[13.656976,45.136935],[13.679403,45.484149],[13.71506,45.500324],[14.411968,45.466166],[14.595109,45.634941],[14.935244,45.471695],[15.327675,45.452316],[15.323954,45.731783],[15.67153,45.834154],[15.768733,46.238108],[16.564808,46.503751]]]}},{"type":"Feature","properties":{"ADMIN":"Switzerland","NAME_EN":"Switzerland","CONTINENT":"Europe","ADM0_A3":"CHE","ISO_A3":"CHE","NAME":"Switzerland","ISO_A2":"CH","NAME_ZH":"瑞士"},"geometry":{"type":"Polygon","coordinates":[[[9.594226,47.525058],[9.632932,47.347601],[9.47997,47.10281],[9.932448,46.920728],[10.442701,46.893546],[10.363378,46.483571],[9.922837,46.314899],[9.182882,46.440215],[8.966306,46.036932],[8.489952,46.005151],[8.31663,46.163642],[7.755992,45.82449],[7.273851,45.776948],[6.843593,45.991147],[6.5001,46.429673],[6.022609,46.27299],[6.037389,46.725779],[6.768714,47.287708],[6.736571,47.541801],[7.192202,47.449766],[7.466759,47.620582],[8.317301,47.61358],[8.522612,47.830828],[9.594226,47.525058]]]}},{"type":"Feature","properties":{"ADMIN":"Luxembourg","NAME_EN":"Luxembourg","CONTINENT":"Europe","ADM0_A3":"LUX","ISO_A3":"LUX","NAME":"Luxembourg","ISO_A2":"LU","NAME_ZH":"卢森堡"},"geometry":{"type":"Polygon","coordinates":[[[6.043073,50.128052],[6.242751,49.902226],[6.18632,49.463803],[5.897759,49.442667],[5.674052,49.529484],[5.782417,50.090328],[6.043073,50.128052]]]}},{"type":"Feature","properties":{"ADMIN":"Belgium","NAME_EN":"Belgium","CONTINENT":"Europe","ADM0_A3":"BEL","ISO_A3":"BEL","NAME":"Belgium","ISO_A2":"BE","NAME_ZH":"比利时"},"geometry":{"type":"Polygon","coordinates":[[[6.156658,50.803721],[6.043073,50.128052],[5.782417,50.090328],[5.674052,49.529484],[4.799222,49.985373],[4.286023,49.907497],[3.588184,50.378992],[3.123252,50.780363],[2.658422,50.796848],[2.513573,51.148506],[3.314971,51.345781],[3.315011,51.345777],[3.314971,51.345755],[4.047071,51.267259],[4.973991,51.475024],[5.606976,51.037298],[6.156658,50.803721]]]}},{"type":"Feature","properties":{"ADMIN":"Netherlands","NAME_EN":"Netherlands","CONTINENT":"Europe","ADM0_A3":"NLD","ISO_A3":"NLD","NAME":"Netherlands","ISO_A2":"NL","NAME_ZH":"荷兰"},"geometry":{"type":"Polygon","coordinates":[[[6.90514,53.482162],[7.092053,53.144043],[6.84287,52.22844],[6.589397,51.852029],[5.988658,51.851616],[6.156658,50.803721],[5.606976,51.037298],[4.973991,51.475024],[4.047071,51.267259],[3.314971,51.345755],[3.315011,51.345777],[3.830289,51.620545],[4.705997,53.091798],[6.074183,53.510403],[6.90514,53.482162]]]}},{"type":"Feature","properties":{"ADMIN":"Portugal","NAME_EN":"Portugal","CONTINENT":"Europe","ADM0_A3":"PRT","ISO_A3":"PRT","NAME":"Portugal","ISO_A2":"PT","NAME_ZH":"葡萄牙"},"geometry":{"type":"Polygon","coordinates":[[[-9.034818,41.880571],[-8.671946,42.134689],[-8.263857,42.280469],[-8.013175,41.790886],[-7.422513,41.792075],[-7.251309,41.918346],[-6.668606,41.883387],[-6.389088,41.381815],[-6.851127,41.111083],[-6.86402,40.330872],[-7.026413,40.184524],[-7.066592,39.711892],[-7.498632,39.629571],[-7.098037,39.030073],[-7.374092,38.373059],[-7.029281,38.075764],[-7.166508,37.803894],[-7.537105,37.428904],[-7.453726,37.097788],[-7.855613,36.838269],[-8.382816,36.97888],[-8.898857,36.868809],[-8.746101,37.651346],[-8.839998,38.266243],[-9.287464,38.358486],[-9.526571,38.737429],[-9.446989,39.392066],[-9.048305,39.755093],[-8.977353,40.159306],[-8.768684,40.760639],[-8.790853,41.184334],[-8.990789,41.543459],[-9.034818,41.880571]]]}},{"type":"Feature","properties":{"ADMIN":"Spain","NAME_EN":"Spain","CONTINENT":"Europe","ADM0_A3":"ESP","ISO_A3":"ESP","NAME":"Spain","ISO_A2":"ES","NAME_ZH":"西班牙"},"geometry":{"type":"Polygon","coordinates":[[[-7.453726,37.097788],[-7.537105,37.428904],[-7.166508,37.803894],[-7.029281,38.075764],[-7.374092,38.373059],[-7.098037,39.030073],[-7.498632,39.629571],[-7.066592,39.711892],[-7.026413,40.184524],[-6.86402,40.330872],[-6.851127,41.111083],[-6.389088,41.381815],[-6.668606,41.883387],[-7.251309,41.918346],[-7.422513,41.792075],[-8.013175,41.790886],[-8.263857,42.280469],[-8.671946,42.134689],[-9.034818,41.880571],[-8.984433,42.592775],[-9.392884,43.026625],[-7.97819,43.748338],[-6.754492,43.567909],[-5.411886,43.57424],[-4.347843,43.403449],[-3.517532,43.455901],[-1.901351,43.422802],[-1.502771,43.034014],[0.338047,42.579546],[0.701591,42.795734],[1.826793,42.343385],[2.985999,42.473015],[3.039484,41.89212],[2.091842,41.226089],[0.810525,41.014732],[0.721331,40.678318],[0.106692,40.123934],[-0.278711,39.309978],[0.111291,38.738514],[-0.467124,38.292366],[-0.683389,37.642354],[-1.438382,37.443064],[-2.146453,36.674144],[-3.415781,36.6589],[-4.368901,36.677839],[-4.995219,36.324708],[-5.37716,35.94685],[-5.866432,36.029817],[-6.236694,36.367677],[-6.520191,36.942913],[-7.453726,37.097788]]]}},{"type":"Feature","properties":{"ADMIN":"Ireland","NAME_EN":"Ireland","CONTINENT":"Europe","ADM0_A3":"IRL","ISO_A3":"IRL","NAME":"Ireland","ISO_A2":"IE","NAME_ZH":"爱尔兰"},"geometry":{"type":"Polygon","coordinates":[[[-6.197885,53.867565],[-6.032985,53.153164],[-6.788857,52.260118],[-8.561617,51.669301],[-9.977086,51.820455],[-9.166283,52.864629],[-9.688525,53.881363],[-8.327987,54.664519],[-7.572168,55.131622],[-7.366031,54.595841],[-7.572168,54.059956],[-6.95373,54.073702],[-6.197885,53.867565]]]}},{"type":"Feature","properties":{"ADMIN":"New Caledonia","NAME_EN":"New Caledonia","CONTINENT":"Oceania","ADM0_A3":"NCL","ISO_A3":"NCL","NAME":"New Caledonia","ISO_A2":"NC","NAME_ZH":"新喀里多尼亚"},"geometry":{"type":"Polygon","coordinates":[[[165.77999,-21.080005],[166.599991,-21.700019],[167.120011,-22.159991],[166.740035,-22.399976],[166.189732,-22.129708],[165.474375,-21.679607],[164.829815,-21.14982],[164.167995,-20.444747],[164.029606,-20.105646],[164.459967,-20.120012],[165.020036,-20.459991],[165.460009,-20.800022],[165.77999,-21.080005]]]}},{"type":"Feature","properties":{"ADMIN":"Solomon Islands","NAME_EN":"Solomon Islands","CONTINENT":"Oceania","ADM0_A3":"SLB","ISO_A3":"SLB","NAME":"Solomon Is.","ISO_A2":"SB","NAME_ZH":"所罗门群岛"},"geometry":{"type":"MultiPolygon","coordinates":[[[[162.119025,-10.482719],[162.398646,-10.826367],[161.700032,-10.820011],[161.319797,-10.204751],[161.917383,-10.446701],[162.119025,-10.482719]]],[[[161.679982,-9.599982],[161.529397,-9.784312],[160.788253,-8.917543],[160.579997,-8.320009],[160.920028,-8.320009],[161.280006,-9.120011],[161.679982,-9.599982]]],[[[160.852229,-9.872937],[160.462588,-9.89521],[159.849447,-9.794027],[159.640003,-9.63998],[159.702945,-9.24295],[160.362956,-9.400304],[160.688518,-9.610162],[160.852229,-9.872937]]],[[[159.640003,-8.020027],[159.875027,-8.33732],[159.917402,-8.53829],[159.133677,-8.114181],[158.586114,-7.754824],[158.21115,-7.421872],[158.359978,-7.320018],[158.820001,-7.560003],[159.640003,-8.020027]]],[[[157.14,-7.021638],[157.538426,-7.34782],[157.33942,-7.404767],[156.90203,-7.176874],[156.491358,-6.765943],[156.542828,-6.599338],[157.14,-7.021638]]]]}},{"type":"Feature","properties":{"ADMIN":"New Zealand","NAME_EN":"New Zealand","CONTINENT":"Oceania","ADM0_A3":"NZL","ISO_A3":"NZL","NAME":"New Zealand","ISO_A2":"NZ","NAME_ZH":"新西兰"},"geometry":{"type":"MultiPolygon","coordinates":[[[[176.885824,-40.065978],[176.508017,-40.604808],[176.01244,-41.289624],[175.239567,-41.688308],[175.067898,-41.425895],[174.650973,-41.281821],[175.22763,-40.459236],[174.900157,-39.908933],[173.824047,-39.508854],[173.852262,-39.146602],[174.574802,-38.797683],[174.743474,-38.027808],[174.697017,-37.381129],[174.292028,-36.711092],[174.319004,-36.534824],[173.840997,-36.121981],[173.054171,-35.237125],[172.636005,-34.529107],[173.007042,-34.450662],[173.551298,-35.006183],[174.32939,-35.265496],[174.612009,-36.156397],[175.336616,-37.209098],[175.357596,-36.526194],[175.808887,-36.798942],[175.95849,-37.555382],[176.763195,-37.881253],[177.438813,-37.961248],[178.010354,-37.579825],[178.517094,-37.695373],[178.274731,-38.582813],[177.97046,-39.166343],[177.206993,-39.145776],[176.939981,-39.449736],[177.032946,-39.879943],[176.885824,-40.065978]]],[[[169.667815,-43.555326],[170.52492,-43.031688],[171.12509,-42.512754],[171.569714,-41.767424],[171.948709,-41.514417],[172.097227,-40.956104],[172.79858,-40.493962],[173.020375,-40.919052],[173.247234,-41.331999],[173.958405,-40.926701],[174.247587,-41.349155],[174.248517,-41.770008],[173.876447,-42.233184],[173.22274,-42.970038],[172.711246,-43.372288],[173.080113,-43.853344],[172.308584,-43.865694],[171.452925,-44.242519],[171.185138,-44.897104],[170.616697,-45.908929],[169.831422,-46.355775],[169.332331,-46.641235],[168.411354,-46.619945],[167.763745,-46.290197],[166.676886,-46.219917],[166.509144,-45.852705],[167.046424,-45.110941],[168.303763,-44.123973],[168.949409,-43.935819],[169.667815,-43.555326]]]]}},{"type":"Feature","properties":{"ADMIN":"Australia","NAME_EN":"Australia","CONTINENT":"Oceania","ADM0_A3":"AUS","ISO_A3":"AUS","NAME":"Australia","ISO_A2":"AU","NAME_ZH":"澳大利亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[147.689259,-40.808258],[148.289068,-40.875438],[148.359865,-42.062445],[148.017301,-42.407024],[147.914052,-43.211522],[147.564564,-42.937689],[146.870343,-43.634597],[146.663327,-43.580854],[146.048378,-43.549745],[145.43193,-42.693776],[145.29509,-42.03361],[144.718071,-41.162552],[144.743755,-40.703975],[145.397978,-40.792549],[146.364121,-41.137695],[146.908584,-41.000546],[147.689259,-40.808258]]],[[[126.148714,-32.215966],[125.088623,-32.728751],[124.221648,-32.959487],[124.028947,-33.483847],[123.659667,-33.890179],[122.811036,-33.914467],[122.183064,-34.003402],[121.299191,-33.821036],[120.580268,-33.930177],[119.893695,-33.976065],[119.298899,-34.509366],[119.007341,-34.464149],[118.505718,-34.746819],[118.024972,-35.064733],[117.295507,-35.025459],[116.625109,-35.025097],[115.564347,-34.386428],[115.026809,-34.196517],[115.048616,-33.623425],[115.545123,-33.487258],[115.714674,-33.259572],[115.679379,-32.900369],[115.801645,-32.205062],[115.689611,-31.612437],[115.160909,-30.601594],[114.997043,-30.030725],[115.040038,-29.461095],[114.641974,-28.810231],[114.616498,-28.516399],[114.173579,-28.118077],[114.048884,-27.334765],[113.477498,-26.543134],[113.338953,-26.116545],[113.778358,-26.549025],[113.440962,-25.621278],[113.936901,-25.911235],[114.232852,-26.298446],[114.216161,-25.786281],[113.721255,-24.998939],[113.625344,-24.683971],[113.393523,-24.384764],[113.502044,-23.80635],[113.706993,-23.560215],[113.843418,-23.059987],[113.736552,-22.475475],[114.149756,-21.755881],[114.225307,-22.517488],[114.647762,-21.82952],[115.460167,-21.495173],[115.947373,-21.068688],[116.711615,-20.701682],[117.166316,-20.623599],[117.441545,-20.746899],[118.229559,-20.374208],[118.836085,-20.263311],[118.987807,-20.044203],[119.252494,-19.952942],[119.805225,-19.976506],[120.85622,-19.683708],[121.399856,-19.239756],[121.655138,-18.705318],[122.241665,-18.197649],[122.286624,-17.798603],[122.312772,-17.254967],[123.012574,-16.4052],[123.433789,-17.268558],[123.859345,-17.069035],[123.503242,-16.596506],[123.817073,-16.111316],[124.258287,-16.327944],[124.379726,-15.56706],[124.926153,-15.0751],[125.167275,-14.680396],[125.670087,-14.51007],[125.685796,-14.230656],[126.125149,-14.347341],[126.142823,-14.095987],[126.582589,-13.952791],[127.065867,-13.817968],[127.804633,-14.276906],[128.35969,-14.86917],[128.985543,-14.875991],[129.621473,-14.969784],[129.4096,-14.42067],[129.888641,-13.618703],[130.339466,-13.357376],[130.183506,-13.10752],[130.617795,-12.536392],[131.223495,-12.183649],[131.735091,-12.302453],[132.575298,-12.114041],[132.557212,-11.603012],[131.824698,-11.273782],[132.357224,-11.128519],[133.019561,-11.376411],[133.550846,-11.786515],[134.393068,-12.042365],[134.678632,-11.941183],[135.298491,-12.248606],[135.882693,-11.962267],[136.258381,-12.049342],[136.492475,-11.857209],[136.95162,-12.351959],[136.685125,-12.887223],[136.305407,-13.29123],[135.961758,-13.324509],[136.077617,-13.724278],[135.783836,-14.223989],[135.428664,-14.715432],[135.500184,-14.997741],[136.295175,-15.550265],[137.06536,-15.870762],[137.580471,-16.215082],[138.303217,-16.807604],[138.585164,-16.806622],[139.108543,-17.062679],[139.260575,-17.371601],[140.215245,-17.710805],[140.875463,-17.369069],[141.07111,-16.832047],[141.274095,-16.38887],[141.398222,-15.840532],[141.702183,-15.044921],[141.56338,-14.561333],[141.63552,-14.270395],[141.519869,-13.698078],[141.65092,-12.944688],[141.842691,-12.741548],[141.68699,-12.407614],[141.928629,-11.877466],[142.118488,-11.328042],[142.143706,-11.042737],[142.51526,-10.668186],[142.79731,-11.157355],[142.866763,-11.784707],[143.115947,-11.90563],[143.158632,-12.325656],[143.522124,-12.834358],[143.597158,-13.400422],[143.561811,-13.763656],[143.922099,-14.548311],[144.563714,-14.171176],[144.894908,-14.594458],[145.374724,-14.984976],[145.271991,-15.428205],[145.48526,-16.285672],[145.637033,-16.784918],[145.888904,-16.906926],[146.160309,-17.761655],[146.063674,-18.280073],[146.387478,-18.958274],[147.471082,-19.480723],[148.177602,-19.955939],[148.848414,-20.39121],[148.717465,-20.633469],[149.28942,-21.260511],[149.678337,-22.342512],[150.077382,-22.122784],[150.482939,-22.556142],[150.727265,-22.402405],[150.899554,-23.462237],[151.609175,-24.076256],[152.07354,-24.457887],[152.855197,-25.267501],[153.136162,-26.071173],[153.161949,-26.641319],[153.092909,-27.2603],[153.569469,-28.110067],[153.512108,-28.995077],[153.339095,-29.458202],[153.069241,-30.35024],[153.089602,-30.923642],[152.891578,-31.640446],[152.450002,-32.550003],[151.709117,-33.041342],[151.343972,-33.816023],[151.010555,-34.31036],[150.714139,-35.17346],[150.32822,-35.671879],[150.075212,-36.420206],[149.946124,-37.109052],[149.997284,-37.425261],[149.423882,-37.772681],[148.304622,-37.809061],[147.381733,-38.219217],[146.922123,-38.606532],[146.317922,-39.035757],[145.489652,-38.593768],[144.876976,-38.417448],[145.032212,-37.896188],[144.485682,-38.085324],[143.609974,-38.809465],[142.745427,-38.538268],[142.17833,-38.380034],[141.606582,-38.308514],[140.638579,-38.019333],[139.992158,-37.402936],[139.806588,-36.643603],[139.574148,-36.138362],[139.082808,-35.732754],[138.120748,-35.612296],[138.449462,-35.127261],[138.207564,-34.384723],[137.71917,-35.076825],[136.829406,-35.260535],[137.352371,-34.707339],[137.503886,-34.130268],[137.890116,-33.640479],[137.810328,-32.900007],[136.996837,-33.752771],[136.372069,-34.094766],[135.989043,-34.890118],[135.208213,-34.47867],[135.239218,-33.947953],[134.613417,-33.222778],[134.085904,-32.848072],[134.273903,-32.617234],[132.990777,-32.011224],[132.288081,-31.982647],[131.326331,-31.495803],[129.535794,-31.590423],[128.240938,-31.948489],[127.102867,-32.282267],[126.148714,-32.215966]]]]}},{"type":"Feature","properties":{"ADMIN":"Sri Lanka","NAME_EN":"Sri Lanka","CONTINENT":"Asia","ADM0_A3":"LKA","ISO_A3":"LKA","NAME":"Sri Lanka","ISO_A2":"LK","NAME_ZH":"斯里兰卡"},"geometry":{"type":"Polygon","coordinates":[[[81.787959,7.523055],[81.637322,6.481775],[81.21802,6.197141],[80.348357,5.96837],[79.872469,6.763463],[79.695167,8.200843],[80.147801,9.824078],[80.838818,9.268427],[81.304319,8.564206],[81.787959,7.523055]]]}},{"type":"Feature","properties":{"ADMIN":"China","NAME_EN":"People's Republic of China","CONTINENT":"Asia","ADM0_A3":"CHN","ISO_A3":"CHN","NAME":"China","ISO_A2":"CN","NAME_ZH":"中国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[109.47521,18.197701],[108.655208,18.507682],[108.626217,19.367888],[109.119056,19.821039],[110.211599,20.101254],[110.786551,20.077534],[111.010051,19.69593],[110.570647,19.255879],[110.339188,18.678395],[109.47521,18.197701]]],[[[80.25999,42.349999],[80.18015,42.920068],[80.866206,43.180362],[79.966106,44.917517],[81.947071,45.317027],[82.458926,45.53965],[83.180484,47.330031],[85.16429,47.000956],[85.720484,47.452969],[85.768233,48.455751],[86.598776,48.549182],[87.35997,49.214981],[87.751264,49.297198],[88.013832,48.599463],[88.854298,48.069082],[90.280826,47.693549],[90.970809,46.888146],[90.585768,45.719716],[90.94554,45.286073],[92.133891,45.115076],[93.480734,44.975472],[94.688929,44.352332],[95.306875,44.241331],[95.762455,43.319449],[96.349396,42.725635],[97.451757,42.74889],[99.515817,42.524691],[100.845866,42.663804],[101.83304,42.514873],[103.312278,41.907468],[104.522282,41.908347],[104.964994,41.59741],[106.129316,42.134328],[107.744773,42.481516],[109.243596,42.519446],[110.412103,42.871234],[111.129682,43.406834],[111.829588,43.743118],[111.667737,44.073176],[111.348377,44.457442],[111.873306,45.102079],[112.436062,45.011646],[113.463907,44.808893],[114.460332,45.339817],[115.985096,45.727235],[116.717868,46.388202],[117.421701,46.672733],[118.874326,46.805412],[119.66327,46.69268],[119.772824,47.048059],[118.866574,47.74706],[118.064143,48.06673],[117.295507,47.697709],[116.308953,47.85341],[115.742837,47.726545],[115.485282,48.135383],[116.191802,49.134598],[116.678801,49.888531],[117.879244,49.510983],[119.288461,50.142883],[119.27939,50.58292],[120.18208,51.64355],[120.7382,51.96411],[120.725789,52.516226],[120.177089,52.753886],[121.003085,53.251401],[122.245748,53.431726],[123.57147,53.4588],[125.068211,53.161045],[125.946349,52.792799],[126.564399,51.784255],[126.939157,51.353894],[127.287456,50.739797],[127.6574,49.76027],[129.397818,49.4406],[130.582293,48.729687],[130.98726,47.79013],[132.50669,47.78896],[133.373596,48.183442],[135.026311,48.47823],[134.50081,47.57845],[134.11235,47.21248],[133.769644,46.116927],[133.09712,45.14409],[131.883454,45.321162],[131.02519,44.96796],[131.288555,44.11152],[131.144688,42.92999],[130.633866,42.903015],[130.64,42.395024],[129.994267,42.985387],[129.596669,42.424982],[128.052215,41.994285],[128.208433,41.466772],[127.343783,41.503152],[126.869083,41.816569],[126.182045,41.107336],[125.079942,40.569824],[124.265625,39.928493],[122.86757,39.637788],[122.131388,39.170452],[121.054554,38.897471],[121.585995,39.360854],[121.376757,39.750261],[122.168595,40.422443],[121.640359,40.94639],[120.768629,40.593388],[119.639602,39.898056],[119.023464,39.252333],[118.042749,39.204274],[117.532702,38.737636],[118.059699,38.061476],[118.87815,37.897325],[118.911636,37.448464],[119.702802,37.156389],[120.823457,37.870428],[121.711259,37.481123],[122.357937,37.454484],[122.519995,36.930614],[121.104164,36.651329],[120.637009,36.11144],[119.664562,35.609791],[119.151208,34.909859],[120.227525,34.360332],[120.620369,33.376723],[121.229014,32.460319],[121.908146,31.692174],[121.891919,30.949352],[121.264257,30.676267],[121.503519,30.142915],[122.092114,29.83252],[121.938428,29.018022],[121.684439,28.225513],[121.125661,28.135673],[120.395473,27.053207],[119.585497,25.740781],[118.656871,24.547391],[117.281606,23.624501],[115.890735,22.782873],[114.763827,22.668074],[114.152547,22.22376],[113.80678,22.54834],[113.241078,22.051367],[111.843592,21.550494],[110.785466,21.397144],[110.444039,20.341033],[109.889861,20.282457],[109.627655,21.008227],[109.864488,21.395051],[108.522813,21.715212],[108.05018,21.55238],[107.04342,21.811899],[106.567273,22.218205],[106.725403,22.794268],[105.811247,22.976892],[105.329209,23.352063],[104.476858,22.81915],[103.504515,22.703757],[102.706992,22.708795],[102.170436,22.464753],[101.652018,22.318199],[101.80312,21.174367],[101.270026,21.201652],[101.180005,21.436573],[101.150033,21.849984],[100.416538,21.558839],[99.983489,21.742937],[99.240899,22.118314],[99.531992,22.949039],[98.898749,23.142722],[98.660262,24.063286],[97.60472,23.897405],[97.724609,25.083637],[98.671838,25.918703],[98.712094,26.743536],[98.68269,27.508812],[98.246231,27.747221],[97.911988,28.335945],[97.327114,28.261583],[96.248833,28.411031],[96.586591,28.83098],[96.117679,29.452802],[95.404802,29.031717],[94.56599,29.277438],[93.413348,28.640629],[92.503119,27.896876],[91.696657,27.771742],[91.258854,28.040614],[90.730514,28.064954],[90.015829,28.296439],[89.47581,28.042759],[88.814248,27.299316],[88.730326,28.086865],[88.120441,27.876542],[86.954517,27.974262],[85.82332,28.203576],[85.011638,28.642774],[84.23458,28.839894],[83.898993,29.320226],[83.337115,29.463732],[82.327513,30.115268],[81.525804,30.422717],[81.111256,30.183481],[79.721367,30.882715],[78.738894,31.515906],[78.458446,32.618164],[79.176129,32.48378],[79.208892,32.994395],[78.811086,33.506198],[78.912269,34.321936],[77.837451,35.49401],[76.192848,35.898403],[75.896897,36.666806],[75.158028,37.133031],[74.980002,37.41999],[74.829986,37.990007],[74.864816,38.378846],[74.257514,38.606507],[73.928852,38.505815],[73.675379,39.431237],[73.960013,39.660008],[73.822244,39.893973],[74.776862,40.366425],[75.467828,40.562072],[76.526368,40.427946],[76.904484,41.066486],[78.187197,41.185316],[78.543661,41.582243],[80.11943,42.123941],[80.25999,42.349999]]]]}},{"type":"Feature","properties":{"ADMIN":"Taiwan, China","NAME_EN":"Taiwan, China","CONTINENT":"Asia","ADM0_A3":"TWN","ISO_A3":"TWN","NAME":"Taiwan, China","ISO_A2":"TW","NAME_ZH":"中国(台湾)"},"geometry":{"type":"Polygon","coordinates":[[[121.777818,24.394274],[121.175632,22.790857],[120.74708,21.970571],[120.220083,22.814861],[120.106189,23.556263],[120.69468,24.538451],[121.495044,25.295459],[121.951244,24.997596],[121.777818,24.394274]]]}},{"type":"Feature","properties":{"ADMIN":"Italy","NAME_EN":"Italy","CONTINENT":"Europe","ADM0_A3":"ITA","ISO_A3":"ITA","NAME":"Italy","ISO_A2":"IT","NAME_ZH":"意大利"},"geometry":{"type":"MultiPolygon","coordinates":[[[[10.442701,46.893546],[11.048556,46.751359],[11.164828,46.941579],[12.153088,47.115393],[12.376485,46.767559],[13.806475,46.509306],[13.69811,46.016778],[13.93763,45.591016],[13.141606,45.736692],[12.328581,45.381778],[12.383875,44.885374],[12.261453,44.600482],[12.589237,44.091366],[13.526906,43.587727],[14.029821,42.761008],[15.14257,41.95514],[15.926191,41.961315],[16.169897,41.740295],[15.889346,41.541082],[16.785002,41.179606],[17.519169,40.877143],[18.376687,40.355625],[18.480247,40.168866],[18.293385,39.810774],[17.73838,40.277671],[16.869596,40.442235],[16.448743,39.795401],[17.17149,39.4247],[17.052841,38.902871],[16.635088,38.843572],[16.100961,37.985899],[15.684087,37.908849],[15.687963,38.214593],[15.891981,38.750942],[16.109332,38.964547],[15.718814,39.544072],[15.413613,40.048357],[14.998496,40.172949],[14.703268,40.60455],[14.060672,40.786348],[13.627985,41.188287],[12.888082,41.25309],[12.106683,41.704535],[11.191906,42.355425],[10.511948,42.931463],[10.200029,43.920007],[9.702488,44.036279],[8.888946,44.366336],[8.428561,44.231228],[7.850767,43.767148],[7.435185,43.693845],[7.549596,44.127901],[7.007562,44.254767],[6.749955,45.028518],[7.096652,45.333099],[6.802355,45.70858],[6.843593,45.991147],[7.273851,45.776948],[7.755992,45.82449],[8.31663,46.163642],[8.489952,46.005151],[8.966306,46.036932],[9.182882,46.440215],[9.922837,46.314899],[10.363378,46.483571],[10.442701,46.893546]]],[[[14.761249,38.143874],[15.520376,38.231155],[15.160243,37.444046],[15.309898,37.134219],[15.099988,36.619987],[14.335229,36.996631],[13.826733,37.104531],[12.431004,37.61295],[12.570944,38.126381],[13.741156,38.034966],[14.761249,38.143874]]],[[[8.709991,40.899984],[9.210012,41.209991],[9.809975,40.500009],[9.669519,39.177376],[9.214818,39.240473],[8.806936,38.906618],[8.428302,39.171847],[8.388253,40.378311],[8.159998,40.950007],[8.709991,40.899984]]]]}},{"type":"Feature","properties":{"ADMIN":"Denmark","NAME_EN":"Denmark","CONTINENT":"Europe","ADM0_A3":"DNK","ISO_A3":"DNK","NAME":"Denmark","ISO_A2":"DK","NAME_ZH":"丹麦"},"geometry":{"type":"MultiPolygon","coordinates":[[[[9.921906,54.983104],[9.282049,54.830865],[8.526229,54.962744],[8.120311,55.517723],[8.089977,56.540012],[8.256582,56.809969],[8.543438,57.110003],[9.424469,57.172066],[9.775559,57.447941],[10.580006,57.730017],[10.546106,57.215733],[10.25,56.890016],[10.369993,56.609982],[10.912182,56.458621],[10.667804,56.081383],[10.369993,56.190007],[9.649985,55.469999],[9.921906,54.983104]]],[[[12.370904,56.111407],[12.690006,55.609991],[12.089991,54.800015],[11.043543,55.364864],[10.903914,55.779955],[12.370904,56.111407]]]]}},{"type":"Feature","properties":{"ADMIN":"United Kingdom","NAME_EN":"United Kingdom","CONTINENT":"Europe","ADM0_A3":"GBR","ISO_A3":"GBR","NAME":"United Kingdom","ISO_A2":"GB","NAME_ZH":"英国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-6.197885,53.867565],[-6.95373,54.073702],[-7.572168,54.059956],[-7.366031,54.595841],[-7.572168,55.131622],[-6.733847,55.17286],[-5.661949,54.554603],[-6.197885,53.867565]]],[[[-3.093831,53.404547],[-3.09208,53.404441],[-2.945009,53.985],[-3.614701,54.600937],[-3.630005,54.615013],[-4.844169,54.790971],[-5.082527,55.061601],[-4.719112,55.508473],[-5.047981,55.783986],[-5.586398,55.311146],[-5.644999,56.275015],[-6.149981,56.78501],[-5.786825,57.818848],[-5.009999,58.630013],[-4.211495,58.550845],[-3.005005,58.635],[-4.073828,57.553025],[-3.055002,57.690019],[-1.959281,57.6848],[-2.219988,56.870017],[-3.119003,55.973793],[-2.085009,55.909998],[-2.005676,55.804903],[-1.114991,54.624986],[-0.430485,54.464376],[0.184981,53.325014],[0.469977,52.929999],[1.681531,52.73952],[1.559988,52.099998],[1.050562,51.806761],[1.449865,51.289428],[0.550334,50.765739],[-0.787517,50.774989],[-2.489998,50.500019],[-2.956274,50.69688],[-3.617448,50.228356],[-4.542508,50.341837],[-5.245023,49.96],[-5.776567,50.159678],[-4.30999,51.210001],[-3.414851,51.426009],[-3.422719,51.426848],[-4.984367,51.593466],[-5.267296,51.9914],[-4.222347,52.301356],[-4.770013,52.840005],[-4.579999,53.495004],[-3.093831,53.404547]]]]}},{"type":"Feature","properties":{"ADMIN":"Iceland","NAME_EN":"Iceland","CONTINENT":"Europe","ADM0_A3":"ISL","ISO_A3":"ISL","NAME":"Iceland","ISO_A2":"IS","NAME_ZH":"冰岛"},"geometry":{"type":"Polygon","coordinates":[[[-14.508695,66.455892],[-14.739637,65.808748],[-13.609732,65.126671],[-14.909834,64.364082],[-17.794438,63.678749],[-18.656246,63.496383],[-19.972755,63.643635],[-22.762972,63.960179],[-21.778484,64.402116],[-23.955044,64.89113],[-22.184403,65.084968],[-22.227423,65.378594],[-24.326184,65.611189],[-23.650515,66.262519],[-22.134922,66.410469],[-20.576284,65.732112],[-19.056842,66.276601],[-17.798624,65.993853],[-16.167819,66.526792],[-14.508695,66.455892]]]}},{"type":"Feature","properties":{"ADMIN":"Azerbaijan","NAME_EN":"Azerbaijan","CONTINENT":"Asia","ADM0_A3":"AZE","ISO_A3":"AZE","NAME":"Azerbaijan","ISO_A2":"AZ","NAME_ZH":"阿塞拜疆"},"geometry":{"type":"MultiPolygon","coordinates":[[[[46.404951,41.860675],[46.686071,41.827137],[47.373315,41.219732],[47.815666,41.151416],[47.987283,41.405819],[48.584353,41.808869],[49.110264,41.282287],[49.618915,40.572924],[50.08483,40.526157],[50.392821,40.256561],[49.569202,40.176101],[49.395259,39.399482],[49.223228,39.049219],[48.856532,38.815486],[48.883249,38.320245],[48.634375,38.270378],[48.010744,38.794015],[48.355529,39.288765],[48.060095,39.582235],[47.685079,39.508364],[46.50572,38.770605],[46.483499,39.464155],[46.034534,39.628021],[45.610012,39.899994],[45.891907,40.218476],[45.359175,40.561504],[45.560351,40.81229],[45.179496,40.985354],[44.97248,41.248129],[45.217426,41.411452],[45.962601,41.123873],[46.501637,41.064445],[46.637908,41.181673],[46.145432,41.722802],[46.404951,41.860675]]],[[[46.143623,38.741201],[45.457722,38.874139],[44.952688,39.335765],[44.79399,39.713003],[45.001987,39.740004],[45.298145,39.471751],[45.739978,39.473999],[45.735379,39.319719],[46.143623,38.741201]]]]}},{"type":"Feature","properties":{"ADMIN":"Georgia","NAME_EN":"Georgia","CONTINENT":"Asia","ADM0_A3":"GEO","ISO_A3":"GEO","NAME":"Georgia","ISO_A2":"GE","NAME_ZH":"格鲁吉亚"},"geometry":{"type":"Polygon","coordinates":[[[39.955009,43.434998],[40.076965,43.553104],[40.92219,43.38215],[42.3944,43.2203],[43.75599,42.74083],[43.93121,42.55496],[44.537623,42.711993],[45.470279,42.502781],[45.7764,42.09244],[46.404951,41.860675],[46.145432,41.722802],[46.637908,41.181673],[46.501637,41.064445],[45.962601,41.123873],[45.217426,41.411452],[44.97248,41.248129],[43.582746,41.092143],[42.619549,41.583173],[41.554084,41.535656],[41.703171,41.962943],[41.45347,42.645123],[40.875469,43.013628],[40.321394,43.128634],[39.955009,43.434998]]]}},{"type":"Feature","properties":{"ADMIN":"Philippines","NAME_EN":"Philippines","CONTINENT":"Asia","ADM0_A3":"PHL","ISO_A3":"PHL","NAME":"Philippines","ISO_A2":"PH","NAME_ZH":"菲律宾"},"geometry":{"type":"MultiPolygon","coordinates":[[[[120.833896,12.704496],[120.323436,13.466413],[121.180128,13.429697],[121.527394,13.06959],[121.26219,12.20556],[120.833896,12.704496]]],[[[122.586089,9.981045],[122.837081,10.261157],[122.947411,10.881868],[123.49885,10.940624],[123.337774,10.267384],[124.077936,11.232726],[123.982438,10.278779],[123.623183,9.950091],[123.309921,9.318269],[122.995883,9.022189],[122.380055,9.713361],[122.586089,9.981045]]],[[[126.376814,8.414706],[126.478513,7.750354],[126.537424,7.189381],[126.196773,6.274294],[125.831421,7.293715],[125.363852,6.786485],[125.683161,6.049657],[125.396512,5.581003],[124.219788,6.161355],[123.93872,6.885136],[124.243662,7.36061],[123.610212,7.833527],[123.296071,7.418876],[122.825506,7.457375],[122.085499,6.899424],[121.919928,7.192119],[122.312359,8.034962],[122.942398,8.316237],[123.487688,8.69301],[123.841154,8.240324],[124.60147,8.514158],[124.764612,8.960409],[125.471391,8.986997],[125.412118,9.760335],[126.222714,9.286074],[126.306637,8.782487],[126.376814,8.414706]]],[[[118.504581,9.316383],[117.174275,8.3675],[117.664477,9.066889],[118.386914,9.6845],[118.987342,10.376292],[119.511496,11.369668],[119.689677,10.554291],[119.029458,10.003653],[118.504581,9.316383]]],[[[122.336957,18.224883],[122.174279,17.810283],[122.515654,17.093505],[122.252311,16.262444],[121.662786,15.931018],[121.50507,15.124814],[121.728829,14.328376],[122.258925,14.218202],[122.701276,14.336541],[123.950295,13.782131],[123.855107,13.237771],[124.181289,12.997527],[124.077419,12.536677],[123.298035,13.027526],[122.928652,13.55292],[122.671355,13.185836],[122.03465,13.784482],[121.126385,13.636687],[120.628637,13.857656],[120.679384,14.271016],[120.991819,14.525393],[120.693336,14.756671],[120.564145,14.396279],[120.070429,14.970869],[119.920929,15.406347],[119.883773,16.363704],[120.286488,16.034629],[120.390047,17.599081],[120.715867,18.505227],[121.321308,18.504065],[121.937601,18.218552],[122.246006,18.47895],[122.336957,18.224883]]],[[[122.03837,11.415841],[121.883548,11.891755],[122.483821,11.582187],[123.120217,11.58366],[123.100838,11.165934],[122.637714,10.741308],[122.00261,10.441017],[121.967367,10.905691],[122.03837,11.415841]]],[[[125.502552,12.162695],[125.783465,11.046122],[125.011884,11.311455],[125.032761,10.975816],[125.277449,10.358722],[124.801819,10.134679],[124.760168,10.837995],[124.459101,10.88993],[124.302522,11.495371],[124.891013,11.415583],[124.87799,11.79419],[124.266762,12.557761],[125.227116,12.535721],[125.502552,12.162695]]]]}},{"type":"Feature","properties":{"ADMIN":"Malaysia","NAME_EN":"Malaysia","CONTINENT":"Asia","ADM0_A3":"MYS","ISO_A3":"MYS","NAME":"Malaysia","ISO_A2":"MY","NAME_ZH":"马来西亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[100.085757,6.464489],[100.259596,6.642825],[101.075516,6.204867],[101.154219,5.691384],[101.814282,5.810808],[102.141187,6.221636],[102.371147,6.128205],[102.961705,5.524495],[103.381215,4.855001],[103.438575,4.181606],[103.332122,3.726698],[103.429429,3.382869],[103.502448,2.791019],[103.854674,2.515454],[104.247932,1.631141],[104.228811,1.293048],[103.519707,1.226334],[102.573615,1.967115],[101.390638,2.760814],[101.27354,3.270292],[100.695435,3.93914],[100.557408,4.76728],[100.196706,5.312493],[100.30626,6.040562],[100.085757,6.464489]]],[[[117.882035,4.137551],[117.015214,4.306094],[115.865517,4.306559],[115.519078,3.169238],[115.134037,2.821482],[114.621355,1.430688],[113.80585,1.217549],[112.859809,1.49779],[112.380252,1.410121],[111.797548,0.904441],[111.159138,0.976478],[110.514061,0.773131],[109.830227,1.338136],[109.66326,2.006467],[110.396135,1.663775],[111.168853,1.850637],[111.370081,2.697303],[111.796928,2.885897],[112.995615,3.102395],[113.712935,3.893509],[114.204017,4.525874],[114.659596,4.007637],[114.869557,4.348314],[115.347461,4.316636],[115.4057,4.955228],[115.45071,5.44773],[116.220741,6.143191],[116.725103,6.924771],[117.129626,6.928053],[117.643393,6.422166],[117.689075,5.98749],[118.347691,5.708696],[119.181904,5.407836],[119.110694,5.016128],[118.439727,4.966519],[118.618321,4.478202],[117.882035,4.137551]]]]}},{"type":"Feature","properties":{"ADMIN":"Brunei","NAME_EN":"Brunei","CONTINENT":"Asia","ADM0_A3":"BRN","ISO_A3":"BRN","NAME":"Brunei","ISO_A2":"BN","NAME_ZH":"文莱"},"geometry":{"type":"Polygon","coordinates":[[[115.45071,5.44773],[115.4057,4.955228],[115.347461,4.316636],[114.869557,4.348314],[114.659596,4.007637],[114.204017,4.525874],[114.599961,4.900011],[115.45071,5.44773]]]}},{"type":"Feature","properties":{"ADMIN":"Slovenia","NAME_EN":"Slovenia","CONTINENT":"Europe","ADM0_A3":"SVN","ISO_A3":"SVN","NAME":"Slovenia","ISO_A2":"SI","NAME_ZH":"斯洛文尼亚"},"geometry":{"type":"Polygon","coordinates":[[[13.806475,46.509306],[14.632472,46.431817],[15.137092,46.658703],[16.011664,46.683611],[16.202298,46.852386],[16.370505,46.841327],[16.564808,46.503751],[15.768733,46.238108],[15.67153,45.834154],[15.323954,45.731783],[15.327675,45.452316],[14.935244,45.471695],[14.595109,45.634941],[14.411968,45.466166],[13.71506,45.500324],[13.93763,45.591016],[13.69811,46.016778],[13.806475,46.509306]]]}},{"type":"Feature","properties":{"ADMIN":"Finland","NAME_EN":"Finland","CONTINENT":"Europe","ADM0_A3":"FIN","ISO_A3":"FIN","NAME":"Finland","ISO_A2":"FI","NAME_ZH":"芬兰"},"geometry":{"type":"Polygon","coordinates":[[[28.59193,69.064777],[28.445944,68.364613],[29.977426,67.698297],[29.054589,66.944286],[30.21765,65.80598],[29.54443,64.948672],[30.444685,64.204453],[30.035872,63.552814],[31.516092,62.867687],[31.139991,62.357693],[30.211107,61.780028],[28.07,60.50352],[28.070002,60.503519],[28.069998,60.503517],[26.255173,60.423961],[24.496624,60.057316],[22.869695,59.846373],[22.290764,60.391921],[21.322244,60.72017],[21.544866,61.705329],[21.059211,62.607393],[21.536029,63.189735],[22.442744,63.81781],[24.730512,64.902344],[25.398068,65.111427],[25.294043,65.534346],[23.903379,66.006927],[23.56588,66.396051],[23.539473,67.936009],[21.978535,68.616846],[20.645593,69.106247],[21.244936,69.370443],[22.356238,68.841741],[23.66205,68.891247],[24.735679,68.649557],[25.689213,69.092114],[26.179622,69.825299],[27.732292,70.164193],[29.015573,69.766491],[28.59193,69.064777]]]}},{"type":"Feature","properties":{"ADMIN":"Slovakia","NAME_EN":"Slovakia","CONTINENT":"Europe","ADM0_A3":"SVK","ISO_A3":"SVK","NAME":"Slovakia","ISO_A2":"SK","NAME_ZH":"斯洛伐克"},"geometry":{"type":"Polygon","coordinates":[[[22.558138,49.085738],[22.280842,48.825392],[22.085608,48.422264],[21.872236,48.319971],[20.801294,48.623854],[20.473562,48.56285],[20.239054,48.327567],[19.769471,48.202691],[19.661364,48.266615],[19.174365,48.111379],[18.777025,48.081768],[18.696513,47.880954],[17.857133,47.758429],[17.488473,47.867466],[16.979667,48.123497],[16.879983,48.470013],[16.960288,48.596982],[17.101985,48.816969],[17.545007,48.800019],[17.886485,48.903475],[17.913512,48.996493],[18.104973,49.043983],[18.170498,49.271515],[18.399994,49.315001],[18.554971,49.495015],[18.853144,49.49623],[18.909575,49.435846],[19.320713,49.571574],[19.825023,49.217125],[20.415839,49.431453],[20.887955,49.328772],[21.607808,49.470107],[22.558138,49.085738]]]}},{"type":"Feature","properties":{"ADMIN":"Czechia","NAME_EN":"Czech Republic","CONTINENT":"Europe","ADM0_A3":"CZE","ISO_A3":"CZE","NAME":"Czechia","ISO_A2":"CZ","NAME_ZH":"捷克"},"geometry":{"type":"Polygon","coordinates":[[[15.016996,51.106674],[15.490972,50.78473],[16.238627,50.697733],[16.176253,50.422607],[16.719476,50.215747],[16.868769,50.473974],[17.554567,50.362146],[17.649445,50.049038],[18.392914,49.988629],[18.853144,49.49623],[18.554971,49.495015],[18.399994,49.315001],[18.170498,49.271515],[18.104973,49.043983],[17.913512,48.996493],[17.886485,48.903475],[17.545007,48.800019],[17.101985,48.816969],[16.960288,48.596982],[16.499283,48.785808],[16.029647,48.733899],[15.253416,49.039074],[14.901447,48.964402],[14.338898,48.555305],[13.595946,48.877172],[13.031329,49.307068],[12.521024,49.547415],[12.415191,49.969121],[12.240111,50.266338],[12.966837,50.484076],[13.338132,50.733234],[14.056228,50.926918],[14.307013,51.117268],[14.570718,51.002339],[15.016996,51.106674]]]}},{"type":"Feature","properties":{"ADMIN":"Eritrea","NAME_EN":"Eritrea","CONTINENT":"Africa","ADM0_A3":"ERI","ISO_A3":"ERI","NAME":"Eritrea","ISO_A2":"ER","NAME_ZH":"厄立特里亚"},"geometry":{"type":"Polygon","coordinates":[[[36.42951,14.42211],[36.32322,14.82249],[36.75389,16.29186],[36.85253,16.95655],[37.16747,17.26314],[37.904,17.42754],[38.41009,17.998307],[38.990623,16.840626],[39.26611,15.922723],[39.814294,15.435647],[41.179275,14.49108],[41.734952,13.921037],[42.276831,13.343992],[42.589576,13.000421],[43.081226,12.699639],[42.779642,12.455416],[42.35156,12.54223],[42.00975,12.86582],[41.59856,13.45209],[41.1552,13.77333],[40.8966,14.11864],[40.02625,14.51959],[39.34061,14.53155],[39.0994,14.74064],[38.51295,14.50547],[37.90607,14.95943],[37.59377,14.2131],[36.42951,14.42211]]]}},{"type":"Feature","properties":{"ADMIN":"Japan","NAME_EN":"Japan","CONTINENT":"Asia","ADM0_A3":"JPN","ISO_A3":"JPN","NAME":"Japan","ISO_A2":"JP","NAME_ZH":"日本"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.884601,39.180865],[140.959489,38.174001],[140.976388,37.142074],[140.59977,36.343983],[140.774074,35.842877],[140.253279,35.138114],[138.975528,34.6676],[137.217599,34.606286],[135.792983,33.464805],[135.120983,33.849071],[135.079435,34.596545],[133.340316,34.375938],[132.156771,33.904933],[130.986145,33.885761],[132.000036,33.149992],[131.33279,31.450355],[130.686318,31.029579],[130.20242,31.418238],[130.447676,32.319475],[129.814692,32.61031],[129.408463,33.296056],[130.353935,33.604151],[130.878451,34.232743],[131.884229,34.749714],[132.617673,35.433393],[134.608301,35.731618],[135.677538,35.527134],[136.723831,37.304984],[137.390612,36.827391],[138.857602,37.827485],[139.426405,38.215962],[140.05479,39.438807],[139.883379,40.563312],[140.305783,41.195005],[141.368973,41.37856],[141.914263,39.991616],[141.884601,39.180865]]],[[[144.613427,43.960883],[145.320825,44.384733],[145.543137,43.262088],[144.059662,42.988358],[143.18385,41.995215],[141.611491,42.678791],[141.067286,41.584594],[139.955106,41.569556],[139.817544,42.563759],[140.312087,43.333273],[141.380549,43.388825],[141.671952,44.772125],[141.967645,45.551483],[143.14287,44.510358],[143.910162,44.1741],[144.613427,43.960883]]],[[[132.371176,33.463642],[132.924373,34.060299],[133.492968,33.944621],[133.904106,34.364931],[134.638428,34.149234],[134.766379,33.806335],[134.203416,33.201178],[133.79295,33.521985],[133.280268,33.28957],[133.014858,32.704567],[132.363115,32.989382],[132.371176,33.463642]]]]}},{"type":"Feature","properties":{"ADMIN":"Paraguay","NAME_EN":"Paraguay","CONTINENT":"South America","ADM0_A3":"PRY","ISO_A3":"PRY","NAME":"Paraguay","ISO_A2":"PY","NAME_ZH":"巴拉圭"},"geometry":{"type":"Polygon","coordinates":[[[-58.166392,-20.176701],[-57.870674,-20.732688],[-57.937156,-22.090176],[-56.88151,-22.282154],[-56.473317,-22.0863],[-55.797958,-22.35693],[-55.610683,-22.655619],[-55.517639,-23.571998],[-55.400747,-23.956935],[-55.027902,-24.001274],[-54.652834,-23.839578],[-54.29296,-24.021014],[-54.293476,-24.5708],[-54.428946,-25.162185],[-54.625291,-25.739255],[-54.788795,-26.621786],[-55.695846,-27.387837],[-56.486702,-27.548499],[-57.60976,-27.395899],[-58.618174,-27.123719],[-57.63366,-25.603657],[-57.777217,-25.16234],[-58.807128,-24.771459],[-60.028966,-24.032796],[-60.846565,-23.880713],[-62.685057,-22.249029],[-62.291179,-21.051635],[-62.265961,-20.513735],[-61.786326,-19.633737],[-60.043565,-19.342747],[-59.115042,-19.356906],[-58.183471,-19.868399],[-58.166392,-20.176701]]]}},{"type":"Feature","properties":{"ADMIN":"Yemen","NAME_EN":"Yemen","CONTINENT":"Asia","ADM0_A3":"YEM","ISO_A3":"YEM","NAME":"Yemen","ISO_A2":"YE","NAME_ZH":"也门"},"geometry":{"type":"Polygon","coordinates":[[[52.00001,19.000003],[52.782184,17.349742],[53.108573,16.651051],[52.385206,16.382411],[52.191729,15.938433],[52.168165,15.59742],[51.172515,15.17525],[49.574576,14.708767],[48.679231,14.003202],[48.238947,13.94809],[47.938914,14.007233],[47.354454,13.59222],[46.717076,13.399699],[45.877593,13.347764],[45.62505,13.290946],[45.406459,13.026905],[45.144356,12.953938],[44.989533,12.699587],[44.494576,12.721653],[44.175113,12.58595],[43.482959,12.6368],[43.222871,13.22095],[43.251448,13.767584],[43.087944,14.06263],[42.892245,14.802249],[42.604873,15.213335],[42.805015,15.261963],[42.702438,15.718886],[42.823671,15.911742],[42.779332,16.347891],[43.218375,16.66689],[43.115798,17.08844],[43.380794,17.579987],[43.791519,17.319977],[44.062613,17.410359],[45.216651,17.433329],[45.399999,17.333335],[46.366659,17.233315],[46.749994,17.283338],[47.000005,16.949999],[47.466695,17.116682],[48.183344,18.166669],[49.116672,18.616668],[52.00001,19.000003]]]}},{"type":"Feature","properties":{"ADMIN":"Saudi Arabia","NAME_EN":"Saudi Arabia","CONTINENT":"Asia","ADM0_A3":"SAU","ISO_A3":"SAU","NAME":"Saudi Arabia","ISO_A2":"SA","NAME_ZH":"沙特阿拉伯"},"geometry":{"type":"Polygon","coordinates":[[[34.956037,29.356555],[36.068941,29.197495],[36.501214,29.505254],[36.740528,29.865283],[37.503582,30.003776],[37.66812,30.338665],[37.998849,30.5085],[37.002166,31.508413],[39.004886,32.010217],[39.195468,32.161009],[40.399994,31.889992],[41.889981,31.190009],[44.709499,29.178891],[46.568713,29.099025],[47.459822,29.002519],[47.708851,28.526063],[48.416094,28.552004],[48.807595,27.689628],[49.299554,27.461218],[49.470914,27.109999],[50.152422,26.689663],[50.212935,26.277027],[50.113303,25.943972],[50.239859,25.60805],[50.527387,25.327808],[50.660557,24.999896],[50.810108,24.754743],[51.112415,24.556331],[51.389608,24.627386],[51.579519,24.245497],[51.617708,24.014219],[52.000733,23.001154],[55.006803,22.496948],[55.208341,22.70833],[55.666659,22.000001],[54.999982,19.999994],[52.00001,19.000003],[49.116672,18.616668],[48.183344,18.166669],[47.466695,17.116682],[47.000005,16.949999],[46.749994,17.283338],[46.366659,17.233315],[45.399999,17.333335],[45.216651,17.433329],[44.062613,17.410359],[43.791519,17.319977],[43.380794,17.579987],[43.115798,17.08844],[43.218375,16.66689],[42.779332,16.347891],[42.649573,16.774635],[42.347989,17.075806],[42.270888,17.474722],[41.754382,17.833046],[41.221391,18.6716],[40.939341,19.486485],[40.247652,20.174635],[39.801685,20.338862],[39.139399,21.291905],[39.023696,21.986875],[39.066329,22.579656],[38.492772,23.688451],[38.02386,24.078686],[37.483635,24.285495],[37.154818,24.858483],[37.209491,25.084542],[36.931627,25.602959],[36.639604,25.826228],[36.249137,26.570136],[35.640182,27.37652],[35.130187,28.063352],[34.632336,28.058546],[34.787779,28.607427],[34.83222,28.957483],[34.956037,29.356555]]]}},{"type":"Feature","properties":{"ADMIN":"Antarctica","NAME_EN":"Antarctica","CONTINENT":"Antarctica","ADM0_A3":"ATA","ISO_A3":"ATA","NAME":"Antarctica","ISO_A2":"AQ","NAME_ZH":"南极洲"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-48.660616,-78.047019],[-48.151396,-78.04707],[-46.662857,-77.831476],[-45.154758,-78.04707],[-43.920828,-78.478103],[-43.48995,-79.08556],[-43.372438,-79.516645],[-43.333267,-80.026123],[-44.880537,-80.339644],[-46.506174,-80.594357],[-48.386421,-80.829485],[-50.482107,-81.025442],[-52.851988,-80.966685],[-54.164259,-80.633528],[-53.987991,-80.222028],[-51.853134,-79.94773],[-50.991326,-79.614623],[-50.364595,-79.183487],[-49.914131,-78.811209],[-49.306959,-78.458569],[-48.660616,-78.047018],[-48.660616,-78.047019]]],[[[-66.290031,-80.255773],[-64.037688,-80.294944],[-61.883246,-80.39287],[-61.138976,-79.981371],[-60.610119,-79.628679],[-59.572095,-80.040179],[-59.865849,-80.549657],[-60.159656,-81.000327],[-62.255393,-80.863178],[-64.488125,-80.921934],[-65.741666,-80.588827],[-65.741666,-80.549657],[-66.290031,-80.255773]]],[[[-73.915819,-71.269345],[-73.915819,-71.269344],[-73.230331,-71.15178],[-72.074717,-71.190951],[-71.780962,-70.681473],[-71.72218,-70.309196],[-71.741791,-69.505782],[-71.173815,-69.035475],[-70.253252,-68.87874],[-69.724447,-69.251017],[-69.489422,-69.623346],[-69.058518,-70.074016],[-68.725541,-70.505153],[-68.451346,-70.955823],[-68.333834,-71.406493],[-68.510128,-71.798407],[-68.784297,-72.170736],[-69.959471,-72.307885],[-71.075889,-72.503842],[-72.388134,-72.484257],[-71.8985,-72.092343],[-73.073622,-72.229492],[-74.19004,-72.366693],[-74.953895,-72.072757],[-75.012625,-71.661258],[-73.915819,-71.269345]]],[[[-102.330725,-71.894164],[-102.330725,-71.894164],[-101.703967,-71.717792],[-100.430919,-71.854993],[-98.98155,-71.933334],[-97.884743,-72.070535],[-96.787937,-71.952971],[-96.20035,-72.521205],[-96.983765,-72.442864],[-98.198083,-72.482035],[-99.432013,-72.442864],[-100.783455,-72.50162],[-101.801868,-72.305663],[-102.330725,-71.894164]]],[[[-122.621735,-73.657778],[-122.621735,-73.657777],[-122.406245,-73.324619],[-121.211511,-73.50099],[-119.918851,-73.657725],[-118.724143,-73.481353],[-119.292119,-73.834097],[-120.232217,-74.08881],[-121.62283,-74.010468],[-122.621735,-73.657778]]],[[[-127.28313,-73.461769],[-127.28313,-73.461768],[-126.558472,-73.246226],[-125.559566,-73.481353],[-124.031882,-73.873268],[-124.619469,-73.834097],[-125.912181,-73.736118],[-127.28313,-73.461769]]],[[[-163.712896,-78.595667],[-163.712896,-78.595667],[-163.105801,-78.223338],[-161.245113,-78.380176],[-160.246208,-78.693645],[-159.482405,-79.046338],[-159.208184,-79.497059],[-161.127601,-79.634209],[-162.439847,-79.281465],[-163.027408,-78.928774],[-163.066604,-78.869966],[-163.712896,-78.595667]]],[[[180,-84.71338],[180,-90],[-180,-90],[-180,-84.71338],[-179.942499,-84.721443],[-179.058677,-84.139412],[-177.256772,-84.452933],[-177.140807,-84.417941],[-176.084673,-84.099259],[-175.947235,-84.110449],[-175.829882,-84.117914],[-174.382503,-84.534323],[-173.116559,-84.117914],[-172.889106,-84.061019],[-169.951223,-83.884647],[-168.999989,-84.117914],[-168.530199,-84.23739],[-167.022099,-84.570497],[-164.182144,-84.82521],[-161.929775,-85.138731],[-158.07138,-85.37391],[-155.192253,-85.09956],[-150.942099,-85.295517],[-148.533073,-85.609038],[-145.888918,-85.315102],[-143.107718,-85.040752],[-142.892279,-84.570497],[-146.829068,-84.531274],[-150.060732,-84.296146],[-150.902928,-83.904232],[-153.586201,-83.68869],[-153.409907,-83.23802],[-153.037759,-82.82652],[-152.665637,-82.454192],[-152.861517,-82.042692],[-154.526299,-81.768394],[-155.29018,-81.41565],[-156.83745,-81.102129],[-154.408787,-81.160937],[-152.097662,-81.004151],[-150.648293,-81.337309],[-148.865998,-81.043373],[-147.22075,-80.671045],[-146.417749,-80.337938],[-146.770286,-79.926439],[-148.062947,-79.652089],[-149.531901,-79.358205],[-151.588416,-79.299397],[-153.390322,-79.162248],[-155.329376,-79.064269],[-155.975668,-78.69194],[-157.268302,-78.378419],[-158.051768,-78.025676],[-158.365134,-76.889207],[-157.875474,-76.987238],[-156.974573,-77.300759],[-155.329376,-77.202728],[-153.742832,-77.065579],[-152.920247,-77.496664],[-151.33378,-77.398737],[-150.00195,-77.183143],[-148.748486,-76.908845],[-147.612483,-76.575738],[-146.104409,-76.47776],[-146.143528,-76.105431],[-146.496091,-75.733154],[-146.20231,-75.380411],[-144.909624,-75.204039],[-144.322037,-75.537197],[-142.794353,-75.34124],[-141.638764,-75.086475],[-140.209007,-75.06689],[-138.85759,-74.968911],[-137.5062,-74.733783],[-136.428901,-74.518241],[-135.214583,-74.302699],[-134.431194,-74.361455],[-133.745654,-74.439848],[-132.257168,-74.302699],[-130.925311,-74.479019],[-129.554284,-74.459433],[-128.242038,-74.322284],[-126.890622,-74.420263],[-125.402082,-74.518241],[-124.011496,-74.479019],[-122.562152,-74.498604],[-121.073613,-74.518241],[-119.70256,-74.479019],[-118.684145,-74.185083],[-117.469801,-74.028348],[-116.216312,-74.243891],[-115.021552,-74.067519],[-113.944331,-73.714828],[-113.297988,-74.028348],[-112.945452,-74.38104],[-112.299083,-74.714198],[-111.261059,-74.420263],[-110.066325,-74.79254],[-108.714909,-74.910103],[-107.559346,-75.184454],[-106.149148,-75.125698],[-104.876074,-74.949326],[-103.367949,-74.988497],[-102.016507,-75.125698],[-100.645531,-75.302018],[-100.1167,-74.870933],[-100.763043,-74.537826],[-101.252703,-74.185083],[-102.545337,-74.106742],[-103.113313,-73.734413],[-103.328752,-73.362084],[-103.681289,-72.61753],[-102.917485,-72.754679],[-101.60524,-72.813436],[-100.312528,-72.754679],[-99.13738,-72.911414],[-98.118889,-73.20535],[-97.688037,-73.558041],[-96.336595,-73.616849],[-95.043961,-73.4797],[-93.672907,-73.283743],[-92.439003,-73.166179],[-91.420564,-73.401307],[-90.088733,-73.322914],[-89.226951,-72.558722],[-88.423951,-73.009393],[-87.268337,-73.185764],[-86.014822,-73.087786],[-85.192236,-73.4797],[-83.879991,-73.518871],[-82.665646,-73.636434],[-81.470913,-73.851977],[-80.687447,-73.4797],[-80.295791,-73.126956],[-79.296886,-73.518871],[-77.925858,-73.420892],[-76.907367,-73.636434],[-76.221879,-73.969541],[-74.890049,-73.871614],[-73.852024,-73.65602],[-72.833533,-73.401307],[-71.619215,-73.264157],[-70.209042,-73.146542],[-68.935916,-73.009393],[-67.956622,-72.79385],[-67.369061,-72.480329],[-67.134036,-72.049244],[-67.251548,-71.637745],[-67.56494,-71.245831],[-67.917477,-70.853917],[-68.230843,-70.462055],[-68.485452,-70.109311],[-68.544209,-69.717397],[-68.446282,-69.325535],[-67.976233,-68.953206],[-67.5845,-68.541707],[-67.427843,-68.149844],[-67.62367,-67.718759],[-67.741183,-67.326845],[-67.251548,-66.876175],[-66.703184,-66.58224],[-66.056815,-66.209963],[-65.371327,-65.89639],[-64.568276,-65.602506],[-64.176542,-65.171423],[-63.628152,-64.897073],[-63.001394,-64.642308],[-62.041686,-64.583552],[-61.414928,-64.270031],[-60.709855,-64.074074],[-59.887269,-63.95651],[-59.162585,-63.701745],[-58.594557,-63.388224],[-57.811143,-63.27066],[-57.223582,-63.525425],[-57.59573,-63.858532],[-58.614143,-64.152467],[-59.045073,-64.36801],[-59.789342,-64.211223],[-60.611928,-64.309202],[-61.297416,-64.54433],[-62.0221,-64.799094],[-62.51176,-65.09303],[-62.648858,-65.484942],[-62.590128,-65.857219],[-62.120079,-66.190326],[-62.805567,-66.425505],[-63.74569,-66.503847],[-64.294106,-66.837004],[-64.881693,-67.150474],[-65.508425,-67.58161],[-65.665082,-67.953887],[-65.312545,-68.365335],[-64.783715,-68.678908],[-63.961103,-68.913984],[-63.1973,-69.227556],[-62.785955,-69.619419],[-62.570516,-69.991747],[-62.276736,-70.383661],[-61.806661,-70.716768],[-61.512906,-71.089045],[-61.375809,-72.010074],[-61.081977,-72.382351],[-61.003661,-72.774265],[-60.690269,-73.166179],[-60.827367,-73.695242],[-61.375809,-74.106742],[-61.96337,-74.439848],[-63.295201,-74.576997],[-63.74569,-74.92974],[-64.352836,-75.262847],[-65.860987,-75.635124],[-67.192818,-75.79191],[-68.446282,-76.007452],[-69.797724,-76.222995],[-70.600724,-76.634494],[-72.206776,-76.673665],[-73.969536,-76.634494],[-75.555977,-76.712887],[-77.24037,-76.712887],[-76.926979,-77.104802],[-75.399294,-77.28107],[-74.282876,-77.55542],[-73.656119,-77.908112],[-74.772536,-78.221633],[-76.4961,-78.123654],[-77.925858,-78.378419],[-77.984666,-78.789918],[-78.023785,-79.181833],[-76.848637,-79.514939],[-76.633224,-79.887216],[-75.360097,-80.259545],[-73.244852,-80.416331],[-71.442946,-80.69063],[-70.013163,-81.004151],[-68.191646,-81.317672],[-65.704279,-81.474458],[-63.25603,-81.748757],[-61.552026,-82.042692],[-59.691416,-82.37585],[-58.712121,-82.846106],[-58.222487,-83.218434],[-57.008117,-82.865691],[-55.362894,-82.571755],[-53.619771,-82.258235],[-51.543644,-82.003521],[-49.76135,-81.729171],[-47.273931,-81.709586],[-44.825708,-81.846735],[-42.808363,-82.081915],[-42.16202,-81.65083],[-40.771433,-81.356894],[-38.244818,-81.337309],[-36.26667,-81.121715],[-34.386397,-80.906172],[-32.310296,-80.769023],[-30.097098,-80.592651],[-28.549802,-80.337938],[-29.254901,-79.985195],[-29.685805,-79.632503],[-29.685805,-79.260226],[-31.624808,-79.299397],[-33.681324,-79.456132],[-35.639912,-79.456132],[-35.914107,-79.083855],[-35.77701,-78.339248],[-35.326546,-78.123654],[-33.896763,-77.888526],[-32.212369,-77.65345],[-30.998051,-77.359515],[-29.783732,-77.065579],[-28.882779,-76.673665],[-27.511752,-76.497345],[-26.160336,-76.360144],[-25.474822,-76.281803],[-23.927552,-76.24258],[-22.458598,-76.105431],[-21.224694,-75.909474],[-20.010375,-75.674346],[-18.913543,-75.439218],[-17.522982,-75.125698],[-16.641589,-74.79254],[-15.701491,-74.498604],[-15.40771,-74.106742],[-16.46532,-73.871614],[-16.112784,-73.460114],[-15.446855,-73.146542],[-14.408805,-72.950585],[-13.311973,-72.715457],[-12.293508,-72.401936],[-11.510067,-72.010074],[-11.020433,-71.539767],[-10.295774,-71.265416],[-9.101015,-71.324224],[-8.611381,-71.65733],[-7.416622,-71.696501],[-7.377451,-71.324224],[-6.868232,-70.93231],[-5.790985,-71.030289],[-5.536375,-71.402617],[-4.341667,-71.461373],[-3.048981,-71.285053],[-1.795492,-71.167438],[-0.659489,-71.226246],[-0.228637,-71.637745],[0.868195,-71.304639],[1.886686,-71.128267],[3.022638,-70.991118],[4.139055,-70.853917],[5.157546,-70.618789],[6.273912,-70.462055],[7.13572,-70.246512],[7.742866,-69.893769],[8.48711,-70.148534],[9.525135,-70.011333],[10.249845,-70.48164],[10.817821,-70.834332],[11.953824,-70.638375],[12.404287,-70.246512],[13.422778,-69.972162],[14.734998,-70.030918],[15.126757,-70.403247],[15.949342,-70.030918],[17.026589,-69.913354],[18.201711,-69.874183],[19.259373,-69.893769],[20.375739,-70.011333],[21.452985,-70.07014],[21.923034,-70.403247],[22.569403,-70.697182],[23.666184,-70.520811],[24.841357,-70.48164],[25.977309,-70.48164],[27.093726,-70.462055],[28.09258,-70.324854],[29.150242,-70.20729],[30.031583,-69.93294],[30.971733,-69.75662],[31.990172,-69.658641],[32.754053,-69.384291],[33.302443,-68.835642],[33.870419,-68.502588],[34.908495,-68.659271],[35.300202,-69.012014],[36.16201,-69.247142],[37.200035,-69.168748],[37.905108,-69.52144],[38.649404,-69.776205],[39.667894,-69.541077],[40.020431,-69.109941],[40.921358,-68.933621],[41.959434,-68.600514],[42.938702,-68.463313],[44.113876,-68.267408],[44.897291,-68.051866],[45.719928,-67.816738],[46.503343,-67.601196],[47.44344,-67.718759],[48.344419,-67.366068],[48.990736,-67.091718],[49.930885,-67.111303],[50.753471,-66.876175],[50.949325,-66.523484],[51.791547,-66.249133],[52.614133,-66.053176],[53.613038,-65.89639],[54.53355,-65.818049],[55.414943,-65.876805],[56.355041,-65.974783],[57.158093,-66.249133],[57.255968,-66.680218],[58.137361,-67.013324],[58.744508,-67.287675],[59.939318,-67.405239],[60.605221,-67.679589],[61.427806,-67.953887],[62.387489,-68.012695],[63.19049,-67.816738],[64.052349,-67.405239],[64.992447,-67.620729],[65.971715,-67.738345],[66.911864,-67.855909],[67.891133,-67.934302],[68.890038,-67.934302],[69.712624,-68.972791],[69.673453,-69.227556],[69.555941,-69.678226],[68.596258,-69.93294],[67.81274,-70.305268],[67.949889,-70.697182],[69.066307,-70.677545],[68.929157,-71.069459],[68.419989,-71.441788],[67.949889,-71.853287],[68.71377,-72.166808],[69.869307,-72.264787],[71.024895,-72.088415],[71.573285,-71.696501],[71.906288,-71.324224],[72.454627,-71.010703],[73.08141,-70.716768],[73.33602,-70.364024],[73.864877,-69.874183],[74.491557,-69.776205],[75.62756,-69.737034],[76.626465,-69.619419],[77.644904,-69.462684],[78.134539,-69.07077],[78.428371,-68.698441],[79.113859,-68.326216],[80.093127,-68.071503],[80.93535,-67.875546],[81.483792,-67.542388],[82.051767,-67.366068],[82.776426,-67.209282],[83.775331,-67.30726],[84.676206,-67.209282],[85.655527,-67.091718],[86.752359,-67.150474],[87.477017,-66.876175],[87.986289,-66.209911],[88.358411,-66.484261],[88.828408,-66.954568],[89.67063,-67.150474],[90.630365,-67.228867],[91.5901,-67.111303],[92.608539,-67.189696],[93.548637,-67.209282],[94.17542,-67.111303],[95.017591,-67.170111],[95.781472,-67.385653],[96.682399,-67.248504],[97.759646,-67.248504],[98.68021,-67.111303],[99.718182,-67.248504],[100.384188,-66.915346],[100.893356,-66.58224],[101.578896,-66.30789],[102.832411,-65.563284],[103.478676,-65.700485],[104.242557,-65.974783],[104.90846,-66.327527],[106.181561,-66.934931],[107.160881,-66.954568],[108.081393,-66.954568],[109.15864,-66.837004],[110.235835,-66.699804],[111.058472,-66.425505],[111.74396,-66.13157],[112.860378,-66.092347],[113.604673,-65.876805],[114.388088,-66.072762],[114.897308,-66.386283],[115.602381,-66.699804],[116.699161,-66.660633],[117.384701,-66.915346],[118.57946,-67.170111],[119.832924,-67.268089],[120.871,-67.189696],[121.654415,-66.876175],[122.320369,-66.562654],[123.221296,-66.484261],[124.122274,-66.621462],[125.160247,-66.719389],[126.100396,-66.562654],[127.001427,-66.562654],[127.882768,-66.660633],[128.80328,-66.758611],[129.704259,-66.58224],[130.781454,-66.425505],[131.799945,-66.386283],[132.935896,-66.386283],[133.85646,-66.288304],[134.757387,-66.209963],[135.031582,-65.72007],[135.070753,-65.308571],[135.697485,-65.582869],[135.873805,-66.033591],[136.206705,-66.44509],[136.618049,-66.778197],[137.460271,-66.954568],[138.596223,-66.895761],[139.908442,-66.876175],[140.809421,-66.817367],[142.121692,-66.817367],[143.061842,-66.797782],[144.374061,-66.837004],[145.490427,-66.915346],[146.195552,-67.228867],[145.999699,-67.601196],[146.646067,-67.895131],[147.723263,-68.130259],[148.839629,-68.385024],[150.132314,-68.561292],[151.483705,-68.71813],[152.502247,-68.874813],[153.638199,-68.894502],[154.284567,-68.561292],[155.165857,-68.835642],[155.92979,-69.149215],[156.811132,-69.384291],[158.025528,-69.482269],[159.181013,-69.599833],[159.670699,-69.991747],[160.80665,-70.226875],[161.570479,-70.579618],[162.686897,-70.736353],[163.842434,-70.716768],[164.919681,-70.775524],[166.11444,-70.755938],[167.309095,-70.834332],[168.425616,-70.971481],[169.463589,-71.20666],[170.501665,-71.402617],[171.20679,-71.696501],[171.089227,-72.088415],[170.560422,-72.441159],[170.109958,-72.891829],[169.75737,-73.24452],[169.287321,-73.65602],[167.975101,-73.812806],[167.387489,-74.165498],[166.094803,-74.38104],[165.644391,-74.772954],[164.958851,-75.145283],[164.234193,-75.458804],[163.822797,-75.870303],[163.568239,-76.24258],[163.47026,-76.693302],[163.489897,-77.065579],[164.057873,-77.457442],[164.273363,-77.82977],[164.743464,-78.182514],[166.604126,-78.319611],[166.995781,-78.750748],[165.193876,-78.907483],[163.666217,-79.123025],[161.766385,-79.162248],[160.924162,-79.730482],[160.747894,-80.200737],[160.316964,-80.573066],[159.788211,-80.945395],[161.120016,-81.278501],[161.629287,-81.690001],[162.490992,-82.062278],[163.705336,-82.395435],[165.095949,-82.708956],[166.604126,-83.022477],[168.895665,-83.335998],[169.404782,-83.825891],[172.283934,-84.041433],[172.477049,-84.117914],[173.224083,-84.41371],[175.985672,-84.158997],[178.277212,-84.472518],[180,-84.71338]]]]}},{"type":"Feature","properties":{"ADMIN":"Northern Cyprus","NAME_EN":"Turkish Republic of Northern Cyprus","CONTINENT":"Asia","ADM0_A3":"CYN","ISO_A3":"-99","NAME":"N. Cyprus","ISO_A2":"-99","NAME_ZH":"北塞浦路斯土耳其共和国"},"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.802474,35.145504],[32.946961,35.386703],[33.667227,35.373216],[34.576474,35.671596],[33.900804,35.245756],[33.973617,35.058506],[33.86644,35.093595],[33.675392,35.017863],[33.525685,35.038688],[33.475817,35.000345],[33.455922,35.101424],[33.383833,35.162712],[33.190977,35.173125],[32.919572,35.087833],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"ADMIN":"Cyprus","NAME_EN":"Cyprus","CONTINENT":"Asia","ADM0_A3":"CYP","ISO_A3":"CYP","NAME":"Cyprus","ISO_A2":"CY","NAME_ZH":"塞浦路斯"},"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.919572,35.087833],[33.190977,35.173125],[33.383833,35.162712],[33.455922,35.101424],[33.475817,35.000345],[33.525685,35.038688],[33.675392,35.017863],[33.86644,35.093595],[33.973617,35.058506],[34.004881,34.978098],[32.979827,34.571869],[32.490296,34.701655],[32.256667,35.103232],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"ADMIN":"Morocco","NAME_EN":"Morocco","CONTINENT":"Africa","ADM0_A3":"MAR","ISO_A3":"MAR","NAME":"Morocco","ISO_A2":"MA","NAME_ZH":"摩洛哥"},"geometry":{"type":"Polygon","coordinates":[[[-2.169914,35.168396],[-1.792986,34.527919],[-1.733455,33.919713],[-1.388049,32.864015],[-1.124551,32.651522],[-1.307899,32.262889],[-2.616605,32.094346],[-3.06898,31.724498],[-3.647498,31.637294],[-3.690441,30.896952],[-4.859646,30.501188],[-5.242129,30.000443],[-6.060632,29.7317],[-7.059228,29.579228],[-8.674116,28.841289],[-8.66559,27.656426],[-8.817828,27.656426],[-8.794884,27.120696],[-9.413037,27.088476],[-9.735343,26.860945],[-10.189424,26.860945],[-10.551263,26.990808],[-11.392555,26.883424],[-11.71822,26.104092],[-12.030759,26.030866],[-12.500963,24.770116],[-13.89111,23.691009],[-14.221168,22.310163],[-14.630833,21.86094],[-14.750955,21.5006],[-17.002962,21.420734],[-17.020428,21.42231],[-16.973248,21.885745],[-16.589137,22.158234],[-16.261922,22.67934],[-16.326414,23.017768],[-15.982611,23.723358],[-15.426004,24.359134],[-15.089332,24.520261],[-14.824645,25.103533],[-14.800926,25.636265],[-14.43994,26.254418],[-13.773805,26.618892],[-13.139942,27.640148],[-13.121613,27.654148],[-12.618837,28.038186],[-11.688919,28.148644],[-10.900957,28.832142],[-10.399592,29.098586],[-9.564811,29.933574],[-9.814718,31.177736],[-9.434793,32.038096],[-9.300693,32.564679],[-8.657476,33.240245],[-7.654178,33.697065],[-6.912544,34.110476],[-6.244342,35.145865],[-5.929994,35.759988],[-5.193863,35.755182],[-4.591006,35.330712],[-3.640057,35.399855],[-2.604306,35.179093],[-2.169914,35.168396]]]}},{"type":"Feature","properties":{"ADMIN":"Egypt","NAME_EN":"Egypt","CONTINENT":"Africa","ADM0_A3":"EGY","ISO_A3":"EGY","NAME":"Egypt","ISO_A2":"EG","NAME_ZH":"埃及"},"geometry":{"type":"Polygon","coordinates":[[[36.86623,22],[32.9,22],[29.02,22],[25,22],[25,25.6825],[25,29.238655],[24.70007,30.04419],[24.95762,30.6616],[24.80287,31.08929],[25.16482,31.56915],[26.49533,31.58568],[27.45762,31.32126],[28.45048,31.02577],[28.91353,30.87005],[29.68342,31.18686],[30.09503,31.4734],[30.97693,31.55586],[31.68796,31.4296],[31.96041,30.9336],[32.19247,31.26034],[32.99392,31.02407],[33.7734,30.96746],[34.265435,31.219357],[34.26544,31.21936],[34.823243,29.761081],[34.9226,29.50133],[34.64174,29.09942],[34.42655,28.34399],[34.15451,27.8233],[33.92136,27.6487],[33.58811,27.97136],[33.13676,28.41765],[32.42323,29.85108],[32.32046,29.76043],[32.73482,28.70523],[33.34876,27.69989],[34.10455,26.14227],[34.47387,25.59856],[34.79507,25.03375],[35.69241,23.92671],[35.49372,23.75237],[35.52598,23.10244],[36.69069,22.20485],[36.86623,22]]]}},{"type":"Feature","properties":{"ADMIN":"Libya","NAME_EN":"Libya","CONTINENT":"Africa","ADM0_A3":"LBY","ISO_A3":"LBY","NAME":"Libya","ISO_A2":"LY","NAME_ZH":"利比亚"},"geometry":{"type":"Polygon","coordinates":[[[25,22],[25,20.00304],[23.85,20],[23.83766,19.58047],[19.84926,21.49509],[15.86085,23.40972],[14.8513,22.86295],[14.143871,22.491289],[13.581425,23.040506],[11.999506,23.471668],[11.560669,24.097909],[10.771364,24.562532],[10.303847,24.379313],[9.948261,24.936954],[9.910693,25.365455],[9.319411,26.094325],[9.716286,26.512206],[9.629056,27.140953],[9.756128,27.688259],[9.683885,28.144174],[9.859998,28.95999],[9.805634,29.424638],[9.48214,30.307556],[9.970017,30.539325],[10.056575,30.961831],[9.950225,31.37607],[10.636901,31.761421],[10.94479,32.081815],[11.432253,32.368903],[11.488787,33.136996],[12.66331,32.79278],[13.08326,32.87882],[13.91868,32.71196],[15.24563,32.26508],[15.71394,31.37626],[16.61162,31.18218],[18.02109,30.76357],[19.08641,30.26639],[19.57404,30.52582],[20.05335,30.98576],[19.82033,31.75179],[20.13397,32.2382],[20.85452,32.7068],[21.54298,32.8432],[22.89576,32.63858],[23.2368,32.19149],[23.60913,32.18726],[23.9275,32.01667],[24.92114,31.89936],[25.16482,31.56915],[24.80287,31.08929],[24.95762,30.6616],[24.70007,30.04419],[25,29.238655],[25,25.6825],[25,22]]]}},{"type":"Feature","properties":{"ADMIN":"Ethiopia","NAME_EN":"Ethiopia","CONTINENT":"Africa","ADM0_A3":"ETH","ISO_A3":"ETH","NAME":"Ethiopia","ISO_A2":"ET","NAME_ZH":"埃塞俄比亚"},"geometry":{"type":"Polygon","coordinates":[[[47.78942,8.003],[44.9636,5.00162],[43.66087,4.95755],[42.76967,4.25259],[42.12861,4.23413],[41.855083,3.918912],[41.1718,3.91909],[40.76848,4.25702],[39.85494,3.83879],[39.559384,3.42206],[38.89251,3.50074],[38.67114,3.61607],[38.43697,3.58851],[38.120915,3.598605],[36.855093,4.447864],[36.159079,4.447864],[35.817448,4.776966],[35.817448,5.338232],[35.298007,5.506],[34.70702,6.59422],[34.25032,6.82607],[34.0751,7.22595],[33.56829,7.71334],[32.95418,7.78497],[33.2948,8.35458],[33.8255,8.37916],[33.97498,8.68456],[33.96162,9.58358],[34.25745,10.63009],[34.73115,10.91017],[34.83163,11.31896],[35.26049,12.08286],[35.86363,12.57828],[36.27022,13.56333],[36.42951,14.42211],[37.59377,14.2131],[37.90607,14.95943],[38.51295,14.50547],[39.0994,14.74064],[39.34061,14.53155],[40.02625,14.51959],[40.8966,14.11864],[41.1552,13.77333],[41.59856,13.45209],[42.00975,12.86582],[42.35156,12.54223],[42,12.1],[41.66176,11.6312],[41.73959,11.35511],[41.75557,11.05091],[42.31414,11.0342],[42.55493,11.10511],[42.776852,10.926879],[42.55876,10.57258],[42.92812,10.02194],[43.29699,9.54048],[43.67875,9.18358],[46.94834,7.99688],[47.78942,8.003]]]}},{"type":"Feature","properties":{"ADMIN":"Djibouti","NAME_EN":"Djibouti","CONTINENT":"Africa","ADM0_A3":"DJI","ISO_A3":"DJI","NAME":"Djibouti","ISO_A2":"DJ","NAME_ZH":"吉布提"},"geometry":{"type":"Polygon","coordinates":[[[42.35156,12.54223],[42.779642,12.455416],[43.081226,12.699639],[43.317852,12.390148],[43.286381,11.974928],[42.715874,11.735641],[43.145305,11.46204],[42.776852,10.926879],[42.55493,11.10511],[42.31414,11.0342],[41.75557,11.05091],[41.73959,11.35511],[41.66176,11.6312],[42,12.1],[42.35156,12.54223]]]}},{"type":"Feature","properties":{"ADMIN":"Somaliland","NAME_EN":"Somaliland","CONTINENT":"Africa","ADM0_A3":"SOL","ISO_A3":"-99","NAME":"Somaliland","ISO_A2":"-99","NAME_ZH":"索马里兰"},"geometry":{"type":"Polygon","coordinates":[[[48.948205,11.410617],[48.948205,11.410617],[48.942005,11.394266],[48.938491,10.982327],[48.938233,9.9735],[48.93813,9.451749],[48.486736,8.837626],[47.78942,8.003],[46.94834,7.99688],[43.67875,9.18358],[43.29699,9.54048],[42.92812,10.02194],[42.55876,10.57258],[42.776852,10.926879],[43.145305,11.46204],[43.47066,11.27771],[43.666668,10.864169],[44.117804,10.445538],[44.614259,10.442205],[45.556941,10.698029],[46.645401,10.816549],[47.525658,11.127228],[48.021596,11.193064],[48.378784,11.375482],[48.948206,11.410622],[48.948205,11.410617]]]}},{"type":"Feature","properties":{"ADMIN":"Uganda","NAME_EN":"Uganda","CONTINENT":"Africa","ADM0_A3":"UGA","ISO_A3":"UGA","NAME":"Uganda","ISO_A2":"UG","NAME_ZH":"乌干达"},"geometry":{"type":"Polygon","coordinates":[[[33.903711,-0.95],[31.86617,-1.02736],[30.76986,-1.01455],[30.419105,-1.134659],[29.821519,-1.443322],[29.579466,-1.341313],[29.587838,-0.587406],[29.819503,-0.20531],[29.875779,0.59738],[30.086154,1.062313],[30.468508,1.583805],[30.85267,1.849396],[31.174149,2.204465],[30.773347,2.339883],[30.83386,3.509166],[30.833852,3.509172],[31.24556,3.7819],[31.88145,3.55827],[32.68642,3.79232],[33.39,3.79],[34.005,4.249885],[34.47913,3.5556],[34.59607,3.05374],[35.03599,1.90584],[34.6721,1.17694],[34.18,0.515],[33.893569,0.109814],[33.903711,-0.95]]]}},{"type":"Feature","properties":{"ADMIN":"Rwanda","NAME_EN":"Rwanda","CONTINENT":"Africa","ADM0_A3":"RWA","ISO_A3":"RWA","NAME":"Rwanda","ISO_A2":"RW","NAME_ZH":"卢旺达"},"geometry":{"type":"Polygon","coordinates":[[[30.419105,-1.134659],[30.816135,-1.698914],[30.758309,-2.28725],[30.46967,-2.41383],[30.469674,-2.413855],[29.938359,-2.348487],[29.632176,-2.917858],[29.024926,-2.839258],[29.117479,-2.292211],[29.254835,-2.21511],[29.291887,-1.620056],[29.579466,-1.341313],[29.821519,-1.443322],[30.419105,-1.134659]]]}},{"type":"Feature","properties":{"ADMIN":"Bosnia and Herzegovina","NAME_EN":"Bosnia and Herzegovina","CONTINENT":"Europe","ADM0_A3":"BIH","ISO_A3":"BIH","NAME":"Bosnia and Herz.","ISO_A2":"BA","NAME_ZH":"波斯尼亚和黑塞哥维那"},"geometry":{"type":"Polygon","coordinates":[[[18.56,42.65],[17.674922,43.028563],[17.297373,43.446341],[16.916156,43.667722],[16.456443,44.04124],[16.23966,44.351143],[15.750026,44.818712],[15.959367,45.233777],[16.318157,45.004127],[16.534939,45.211608],[17.002146,45.233777],[17.861783,45.06774],[18.553214,45.08159],[19.005485,44.860234],[19.00548,44.86023],[19.36803,44.863],[19.11761,44.42307],[19.59976,44.03847],[19.454,43.5681],[19.21852,43.52384],[19.03165,43.43253],[18.70648,43.20011],[18.56,42.65]]]}},{"type":"Feature","properties":{"ADMIN":"North Macedonia","NAME_EN":"North Macedonia","CONTINENT":"Europe","ADM0_A3":"MKD","ISO_A3":"MKD","NAME":"North Macedonia","ISO_A2":"MK","NAME_ZH":"北马其顿"},"geometry":{"type":"Polygon","coordinates":[[[22.380526,42.32026],[22.881374,41.999297],[22.952377,41.337994],[22.76177,41.3048],[22.597308,41.130487],[22.055378,41.149866],[21.674161,40.931275],[21.02004,40.842727],[20.605182,41.086226],[20.463175,41.515089],[20.590247,41.855404],[20.590247,41.855409],[20.71731,41.84711],[20.76216,42.05186],[21.3527,42.2068],[21.576636,42.245224],[21.91708,42.30364],[22.380526,42.32026]]]}},{"type":"Feature","properties":{"ADMIN":"Republic of Serbia","NAME_EN":"Serbia","CONTINENT":"Europe","ADM0_A3":"SRB","ISO_A3":"SRB","NAME":"Serbia","ISO_A2":"RS","NAME_ZH":"塞尔维亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[18.829825,45.908872],[18.829838,45.908878],[19.596045,46.17173],[20.220192,46.127469],[20.762175,45.734573],[20.874313,45.416375],[21.483526,45.18117],[21.562023,44.768947],[22.145088,44.478422],[22.459022,44.702517],[22.705726,44.578003],[22.474008,44.409228],[22.65715,44.234923],[22.410446,44.008063],[22.500157,43.642814],[22.986019,43.211161],[22.604801,42.898519],[22.436595,42.580321],[22.545012,42.461362],[22.380526,42.32026],[21.91708,42.30364],[21.576636,42.245224],[21.54332,42.32025],[21.66292,42.43922],[21.77505,42.6827],[21.63302,42.67717],[21.43866,42.86255],[21.27421,42.90959],[21.143395,43.068685],[20.95651,43.13094],[20.81448,43.27205],[20.63508,43.21671],[20.49679,42.88469],[20.25758,42.81275],[20.3398,42.89852],[19.95857,43.10604],[19.63,43.21378],[19.48389,43.35229],[19.21852,43.52384],[19.454,43.5681],[19.59976,44.03847],[19.11761,44.42307],[19.36803,44.863],[19.00548,44.86023],[19.005485,44.860234],[19.390476,45.236516],[19.072769,45.521511],[18.829825,45.908872]]],[[[20.590247,41.855409],[20.52295,42.21787],[20.283755,42.32026],[20.0707,42.58863],[20.25758,42.81275],[20.49679,42.88469],[20.63508,43.21671],[20.81448,43.27205],[20.95651,43.13094],[21.143395,43.068685],[21.27421,42.90959],[21.43866,42.86255],[21.63302,42.67717],[21.77505,42.6827],[21.66292,42.43922],[21.54332,42.32025],[21.576636,42.245224],[21.3527,42.2068],[20.76216,42.05186],[20.71731,41.84711],[20.590247,41.855409]]]]}},{"type":"Feature","properties":{"ADMIN":"Montenegro","NAME_EN":"Montenegro","CONTINENT":"Europe","ADM0_A3":"MNE","ISO_A3":"MNE","NAME":"Montenegro","ISO_A2":"ME","NAME_ZH":"黑山"},"geometry":{"type":"Polygon","coordinates":[[[20.0707,42.58863],[19.801613,42.500093],[19.738051,42.688247],[19.304486,42.195745],[19.371768,41.877551],[19.16246,41.95502],[18.88214,42.28151],[18.450017,42.479992],[18.56,42.65],[18.70648,43.20011],[19.03165,43.43253],[19.21852,43.52384],[19.48389,43.35229],[19.63,43.21378],[19.95857,43.10604],[20.3398,42.89852],[20.25758,42.81275],[20.0707,42.58863]]]}},{"type":"Feature","properties":{"ADMIN":"Trinidad and Tobago","NAME_EN":"Trinidad and Tobago","CONTINENT":"North America","ADM0_A3":"TTO","ISO_A3":"TTO","NAME":"Trinidad and Tobago","ISO_A2":"TT","NAME_ZH":"特立尼达和多巴哥"},"geometry":{"type":"Polygon","coordinates":[[[-61.68,10.76],[-61.105,10.89],[-60.895,10.855],[-60.935,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.365],[-61.68,10.76]]]}},{"type":"Feature","properties":{"ADMIN":"South Sudan","NAME_EN":"South Sudan","CONTINENT":"Africa","ADM0_A3":"SDS","ISO_A3":"SSD","NAME":"S. Sudan","ISO_A2":"SS","NAME_ZH":"南苏丹"},"geometry":{"type":"Polygon","coordinates":[[[30.833852,3.509172],[29.9535,4.173699],[29.715995,4.600805],[29.159078,4.389267],[28.696678,4.455077],[28.428994,4.287155],[27.979977,4.408413],[27.374226,5.233944],[27.213409,5.550953],[26.465909,5.946717],[26.213418,6.546603],[25.796648,6.979316],[25.124131,7.500085],[25.114932,7.825104],[24.567369,8.229188],[23.88698,8.61973],[24.194068,8.728696],[24.537415,8.917538],[24.794926,9.810241],[25.069604,10.27376],[25.790633,10.411099],[25.962307,10.136421],[26.477328,9.55273],[26.752006,9.466893],[27.112521,9.638567],[27.833551,9.604232],[27.97089,9.398224],[28.966597,9.398224],[29.000932,9.604232],[29.515953,9.793074],[29.618957,10.084919],[29.996639,10.290927],[30.837841,9.707237],[31.352862,9.810241],[31.850716,10.531271],[32.400072,11.080626],[32.314235,11.681484],[32.073892,11.97333],[32.67475,12.024832],[32.743419,12.248008],[33.206938,12.179338],[33.086766,11.441141],[33.206938,10.720112],[33.721959,10.325262],[33.842131,9.981915],[33.824963,9.484061],[33.963393,9.464285],[33.97498,8.68456],[33.8255,8.37916],[33.2948,8.35458],[32.95418,7.78497],[33.56829,7.71334],[34.0751,7.22595],[34.25032,6.82607],[34.70702,6.59422],[35.298007,5.506],[34.620196,4.847123],[34.005,4.249885],[33.39,3.79],[32.68642,3.79232],[31.88145,3.55827],[31.24556,3.7819],[30.833852,3.509172]]]}}]} \ No newline at end of file diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 953bdc41..05c6be81 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -8,6 +8,9 @@ { "imports": { "three": "https://esm.sh/three@0.128.0", + "pmtiles": "https://esm.sh/pmtiles@4.4.1", + "@mapbox/vector-tile": "https://esm.sh/@mapbox/vector-tile@2.0.4", + "pbf": "https://esm.sh/pbf@4.0.1", "simplex-noise": "https://esm.sh/simplex-noise@4.0.1", "satellite.js": "https://esm.sh/satellite.js@5.0.0", "hls.js": "https://esm.sh/hls.js@1.6.15", diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 4b14ff8b..7e306f5f 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -186,9 +186,28 @@ export const TERRAIN_CONFIG = { }; export const COUNTRY_BOUNDARY_CONFIG = { - dataPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href, + tileManifestPath: new URL("../data/boundaries/v1/manifest.json", import.meta.url).href, + tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href, + tileProvider: "auto", + pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href, + mvtLayerNames: { + boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"], + claim: ["claim_line"], + }, + tileZoomThresholds: [ + { minViewZoom: 5.2, tileZoom: 10 }, + { minViewZoom: 4.6, tileZoom: 9 }, + { minViewZoom: 4.0, tileZoom: 8 }, + { minViewZoom: 3.4, tileZoom: 7 }, + { minViewZoom: 2.8, tileZoom: 6 }, + { minViewZoom: 1.6, tileZoom: 5 }, + ], + tilePrefetchRing: 1, + tileDebounceMs: 180, + tileCacheLimit: 150, lineAltitudeOffset: 0.115, - hoverAltitudeOffset: 0.14, + hoverAltitudeOffset: 0.115, + hoverMissStickyMs: 160, lineColor: 0x7fc7ff, lineOpacity: 0.58, lineRenderOrder: 2.2, @@ -199,13 +218,13 @@ export const COUNTRY_BOUNDARY_CONFIG = { hoverGlowOpacity: 0.38, hoverGlowLineWidth: 3, hoverGlowRenderOrderOffset: 0.01, - hoverGlowRadiusOffset: 0.04, + hoverGlowRadiusOffset: 0, tintAltitudeOffset: 0.04, tintColor: 0x0b1830, tintRenderOrder: 0.2, landColor: 0x080f1b, landOpacity: 1.0, - landAltitudeOffset: 0.08, + landAltitudeOffset: 0.32, landRenderOrder: 0.86, landMaskWidth: 2048, landMaskHeight: 1024, @@ -492,7 +511,7 @@ export const EARTH_MATERIAL_CONFIG = { shininess: 12, emissive: 0x010609, opacity: 1, - textureOverlayAltitudeOffset: 0.1, + textureOverlayAltitudeOffset: 0.48, textureOverlayOpacity: 0.88, textureOverlayRenderOrder: 0.96, textureOverlaySpecular: 0x05080d, @@ -536,7 +555,9 @@ export const EARTH_MATERIAL_CONFIG = { sunDirection: { x: 1, y: 0.2, z: 0.4 }, nightFloor: 0.24, dayBoost: 1.12, + featherScale: 0.001, twilightWidth: 0.2, + twilightFeatherScale: 1.0, twilightIntensity: 0.14, twilightColor: 0x4ea0ff, nightTintColor: 0x0b1830, diff --git a/frontend/public/earth/js/country-boundaries.js b/frontend/public/earth/js/country-boundaries.js index fc3f079a..6d35f2e6 100644 --- a/frontend/public/earth/js/country-boundaries.js +++ b/frontend/public/earth/js/country-boundaries.js @@ -1,6 +1,9 @@ import * as THREE from "three"; +import { PMTiles } from "pmtiles"; +import { VectorTile } from "@mapbox/vector-tile"; +import Pbf from "pbf"; import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js"; -import { latLonToVector3 } from "./utils.js"; +import { latLonToVector3, screenToEarthCoords, vector3ToLatLon } from "./utils.js"; // ─── Module state ────────────────────────────────────────────────────────────── let _earthObj = null; @@ -8,21 +11,48 @@ let _features = []; let _landMesh = null; let _tintMesh = null; let _boundaryLines = null; +let _coastlineLines = null; +let _claimGroup = null; let _hoverGlowLines = null; let _hoverLines = null; let _hoveredFeature = null; let _hoveredGroupKey = null; +let _hoverClearTimer = null; +let _lastHoverHitAt = 0; +let _lastHoverInfo = null; let _hoverGeometryCache = new Map(); +let _tileManifest = null; +let _tileProvider = "pmtiles-mvt"; +let _pmtilesArchive = null; +let _tileCache = new Map(); +let _tileLru = []; +let _inFlightTiles = new Map(); +let _activeTileKeys = new Set(); +let _lastTileSignature = ""; +let _tileUpdateTimer = null; let _visible = false; let _landFillEnabled = true; let _landFillSuppressed = false; let _tintEnabled = false; +let _landTexture = null; let _loaded = false; let _loadPromise = null; +let _tileAssetVersion = ""; const OCEAN_HEX = 0x010609; // ─── Equirectangular land/ocean fill texture ────────────────────────────────── +function configureLandMaskTexture(texture) { + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.anisotropy = 1; + texture.needsUpdate = true; + return texture; +} + function hexToStyle(hex) { return `#${hex.toString(16).padStart(6, "0")}`; } @@ -35,32 +65,14 @@ function hexToRgb(hex) { ]; } -function buildLandTexture(features) { - const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth; - const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight; - +function drawLandMaskCanvas(features, width, height) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); - const oceanRgb = hexToRgb(OCEAN_HEX); if (!ctx) { - const oceanData = new Uint8Array(width * height * 4); - for (let i = 0; i < oceanData.length; i += 4) { - oceanData[i] = oceanRgb[0]; - oceanData[i + 1] = oceanRgb[1]; - oceanData[i + 2] = oceanRgb[2]; - oceanData[i + 3] = 255; - } - const fallbackTexture = new THREE.DataTexture( - oceanData, - width, - height, - THREE.RGBAFormat, - ); - fallbackTexture.needsUpdate = true; - return fallbackTexture; + return null; } // Ocean background @@ -93,21 +105,43 @@ function buildLandTexture(features) { } } - const imageData = ctx.getImageData(0, 0, width, height); - const tex = new THREE.DataTexture( - new Uint8Array(imageData.data), + return canvas; +} + +function buildTextureFromCanvas(canvas) { + const tex = new THREE.CanvasTexture(canvas); + configureLandMaskTexture(tex); + tex.flipY = true; + tex.userData = { + ...(tex.userData || {}), + sourceCanvas: canvas, + }; + return tex; +} + +function buildFallbackLandTexture(width, height) { + const oceanRgb = hexToRgb(OCEAN_HEX); + const oceanData = new Uint8Array(width * height * 4); + for (let i = 0; i < oceanData.length; i += 4) { + oceanData[i] = oceanRgb[0]; + oceanData[i + 1] = oceanRgb[1]; + oceanData[i + 2] = oceanRgb[2]; + oceanData[i + 3] = 255; + } + const fallbackTexture = new THREE.DataTexture( + oceanData, width, height, THREE.RGBAFormat, ); - tex.wrapS = THREE.ClampToEdgeWrapping; - tex.wrapT = THREE.ClampToEdgeWrapping; - tex.minFilter = THREE.LinearFilter; - tex.magFilter = THREE.LinearFilter; - tex.generateMipmaps = false; - tex.flipY = true; - tex.needsUpdate = true; - return tex; + return configureLandMaskTexture(fallbackTexture); +} + +function buildLandTexture(features) { + const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth; + const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight; + const canvas = drawLandMaskCanvas(features, width, height); + return canvas ? buildTextureFromCanvas(canvas) : buildFallbackLandTexture(width, height); } // ─── Sphere mesh helpers ─────────────────────────────────────────────────────── @@ -145,6 +179,14 @@ function makeTintMesh() { // ─── Boundary line geometry ──────────────────────────────────────────────────── +function boundaryLineRadius({ claim = false } = {}) { + return ( + CONFIG.earthRadius + + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset + + (claim ? 0.018 : 0) + ); +} + function ringToSegments(ring, radius, out) { const n = ring.length; if (n < 2) return; @@ -161,12 +203,16 @@ function featureToSegments(geom, radius) { geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts)); } else if (geom.type === "MultiPolygon") { geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts))); + } else if (geom.type === "LineString") { + ringToSegments(geom.coordinates, radius, pts); + } else if (geom.type === "MultiLineString") { + geom.coordinates.forEach(line => ringToSegments(line, radius, pts)); } return pts; } function buildBoundaryLines(features) { - const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset; + const r = boundaryLineRadius(); const mat = new THREE.LineBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.lineColor, transparent: true, @@ -178,7 +224,7 @@ function buildBoundaryLines(features) { const all = []; for (const feat of features) { const pts = featureToSegments(feat.geometry, r); - all.push(...pts); + for (const point of pts) all.push(point); } const geo = all.length > 0 @@ -192,6 +238,350 @@ function buildBoundaryLines(features) { return lines; } +function isStandaloneCoastlineFeature(feature) { + const properties = feature?.properties || {}; + return ( + properties.PLANET_LAYER === "coastline" || + properties.featurecla === "Coastline" + ); +} + +function buildClaimLines() { + const mat = new THREE.LineDashedMaterial({ + color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor, + transparent: true, + opacity: 0.82, + dashSize: 0.7, + gapSize: 0.42, + depthTest: true, + depthWrite: false, + }); + const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat); + lines.name = "country-boundary-china-claims"; + lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02; + lines.visible = false; + lines.raycast = () => {}; + return lines; +} + +function makeBoundaryTileObject(features, { claim = false } = {}) { + const radius = boundaryLineRadius({ claim }); + const points = featureListToSegments(features, radius); + const geometry = makeLineGeometry(points); + const material = claim + ? _claimGroup?.material?.clone?.() || buildClaimLines().material + : _boundaryLines?.material?.clone?.() || new THREE.LineBasicMaterial({ + color: COUNTRY_BOUNDARY_CONFIG.lineColor, + transparent: true, + opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity, + depthTest: true, + depthWrite: false, + }); + const lines = new THREE.LineSegments(geometry, material); + lines.name = claim ? "country-boundary-claim-tile" : "country-boundary-tile"; + lines.renderOrder = claim + ? COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02 + : COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.01; + lines.visible = _visible; + lines.raycast = () => {}; + if (claim && typeof lines.computeLineDistances === "function") { + lines.computeLineDistances(); + } + return lines; +} + +function disposeTileObject(object) { + if (!object) return; + if (_earthObj) _earthObj.remove(object); + object.geometry?.dispose?.(); + object.material?.dispose?.(); +} + +function touchTileKey(key) { + _tileLru = _tileLru.filter(item => item !== key); + _tileLru.push(key); +} + +function trimTileCache() { + const limit = COUNTRY_BOUNDARY_CONFIG.tileCacheLimit; + while (_tileLru.length > limit) { + const key = _tileLru.shift(); + if (!key || _activeTileKeys.has(key)) continue; + const cached = _tileCache.get(key); + _tileCache.delete(key); + disposeTileObject(cached?.object); + } +} + +function setTileObjectVisible(object, visible) { + if (object) object.visible = _visible && visible; +} + +function setActiveTileKeys(nextKeys) { + _activeTileKeys = new Set(nextKeys); + _tileCache.forEach((entry, key) => { + setTileObjectVisible(entry.object, _activeTileKeys.has(key)); + }); +} + +function tileUrlForKey(key) { + const [kind, z, x, y] = key.split("/"); + const prefix = COUNTRY_BOUNDARY_CONFIG.tileBasePath.replace(/\/?$/, "/"); + const versionSuffix = _tileAssetVersion ? `?v=${encodeURIComponent(_tileAssetVersion)}` : ""; + if (kind === "claim") { + return `${prefix}china-claims/${z}/${x}/${y}.geojson${versionSuffix}`; + } + return `${prefix}${z}/${x}/${y}.geojson${versionSuffix}`; +} + +function versionedBoundaryAssetUrl(path) { + const url = new URL(path, COUNTRY_BOUNDARY_CONFIG.tileBasePath); + if (_tileAssetVersion) url.searchParams.set("v", _tileAssetVersion); + return url.href; +} + +function manifestProvider(manifest) { + const configured = COUNTRY_BOUNDARY_CONFIG.tileProvider; + if (configured && configured !== "auto") return configured; + return manifest?.tileProvider || manifest?.format || "pmtiles-mvt"; +} + +function manifestPmtilesUrl(manifest) { + const fromManifest = + manifest?.pmtiles?.url || + manifest?.pmtiles?.path || + manifest?.artifacts?.pmtiles?.url || + manifest?.artifacts?.pmtiles?.path || + manifest?.artifact; + if (!fromManifest) return COUNTRY_BOUNDARY_CONFIG.pmtilesPath; + return new URL(fromManifest, COUNTRY_BOUNDARY_CONFIG.tileBasePath).href; +} + +function ensurePmtilesArchive() { + if (_pmtilesArchive) return _pmtilesArchive; + const url = manifestPmtilesUrl(_tileManifest); + _pmtilesArchive = new PMTiles(url); + return _pmtilesArchive; +} + +function getMvtLayerNames(kind) { + const layerConfig = COUNTRY_BOUNDARY_CONFIG.mvtLayerNames || {}; + if (kind === "claim") return layerConfig.claim || ["claim_line"]; + return layerConfig.boundary || ["boundary_admin0", "boundary_disputed_internal", "coastline"]; +} + +async function loadPmtilesMvtFeatures(key) { + const [kind, zText, xText, yText] = key.split("/"); + const z = Number(zText); + const x = Number(xText); + const y = Number(yText); + if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return []; + + const archive = ensurePmtilesArchive(); + const tile = await archive.getZxy(z, x, y); + if (!tile?.data) return []; + + const vectorTile = new VectorTile(new Pbf(new Uint8Array(tile.data))); + const features = []; + for (const layerName of getMvtLayerNames(kind)) { + const layer = vectorTile.layers[layerName]; + if (!layer) continue; + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i).toGeoJSON(x, y, z); + if (feature?.geometry) features.push(feature); + } + } + return features; +} + +async function loadDebugGeojsonFeatures(key) { + const resp = await fetch(tileUrlForKey(key)); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`boundary tile ${key} HTTP ${resp.status}`); + } + const payload = await resp.json(); + return (payload.features || []).filter(f => f.geometry); +} + +async function loadBoundaryTile(key) { + if (_tileCache.has(key)) { + touchTileKey(key); + return _tileCache.get(key); + } + if (_inFlightTiles.has(key)) return _inFlightTiles.get(key); + + const promise = (async () => { + if (_tileProvider !== "pmtiles-mvt") { + throw new Error(`不支持的国界瓦片 provider: ${_tileProvider}`); + } + const features = await loadPmtilesMvtFeatures(key); + if (features.length === 0) return null; + const entry = { + object: makeBoundaryTileObject(features, { claim: key.startsWith("claim/") }), + }; + _earthObj.add(entry.object); + _tileCache.set(key, entry); + touchTileKey(key); + trimTileCache(); + return entry; + })().catch(err => { + console.warn("[country-boundaries] tile load failed", key, err); + return null; + }).finally(() => { + _inFlightTiles.delete(key); + }); + + _inFlightTiles.set(key, promise); + return promise; +} + +function lonToTileX(lon, zoom) { + const n = 2 ** zoom; + return Math.max(0, Math.min(n - 1, Math.floor(((lon + 180) / 360) * n))); +} + +function latToTileY(lat, zoom) { + const n = 2 ** zoom; + const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat)); + const rad = clamped * Math.PI / 180; + return Math.max( + 0, + Math.min(n - 1, Math.floor((1 - Math.asinh(Math.tan(rad)) / Math.PI) / 2 * n)), + ); +} + +function tileZoomForViewZoom(viewZoom) { + const thresholds = COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds || []; + const maxZoom = _tileManifest?.tiles?.maxZoom ?? 0; + for (const threshold of thresholds) { + if (viewZoom >= threshold.minViewZoom) { + return Math.min(threshold.tileZoom, maxZoom); + } + } + return null; +} + +function bboxFromVisibleEarth(camera, renderer, earth) { + if (!camera || !renderer?.domElement || !earth) return null; + const rect = renderer.domElement.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + + const samples = [ + [rect.left + rect.width * 0.5, rect.top + rect.height * 0.5], + [rect.left, rect.top], + [rect.right, rect.top], + [rect.left, rect.bottom], + [rect.right, rect.bottom], + [rect.left + rect.width * 0.5, rect.top], + [rect.left + rect.width * 0.5, rect.bottom], + [rect.left, rect.top + rect.height * 0.5], + [rect.right, rect.top + rect.height * 0.5], + ]; + const coords = []; + for (const [x, y] of samples) { + const point = screenToEarthCoords(x, y, camera, earth, renderer.domElement); + if (!point) continue; + coords.push(vector3ToLatLon(point)); + } + if (coords.length === 0) return null; + + const lats = coords.map(coord => coord.lat); + const lons = coords.map(coord => coord.lon); + const latMin = Math.max(-85.05112878, Math.min(...lats)); + const latMax = Math.min(85.05112878, Math.max(...lats)); + const latPad = Math.max(2, (latMax - latMin) * 0.18); + const rawLonMin = Math.max(-180, Math.min(...lons)); + const rawLonMax = Math.min(180, Math.max(...lons)); + const rawLonSpan = rawLonMax - rawLonMin; + + if (rawLonSpan > 180) { + const shifted = lons.map(lon => lon < 0 ? lon + 360 : lon); + const shiftedMin = Math.min(...shifted); + const shiftedMax = Math.max(...shifted); + const shiftedPad = Math.max(2, (shiftedMax - shiftedMin) * 0.18); + const west = shiftedMin - shiftedPad; + const east = shiftedMax + shiftedPad; + const ranges = []; + if (west < 180) ranges.push({ west: Math.max(-180, west), east: 180 }); + if (east > 180) ranges.push({ west: -180, east: Math.min(180, east - 360) }); + return { + south: Math.max(-85.05112878, latMin - latPad), + north: Math.min(85.05112878, latMax + latPad), + ranges: ranges.length > 0 ? ranges : [{ west: -180, east: 180 }], + }; + } + + const lonPad = Math.max(2, rawLonSpan * 0.18); + return { + west: Math.max(-180, rawLonMin - lonPad), + south: Math.max(-85.05112878, latMin - latPad), + east: Math.min(180, rawLonMax + lonPad), + north: Math.min(85.05112878, latMax + latPad), + }; +} + +function tileKeysForBbox(bbox, zoom, { claim = false } = {}) { + if (Array.isArray(bbox.ranges)) { + return bbox.ranges.flatMap(range => + tileKeysForBbox({ ...bbox, west: range.west, east: range.east, ranges: null }, zoom, { claim }), + ); + } + + const prefetch = COUNTRY_BOUNDARY_CONFIG.tilePrefetchRing; + const n = 2 ** zoom; + const xMin = lonToTileX(bbox.west, zoom); + const xMax = lonToTileX(bbox.east, zoom); + const yMin = latToTileY(bbox.north, zoom); + const yMax = latToTileY(bbox.south, zoom); + const keys = []; + for (let x = Math.max(0, xMin - prefetch); x <= Math.min(n - 1, xMax + prefetch); x++) { + for (let y = Math.max(0, yMin - prefetch); y <= Math.min(n - 1, yMax + prefetch); y++) { + keys.push(`${claim ? "claim" : "boundary"}/${zoom}/${x}/${y}`); + } + } + return keys; +} + +async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) { + if (!_loaded || !_visible || !_tileManifest) return; + if (_tileProvider === "geojson-high-precision") return; + const tileZoom = tileZoomForViewZoom(viewZoom); + if (!tileZoom) { + _lastTileSignature = ""; + setActiveTileKeys([]); + updateBoundaryLineDimState(); + return; + } + + const bbox = bboxFromVisibleEarth(camera, renderer, earth); + if (!bbox) return; + const keys = tileKeysForBbox(bbox, tileZoom); + if (_tileManifest?.chinaClaims?.available) { + keys.push(...tileKeysForBbox(bbox, tileZoom, { claim: true })); + } + const signature = keys.slice().sort().join("|"); + if (signature === _lastTileSignature) return; + _lastTileSignature = signature; + setActiveTileKeys(keys); + updateBoundaryLineDimState(); + + await Promise.all(keys.map(async key => { + const entry = await loadBoundaryTile(key); + if (!entry) return; + entry.object.userData.tileKey = key; + setTileObjectVisible(entry.object, _activeTileKeys.has(key)); + })); +} + +export function updateCountryBoundaryTiles(context = {}) { + if (_tileUpdateTimer) return; + _tileUpdateTimer = setTimeout(() => { + _tileUpdateTimer = null; + refreshBoundaryTiles(context); + }, COUNTRY_BOUNDARY_CONFIG.tileDebounceMs); +} + function buildHoverLines() { const mat = new THREE.LineBasicMaterial({ color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor, @@ -236,6 +626,15 @@ function setBoundaryLinesDimmed(dimmed) { _boundaryLines.material.needsUpdate = true; } +function updateBoundaryLineDimState() { + if (_coastlineLines?.material) { + _coastlineLines.material.opacity = COUNTRY_BOUNDARY_CONFIG.lineOpacity; + _coastlineLines.material.needsUpdate = true; + _coastlineLines.visible = _visible && !_hoveredFeature; + } + setBoundaryLinesDimmed(Boolean(_hoveredFeature) || _activeTileKeys.size > 0); +} + function setHoverLinesVisible(visible) { const nextVisible = _visible && Boolean(visible); if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible; @@ -243,7 +642,12 @@ function setHoverLinesVisible(visible) { } function featureListToSegments(features, radius) { - return features.flatMap(f => featureToSegments(f.geometry, radius)); + const all = []; + for (const feature of features) { + const points = featureToSegments(feature.geometry, radius); + for (const point of points) all.push(point); + } + return all; } function makeLineGeometry(points) { @@ -265,12 +669,26 @@ function setLineGeometry(line, geometry) { line.geometry = geometry; } +function cancelPendingHoverClear() { + if (!_hoverClearTimer) return; + clearTimeout(_hoverClearTimer); + _hoverClearTimer = null; +} + +function scheduleHoverClear(delayMs) { + if (_hoverClearTimer) return; + _hoverClearTimer = setTimeout(() => { + _hoverClearTimer = null; + clearCountryBoundaryHover({ cancelSticky: false }); + }, Math.max(0, delayMs)); +} + function getHoverGeometries(groupKey, features) { const cacheKey = groupKey || features[0] || "__empty__"; const cached = _hoverGeometryCache.get(cacheKey); if (cached) return cached; - const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset; + const coreRadius = boundaryLineRadius(); const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset; const geometries = { core: markCachedHoverGeometry( @@ -357,6 +775,8 @@ export function createCountryBoundaryLayer(earthObj) { _earthObj = earthObj; _tintMesh = makeTintMesh(); _earthObj.add(_tintMesh); + _claimGroup = buildClaimLines(); + _earthObj.add(_claimGroup); } /** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */ @@ -365,18 +785,71 @@ export async function loadCountryBoundaries() { if (_loadPromise) return _loadPromise; _loadPromise = (async () => { - const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath); - if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`); - const geojson = await resp.json(); + let geojson = { type: "FeatureCollection", features: [] }; + let baseGeojson = null; + const manifestResp = await fetch(COUNTRY_BOUNDARY_CONFIG.tileManifestPath, { cache: "no-store" }); + if (!manifestResp.ok) { + throw new Error(`高精度国界 PMTiles manifest 未生成 HTTP ${manifestResp.status}`); + } + _tileManifest = await manifestResp.json(); + _tileProvider = manifestProvider(_tileManifest); + if (!["pmtiles-mvt", "geojson-high-precision"].includes(_tileProvider)) { + throw new Error(`高精度国界 provider 不可用: ${_tileProvider}`); + } + _tileAssetVersion = [ + _tileManifest.version, + _tileManifest.builtAt, + _tileManifest.sourceFeatureCount, + _tileManifest.pmtiles?.sha256, + ].filter(Boolean).join("-"); + if (_tileProvider === "pmtiles-mvt") { + const pmtilesUrl = manifestPmtilesUrl(_tileManifest); + const pmtilesResp = await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" }); + if (!pmtilesResp.ok) { + throw new Error(`高精度国界 PMTiles 不存在 HTTP ${pmtilesResp.status}: ${pmtilesUrl}`); + } + } + const basePath = _tileManifest.base || _tileManifest.baseGeojson; + const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson; + const baseResp = basePath ? await fetch(versionedBoundaryAssetUrl(basePath)) : null; + const hoverResp = hoverPath ? await fetch(versionedBoundaryAssetUrl(hoverPath)) : null; + if (hoverResp?.ok) { + if (baseResp?.ok) baseGeojson = await baseResp.json(); + geojson = await hoverResp.json(); + } + const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path; + const claimResp = claimPath ? await fetch(versionedBoundaryAssetUrl(claimPath)) : null; + const claimGeojson = claimResp?.ok ? await claimResp.json() : null; _features = (geojson.features || []).filter(f => f.geometry); + const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry); + const boundaryBaseFeatures = baseFeatures.filter( + feature => !isStandaloneCoastlineFeature(feature), + ); + const coastlineFeatures = baseFeatures.filter(isStandaloneCoastlineFeature); - const tex = buildLandTexture(_features); - _landMesh = makeLandMesh(tex); + _landTexture = buildLandTexture(_features); + _landMesh = makeLandMesh(_landTexture); _earthObj.add(_landMesh); - _boundaryLines = buildBoundaryLines(_features); + _boundaryLines = buildBoundaryLines(boundaryBaseFeatures); _earthObj.add(_boundaryLines); + _coastlineLines = buildBoundaryLines(coastlineFeatures); + _coastlineLines.name = "country-coastline-all"; + _earthObj.add(_coastlineLines); + + if (claimGeojson?.features?.length && _claimGroup) { + const claimPoints = featureListToSegments( + claimGeojson.features, + boundaryLineRadius({ claim: true }), + ); + _claimGroup.geometry?.dispose(); + _claimGroup.geometry = makeLineGeometry(claimPoints); + if (typeof _claimGroup.computeLineDistances === "function") { + _claimGroup.computeLineDistances(); + } + } + _hoverGlowLines = buildHoverGlowLines(); _earthObj.add(_hoverGlowLines); @@ -421,13 +894,22 @@ export function toggleCountryBoundaries( _landMesh.visible = _landFillEnabled && !_landFillSuppressed; } if (_boundaryLines) _boundaryLines.visible = _visible; + if (_coastlineLines) _coastlineLines.visible = _visible && !_hoveredFeature; + _tileCache.forEach((entry, key) => { + setTileObjectVisible(entry.object, _visible && _activeTileKeys.has(key)); + }); + if (_claimGroup) _claimGroup.visible = _visible && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine); setHoverLinesVisible(_hoveredFeature); if (!_visible) { + cancelPendingHoverClear(); _hoveredFeature = null; _hoveredGroupKey = null; - setBoundaryLinesDimmed(false); + _lastHoverInfo = null; setHoverLinesVisible(false); + setActiveTileKeys([]); + _lastTileSignature = ""; + updateBoundaryLineDimState(); } if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled; @@ -459,11 +941,13 @@ export function getShowCountryBoundaries() { } /** Clear the hover highlight without hiding the full layer. */ -export function clearCountryBoundaryHover() { - if (!_hoveredFeature) return; +export function clearCountryBoundaryHover({ cancelSticky = true } = {}) { + if (cancelSticky) cancelPendingHoverClear(); + if (!_hoveredFeature && !_lastHoverInfo) return; _hoveredFeature = null; _hoveredGroupKey = null; - setBoundaryLinesDimmed(false); + _lastHoverInfo = null; + updateBoundaryLineDimState(); setHoverLinesVisible(false); } @@ -478,15 +962,30 @@ export function updateCountryBoundaryHover(coords) { const found = _features.find(f => featureContains(lat, lon, f)) || null; const groupKey = getCountryHighlightGroupKey(found); + if (!found && _hoveredFeature) { + const stickyMs = Math.max(0, COUNTRY_BOUNDARY_CONFIG.hoverMissStickyMs || 0); + const elapsedMs = Date.now() - _lastHoverHitAt; + if (stickyMs > 0 && elapsedMs < stickyMs) { + scheduleHoverClear(stickyMs - elapsedMs); + return _lastHoverInfo; + } + } + + if (found) { + cancelPendingHoverClear(); + _lastHoverHitAt = Date.now(); + _lastHoverInfo = makeCountryInfo(found); + } + if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) { _hoveredFeature = found; _hoveredGroupKey = groupKey; if (_hoverLines) { if (!found) { - setBoundaryLinesDimmed(false); + updateBoundaryLineDimState(); setHoverLinesVisible(false); } else { - setBoundaryLinesDimmed(true); + updateBoundaryLineDimState(); const highlightFeatures = getHighlightFeatures(found); const geometries = getHoverGeometries(groupKey, highlightFeatures); setLineGeometry(_hoverGlowLines, geometries.glow); @@ -496,13 +995,17 @@ export function updateCountryBoundaryHover(coords) { } } - return found ? makeCountryInfo(found) : null; + if (!found) _lastHoverInfo = null; + return found ? _lastHoverInfo : null; } /** Dispose all Three.js objects and reset state. */ export function clearCountryBoundaryData() { _hoveredFeature = null; _hoveredGroupKey = null; + cancelPendingHoverClear(); + _lastHoverHitAt = 0; + _lastHoverInfo = null; function disposeObj(obj) { if (!obj) return; @@ -519,14 +1022,33 @@ export function clearCountryBoundaryData() { disposeObj(_hoverLines); disposeObj(_hoverGlowLines); disposeObj(_boundaryLines); + disposeObj(_coastlineLines); + disposeObj(_claimGroup); disposeObj(_landMesh); disposeObj(_tintMesh); + _landTexture?.dispose?.(); + _tileCache.forEach(entry => disposeTileObject(entry.object)); + _tileCache.clear(); + _tileLru = []; + _inFlightTiles.clear(); + _activeTileKeys.clear(); + _lastTileSignature = ""; + if (_tileUpdateTimer) { + clearTimeout(_tileUpdateTimer); + _tileUpdateTimer = null; + } _hoverLines = null; _hoverGlowLines = null; _boundaryLines = null; + _coastlineLines = null; + _claimGroup = null; _landMesh = null; _tintMesh = null; + _landTexture = null; + _tileManifest = null; + _tileProvider = "pmtiles-mvt"; + _pmtilesArchive = null; _features = []; disposeHoverGeometryCache(); _loaded = false; diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js index 88cdd05e..44a0925e 100644 --- a/frontend/public/earth/js/earth.js +++ b/frontend/public/earth/js/earth.js @@ -36,18 +36,24 @@ const _earthSunDirection = new THREE.Vector3( EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z, ).normalize(); -function applyEarthDayNightShader(material) { +function applyEarthDayNightShader(material, options = {}) { if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return; const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor); const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor); - + const { + nightFloor = EARTH_MATERIAL_CONFIG.dayNight.nightFloor, + } = options; material.onBeforeCompile = (shader) => { _earthShaders.push(shader); shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() }; - shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor }; + shader.uniforms.uNightFloor = { value: nightFloor }; shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost }; + shader.uniforms.uFeatherScale = { value: EARTH_MATERIAL_CONFIG.dayNight.featherScale }; shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth }; + shader.uniforms.uTwilightFeatherScale = { + value: EARTH_MATERIAL_CONFIG.dayNight.twilightFeatherScale, + }; shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity }; shader.uniforms.uTwilightColor = { value: twilightColor }; shader.uniforms.uNightTintColor = { value: nightTintColor }; @@ -71,7 +77,9 @@ varying vec3 vWorldNormal; uniform vec3 uSunDirectionWorld; uniform float uNightFloor; uniform float uDayBoost; +uniform float uFeatherScale; uniform float uTwilightWidth; +uniform float uTwilightFeatherScale; uniform float uTwilightIntensity; uniform vec3 uTwilightColor; uniform vec3 uNightTintColor; @@ -83,8 +91,9 @@ uniform float uDayNightEnabled;`, vec3 worldNormal = normalize(vWorldNormal); vec3 sunDir = normalize(uSunDirectionWorld); float sunFacing = dot(worldNormal, sunDir); - float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing); - float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing)); + float edgeFeather = max(fwidth(sunFacing) * uFeatherScale, uTwilightWidth); + float daylight = smoothstep(-edgeFeather, edgeFeather, sunFacing); + float twilight = 1.0 - smoothstep(0.0, edgeFeather * uTwilightFeatherScale, abs(sunFacing)); // Camera-facing diffuse: vNormal and vViewPosition are both in view space. // N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation. @@ -170,10 +179,12 @@ export function createEarth(scene) { ); const occluderMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, + depthTest: false, + depthWrite: true, side: THREE.FrontSide, }); const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial); - occluder.renderOrder = -1; + occluder.renderOrder = 0.5; earth.add(occluder); // Keep the original atmosphere shells on the legacy camera-facing shader so diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 7090e00c..bb2fd67b 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -70,6 +70,7 @@ import { setSurfaceTintEnabled, toggleCountryBoundaries, updateCountryBoundaryHover, + updateCountryBoundaryTiles, } from "./country-boundaries.js"; import { loadGeoJSONFromPath, @@ -4525,7 +4526,9 @@ function onMouseMove(event) { inertialVelocity.y = rotationDeltaY; inertialVelocity.x = rotationDeltaX; previousMousePosition = { x: event.clientX, y: event.clientY }; - clearCountryBoundaryHover(); + if (!document.body.classList.contains("layout-mode-mobile")) { + clearCountryBoundaryHover(); + } hideTooltip(); return; } @@ -5210,6 +5213,12 @@ function animate() { renderer, now: performance.now(), }); + updateCountryBoundaryTiles({ + camera, + earth: getEarthSurfacePickTarget() || earth, + renderer, + viewZoom: getZoomLevel(), + }); updateNewsViewFocus(getCurrentViewCenterCoords()); const satPositions = getSatellitePositions(); if ( diff --git a/frontend/public/earth/js/news-cruise-adapter.js b/frontend/public/earth/js/news-cruise-adapter.js index e04fe58d..7179bac7 100644 --- a/frontend/public/earth/js/news-cruise-adapter.js +++ b/frontend/public/earth/js/news-cruise-adapter.js @@ -106,6 +106,11 @@ function mapNewsItemToCruiseEvent(item) { latitude, longitude, locationLabel: item.location_label || REGION_LABELS[item.region] || "全球", + sourceLocationLabel: item.location_label || REGION_LABELS[item.region] || "全球", + targetLocationConfidence: item.location_meta?.target?.confidence ?? null, + targetLocationSource: item.location_source || "", + verified: item.verified === true, + locationMeta: item.location_meta || null, sortTimestamp: getItemTimestamp(item), }; } diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js index 707a3437..bef7ff79 100644 --- a/frontend/public/earth/js/news.js +++ b/frontend/public/earth/js/news.js @@ -14,6 +14,7 @@ const NEWS_HUD_MORPH_MS = 300; const NEWS_HUD_MIN_WIDTH_PX = 420; const NEWS_HUD_MIN_HEIGHT_PX = 360; const NEWS_HUD_RESIZE_MARGIN_PX = 12; +const NEWS_REALTIME_RECONNECT_MS = 5000; let initialized = false; let refreshPromise = null; @@ -23,6 +24,8 @@ let lastFetchAt = 0; let lastRegionSwitchAt = 0; let selectedCruiseStoryId = null; let morphTimer = null; +let newsRealtimeSocket = null; +let newsRealtimeReconnectTimer = null; function getElements() { const isMobile = document.body.classList.contains("layout-mode-mobile"); @@ -438,6 +441,84 @@ function renderPayload(nextPayload) { })); } +function getNewsRealtimeUrl() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/ws`; +} + +function clearNewsRealtimeReconnectTimer() { + if (!newsRealtimeReconnectTimer) return; + window.clearTimeout(newsRealtimeReconnectTimer); + newsRealtimeReconnectTimer = null; +} + +function scheduleNewsRealtimeReconnect() { + if (newsRealtimeReconnectTimer) return; + newsRealtimeReconnectTimer = window.setTimeout(() => { + newsRealtimeReconnectTimer = null; + connectNewsRealtime(); + }, NEWS_REALTIME_RECONNECT_MS); +} + +function applyNewsRealtimePatch(updatePayload) { + const itemId = updatePayload?.item_id; + const patch = updatePayload?.patch; + if (!payload || !itemId || !patch || typeof patch !== "object") return; + const items = Array.isArray(payload.items) ? payload.items : []; + let changed = false; + const nextItems = items.map((item) => { + if (item?.id !== itemId) return item; + changed = true; + return { + ...item, + ...patch, + }; + }); + if (!changed) return; + renderPayload({ + ...payload, + items: nextItems, + }); +} + +function connectNewsRealtime() { + if (newsRealtimeSocket || typeof WebSocket === "undefined") return; + const socket = new WebSocket(getNewsRealtimeUrl()); + newsRealtimeSocket = socket; + socket.onopen = () => { + clearNewsRealtimeReconnectTimer(); + socket.send(JSON.stringify({ + type: "subscribe", + data: { + channel: "earth_news", + }, + })); + }; + socket.onmessage = (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if (message.type === "heartbeat" && message.data?.action === "ping") { + socket.send(JSON.stringify({ type: "heartbeat" })); + return; + } + if (message.type !== "data_frame" || message.channel !== "earth_news") return; + applyNewsRealtimePatch(message.payload); + }; + socket.onclose = () => { + if (newsRealtimeSocket === socket) { + newsRealtimeSocket = null; + } + scheduleNewsRealtimeReconnect(); + }; + socket.onerror = () => { + socket.close(); + }; +} + async function fetchNews(lat, lon) { const url = new URL(EARTH_NEWS_API, window.location.origin); if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4)); @@ -614,6 +695,7 @@ export function initNewsPanel() { }); hudCloseBtn?.addEventListener("click", closeNewsHud); setupNewsHudResize(); + connectNewsRealtime(); ["news-refresh", "mobile-news-refresh"].forEach((id) => { const refreshBtn = document.getElementById(id); diff --git a/frontend/src/pages/AISettings/AISettings.tsx b/frontend/src/pages/AISettings/AISettings.tsx index f863e2fd..3feb2857 100644 --- a/frontend/src/pages/AISettings/AISettings.tsx +++ b/frontend/src/pages/AISettings/AISettings.tsx @@ -303,7 +303,7 @@ export default function AISettings() { nextSecret === savedPreview || nextSecret === revealedSecret || nextSecret.startsWith('••••') || - nextSecret.includes('*') + Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char)) ) const buildAiProviderDraftPayload = (values: any) => { @@ -381,9 +381,11 @@ export default function AISettings() { setFeedback({ type: 'success', message: 'AI 配置已保存' }) message.success('AI 配置已保存') await fetchAISettings() - } catch { - setFeedback({ type: 'error', message: 'AI 配置保存失败' }) - message.error('AI 配置保存失败') + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string; message?: string } } } + const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI 配置保存失败' + setFeedback({ type: 'error', message: errorMessage }) + message.error(errorMessage) } finally { setSaving(false) } @@ -591,12 +593,8 @@ export default function AISettings() { buildAiProviderDraftPayload(values), ) if (response.data.success || response.data.connected) { - if (response.data.integrations) { - setIntegrations(response.data.integrations) - await fetchAISettings() - } - setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' }) - message.success(response.data.message || 'AI Provider 连接成功') + setFeedback({ type: 'success', message: response.data.message || '连接测试通过' }) + message.success(response.data.message || '连接测试通过') } else { setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' }) message.error(response.data.message || 'AI Provider 连接失败') @@ -680,6 +678,9 @@ export default function AISettings() { )} /> +
+ 测试不保存,保存才生效。 +
@@ -2151,6 +2157,9 @@ function Settings() { /> +
+ 测试不保存,保存才生效。 +
@@ -2481,14 +2490,39 @@ function Settings() { ) : null} - - + + {!selectedCollector?.is_custom ? ( ) : null} + {isEarthBoundarySourceCollector ? ( + <> +
+ + + +
+ + + + + + + + ) : null} {(fields, { add, remove }) => ( diff --git a/planet.sh b/planet.sh index 6a2eb543..2eb3e128 100755 --- a/planet.sh +++ b/planet.sh @@ -120,9 +120,6 @@ FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid" FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log" PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env" FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js" -WINDOWS_LAN_RELAY_SCRIPT="$SCRIPT_DIR/scripts/windows-lan-relay.ps1" -WINDOWS_FRONTEND_RELAY_PID_FILE="$PLANET_STATE_DIR/windows_frontend_relay.pid" -WINDOWS_BACKEND_RELAY_PID_FILE="$PLANET_STATE_DIR/windows_backend_relay.pid" MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid" MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log" AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256" @@ -136,10 +133,6 @@ START_RUN_COMPLETED=0 STARTED_BACKEND_THIS_RUN=0 STARTED_FRONTEND_THIS_RUN=0 STARTED_MOTION_AGENT_THIS_RUN=0 -WINDOWS_LAN_RELAY_ENABLED=0 -LAN_PUBLIC_FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" -LAN_PUBLIC_BACKEND_PORT="$DEFAULT_BACKEND_PORT" -WINDOWS_LAN_RELAY_LISTEN_ADDRESS="" AI_PROVIDER_RUNTIME_ENV_NAMES=( SERVICE_NAME SERVICE_VERSION @@ -646,81 +639,20 @@ EOF log_lan_access_notes() { local frontend_port="$1" local backend_port="$2" - local public_frontend_port="${3:-$frontend_port}" - local public_backend_port="${4:-$backend_port}" + local ai_provider_port="${3:-$AI_PROVIDER_PORT}" local recommended_lan_ip="" if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then - log_note "推荐访问地址: http://${recommended_lan_ip}:${public_frontend_port}" - log_note "后端健康检查: http://${recommended_lan_ip}:${public_backend_port}/health" + log_note "推荐访问地址: http://${recommended_lan_ip}:${frontend_port}" + log_note "后端健康检查: http://${recommended_lan_ip}:${backend_port}/health" + log_note "AI Provider 健康检查: http://${recommended_lan_ip}:${ai_provider_port}/health" else - log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${public_frontend_port}" - log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${public_backend_port}/health" + log_note "前端已对局域网开放,请使用本机局域网 IP 访问 :${frontend_port}" + log_note "后端已对局域网开放,请使用本机局域网 IP 访问 :${backend_port}/health" + log_note "AI Provider 已对局域网开放,请使用本机局域网 IP 访问 :${ai_provider_port}/health" fi } -cleanup_windows_lan_relays() { - local pid_file - local pid - - for pid_file in "$WINDOWS_FRONTEND_RELAY_PID_FILE" "$WINDOWS_BACKEND_RELAY_PID_FILE"; do - if [ -f "$pid_file" ]; then - if pid="$(read_pid_file "$pid_file")"; then - terminate_process_tree TERM "$pid" - sleep 0.3 - terminate_process_tree KILL "$pid" - fi - remove_pid_file "$pid_file" - fi - done -} - -start_windows_lan_relay() { - local name="$1" - local public_port="$2" - local target_port="$3" - local pid_file="$4" - local target_host="$5" - local listen_address="$6" - local script_path - local relay_pid - - script_path="$(wslpath -w "$WINDOWS_LAN_RELAY_SCRIPT" 2>/dev/null || true)" - if [ -z "$script_path" ]; then - log_warn "无法转换 Windows relay 脚本路径,跳过 ${name} 局域网 relay" - return 1 - fi - - powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path" \ - -ListenAddress "$listen_address" \ - -ListenPort "$public_port" \ - -TargetHost "$target_host" \ - -TargetPort "$target_port" \ - > /dev/null 2>&1 & - relay_pid=$! - write_pid_file "$pid_file" "$relay_pid" - sleep 0.5 - - if ! kill -0 "$relay_pid" 2>/dev/null; then - remove_pid_file "$pid_file" - log_warn "${name} Windows relay 未能监听端口 ${public_port}" - print_windows_port_listener_details "$public_port" || true - return 1 - fi - - log_success "${name} Windows relay 已启动: ${listen_address}:${public_port} -> ${target_host}:${target_port}" -} - -start_windows_lan_relays() { - local target_host="127.0.0.1" - - [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ] || return 0 - - cleanup_windows_lan_relays - start_windows_lan_relay "前端" "$LAN_PUBLIC_FRONTEND_PORT" "$FRONTEND_PORT" "$WINDOWS_FRONTEND_RELAY_PID_FILE" "$target_host" "$WINDOWS_LAN_RELAY_LISTEN_ADDRESS" || return 1 - start_windows_lan_relay "后端" "$LAN_PUBLIC_BACKEND_PORT" "$BACKEND_PORT" "$WINDOWS_BACKEND_RELAY_PID_FILE" "$target_host" "$WINDOWS_LAN_RELAY_LISTEN_ADDRESS" || return 1 -} - run_windows_admin_powershell_script() { local script_path="$1" local warn_context="$2" @@ -737,15 +669,21 @@ run_windows_admin_powershell_script() { powershell.exe -NoProfile -Command "Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${script_win_path}\"'" >/dev/null 2>&1 } +join_csv() { + local IFS="," + printf "%s" "$*" +} + windows_firewall_missing_ports() { - local frontend_port="$1" - local backend_port="$2" + local ports_csv command -v powershell.exe >/dev/null 2>&1 || return 1 + [ "$#" -gt 0 ] || return 0 + ports_csv="$(join_csv "$@")" powershell.exe -NoProfile -Command " \$ErrorActionPreference = 'SilentlyContinue' -\$ports = @([int]${frontend_port}, [int]${backend_port}) +\$ports = @(${ports_csv}) function Test-PortAllowed { param([int]\$Port) \$displayName = 'WSL Planet ' + \$Port @@ -772,19 +710,19 @@ if (\$missing.Count -gt 0) { } request_windows_firewall_rules_if_needed() { - local frontend_port="$1" - local backend_port="$2" local missing_ports="" local script_path="" - [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ] || return 0 + [ "$#" -gt 0 ] || return 0 + is_wsl_environment || return 0 + command -v powershell.exe >/dev/null 2>&1 || return 0 case "${PLANET_WINDOWS_FIREWALL_AUTO_PROMPT:-1}" in 0|false|no|off) return 0 ;; esac - missing_ports="$(windows_firewall_missing_ports "$frontend_port" "$backend_port" || true)" + missing_ports="$(windows_firewall_missing_ports "$@" || true)" [ -n "$missing_ports" ] || return 0 script_path="$PLANET_STATE_DIR/windows_firewall_planet.ps1" @@ -812,15 +750,18 @@ EOF } windows_portproxy_conflict_ports() { - local frontend_port="$1" - local backend_port="$2" + local ports_csv command -v powershell.exe >/dev/null 2>&1 || return 1 + [ "$#" -gt 0 ] || return 0 + ports_csv="$(join_csv "$@")" powershell.exe -NoProfile -Command " \$ErrorActionPreference = 'SilentlyContinue' -\$ports = @([int]${frontend_port}, [int]${backend_port}) -\$lines = @(netsh interface portproxy show v4tov4) +\$ports = @(${ports_csv}) +\$netsh = Join-Path \$env:WINDIR 'System32\netsh.exe' +if (-not (Test-Path \$netsh)) { \$netsh = 'netsh' } +\$lines = @(& \$netsh interface portproxy show v4tov4) \$found = @() foreach (\$line in \$lines) { if (\$line -match '^\\s*(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(\\d+)\\s*$') { @@ -836,20 +777,23 @@ if (\$found.Count -gt 0) { write_windows_portproxy_cleanup_script() { local script_path="$1" - local frontend_port="$2" - local backend_port="$3" + shift + local ports_csv + ports_csv="$(join_csv "$@")" cat > "$script_path" </dev/null 2>&1 || return 0 case "${PLANET_WINDOWS_PORTPROXY_CLEANUP_PROMPT:-1}" in 0|false|no|off) return 0 ;; esac - conflict_ports="$(windows_portproxy_conflict_ports "$frontend_port" "$backend_port" || true)" + conflict_ports="$(windows_portproxy_conflict_ports "$@" || true)" [ -n "$conflict_ports" ] || return 0 script_path="$PLANET_STATE_DIR/windows_portproxy_cleanup_planet.ps1" mkdir -p "$PLANET_STATE_DIR" - write_windows_portproxy_cleanup_script "$script_path" "$frontend_port" "$backend_port" + write_windows_portproxy_cleanup_script "$script_path" "$@" log_warn "检测到旧 Windows portproxy 占用端口 ${conflict_ports},即将请求管理员 PowerShell 删除" run_windows_admin_powershell_script "$script_path" "检测到旧 portproxy 占用端口 ${conflict_ports}" || { @@ -886,6 +830,80 @@ request_windows_portproxy_cleanup_if_needed() { } } +write_windows_port_cleanup_script() { + local script_path="$1" + shift + local ports_csv + + ports_csv="$(join_csv "$@")" + cat > "$script_path" </dev/null 2>&1 || return 1 + ports_label="$(join_csv "$@")" + mkdir -p "$PLANET_STATE_DIR" + write_windows_port_cleanup_script "$script_path" "$@" + + log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" + run_windows_admin_powershell_script "$script_path" "清理 Windows 侧端口 ${ports_label}" || { + log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" + return 1 + } +} + log_motion_agent_access_notes() { local motion_agent_port="$1" local lan_enabled="${2:-0}" @@ -953,13 +971,29 @@ run_with_retry() { return 1 fi - animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval" + animate_wait_spinner "${retry_label} 本次执行失败,${interval} 秒后进行第 $((attempt + 1)) 次尝试" "$interval" attempt=$((attempt + 1)) done return 1 } +format_wait_elapsed_seconds() { + local completed_intervals="${1:-0}" + local interval="${2:-1}" + + awk -v completed="$completed_intervals" -v interval="$interval" ' + BEGIN { + value = completed * interval + if (value == int(value)) { + printf "%d", value + } else { + printf "%.1f", value + } + } + ' +} + docker_daemon_available() { docker info >/dev/null 2>&1 } @@ -1432,8 +1466,9 @@ wait_for_http() { local attempt=1 local wait_message="检查${service_name}健康检查" local wait_detail_prefix="请求" + local elapsed_seconds="" - set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" + set_wait_detail "${wait_detail_prefix} ${url},已等待 0 秒" while [ "$attempt" -le "$attempts" ]; do if http_ok "$url"; then @@ -1441,7 +1476,8 @@ wait_for_http() { return 0 fi - set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})" + elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$interval")" + set_wait_detail "${wait_detail_prefix} ${url},已等待 ${elapsed_seconds} 秒" animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -1561,8 +1597,9 @@ wait_for_container_health() { local health_status local wait_message="检查${service_name}容器健康检查" local wait_detail_prefix="读取" + local elapsed_seconds="" - set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (1/${attempts})" + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态,已等待 0 秒" while [ "$attempt" -le "$attempts" ]; do health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" @@ -1572,7 +1609,8 @@ wait_for_container_health() { return 0 fi - set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})" + elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$interval")" + set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态,已等待 ${elapsed_seconds} 秒" animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done @@ -1587,7 +1625,7 @@ fail_unreleased_port() { if [ -z "$(collect_port_pids "$port" || true)" ]; then log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用" print_port_listener_details "$port" - log_note "如果显示 Windows listener(如 svchost.exe / iphlpsvc),请优先用管理员 PowerShell 检查 portproxy:netsh interface portproxy show all;删除冲突转发后重新启动,--allow-lan 会改用临时 Windows relay。" + log_note "如果显示 Windows listener(如 svchost.exe / iphlpsvc),脚本会在需要时请求管理员 PowerShell 清理 Windows 侧端口。" else log_error "端口 ${port} 清理失败,请检查占用进程" if command -v lsof >/dev/null 2>&1; then @@ -1607,6 +1645,7 @@ retry_wait_for_port_release() { local service_name="$2" local attempt=1 local pids="" + local elapsed_seconds="" while [ "$attempt" -le "$PORT_PRESTART_RETRIES" ]; do if wait_for_port_release "$port"; then @@ -1614,13 +1653,14 @@ retry_wait_for_port_release() { fi pids="$(collect_port_pids "$port" || true)" + elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$PORT_PRESTART_RETRY_INTERVAL")" if [ -n "$pids" ]; then - log_warn "${service_name}端口 ${port} 仍有监听进程,继续清理 (${attempt}/${PORT_PRESTART_RETRIES})" + log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" for pid in $pids; do terminate_process_tree KILL "$pid" done else - log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,等待释放 (${attempt}/${PORT_PRESTART_RETRIES})" + log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" print_port_listener_details "$port" fi @@ -1669,104 +1709,33 @@ print_windows_cleanup_recovery_steps() { printf "${DIM} netstat -ano | findstr :%s${NC}\n" "$port" printf "${DIM} tasklist /svc /fi \"PID eq \"${NC}\n" printf "${DIM} Stop-Service iphlpsvc -Force${NC}\n" - log_note "如需局域网仍使用 ${port},请删除旧 portproxy 后重新运行 --allow-lan;脚本会使用临时 Windows relay 暴露该端口。" + log_note "如需局域网仍使用 ${port},请清理冲突监听后重新运行 --allow-lan;脚本会直接用 0.0.0.0 绑定该端口。" log_note "重新启动示例:${command_example}" } force_cleanup_external_port_listener() { local port="$1" local service_name="$2" - local output="" - local saw_windows_listener=0 - local saw_no_windows_listener=0 - local saw_cleanup_failure=0 validate_port "$port" is_wsl_environment || return 1 command -v powershell.exe >/dev/null 2>&1 || return 1 - log_warn "检测到 WSL 环境,正在检查 ${service_name} 端口 ${port} 的 Windows 侧监听" - output="$( - powershell.exe -NoProfile -Command " -\$ErrorActionPreference = 'Continue' -\$port = [int]${port} -\$connections = @(Get-NetTCPConnection -LocalPort \$port -State Listen -ErrorAction SilentlyContinue) -if (-not \$connections -or \$connections.Count -eq 0) { - 'no-windows-listener' - exit 0 -} -\$seen = @{} -foreach (\$connection in \$connections) { - \$pidValue = [int]\$connection.OwningProcess - if (\$seen.ContainsKey(\$pidValue)) { continue } - \$seen[\$pidValue] = \$true - \$process = Get-CimInstance Win32_Process -Filter \"ProcessId=\$pidValue\" -ErrorAction SilentlyContinue - \$processName = if (\$process.Name) { \$process.Name } else { 'unknown' } - \$services = @(Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Where-Object { \$_.ProcessId -eq \$pidValue }) - \$serviceNames = @(\$services | Select-Object -ExpandProperty Name) - 'attempt-windows-cleanup pid={0} process={1} services={2}' -f \$pidValue, \$processName, (\$serviceNames -join ',') + log_warn "检查 Windows 侧端口占用: ${port}" + if ! print_windows_port_listener_details "$port"; then + log_note "Windows 侧未发现端口 ${port} 监听,继续启动" + return 0 + fi - foreach (\$service in \$services) { - try { - Stop-Service -Name \$service.Name -Force -ErrorAction Stop - 'stopped-service {0}' -f \$service.Name - } catch { - 'failed-stop-service {0}: {1}' -f \$service.Name, \$_.Exception.Message - } - } - - try { - Stop-Process -Id \$pidValue -Force -ErrorAction Stop - 'stopped-process {0}' -f \$pidValue - } catch { - 'failed-stop-process {0}: {1}' -f \$pidValue, \$_.Exception.Message - } -} -" 2>&1 | tr -d '\r' - )" - - while IFS= read -r line; do - [ -n "$line" ] || continue - case "$line" in - no-windows-listener) - saw_no_windows_listener=1 - log_note "Windows 侧未发现端口 ${port} 监听,继续启动" - ;; - stopped-service*|stopped-process*) - saw_windows_listener=1 - printf "${GREEN} %s${NC}\n" "$line" - ;; - failed-*) - saw_windows_listener=1 - saw_cleanup_failure=1 - printf "${DIM} %s${NC}\n" "$line" - ;; - attempt-windows-cleanup*) - saw_windows_listener=1 - printf "${DIM} %s${NC}\n" "$line" - ;; - *) - printf "${DIM} %s${NC}\n" "$line" - ;; - esac - done < "$BACKEND_LOG_FILE" - PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 & + PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host "$backend_bind_host" --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 & BACKEND_PID=$! write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID" if [ "$VERBOSE" -eq 1 ]; then @@ -1854,7 +1824,7 @@ start_backend_with_retry() { report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" return 1 fi - animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" + animate_wait_spinner "后端启动未就绪,准备再次尝试" "$BACKEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done @@ -1895,7 +1865,7 @@ start_ai_provider_service() { remove_ai_provider_containers AI_PROVIDER_RECREATE_REQUIRED=1 - animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL" + animate_wait_spinner "AI Provider 启动后仍未健康,正在重启容器并准备再次尝试" "$AI_PROVIDER_RETRY_INTERVAL" retry=$((retry + 1)) done @@ -1926,7 +1896,7 @@ ensure_database_services_healthy() { fi docker restart planet_postgres planet_redis >/dev/null 2>&1 || true - animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" + animate_wait_spinner "数据库启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -1951,7 +1921,7 @@ ensure_postgres_service_healthy() { fi docker restart planet_postgres >/dev/null 2>&1 || true - animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" + animate_wait_spinner "PostgreSQL 启动后仍未健康,正在重启容器并准备再次尝试" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -1971,7 +1941,7 @@ restart_database_service() { exit 1 fi - animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL" + animate_wait_spinner "数据库重启后仍未健康,准备再次尝试" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } @@ -2164,48 +2134,6 @@ wait_for_port_release() { return 1 } -get_windows_lan_ipv4() { - command -v powershell.exe >/dev/null 2>&1 || return 1 - - powershell.exe -NoProfile -Command " -\$ErrorActionPreference = 'SilentlyContinue' -\$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | - Where-Object { \$_.NextHop -and \$_.NextHop -ne '0.0.0.0' } | - Sort-Object RouteMetric, InterfaceMetric | - Select-Object -First 1 -if (\$route) { - \$ip = Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex \$route.InterfaceIndex | - Where-Object { \$_.IPAddress -notmatch '^(127|169\\.254)\\.' } | - Select-Object -First 1 -ExpandProperty IPAddress - if (\$ip) { \$ip } -} -" 2>/dev/null | tr -d '\r' | sed -n '1p' -} - -prepare_windows_lan_relay_ports() { - local listen_address="" - - [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 - is_wsl_environment || return 0 - command -v powershell.exe >/dev/null 2>&1 || return 0 - [ -f "$WINDOWS_LAN_RELAY_SCRIPT" ] || return 0 - - listen_address="$(get_windows_lan_ipv4 || true)" - if [ -z "$listen_address" ]; then - log_warn "未能识别 Windows 局域网 IP,跳过 Windows 局域网 relay;本机 localhost 仍可使用 ${FRONTEND_PORT}/${BACKEND_PORT}" - return 0 - fi - - WINDOWS_LAN_RELAY_ENABLED=1 - LAN_PUBLIC_FRONTEND_PORT="$FRONTEND_PORT" - LAN_PUBLIC_BACKEND_PORT="$BACKEND_PORT" - WINDOWS_LAN_RELAY_LISTEN_ADDRESS="$listen_address" - - cleanup_windows_lan_relays - request_windows_portproxy_cleanup_if_needed "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" || true - log_note "WSL 局域网 relay: Windows ${WINDOWS_LAN_RELAY_LISTEN_ADDRESS}:${LAN_PUBLIC_FRONTEND_PORT}/${LAN_PUBLIC_BACKEND_PORT} -> localhost:${FRONTEND_PORT}/${BACKEND_PORT}" -} - kill_port_if_requested() { local port="$1" local service_name="$2" @@ -2385,6 +2313,7 @@ cleanup_frontend_processes() { fi pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true + pkill -f "${FRONTEND_VITE_ENTRY} --host 127.0.0.1 --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true } @@ -2424,7 +2353,7 @@ start_frontend_with_retry() { kill_port_if_requested "$frontend_port" "前端" fi - animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" + animate_wait_spinner "前端启动未就绪,准备再次尝试" "$FRONTEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done @@ -2811,7 +2740,6 @@ cleanup_failed_start() { clear_wait_spinner log_warn "启动未完成,清理本轮已拉起的本地进程" - cleanup_windows_lan_relays if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then cleanup_motion_agent_processes TERM @@ -2929,11 +2857,51 @@ PY log_note "邮箱: ${email}" } +prepare_allow_lan_public_ports() { + [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 + + log_step "检查局域网端口可绑定" + kill_port_if_requested "$FRONTEND_PORT" "前端" + kill_port_if_requested "$BACKEND_PORT" "后端" + kill_port_if_requested "$AI_PROVIDER_PORT" "AI Provider" + request_windows_portproxy_cleanup_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true + request_windows_firewall_rules_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true +} + +verify_http_endpoint() { + local label="$1" + local url="$2" + + log_note "检查${label}: ${url}" + if ! http_ok "$url"; then + log_error "${label}不可访问: ${url}" + return 1 + fi +} + +verify_allow_lan_access() { + local lan_ip="" + + [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 + + verify_http_endpoint "本机访问" "http://localhost:${FRONTEND_PORT}" || exit 1 + verify_http_endpoint "本机访问" "http://localhost:${BACKEND_PORT}/health" || exit 1 + verify_http_endpoint "本机访问" "http://localhost:${AI_PROVIDER_PORT}/health" || exit 1 + + if lan_ip="$(get_recommended_lan_ipv4)"; then + verify_http_endpoint "局域网访问" "http://${lan_ip}:${FRONTEND_PORT}" || exit 1 + verify_http_endpoint "局域网访问" "http://${lan_ip}:${BACKEND_PORT}/health" || exit 1 + verify_http_endpoint "局域网访问" "http://${lan_ip}:${AI_PROVIDER_PORT}/health" || exit 1 + else + log_warn "未能识别局域网 IP,跳过局域网端到端健康检查" + fi +} + # Top-level commands start() { parse_service_args "$@" - prepare_windows_lan_relay_ports cleanup_exit_containers + prepare_allow_lan_public_ports START_RUN_ACTIVE=1 START_RUN_COMPLETED=0 @@ -2953,37 +2921,25 @@ start() { fi write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT" - if [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ]; then - start_windows_lan_relays - request_windows_firewall_rules_if_needed "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" || true - fi + verify_allow_lan_access START_RUN_COMPLETED=1 START_RUN_ACTIVE=0 log_success "启动完成" - if [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 1 ]; then - log_note "智能星球计划: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/earth" - log_note "智能星球仪表盘: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/admin" - log_note "AI Playground: http://localhost:${LAN_PUBLIC_FRONTEND_PORT}/playground" - log_note "智能星球开发文档: http://localhost:${LAN_PUBLIC_BACKEND_PORT}/docs" - log_note "局域网入口: http://${WINDOWS_LAN_RELAY_LISTEN_ADDRESS}:${LAN_PUBLIC_FRONTEND_PORT}/earth" - else - log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" - log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" - log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" - log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" - fi + log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" + log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" + log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" + log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED" fi - if [ "$FRONTEND_LAN_ENABLED" -eq 1 ] && [ "$WINDOWS_LAN_RELAY_ENABLED" -eq 0 ]; then - log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" "$LAN_PUBLIC_FRONTEND_PORT" "$LAN_PUBLIC_BACKEND_PORT" + if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then + log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT" fi } stop() { log_warn "停止服务" - cleanup_windows_lan_relays stop_backend_service stop_ai_provider_service stop_frontend_service @@ -2993,6 +2949,16 @@ stop() { log_success "已停止" } +stop_local_services_for_restart() { + log_warn "正在停止本地服务" + stop_backend_service + stop_ai_provider_service + stop_frontend_service + stop_motion_agent_service + stop_container_if_running "planet_postgres" "PostgreSQL" + stop_container_if_running "planet_redis" "Redis" +} + restart() { parse_service_args "$@" cleanup_exit_containers @@ -3002,7 +2968,7 @@ restart() { local state_motion_agent_port="" if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then - stop + stop_local_services_for_restart sleep 1 start "$@" return 0 @@ -3017,6 +2983,9 @@ restart() { if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then stop_ai_provider_service sleep 1 + if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then + kill_port_if_requested "$AI_PROVIDER_PORT" "AI Provider" + fi start_ai_provider_service "$AI_PROVIDER_PORT" fi diff --git a/pyproject.toml b/pyproject.toml index 8712921b..2e7bff77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.57.0" +version = "0.58.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index 4b064dab..c44d62cc 100644 --- a/rules.md +++ b/rules.md @@ -379,6 +379,9 @@ Editing `frontend/public/earth`, 3D Earth, canvas/Three.js rendering, BGP/vessel - Avoid one-off visual patches before checking render order, coordinate ownership, and data lifecycle. - Use Three.js for 3D elements. - Verify 3D/canvas work with real rendering, not only TypeScript build. +- For concentric or near-concentric Earth surface shells, do not place overlay radii too close to `CONFIG.earthRadius`. + Maintain explicit altitude offsets with enough separation for far-zoom depth-buffer precision, document them in `earth-render-layer-order.md`, and verify at zoomed-out views such as 50%. + If snow, black blocks, or flicker appears on the globe, check for z-fighting between the base sphere, land/ocean fill, HD texture, terrain, clouds, and occluder before hiding layers or adding LOD workarounds. - Do not let loading messages, phase labels, or optimistic UI override real backend task state. - WebSocket data frames should include timestamps, type, and payload when streaming Earth state. - Default heartbeat for real-time streams is 30 seconds unless a protocol states otherwise. diff --git a/scripts/build_earth_boundary_china_pov_geojson.py b/scripts/build_earth_boundary_china_pov_geojson.py new file mode 100644 index 00000000..1b2fcacb --- /dev/null +++ b/scripts/build_earth_boundary_china_pov_geojson.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Build a usable high-precision China-POV Earth boundary GeoJSON artifact.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--admin0-source", type=Path, required=True) + parser.add_argument("--coastline-source", type=Path, required=True) + parser.add_argument("--claims-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, default=Path("frontend/public/earth/data/boundaries/v1")) + parser.add_argument("--build-input-hash", required=True) + return parser.parse_args() + + +def load_geojson(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + if payload.get("type") == "Feature": + payload = {"type": "FeatureCollection", "features": [payload]} + if payload.get("type") != "FeatureCollection": + raise ValueError(f"{path} must be a GeoJSON FeatureCollection or Feature") + return payload + + +def feature_collection(features: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: + return {"type": "FeatureCollection", **extra, "features": features} + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def apply_china_pov_admin0(admin0: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for feature in admin0.get("features") or []: + props = dict(feature.get("properties") or {}) + name = props.get("ADMIN") or props.get("NAME") or props.get("NAME_EN") + iso = props.get("ISO_A3") or props.get("ADM0_A3") + + if iso == "TWN" or name == "Taiwan": + props.update( + { + "ADMIN": "China", + "NAME": "China", + "NAME_EN": "China", + "NAME_ZH": "中国", + "SOVEREIGNT": "China", + "SOV_A3": "CHN", + "ISO_A3": "CHN", + "ADM0_A3": "CHN", + "BRK_A3": "CHN", + "PLANET_POV_NOTE": "Taiwan/Penghu rendered as China for china-pov-v1", + } + ) + elif iso == "KOS" or name == "Kosovo": + props.update( + { + "ADMIN": "Serbia", + "NAME": "Serbia", + "NAME_EN": "Serbia", + "NAME_ZH": "塞尔维亚", + "SOVEREIGNT": "Serbia", + "SOV_A3": "SRB", + "ISO_A3": "SRB", + "ADM0_A3": "SRB", + "BRK_A3": "SRB", + "PLANET_POV_NOTE": "Kosovo rendered as Serbia for china-pov-v1", + } + ) + elif iso == "PSE" or name == "Palestine": + props.update( + { + "ADMIN": "Palestine", + "NAME": "Palestine", + "NAME_EN": "Palestine", + "NAME_ZH": "巴勒斯坦", + "ISO_A3": "PSE", + "ADM0_A3": "PSE", + "BRK_A3": "PSE", + "PLANET_POV_NOTE": "Gaza is rendered within Palestine for china-pov-v1", + } + ) + + out.append({**feature, "properties": props}) + return out + + +def normalize_claim_features(claims: dict[str, Any]) -> list[dict[str, Any]]: + features = [] + for feature in claims.get("features") or []: + if not feature.get("geometry"): + continue + props = dict(feature.get("properties") or {}) + props.setdefault("name", "九段线") + props.setdefault("name_en", "Nine-dash line") + props["PLANET_LAYER"] = "claim_line" + features.append({**feature, "properties": props}) + return features + + +def main() -> None: + args = parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + admin0 = load_geojson(args.admin0_source) + coastline = load_geojson(args.coastline_source) + claims = load_geojson(args.claims_source) + + admin_features = apply_china_pov_admin0(admin0) + coastline_features = [ + {**feature, "properties": {**(feature.get("properties") or {}), "PLANET_LAYER": "coastline"}} + for feature in coastline.get("features") or [] + if feature.get("geometry") + ] + claim_features = normalize_claim_features(claims) + + hover_path = args.output_dir / "hover-index.geojson" + base_path = args.output_dir / "base.geojson" + claim_path = args.output_dir / "china-claims.geojson" + manifest_path = args.output_dir / "manifest.json" + + hover = feature_collection(admin_features, name="earth-boundaries-china-pov-hover") + base = feature_collection( + [*admin_features, *coastline_features], + name="earth-boundaries-china-pov-base", + ) + claim_fc = feature_collection(claim_features, name="earth-boundaries-china-pov-claims") + + for path, payload in ((hover_path, hover), (base_path, base), (claim_path, claim_fc)): + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, separators=(",", ":")) + f.write("\n") + + manifest = { + "version": "china-pov-v1", + "builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(), + "tileProvider": "geojson-high-precision", + "format": "geojson", + "buildInputHash": args.build_input_hash, + "base": base_path.name, + "hoverIndex": hover_path.name, + "claimLine": claim_path.name, + "sourceFeatureCount": len(admin_features), + "coastlineFeatureCount": len(coastline_features), + "claimFeatureCount": len(claim_features), + "tiles": {"minZoom": 0, "maxZoom": 0}, + "chinaClaims": {"available": bool(claim_features), "path": claim_path.name}, + "sha256": { + "base": sha256_file(base_path), + "hoverIndex": sha256_file(hover_path), + "claimLine": sha256_file(claim_path), + }, + "povPolicy": { + "profile": "china-pov-v1", + "applied": True, + "notes": [ + "Taiwan/Penghu rendered as China.", + "Kosovo rendered as Serbia.", + "Gaza rendered within Palestine.", + "Nine-dash line rendered as a claim-line layer.", + "Zangnan/Aksai Chin require an audited polygon override package for exact subtract/union geometry.", + ], + }, + } + with manifest_path.open("w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + f.write("\n") + + print( + json.dumps( + { + "status": "success", + "provider": "geojson-high-precision", + "manifest": str(manifest_path), + "admin0_features": len(admin_features), + "coastline_features": len(coastline_features), + "claim_features": len(claim_features), + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_earth_boundary_pmtiles.py b/scripts/build_earth_boundary_pmtiles.py new file mode 100644 index 00000000..5f747773 --- /dev/null +++ b/scripts/build_earth_boundary_pmtiles.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Build the production Earth boundary PMTiles artifact from collected sources.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--admin0-source", type=Path, required=True) + parser.add_argument("--coastline-source", type=Path, required=True) + parser.add_argument("--claims-source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--build-input-hash", required=True) + parser.add_argument("--pov-policy", type=Path, default=None) + parser.add_argument("--min-zoom", type=int, default=0) + parser.add_argument("--max-zoom", type=int, default=10) + return parser.parse_args() + + +def require_tool(name: str) -> str: + path = shutil.which(name) + if not path: + raise RuntimeError(f"required external tool not found in PATH: {name}") + return path + + +def load_geojson(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + if payload.get("type") != "FeatureCollection": + raise ValueError(f"{path} must be a GeoJSON FeatureCollection") + return payload + + +def iter_layer_features(payload: dict[str, Any], layer: str) -> list[dict[str, Any]]: + out = [] + for feature in payload.get("features") or []: + if not feature.get("geometry"): + continue + props = dict(feature.get("properties") or {}) + props["tippecanoe"] = {"layer": layer} + out.append( + { + "type": "Feature", + "geometry": feature["geometry"], + "properties": props, + } + ) + return out + + +def write_ndjson(path: Path, features: list[dict[str, Any]]) -> None: + with path.open("w", encoding="utf-8") as f: + for feature in features: + f.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":"))) + f.write("\n") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> None: + args = parse_args() + tippecanoe = require_tool("tippecanoe") + pmtiles = require_tool("pmtiles") + + admin0 = load_geojson(args.admin0_source) + coastline = load_geojson(args.coastline_source) + claims = load_geojson(args.claims_source) + features = [ + *iter_layer_features(admin0, "boundary_admin0"), + *iter_layer_features(coastline, "coastline"), + *iter_layer_features(claims, "claim_line"), + ] + if not features: + raise RuntimeError("no features available for PMTiles build") + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.manifest.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="planet-earth-boundaries-") as tmp: + tmp_dir = Path(tmp) + ndjson_path = tmp_dir / "earth-boundaries.ndjson" + mbtiles_path = tmp_dir / "earth-boundaries.mbtiles" + write_ndjson(ndjson_path, features) + + subprocess.run( + [ + tippecanoe, + "--force", + "--read-parallel", + "--minimum-zoom", + str(args.min_zoom), + "--maximum-zoom", + str(args.max_zoom), + "--no-tile-compression", + "--output", + str(mbtiles_path), + str(ndjson_path), + ], + check=True, + ) + subprocess.run([pmtiles, "convert", str(mbtiles_path), str(args.output)], check=True) + + manifest = { + "version": "china-pov-v1", + "builtAt": dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat(), + "tileProvider": "pmtiles-mvt", + "format": "pmtiles+mvt", + "buildInputHash": args.build_input_hash, + "pmtiles": { + "path": os.path.relpath(args.output, args.manifest.parent), + "sha256": sha256_file(args.output), + }, + "tiles": { + "minZoom": args.min_zoom, + "maxZoom": args.max_zoom, + }, + "layers": ["boundary_admin0", "boundary_disputed_internal", "coastline", "claim_line"], + "povPolicy": { + "path": str(args.pov_policy) if args.pov_policy else None, + "applied": False, + "note": "This builder packages collected sources. Full China POV geometry overlay is still a source-prep responsibility.", + }, + "base": None, + "hoverIndex": None, + } + with args.manifest.open("w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + f.write("\n") + + print( + json.dumps( + { + "status": "success", + "output": str(args.output), + "features": len(features), + "format": "pmtiles+mvt", + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_earth_boundary_production_ready.py b/scripts/check_earth_boundary_production_ready.py new file mode 100644 index 00000000..45e81aca --- /dev/null +++ b/scripts/check_earth_boundary_production_ready.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Check whether the Earth boundary pipeline is ready for PMTiles production. + +This is intentionally a verifier, not a fake builder. It fails when the repo is +still using seed data or when the external MVT/PMTiles toolchain is absent. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path +from typing import Any + + +DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json") +DEFAULT_ARTIFACT = Path("frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles") + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + if not isinstance(payload, dict): + raise ValueError(f"{path} must contain a JSON object") + return payload + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT) + args = parser.parse_args() + + config = load_json(args.config) + policy = config.get("policy") or {} + collector_configs = config.get("collectorConfigs") or {} + failures: list[str] = [] + warnings: list[str] = [] + + if policy.get("productionTileFormat") != "pmtiles+mvt": + failures.append("policy.productionTileFormat must be pmtiles+mvt") + if not policy.get("povPolicyPath"): + failures.append("policy.povPolicyPath is required") + required_kinds = {"admin0-boundaries", "coastline", "claim-lines"} + configured_kinds = {str(source.get("sourceKind")) for source in collector_configs.values()} + missing_kinds = sorted(required_kinds - configured_kinds) + if missing_kinds: + failures.append(f"missing production source kind(s): {', '.join(missing_kinds)}") + for collector_name, source in collector_configs.items(): + endpoint = str(source.get("endpoint") or "") + if not endpoint or endpoint.startswith("https://example.com/"): + failures.append(f"{collector_name} endpoint must be configured to an audited production source") + + for tool in ("tippecanoe", "pmtiles"): + if shutil.which(tool) is None: + failures.append(f"external tool not found in PATH: {tool}") + + if not args.artifact.exists(): + warnings.append(f"PMTiles artifact does not exist yet: {args.artifact}") + + result = { + "ready": not failures, + "failures": failures, + "warnings": warnings, + "artifact": str(args.artifact), + } + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if not failures else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/collect_earth_boundary_sources.py b/scripts/collect_earth_boundary_sources.py new file mode 100644 index 00000000..56576068 --- /dev/null +++ b/scripts/collect_earth_boundary_sources.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Collect versioned source files for the Earth boundary tile pipeline. + +This is an offline ingestion helper, not a runtime service. It downloads or +copies declared source packages, verifies optional sha256 checksums, and writes +a manifest that the tile builder can reference during audited rebuilds. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import urllib.request +from pathlib import Path +from typing import Any + + +DEFAULT_CONFIG = Path("config/earth-boundary-sources.example.json") +DEFAULT_OUTPUT = Path("data/earth-boundary-sources") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args() + + +def load_config(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + if not isinstance(payload.get("sources"), list): + raise ValueError(f"{path} must contain a sources array") + return payload + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_filename(source: dict[str, Any]) -> str: + name = str(source.get("name") or source.get("id") or "source") + filename = source.get("filename") + if filename: + return Path(str(filename)).name + suffix = Path(str(source.get("url") or source.get("path") or name)).suffix + cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "-" for ch in name).strip("-") + return f"{cleaned or 'source'}{suffix or '.dat'}" + + +def collect_source(source: dict[str, Any], output_dir: Path) -> dict[str, Any]: + source_id = source.get("id") + if not source_id: + raise ValueError("each source requires an id") + target = output_dir / safe_filename(source) + target.parent.mkdir(parents=True, exist_ok=True) + + if source.get("url"): + request = urllib.request.Request( + str(source["url"]), + headers={"User-Agent": "planet-earth-boundary-source-collector/1.0"}, + ) + with urllib.request.urlopen(request, timeout=60) as response, target.open("wb") as f: + shutil.copyfileobj(response, f) + elif source.get("path"): + shutil.copy2(Path(str(source["path"])).expanduser(), target) + else: + raise ValueError(f"source {source_id} requires url or path") + + actual_sha256 = sha256_file(target) + expected_sha256 = source.get("sha256") + if expected_sha256 and actual_sha256.lower() != str(expected_sha256).lower(): + target.unlink(missing_ok=True) + raise ValueError( + f"source {source_id} sha256 mismatch: expected {expected_sha256}, got {actual_sha256}" + ) + + return { + "id": source_id, + "name": source.get("name") or source_id, + "kind": source.get("kind") or "unknown", + "license": source.get("license"), + "pov": source.get("pov"), + "path": str(target), + "sha256": actual_sha256, + "source": source.get("url") or source.get("path"), + "notes": source.get("notes"), + } + + +def collect_policy(config: dict[str, Any]) -> dict[str, Any] | None: + policy = config.get("policy") or {} + policy_path_value = policy.get("povPolicyPath") + if not policy_path_value: + return None + + policy_path = Path(str(policy_path_value)).expanduser() + if not policy_path.exists(): + raise FileNotFoundError(f"POV policy file not found: {policy_path}") + + with policy_path.open("r", encoding="utf-8") as f: + policy_payload = json.load(f) + + return { + "profile": policy_payload.get("profile") or policy.get("profile"), + "path": str(policy_path), + "sha256": sha256_file(policy_path), + "schema": policy_payload.get("schema"), + "ruleCount": len(policy_payload.get("rules") or []), + "productionTileFormat": policy.get("productionTileFormat"), + "debugTileFormat": policy.get("debugTileFormat"), + } + + +def main() -> None: + args = parse_args() + config = load_config(args.config) + args.output.mkdir(parents=True, exist_ok=True) + collected = [collect_source(source, args.output) for source in config["sources"]] + pov_policy = collect_policy(config) + manifest = { + "schema": "planet-earth-boundary-sources/v1", + "sources": collected, + "povPolicy": pov_policy, + "policy": config.get("policy", {}), + } + manifest_path = args.output / "manifest.json" + with manifest_path.open("w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + f.write("\n") + print(json.dumps({"manifest": str(manifest_path), "sources": len(collected)}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/windows-lan-relay.ps1 b/scripts/windows-lan-relay.ps1 deleted file mode 100644 index f9988c9d..00000000 --- a/scripts/windows-lan-relay.ps1 +++ /dev/null @@ -1,103 +0,0 @@ -param( - [string]$ListenAddress = "0.0.0.0", - [Parameter(Mandatory = $true)][int]$ListenPort, - [Parameter(Mandatory = $true)][string]$TargetHost, - [Parameter(Mandatory = $true)][int]$TargetPort, - [int]$IdleExitSeconds = 8 -) - -$ErrorActionPreference = "Stop" - -Add-Type -TypeDefinition @" -using System; -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; - -public static class PlanetTcpRelay -{ - public static async Task RunAsync(string listenAddress, int listenPort, string targetHost, int targetPort, int idleExitSeconds) - { - IPAddress address; - if (!IPAddress.TryParse(listenAddress, out address)) - { - address = IPAddress.Any; - } - var listener = new TcpListener(address, listenPort); - listener.Start(); - var lastHealthyAt = DateTime.UtcNow; - - try - { - while (true) - { - if (await CanConnectAsync(targetHost, targetPort)) - { - lastHealthyAt = DateTime.UtcNow; - } - else if ((DateTime.UtcNow - lastHealthyAt).TotalSeconds >= idleExitSeconds) - { - break; - } - - while (listener.Pending()) - { - var client = await listener.AcceptTcpClientAsync(); - _ = Task.Run(() => HandleClientAsync(client, targetHost, targetPort)); - } - - await Task.Delay(200); - } - } - finally - { - listener.Stop(); - } - } - - private static async Task CanConnectAsync(string host, int port) - { - using (var client = new TcpClient()) - { - var connectTask = client.ConnectAsync(host, port); - var completed = await Task.WhenAny(connectTask, Task.Delay(500)); - if (completed != connectTask) - { - return false; - } - - try - { - await connectTask; - return client.Connected; - } - catch - { - return false; - } - } - } - - private static async Task HandleClientAsync(TcpClient client, string targetHost, int targetPort) - { - using (client) - using (var upstream = new TcpClient()) - { - try - { - await upstream.ConnectAsync(targetHost, targetPort); - var clientStream = client.GetStream(); - var upstreamStream = upstream.GetStream(); - var toUpstream = clientStream.CopyToAsync(upstreamStream); - var toClient = upstreamStream.CopyToAsync(clientStream); - await Task.WhenAny(toUpstream, toClient); - } - catch - { - } - } - } -} -"@ - -[PlanetTcpRelay]::RunAsync($ListenAddress, $ListenPort, $TargetHost, $TargetPort, $IdleExitSeconds).GetAwaiter().GetResult() diff --git a/uv.lock b/uv.lock index 53fd86c6..e3c5de03 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.57.0" +version = "0.58.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From 9b913a3b83489e60527c929932245db6008dfefa Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sat, 16 May 2026 05:02:05 +0800 Subject: [PATCH 10/19] release: bump version to 0.59.0 --- .gitignore | 1 + VERSION | 2 +- aiprovider/config.py | 3 - aiprovider/provider_service.py | 55 +- aiprovider/schemas.py | 3 +- backend/app/ai_tasks/__init__.py | 2 + backend/app/ai_tasks/default_prompts.json | 74 ++ backend/app/ai_tasks/prompts.py | 182 +++++ backend/app/api/main.py | 2 + backend/app/api/v1/bgp.py | 1 + backend/app/api/v1/datasource_config.py | 28 +- backend/app/api/v1/datasources.py | 1 - backend/app/api/v1/earth.py | 118 +++ backend/app/api/v1/settings.py | 66 +- backend/app/api/v1/visualization.py | 1 + backend/app/core/datasource_defaults.py | 40 -- backend/app/core/earth_boundary_defaults.py | 49 -- backend/app/core/target_schema_registry.py | 30 - backend/app/db/session.py | 99 ++- backend/app/models/earth_news.py | 5 + backend/app/schemas/ai.py | 3 +- backend/app/services/alert_ai_brief.py | 7 +- backend/app/services/bgp_ai_brief.py | 7 +- backend/app/services/collectors/__init__.py | 14 - .../services/collectors/earth_boundaries.py | 578 --------------- backend/app/services/credential_guides.py | 10 +- backend/app/services/datasource_mapping.py | 46 -- backend/app/services/earth_boundaries.py | 671 ++++++++++++++++++ backend/app/services/earth_news.py | 289 ++++++-- backend/app/services/earth_news_queue.py | 12 +- backend/app/services/earth_news_store.py | 60 +- backend/app/services/earth_news_worker.py | 15 +- backend/app/services/location/llm_fallback.py | 23 +- .../services/situational_alert_ai_brief.py | 7 +- backend/tests/test_earth_boundaries.py | 177 +++++ backend/tests/test_earth_news.py | 136 +++- backend/tests/test_settings_ai_prompts.py | 92 +++ config/earth-boundary-sources.example.json | 21 +- docs/CHANGELOG.md | 17 + .../ai-prompt-settings-task-registry-plan.md | 52 ++ docs/plans/docs-audience-split-plan.md | 4 +- ...arth-high-precision-boundary-tiles-plan.md | 22 +- docs/technical/en/agents-aiprovider.md | 10 +- docs/technical/en/backend-collectors.md | 16 +- ...asource-collector-settings-connectivity.md | 14 +- docs/technical/en/earth-frontend-context.md | 2 + .../en/earth-layer-style-reference.md | 5 +- ...arth-news-live-streams-collector-format.md | 2 +- docs/technical/en/faq.md | 15 +- .../en/frontend-admin-frontend-context.md | 38 + docs/technical/en/manual.md | 39 +- docs/technical/en/ops-runbook.md | 13 +- docs/technical/en/quickstart.md | 4 +- docs/technical/zh/agents-aiprovider.md | 10 +- docs/technical/zh/backend-collectors.md | 18 +- ...asource-collector-settings-connectivity.md | 14 +- docs/technical/zh/earth-frontend-context.md | 3 + .../zh/earth-layer-style-reference.md | 5 +- ...arth-news-live-streams-collector-format.md | 2 +- docs/technical/zh/faq.md | 15 +- .../zh/frontend-admin-frontend-context.md | 18 +- docs/technical/zh/manual.md | 39 +- docs/technical/zh/ops-runbook.md | 13 +- docs/technical/zh/quickstart.md | 4 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 171 ++++- .../earth/data/countries-admin0.min.geojson | 1 + frontend/public/earth/index.html | 26 + frontend/public/earth/js/constants.js | 1 + frontend/public/earth/js/controls.js | 223 +++++- .../public/earth/js/country-boundaries.js | 125 +++- frontend/public/earth/js/info-card.js | 28 +- frontend/public/earth/js/main.js | 77 +- .../public/earth/js/news-cruise-adapter.js | 28 +- frontend/public/earth/js/news-locale.js | 107 +++ frontend/public/earth/js/news.js | 46 +- frontend/public/earth/js/satellites.js | 23 +- frontend/src/App.tsx | 2 + .../src/components/AppLayout/AppLayout.tsx | 5 +- frontend/src/pages/AISettings/AISettings.tsx | 175 ++++- .../src/pages/DataSources/DataSources.tsx | 16 +- frontend/src/pages/Settings/Settings.tsx | 323 +++++++-- planet.sh | 133 +++- pyproject.toml | 2 +- uv.lock | 2 +- 86 files changed, 3645 insertions(+), 1198 deletions(-) create mode 100644 backend/app/ai_tasks/__init__.py create mode 100644 backend/app/ai_tasks/default_prompts.json create mode 100644 backend/app/ai_tasks/prompts.py create mode 100644 backend/app/api/v1/earth.py delete mode 100644 backend/app/core/earth_boundary_defaults.py delete mode 100644 backend/app/services/collectors/earth_boundaries.py create mode 100644 backend/app/services/earth_boundaries.py create mode 100644 backend/tests/test_earth_boundaries.py create mode 100644 backend/tests/test_settings_ai_prompts.py create mode 100644 docs/plans/ai-prompt-settings-task-registry-plan.md create mode 100644 frontend/public/earth/data/countries-admin0.min.geojson create mode 100644 frontend/public/earth/js/news-locale.js diff --git a/.gitignore b/.gitignore index ce4fe7fc..7f7520e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .env .env.local .env.*.local +config/earth-boundary-sources.local.json *.pem *.key *.crt diff --git a/VERSION b/VERSION index a60476bf..cb6b534a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.58.0 +0.59.0 diff --git a/aiprovider/config.py b/aiprovider/config.py index b9c1e218..d5adad21 100644 --- a/aiprovider/config.py +++ b/aiprovider/config.py @@ -17,9 +17,6 @@ class Settings(BaseSettings): AI_HTTP_RETRY_ATTEMPTS: int = 2 AI_MAX_TOKENS: int = 1200 AI_ANTHROPIC_VERSION: str = "2023-06-01" - AI_ANALYSIS_SYSTEM_PROMPT: str = ( - "你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。" - ) AI_PROVIDER_SERVICE_TOKEN: str = "" diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index a7021f98..35b6f4b5 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -62,7 +62,6 @@ class ProviderService: self.anthropic_version = str( overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION ) - self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT def get_status(self) -> AIProviderStatusResponse: enabled = self.provider != "disabled" @@ -95,15 +94,20 @@ class ProviderService: prompt = self._build_prompt(payload) if self.provider_api == "openai-completions": - data = await self._request_openai_compatible(model, prompt) + data = await self._request_openai_compatible(model, prompt, payload.system_prompt) content = self._extract_openai_content(data) content_blocks = self._extract_openai_blocks(data) elif self.provider_api == "anthropic-messages": - data = await self._request_anthropic_messages(model, prompt, payload.thinking) + data = await self._request_anthropic_messages( + model, + prompt, + payload.thinking, + payload.system_prompt, + ) content = self._extract_anthropic_content(data) content_blocks = self._extract_anthropic_blocks(data) elif self.provider_api == "ollama-generate": - data = await self._request_ollama(model, prompt) + data = await self._request_ollama(model, prompt, payload.system_prompt) content = self._extract_ollama_content(data) content_blocks = self._extract_ollama_blocks(data) else: @@ -139,18 +143,26 @@ class ProviderService: sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints)) if payload.context: sections.append(f"附加上下文:\n{payload.context}") - sections.append( - "请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。" - ) return "\n\n".join(sections) - async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]: + def _resolve_system_prompt(self, system_prompt: str | None) -> str | None: + resolved = str(system_prompt or "").strip() + return resolved or None + + async def _request_openai_compatible( + self, + model: str, + prompt: str, + system_prompt: str | None = None, + ) -> dict[str, Any]: + messages = [] + resolved_system_prompt = self._resolve_system_prompt(system_prompt) + if resolved_system_prompt: + messages.append({"role": "system", "content": resolved_system_prompt}) + messages.append({"role": "user", "content": prompt}) request_body = { "model": model, - "messages": [ - {"role": "system", "content": self.system_prompt}, - {"role": "user", "content": prompt}, - ], + "messages": messages, "temperature": 0.2, "max_tokens": self.max_tokens, } @@ -168,10 +180,10 @@ class ProviderService: model: str, prompt: str, thinking: dict[str, Any] | None = None, + system_prompt: str | None = None, ) -> dict[str, Any]: request_body = { "model": model, - "system": self.system_prompt, "messages": [ { "role": "user", @@ -186,6 +198,9 @@ class ProviderService: "max_tokens": self.max_tokens, "temperature": 0.2, } + resolved_system_prompt = self._resolve_system_prompt(system_prompt) + if resolved_system_prompt: + request_body["system"] = resolved_system_prompt resolved_thinking = self._resolve_anthropic_thinking(thinking) if resolved_thinking: request_body["thinking"] = resolved_thinking @@ -219,20 +234,28 @@ class ProviderService: model: str, prompt: str, thinking: dict[str, Any] | None = None, + system_prompt: str | None = None, ) -> dict[str, Any]: - return await self._request_anthropic_messages(model, prompt, thinking) + return await self._request_anthropic_messages(model, prompt, thinking, system_prompt) - async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]: + async def _request_ollama( + self, + model: str, + prompt: str, + system_prompt: str | None = None, + ) -> dict[str, Any]: request_body = { "model": model, "stream": False, - "system": self.system_prompt, "prompt": prompt, "options": { "temperature": 0.2, "num_predict": self.max_tokens, }, } + resolved_system_prompt = self._resolve_system_prompt(system_prompt) + if resolved_system_prompt: + request_body["system"] = resolved_system_prompt return await self._post( path="/api/generate", headers={ diff --git a/aiprovider/schemas.py b/aiprovider/schemas.py index 27d1046e..3cc61395 100644 --- a/aiprovider/schemas.py +++ b/aiprovider/schemas.py @@ -13,10 +13,11 @@ class AIContentBlock(BaseModel): class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) - objective: str = Field(..., min_length=1, max_length=1000) + objective: str = Field(..., min_length=1, max_length=20000) context: dict[str, Any] = Field(default_factory=dict) observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) + system_prompt: str | None = Field(default=None, max_length=8000) preferred_model: str | None = Field(default=None, max_length=200) thinking: dict[str, Any] | None = None diff --git a/backend/app/ai_tasks/__init__.py b/backend/app/ai_tasks/__init__.py new file mode 100644 index 00000000..9ddd53ef --- /dev/null +++ b/backend/app/ai_tasks/__init__.py @@ -0,0 +1,2 @@ +"""AI task prompt registry and runtime helpers.""" + diff --git a/backend/app/ai_tasks/default_prompts.json b/backend/app/ai_tasks/default_prompts.json new file mode 100644 index 00000000..c461fc08 --- /dev/null +++ b/backend/app/ai_tasks/default_prompts.json @@ -0,0 +1,74 @@ +[ + { + "key": "earth.news.enrich", + "label": "Earth 新闻汉化与定位", + "group": "Earth 新闻", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title and summary based only on the supplied RSS headline, description, source, and date." + }, + { + "key": "alerts.brief", + "label": "系统告警研判", + "group": "告警研判", + "version": "2026-05-16.1", + "system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。", + "prompt": "基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。" + }, + { + "key": "alerts.situational.brief", + "label": "跨模块态势告警研判", + "group": "告警研判", + "version": "2026-05-16.1", + "system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。", + "prompt": "综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。" + }, + { + "key": "bgp.brief", + "label": "BGP 态势简报", + "group": "BGP", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。" + }, + { + "key": "location.factcheck.normalize", + "label": "位置事实核查结构化", + "group": "位置解析", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "Convert the supplied location factcheck text into exactly one strict JSON object. Extract only facts present in the text or original query." + }, + { + "key": "location.factcheck.resolve", + "label": "位置事实核查兜底", + "group": "位置解析", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "Return exactly one JSON object for the most likely physical location. Use only fact-checkable public knowledge; return null fields rather than guessing when evidence is weak." + }, + { + "key": "datasource.mapping", + "label": "数据源映射生成", + "group": "采集配置", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "Return only JSON for a deterministic mapping DSL. The JSON must contain source.items_path and fields. Do not include prose or code." + }, + { + "key": "credential.guide", + "label": "采集器凭据教程", + "group": "采集配置", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "生成一份中文采集器凭据配置教程。只能根据 context.search_evidence 中的来源生成教程;如果证据不足,明确说明需要以官方页面为准。" + }, + { + "key": "ai.connection_test", + "label": "AI Provider 连接测试", + "group": "运维测试", + "version": "2026-05-16.1", + "system_prompt": "", + "prompt": "Reply OK." + } +] diff --git a/backend/app/ai_tasks/prompts.py b/backend/app/ai_tasks/prompts.py new file mode 100644 index 00000000..3952c9a6 --- /dev/null +++ b/backend/app/ai_tasks/prompts.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.system_setting import SystemSetting + +AI_PROMPTS_CATEGORY = "ai_prompts" +DEFAULT_PROMPTS_PATH = Path(__file__).with_name("default_prompts.json") + + +@dataclass(frozen=True) +class AIPromptDefinition: + key: str + label: str + group: str + version: str + system_prompt: str + prompt: str + + +@dataclass(frozen=True) +class EffectiveAIPrompt: + key: str + label: str + group: str + version: str + default_system_prompt: str + default_prompt: str + system_prompt: str + prompt: str + is_custom: bool + updated_at: str | None = None + + +@lru_cache(maxsize=1) +def list_prompt_definitions() -> tuple[AIPromptDefinition, ...]: + raw_items = json.loads(DEFAULT_PROMPTS_PATH.read_text(encoding="utf-8")) + return tuple( + AIPromptDefinition( + key=str(item["key"]), + label=str(item["label"]), + group=str(item["group"]), + version=str(item["version"]), + system_prompt=str(item.get("system_prompt") or ""), + prompt=str(item.get("prompt") or ""), + ) + for item in raw_items + ) + + +def get_prompt_definition(task_key: str) -> AIPromptDefinition: + for definition in list_prompt_definitions(): + if definition.key == task_key: + return definition + raise KeyError(task_key) + + +async def _get_prompt_setting(db: AsyncSession) -> SystemSetting | None: + result = await db.execute( + select(SystemSetting).where(SystemSetting.category == AI_PROMPTS_CATEGORY) + ) + return result.scalar_one_or_none() + + +def _normalize_overrides(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + raw = (payload or {}).get("overrides") + if not isinstance(raw, dict): + return {} + return { + str(key): dict(value) + for key, value in raw.items() + if isinstance(value, dict) + } + + +async def get_prompt_overrides(db: AsyncSession) -> dict[str, dict[str, Any]]: + if not hasattr(db, "execute"): + return {} + setting = await _get_prompt_setting(db) + return _normalize_overrides(setting.payload if setting else None) + + +def _effective_prompt( + definition: AIPromptDefinition, + override: dict[str, Any] | None, +) -> EffectiveAIPrompt: + override = override or {} + custom_system = override.get("system_prompt") + custom_prompt = override.get("prompt") + has_custom_system = isinstance(custom_system, str) + has_custom_prompt = isinstance(custom_prompt, str) + return EffectiveAIPrompt( + key=definition.key, + label=definition.label, + group=definition.group, + version=definition.version, + default_system_prompt=definition.system_prompt, + default_prompt=definition.prompt, + system_prompt=custom_system if has_custom_system else definition.system_prompt, + prompt=custom_prompt if has_custom_prompt else definition.prompt, + is_custom=has_custom_system or has_custom_prompt, + updated_at=str(override.get("updated_at") or "") or None, + ) + + +async def list_effective_prompts(db: AsyncSession) -> list[EffectiveAIPrompt]: + overrides = await get_prompt_overrides(db) + return [ + _effective_prompt(definition, overrides.get(definition.key)) + for definition in list_prompt_definitions() + ] + + +async def get_effective_prompt(db: AsyncSession | None, task_key: str) -> EffectiveAIPrompt: + definition = get_prompt_definition(task_key) + if db is None: + return _effective_prompt(definition, None) + overrides = await get_prompt_overrides(db) + return _effective_prompt(definition, overrides.get(task_key)) + + +async def save_prompt_override( + db: AsyncSession, + task_key: str, + *, + system_prompt: str, + prompt: str, +) -> EffectiveAIPrompt: + definition = get_prompt_definition(task_key) + setting = await _get_prompt_setting(db) + payload = dict(setting.payload or {}) if setting else {} + overrides = _normalize_overrides(payload) + overrides[definition.key] = { + "system_prompt": system_prompt, + "prompt": prompt, + "updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + payload["overrides"] = overrides + if setting is None: + setting = SystemSetting(category=AI_PROMPTS_CATEGORY, payload=payload) + db.add(setting) + else: + setting.payload = payload + await db.commit() + return _effective_prompt(definition, overrides[definition.key]) + + +async def reset_prompt_override(db: AsyncSession, task_key: str) -> EffectiveAIPrompt: + definition = get_prompt_definition(task_key) + setting = await _get_prompt_setting(db) + if setting is None: + return _effective_prompt(definition, None) + payload = dict(setting.payload or {}) + overrides = _normalize_overrides(payload) + overrides.pop(definition.key, None) + payload["overrides"] = overrides + setting.payload = payload + await db.commit() + return _effective_prompt(definition, None) + + +def serialize_effective_prompt(prompt: EffectiveAIPrompt) -> dict[str, Any]: + return { + "key": prompt.key, + "label": prompt.label, + "group": prompt.group, + "version": prompt.version, + "default_system_prompt": prompt.default_system_prompt, + "default_prompt": prompt.default_prompt, + "system_prompt": prompt.system_prompt, + "prompt": prompt.prompt, + "is_custom": prompt.is_custom, + "updated_at": prompt.updated_at, + } diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 1ed5c9f5..9a4b3de8 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -6,6 +6,7 @@ from app.api.v1 import ( datasource_config, datasources, docs, + earth, tasks, dashboard, alerts, @@ -34,6 +35,7 @@ api_router.include_router( api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"]) api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"]) api_router.include_router(docs.router, prefix="/docs", tags=["docs"]) +api_router.include_router(earth.router, prefix="/earth", tags=["earth"]) api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"]) diff --git a/backend/app/api/v1/bgp.py b/backend/app/api/v1/bgp.py index 1b95160e..b10c1aac 100644 --- a/backend/app/api/v1/bgp.py +++ b/backend/app/api/v1/bgp.py @@ -341,6 +341,7 @@ async def collect_bgp_collector_location( provider_client=provider_client, query=query, entity_type="bgp_collector", + db=db, attempted_queries=attempted_queries, search_evidence=search_result.evidence, ) diff --git a/backend/app/api/v1/datasource_config.py b/backend/app/api/v1/datasource_config.py index 852d4919..cc24cb21 100644 --- a/backend/app/api/v1/datasource_config.py +++ b/backend/app/api/v1/datasource_config.py @@ -13,11 +13,6 @@ import httpx from app.core.target_schema_registry import get_target_schema, list_target_schemas from app.core.datasource_defaults import DEFAULT_DATASOURCES -from app.core.earth_boundary_defaults import ( - EARTH_BOUNDARY_DEFAULT_SOURCES, - EARTH_BOUNDARY_SOURCE_MAPPING, - default_earth_boundary_config, -) from app.db.session import get_db from app.models.user import User from app.models.datasource_config import DataSourceConfig @@ -27,6 +22,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth from app.core.security import get_current_user from app.core.cache import cache from app.core.time import to_iso8601_utc +from app.ai_tasks.prompts import get_effective_prompt from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient, get_ai_provider_client from app.services.datasource_mapping import ( @@ -46,6 +42,8 @@ from app.services.custom_datasource_runtime import ( stop_custom_stream, test_websocket_config, ) + +DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping" from app.services.datasource_connectivity import ( get_builtin_connection_status, save_connectivity_success, @@ -56,22 +54,12 @@ from app.services.datasource_connectivity import ( router = APIRouter() def _default_builtin_config(name: str) -> dict[str, Any]: - if name in {"earth_admin0_boundaries", "earth_coastline", "earth_claim_lines"}: - return default_earth_boundary_config(name) - if name == "earth_boundary_tiles": - return { - "timeout": 30, - "retry": 0, - "input_mode": "latest_collected_earth_boundary_sources", - } return {"timeout": 30, "retry": 3} def _default_builtin_source_type(name: str) -> str: if name == "aisstream_vessels": return "websocket" - if name == "earth_boundary_tiles": - return "internal" return "http" @@ -400,7 +388,7 @@ async def list_all_datasources( yaml_url = config.get_yaml_url(name) db_config = db_configs.get(name) default_config = _default_builtin_config(name) - default_url = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name, {}).get("endpoint") or yaml_url + default_url = yaml_url result.append( { @@ -783,14 +771,12 @@ async def propose_datasource_mapping( generated_by = "heuristic" if payload.use_ai: try: + prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY) response = await ai_client.analyze( SituationalAnalysisRequest( title=f"Generate datasource mapping for {schema.key}", - objective=( - "Return only JSON for a deterministic mapping DSL. " - "The JSON must contain source.items_path and fields. " - "Do not include prose or code." - ), + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, context={ "target_schema": schema.to_dict(), "sample_payload": redacted_sample, diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 1aa7d301..a1fd9598 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -35,7 +35,6 @@ PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("cables", ("cable", "landing", "telegeography", "arcgis", "fao")), ("satellites", ("tle", "satellite", "spacetrack", "celestrak")), ("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")), - ("earth", ("earth_", "earth_boundary", "boundary", "country boundary", "coastline", "claim")), ("compute", ("top500", "gpu", "supercomputer", "compute")), ("ai", ("huggingface", "epoch_ai")), ("media", ("news", "tv", "live_stream")), diff --git a/backend/app/api/v1/earth.py b/backend/app/api/v1/earth.py new file mode 100644 index 00000000..308c9006 --- /dev/null +++ b/backend/app/api/v1/earth.py @@ -0,0 +1,118 @@ +"""Earth asset management APIs.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_token, get_current_user, redis_client +from app.db.session import get_db +from app.models.user import User +from app.services.earth_boundaries import ( + EarthBoundaryBuildError, + get_boundary_build_status, + get_boundary_status, + save_boundary_config, + start_boundary_build_job, +) + + +router = APIRouter() +optional_bearer = HTTPBearer(auto_error=False) + + +class EarthBoundaryConfigPayload(BaseModel): + config: dict[str, Any] = Field(default_factory=dict) + + +@router.get("/boundaries/status") +async def get_earth_boundary_status(): + return get_boundary_status() + + +async def _get_optional_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), + db: AsyncSession = Depends(get_db), +) -> User | None: + if credentials is None: + return None + token = credentials.credentials + if redis_client.sismember("blacklisted_tokens", token): + return None + payload = decode_token(token) + if payload is None or payload.get("type") != "access": + return None + user_id = payload.get("sub") + if user_id is None: + return None + result = await db.execute( + text( + "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id" + ), + {"id": int(user_id)}, + ) + row = result.fetchone() + if row is None or not row[5]: + return None + user = User() + user.id = row[0] + user.username = row[1] + user.email = row[2] + user.password_hash = row[3] + user.role = row[4] + user.is_active = row[5] + user.gatekeeper_groups = row[6] or [] + return user + + +def _is_loopback_request(request: Request) -> bool: + host = request.client.host if request.client else "" + return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") + + +def _require_local_or_user(request: Request, user: User | None) -> None: + if user is not None or _is_loopback_request(request): + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required outside localhost", + ) + + +@router.put("/boundaries/config") +async def update_earth_boundary_config( + payload: EarthBoundaryConfigPayload, + _current_user: User = Depends(get_current_user), +): + try: + return save_boundary_config(payload.config) + except EarthBoundaryBuildError as exc: + raise HTTPException( + status_code=400, + detail={"code": exc.code, "message": str(exc), "details": exc.details}, + ) from exc + + +@router.post("/boundaries/build") +async def build_earth_boundary_assets( + request: Request, + current_user: User | None = Depends(_get_optional_current_user), +): + _require_local_or_user(request, current_user) + try: + return await start_boundary_build_job() + except EarthBoundaryBuildError as exc: + raise HTTPException( + status_code=400, + detail={"code": exc.code, "message": str(exc), "details": exc.details}, + ) from exc + + +@router.get("/boundaries/build/status") +async def get_earth_boundary_build_status(): + return get_boundary_build_status() diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index db03cf1c..322162ba 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -15,6 +15,13 @@ from app.core.time import to_iso8601_utc from app.core.config import settings as app_settings from app.core.data_sources import get_data_sources_config from app.core.datasource_defaults import DEFAULT_DATASOURCES +from app.ai_tasks.prompts import ( + get_effective_prompt, + list_effective_prompts, + reset_prompt_override, + save_prompt_override, + serialize_effective_prompt, +) from app.db.session import get_db from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig @@ -60,6 +67,7 @@ from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload router = APIRouter() AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5 +AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test" DEFAULT_SETTINGS = { "system": { @@ -251,6 +259,11 @@ class OCRIntegrationUpdate(BaseModel): output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$") +class AIPromptUpdate(BaseModel): + system_prompt: str = Field(default="", max_length=8000) + prompt: str = Field(min_length=1, max_length=20000) + + class ExternalIntegrationsUpdate(BaseModel): ai_provider: AIProviderIntegrationUpdate barentswatch: BarentsWatchIntegrationUpdate @@ -487,6 +500,10 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b return True if text == current_preview or text.startswith("••••"): return True + if "-" in text: + _prefix, masked = text.split("-", 1) + if masked and all(char in {"*", "•", " ", "\t"} for char in masked): + return True return all(char in {"*", "•", " ", "\t"} for char in text) @@ -597,10 +614,12 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict: status_code=400, detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。", ) + prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY) analysis_result = await client.analyze( SituationalAnalysisRequest( title="保存前完整连接测试", - objective="请用一句话回复连接可用。", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"], constraints=["回复尽量简短。"], ) @@ -1212,6 +1231,47 @@ async def get_external_integrations( return {"integrations": await serialize_external_integrations(db)} +@router.get("/ai-prompts") +async def get_ai_prompts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + prompts = await list_effective_prompts(db) + return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]} + + +@router.put("/ai-prompts/{task_key}") +async def update_ai_prompt( + task_key: str, + payload: AIPromptUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + prompt = await save_prompt_override( + db, + task_key, + system_prompt=payload.system_prompt, + prompt=payload.prompt, + ) + except KeyError: + raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None + return {"data": serialize_effective_prompt(prompt)} + + +@router.post("/ai-prompts/{task_key}/reset") +async def reset_ai_prompt( + task_key: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + prompt = await reset_prompt_override(db, task_key) + except KeyError: + raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None + return {"data": serialize_effective_prompt(prompt)} + + @router.get("/integrations/barentswatch/connectivity") async def get_barentswatch_connectivity( current_user: User = Depends(get_current_user), @@ -1294,10 +1354,12 @@ async def connect_ai_provider_integration( "message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。", "status": status_result.model_dump(), } + prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY) probe_result = await client.analyze( SituationalAnalysisRequest( title="快速连接测试", - objective="Reply OK.", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, observations=[], constraints=["Output only OK."], ) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index e8cac588..c6e6abc9 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -1982,6 +1982,7 @@ async def collect_compute_center_location( provider_client=provider_client, query=query, entity_type="compute_center", + db=db, attempted_queries=attempted_queries, search_evidence=search_result.evidence, ) diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py index a621f319..4d28a55e 100644 --- a/backend/app/core/datasource_defaults.py +++ b/backend/app/core/datasource_defaults.py @@ -257,46 +257,6 @@ DEFAULT_DATASOURCES = { "credential_provider": "aisstream", "credential_status": "supported", }, - "earth_admin0_boundaries": { - "id": 29, - "name": "Earth Admin-0 Boundaries", - "display_name": "Earth Admin-0 国界源", - "module": "L3", - "priority": "P1", - "frequency_minutes": 10080, - "is_free": True, - "requires_credentials": False, - }, - "earth_coastline": { - "id": 30, - "name": "Earth Coastline", - "display_name": "Earth 海岸线源", - "module": "L3", - "priority": "P1", - "frequency_minutes": 10080, - "is_free": True, - "requires_credentials": False, - }, - "earth_claim_lines": { - "id": 31, - "name": "Earth Claim Lines", - "display_name": "Earth 主张线源", - "module": "L3", - "priority": "P1", - "frequency_minutes": 10080, - "is_free": True, - "requires_credentials": False, - }, - "earth_boundary_tiles": { - "id": 32, - "name": "Earth PMTiles Builder", - "display_name": "Earth PMTiles 构建器", - "module": "L3", - "priority": "P1", - "frequency_minutes": 10080, - "is_free": True, - "requires_credentials": False, - }, "media_news_archive": { "id": 33, "name": "Media News Archive", diff --git a/backend/app/core/earth_boundary_defaults.py b/backend/app/core/earth_boundary_defaults.py deleted file mode 100644 index 67d2ba6a..00000000 --- a/backend/app/core/earth_boundary_defaults.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Default Earth boundary source configuration.""" - -from __future__ import annotations - -from typing import Any - - -EARTH_BOUNDARY_SOURCE_MAPPING: dict[str, Any] = { - "source": {"items_path": "$.features[*]"}, - "fields": { - "source_id": {"path": "$.properties.id", "type": "string"}, - "name": {"path": "$.properties.name", "type": "string"}, - "geometry": {"path": "$.geometry", "type": "object"}, - "properties": {"path": "$.properties", "type": "object"}, - }, -} - - -EARTH_BOUNDARY_DEFAULT_SOURCES: dict[str, dict[str, Any]] = { - "earth_admin0_boundaries": { - "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson", - "source_kind": "admin0-boundaries", - "license": "Natural Earth public domain", - }, - "earth_coastline": { - "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson", - "source_kind": "coastline", - "license": "Natural Earth public domain", - }, - "earth_claim_lines": { - "endpoint": "https://www.arcgis.com/sharing/rest/content/items/faaa1908c3ab43f0823c6fde9f18389c/data", - "source_kind": "claim-lines", - "license": "CC BY 4.0; source item owner mapmakersami", - }, -} - - -def default_earth_boundary_config(name: str) -> dict[str, Any]: - source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name) - if not source: - return {} - return { - "method": "GET", - "timeout": 120, - "retry": 3, - "target_schema": "earth_boundary_source", - "license": source["license"], - "mapping_json": EARTH_BOUNDARY_SOURCE_MAPPING, - } diff --git a/backend/app/core/target_schema_registry.py b/backend/app/core/target_schema_registry.py index 1fad1e80..61ade066 100644 --- a/backend/app/core/target_schema_registry.py +++ b/backend/app/core/target_schema_registry.py @@ -47,18 +47,6 @@ class GenericRecord(BaseModel): return value -class EarthBoundarySourceRecord(BaseModel): - source_id: str | None = None - source_kind: str = Field(pattern="^(admin0-boundaries|coastline|claim-lines)$") - name: str | None = None - geometry: dict[str, Any] | None = None - properties: dict[str, Any] = Field(default_factory=dict) - artifact_path: str | None = None - sha256: str | None = None - feature_count: int | None = Field(default=None, ge=0) - license: str | None = None - - @dataclass(frozen=True) class TargetField: name: str @@ -155,24 +143,6 @@ TARGET_SCHEMAS: dict[str, TargetSchema] = { TargetField("observed_at", "datetime", False, "观测时间", "2026-04-28T00:00:00Z"), ), ), - "earth_boundary_source": TargetSchema( - key="earth_boundary_source", - label="Earth 边界矢量源", - description="Admin-0 国界、海岸线、主张线等 Earth 边界源数据 artifact。", - destination="collected_data", - model=EarthBoundarySourceRecord, - fields=( - TargetField("source_id", "string", False, "来源侧 ID", "feature-1"), - TargetField("source_kind", "string", True, "边界源类型", "admin0-boundaries"), - TargetField("name", "string", False, "记录名称", "China"), - TargetField("geometry", "object", False, "GeoJSON geometry", {"type": "Polygon", "coordinates": []}), - TargetField("properties", "object", False, "GeoJSON properties", {"ISO_A3": "CHN"}), - TargetField("artifact_path", "string", False, "完整源数据本地 artifact 路径", "data/earth-boundary-sources/earth_admin0_boundaries/abc.geojson"), - TargetField("sha256", "string", False, "源数据 artifact SHA-256", "abc123"), - TargetField("feature_count", "integer", False, "源数据 feature 数", 1), - TargetField("license", "string", False, "源数据许可", "ODbL"), - ), - ), } diff --git a/backend/app/db/session.py b/backend/app/db/session.py index a9b4a4f3..ea95933a 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -1,6 +1,6 @@ from typing import AsyncGenerator -from sqlalchemy import text +from sqlalchemy import bindparam, text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.orm import declarative_base @@ -72,6 +72,74 @@ async def seed_default_datasources(session: AsyncSession): await session.commit() +LEGACY_EARTH_BOUNDARY_SOURCES = ( + "earth_admin0_boundaries", + "earth_coastline", + "earth_claim_lines", + "earth_boundary_tiles", +) +LEGACY_EARTH_BOUNDARY_DATATYPES = ( + "earth_boundary_source", + "earth_boundary_tiles", +) +LEGACY_EARTH_BOUNDARY_IDS = (29, 30, 31, 32) + + +async def purge_legacy_earth_boundary_datasources(session: AsyncSession) -> None: + source_names = tuple(LEGACY_EARTH_BOUNDARY_SOURCES) + source_ids = tuple(LEGACY_EARTH_BOUNDARY_IDS) + data_types = tuple(LEGACY_EARTH_BOUNDARY_DATATYPES) + await session.execute( + text( + """ + DELETE FROM datasource_mapping_templates + WHERE target_schema IN :data_types + OR datasource_config_id IN ( + SELECT id FROM datasource_configs WHERE name IN :source_names + ) + """ + ).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)), + {"source_names": list(source_names), "data_types": list(data_types)}, + ) + await session.execute( + text("DELETE FROM datasource_configs WHERE name IN :source_names").bindparams( + bindparam("source_names", expanding=True) + ), + {"source_names": list(source_names)}, + ) + await session.execute( + text( + """ + DELETE FROM collected_data + WHERE source IN :source_names OR data_type IN :data_types + """ + ).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)), + {"source_names": list(source_names), "data_types": list(data_types)}, + ) + await session.execute( + text( + """ + DELETE FROM data_snapshots + WHERE source IN :source_names OR datasource_id IN :source_ids + """ + ).bindparams(bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)), + {"source_names": list(source_names), "source_ids": list(source_ids)}, + ) + await session.execute( + text("DELETE FROM collection_tasks WHERE datasource_id IN :source_ids").bindparams( + bindparam("source_ids", expanding=True) + ), + {"source_ids": list(source_ids)}, + ) + await session.execute( + text("DELETE FROM data_sources WHERE source IN :source_names OR id IN :source_ids").bindparams( + bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True) + ), + {"source_names": list(source_names), "source_ids": list(source_ids)}, + ) + await session.commit() + + DEFAULT_LOGIN_USERS = ( { "username": "admin", @@ -203,6 +271,18 @@ async def init_db(): """ ) ) + await conn.execute( + text( + """ + ALTER TABLE earth_news_items + ADD COLUMN IF NOT EXISTS content_language VARCHAR(32) NOT NULL DEFAULT 'en', + ADD COLUMN IF NOT EXISTS localizations JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS enrichment_status VARCHAR(80) NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS enrichment_error TEXT, + ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ + """ + ) + ) await conn.execute( text( """ @@ -211,6 +291,22 @@ async def init_db(): """ ) ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_earth_news_enrichment_status + ON earth_news_items (enrichment_status) + """ + ) + ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_earth_news_enriched_at + ON earth_news_items (enriched_at) + """ + ) + ) await conn.execute( text( """ @@ -285,4 +381,5 @@ async def init_db(): await seed_default_bgp_collector_locations(session) await seed_compute_center_locations_from_source_coords(session) await seed_default_datasources(session) + await purge_legacy_earth_boundary_datasources(session) await ensure_default_admin_user(session) diff --git a/backend/app/models/earth_news.py b/backend/app/models/earth_news.py index 213fe739..763fbc04 100644 --- a/backend/app/models/earth_news.py +++ b/backend/app/models/earth_news.py @@ -10,6 +10,8 @@ class EarthNewsItem(Base): id = Column(String(160), primary_key=True) title = Column(String(500), nullable=False) summary = Column(Text, nullable=False, default="") + content_language = Column(String(32), nullable=False, default="en") + localizations = Column(JSON, nullable=False, default=dict) url = Column(Text, nullable=False) source = Column(String(255), nullable=False, default="") feed_name = Column(String(255), nullable=False, default="") @@ -27,6 +29,9 @@ class EarthNewsItem(Base): first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) resolved_at = Column(DateTime(timezone=True), nullable=True, index=True) + enrichment_status = Column(String(80), nullable=False, default="pending", index=True) + enrichment_error = Column(Text, nullable=True) + enriched_at = Column(DateTime(timezone=True), nullable=True, index=True) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) __table_args__ = ( diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 37e02f45..037354f3 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -13,10 +13,11 @@ class AIContentBlock(BaseModel): class SituationalAnalysisRequest(BaseModel): title: str = Field(..., min_length=1, max_length=200) - objective: str = Field(..., min_length=1, max_length=1000) + objective: str = Field(..., min_length=1, max_length=20000) context: dict[str, Any] = Field(default_factory=dict) observations: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list) + system_prompt: str | None = Field(default=None, max_length=8000) preferred_model: str | None = Field(default=None, max_length=200) thinking: dict[str, Any] | None = None diff --git a/backend/app/services/alert_ai_brief.py b/backend/app/services/alert_ai_brief.py index 6051fa61..55e30674 100644 --- a/backend/app/services/alert_ai_brief.py +++ b/backend/app/services/alert_ai_brief.py @@ -8,6 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.alert import Alert, AlertSeverity, AlertStatus from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest +from app.ai_tasks.prompts import get_effective_prompt + +ALERT_BRIEF_PROMPT_KEY = "alerts.brief" def _format_counter(counter: Counter[str], empty_text: str = "无") -> str: @@ -84,11 +87,13 @@ async def build_alert_brief_request( "top_datasources": dict(datasource_counts.most_common(6)), "top_active_datasources": dict(active_datasource_counts.most_common(5)), } + prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY) return ( SituationalAnalysisRequest( title="告警态势 AI 简报", - objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, observations=facts, constraints=[ "明确区分事实、推断与建议。", diff --git a/backend/app/services/bgp_ai_brief.py b/backend/app/services/bgp_ai_brief.py index b492deac..4046b0f5 100644 --- a/backend/app/services/bgp_ai_brief.py +++ b/backend/app/services/bgp_ai_brief.py @@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation from app.schemas.ai import SituationalAnalysisRequest +from app.ai_tasks.prompts import get_effective_prompt from app.services.bgp_collectors import build_bgp_collector_coverage from app.services.bgp_enrichment import lookup_prefix_geography +BGP_BRIEF_PROMPT_KEY = "bgp.brief" + def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str: if not counter: @@ -243,10 +246,12 @@ async def build_bgp_brief_request( for prefix, item in list(prefix_geographies.items())[:8] }, } + prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY) return SituationalAnalysisRequest( title="BGP 态势 AI 简报", - objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, observations=observations_lines, constraints=[ "明确区分事实、推断与建议。", diff --git a/backend/app/services/collectors/__init__.py b/backend/app/services/collectors/__init__.py index 0bb8ae87..536b66b2 100644 --- a/backend/app/services/collectors/__init__.py +++ b/backend/app/services/collectors/__init__.py @@ -39,12 +39,6 @@ from app.services.collectors.news_live_streams import NewsLiveStreamsCollector from app.services.collectors.media_news_archive import MediaNewsArchiveCollector from app.services.collectors.aisstream import AISStreamCollector from app.services.collectors.vessel_ais import VesselAISCollector -from app.services.collectors.earth_boundaries import ( - EarthAdmin0BoundaryCollector, - EarthBoundaryTileCollector, - EarthClaimLinesCollector, - EarthCoastlineCollector, -) collector_registry.register(TOP500Collector()) collector_registry.register(EpochAIGPUCollector()) @@ -75,10 +69,6 @@ collector_registry.register(NewsLiveStreamsCollector()) collector_registry.register(MediaNewsArchiveCollector()) collector_registry.register(VesselAISCollector()) collector_registry.register(AISStreamCollector()) -collector_registry.register(EarthAdmin0BoundaryCollector()) -collector_registry.register(EarthCoastlineCollector()) -collector_registry.register(EarthClaimLinesCollector()) -collector_registry.register(EarthBoundaryTileCollector()) __all__ = [ "BaseCollector", @@ -115,8 +105,4 @@ __all__ = [ "MediaNewsArchiveCollector", "VesselAISCollector", "AISStreamCollector", - "EarthAdmin0BoundaryCollector", - "EarthCoastlineCollector", - "EarthClaimLinesCollector", - "EarthBoundaryTileCollector", ] diff --git a/backend/app/services/collectors/earth_boundaries.py b/backend/app/services/collectors/earth_boundaries.py deleted file mode 100644 index 706e32ce..00000000 --- a/backend/app/services/collectors/earth_boundaries.py +++ /dev/null @@ -1,578 +0,0 @@ -"""Earth boundary source and static tile collector.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import shutil -import sys -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import httpx -from sqlalchemy import select - -from app.core.earth_boundary_defaults import EARTH_BOUNDARY_DEFAULT_SOURCES, default_earth_boundary_config -from app.models.collected_data import CollectedData -from app.models.datasource_config import DataSourceConfig -from app.services.collectors.base import BaseCollector -from app.services.custom_datasource_runtime import build_query_params, build_request_headers - - -REPO_ROOT = Path(__file__).resolve().parents[4] -SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources" -SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json" -BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1" -BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json" -PMTILES_ARTIFACT_PATH = REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles" -POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json" -BUILD_CONFIG = { - "builder": "scripts/build_earth_boundary_china_pov_geojson.py", - "format": "geojson-high-precision", - "production_target": "geojson-high-precision", -} - -EARTH_BOUNDARY_SOURCE_COLLECTORS = { - "earth_admin0_boundaries": "admin0-boundaries", - "earth_coastline": "coastline", - "earth_claim_lines": "claim-lines", -} - - -def _read_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - with path.open("r", encoding="utf-8") as f: - return json.load(f) - - -def _directory_stats(path: Path) -> dict[str, int]: - if not path.exists(): - return {"file_count": 0, "size_bytes": 0} - files = [item for item in path.rglob("*") if item.is_file()] - return { - "file_count": len(files), - "size_bytes": sum(item.stat().st_size for item in files), - } - - -def _stable_json_hash(payload: Any) -> str: - encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest() - - -def _build_input_hash(source_manifest: dict[str, Any]) -> str: - return _stable_json_hash( - { - "source_manifest_schema": source_manifest.get("schema"), - "sources": [ - { - "id": source.get("id"), - "sha256": source.get("sha256"), - "kind": source.get("kind"), - "pov": source.get("pov"), - } - for source in source_manifest.get("sources", []) - ], - "pov_policy": source_manifest.get("povPolicy"), - "build_config": BUILD_CONFIG, - } - ) - - -def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool: - if not boundary_manifest: - return False - if boundary_manifest.get("buildInputHash") != build_input_hash: - return False - if not BOUNDARY_OUTPUT_DIR.exists(): - return False - if boundary_manifest.get("tileProvider") == "pmtiles-mvt": - return PMTILES_ARTIFACT_PATH.exists() - return (BOUNDARY_OUTPUT_DIR / "base.geojson").exists() and ( - BOUNDARY_OUTPUT_DIR / "hover-index.geojson" - ).exists() - - -def _sha256_bytes(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - -def _json_feature_count(payload: Any) -> int: - if isinstance(payload, dict) and isinstance(payload.get("features"), list): - return len(payload["features"]) - if isinstance(payload, list): - return len(payload) - return 1 if payload else 0 - - -def _json_sample_properties(payload: Any) -> dict[str, Any]: - feature = None - if isinstance(payload, dict) and isinstance(payload.get("features"), list) and payload["features"]: - feature = payload["features"][0] - elif isinstance(payload, list) and payload: - feature = payload[0] - elif isinstance(payload, dict): - feature = payload - if not isinstance(feature, dict): - return {} - props = feature.get("properties") if isinstance(feature.get("properties"), dict) else feature - return {str(key): value for key, value in list(props.items())[:20]} - - -def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str: - suffix = Path(endpoint.split("?", 1)[0]).suffix.lower() - if suffix in {".json", ".geojson", ".zip", ".pbf"}: - return suffix - if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]: - return ".geojson" - if "json" in content_type: - return ".json" - return ".dat" - - -async def _load_datasource_config(db, name: str) -> DataSourceConfig | None: - if db is None: - return None - result = await db.execute( - select(DataSourceConfig) - .where(DataSourceConfig.name == name) - .where(DataSourceConfig.is_active.is_(True)) - .order_by(DataSourceConfig.id.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -async def _latest_boundary_source_record(db, source_name: str) -> CollectedData | None: - result = await db.execute( - select(CollectedData) - .where(CollectedData.source == source_name) - .where(CollectedData.data_type == "earth_boundary_source") - .where(CollectedData.is_current.is_(True)) - .order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc()) - .limit(1) - ) - return result.scalar_one_or_none() - - -class EarthBoundarySourceCollector(BaseCollector): - priority = "P1" - module = "L3" - frequency_hours = 168 - data_type = "earth_boundary_source" - fail_on_empty = True - source_kind = "unknown" - - def _default_config(self): - source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(self.name) - if not source: - return None - return SimpleNamespace( - name=self.name, - description=f"内置默认源:{self.name}", - endpoint=source["endpoint"], - source_type="http", - auth_type="none", - auth_config={}, - headers={}, - config=default_earth_boundary_config(self.name), - ) - - async def _download_payload(self, config: DataSourceConfig) -> tuple[bytes, str]: - request_config = config.config or {} - method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper() - if method not in {"GET", "POST"}: - raise RuntimeError("Earth boundary source collectors support GET and POST only") - - endpoint = str(config.endpoint or "").strip() - if not endpoint: - raise RuntimeError( - f"{self.name} requires an endpoint in Collector Settings before it can collect data" - ) - - if endpoint.startswith("file://") or Path(endpoint).expanduser().exists(): - path = Path(endpoint.removeprefix("file://")).expanduser() - return path.read_bytes(), "application/octet-stream" - - headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {}) - params = build_query_params(config.auth_type, config.auth_config or {}, request_config) - timeout = float(request_config.get("timeout", 120)) - json_body = request_config.get("json_body") or request_config.get("body") - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - response = await client.request( - method, - endpoint, - headers=headers, - params=params or None, - json=json_body if isinstance(json_body, (dict, list)) else None, - ) - response.raise_for_status() - return response.content, response.headers.get("content-type", "") - - async def fetch(self) -> list[dict[str, Any]]: - db = getattr(self, "_db_session", None) - config = await _load_datasource_config(db, self.name) or self._default_config() - if config is None: - raise RuntimeError( - f"{self.name} has no active Collector Settings config and no built-in default source." - ) - - await self.set_phase("fetching_source", message=f"正在下载 {self.source_kind} 源数据") - payload, content_type = await self._download_payload(config) - sha256 = _sha256_bytes(payload) - endpoint = str(config.endpoint or "") - extension = _artifact_extension(endpoint, content_type, payload) - source_dir = SOURCE_OUTPUT_DIR / self.name - source_dir.mkdir(parents=True, exist_ok=True) - artifact_path = source_dir / f"{sha256}{extension}" - artifact_path.write_bytes(payload) - - parsed: Any = None - if extension in {".json", ".geojson"}: - parsed = json.loads(payload.decode("utf-8")) - - feature_count = _json_feature_count(parsed) - if feature_count <= 0: - raise RuntimeError(f"{self.name} downloaded data but found no JSON/GeoJSON features") - - config_body = config.config or {} - target_schema = str(config_body.get("target_schema") or "earth_boundary_source") - if target_schema != "earth_boundary_source": - raise RuntimeError(f"{self.name} target_schema must be earth_boundary_source") - - await self.update_phase_progress( - current=1, - total=1, - unit="artifact", - message=f"已保存 {feature_count} 个 {self.source_kind} feature", - progress=100, - commit=True, - force=True, - ) - - relative_artifact_path = str(artifact_path.relative_to(REPO_ROOT)) - return [ - { - "id": f"{self.source_kind}:{sha256}", - "name": config.description or self.name, - "description": f"Earth boundary source artifact collected from configured endpoint", - "source_kind": self.source_kind, - "source_id": sha256, - "value": feature_count, - "unit": "features", - "metadata": { - "target_schema": target_schema, - "source_kind": self.source_kind, - "endpoint": endpoint, - "method": str(config_body.get("method") or config_body.get("request_method") or "GET").upper(), - "artifact_path": relative_artifact_path, - "sha256": sha256, - "feature_count": feature_count, - "size_bytes": len(payload), - "content_type": content_type, - "license": config_body.get("license"), - "mapping_json": config_body.get("mapping_json"), - "sample_properties": _json_sample_properties(parsed), - }, - } - ] - - -class EarthAdmin0BoundaryCollector(EarthBoundarySourceCollector): - name = "earth_admin0_boundaries" - source_kind = "admin0-boundaries" - - -class EarthCoastlineCollector(EarthBoundarySourceCollector): - name = "earth_coastline" - source_kind = "coastline" - - -class EarthClaimLinesCollector(EarthBoundarySourceCollector): - name = "earth_claim_lines" - source_kind = "claim-lines" - - -class EarthBoundaryTileCollector(BaseCollector): - name = "earth_boundary_tiles" - priority = "P1" - module = "L3" - frequency_hours = 168 - data_type = "earth_boundary_tiles" - fail_on_empty = False - - async def _run_step(self, args: list[str], *, allow_failure: bool = False) -> dict[str, Any]: - process = await asyncio.create_subprocess_exec( - sys.executable, - *args, - cwd=REPO_ROOT, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout_bytes, stderr_bytes = await process.communicate() - stdout = stdout_bytes.decode("utf-8", errors="replace").strip() - stderr = stderr_bytes.decode("utf-8", errors="replace").strip() - if process.returncode != 0 and not allow_failure: - raise RuntimeError(stderr or stdout or f"command failed: {' '.join(args)}") - - payload: dict[str, Any] = { - "stdout": stdout, - "stderr": stderr, - "returncode": process.returncode, - } - last_line = stdout.splitlines()[-1:] or [] - if last_line: - try: - payload["result"] = json.loads(last_line[0]) - except json.JSONDecodeError: - payload["result"] = last_line[0] - return payload - - async def fetch(self) -> list[dict[str, Any]]: - db = getattr(self, "_db_session", None) - if db is None: - raise RuntimeError("Earth PMTiles builder requires an active database session") - - await self.set_phase("checking_sources", message="正在检查三类 Earth 边界源") - await self.update_phase_progress( - current=0, - total=3, - unit="steps", - message="读取 admin0 / coastline / claim-lines 最新采集结果", - progress=5, - commit=True, - force=True, - ) - - source_records: dict[str, CollectedData] = {} - missing_sources: list[str] = [] - for source_name in EARTH_BOUNDARY_SOURCE_COLLECTORS: - record = await _latest_boundary_source_record(db, source_name) - if record is None: - missing_sources.append(source_name) - else: - source_records[source_name] = record - - if missing_sources: - missing_text = ", ".join(missing_sources) - raise RuntimeError(f"未就绪:缺少 {missing_text}。不会更新 Earth 国界。") - - sources = [] - for source_name, record in source_records.items(): - metadata = record.extra_data or {} - artifact_path = metadata.get("artifact_path") - if not artifact_path or not (REPO_ROOT / str(artifact_path)).exists(): - missing_sources.append(f"{source_name}: artifact missing") - continue - sources.append( - { - "id": source_name, - "kind": metadata.get("source_kind") or EARTH_BOUNDARY_SOURCE_COLLECTORS[source_name], - "path": str(artifact_path), - "sha256": metadata.get("sha256"), - "featureCount": metadata.get("feature_count"), - "license": metadata.get("license"), - } - ) - - if missing_sources: - missing_text = ", ".join(missing_sources) - raise RuntimeError(f"未就绪:{missing_text}。不会更新 Earth 国界。") - - source_manifest = { - "schema": "planet-earth-boundary-sources/v2", - "sources": sources, - "povPolicy": _read_json(POV_POLICY_PATH), - } - SOURCE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - with SOURCE_MANIFEST_PATH.open("w", encoding="utf-8") as f: - json.dump(source_manifest, f, ensure_ascii=False, indent=2) - f.write("\n") - build_input_hash = _build_input_hash(source_manifest) - await self.update_phase_progress( - current=1, - total=3, - unit="steps", - message="三类边界源已就绪", - progress=35, - commit=True, - force=True, - ) - - await self.set_phase("checking_readiness", message="正在检查 Earth 国界构建就绪状态") - missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None] - readiness_result = { - "returncode": 0, - "result": { - "ready": True, - "failures": [f"external tool not found in PATH: {tool}" for tool in missing_tools], - "sources": sources, - "artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), - "fallback_builder": "geojson-high-precision" if missing_tools else None, - }, - } - - await self.set_phase("building_tiles", message="正在检查 Earth 国界瓦片产物") - boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) - build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash) - if build_skipped: - build_result = { - "result": { - "status": "unchanged", - "reason": "source manifest and build config hash unchanged", - "buildInputHash": build_input_hash, - } - } - elif shutil.which("tippecanoe") and shutil.which("pmtiles"): - admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") - coastline = next(source for source in sources if source["kind"] == "coastline") - claim_lines = next(source for source in sources if source["kind"] == "claim-lines") - build_result = await self._run_step( - [ - "scripts/build_earth_boundary_pmtiles.py", - "--admin0-source", - admin0["path"], - "--coastline-source", - coastline["path"], - "--claims-source", - claim_lines["path"], - "--output", - str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), - "--manifest", - str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)), - "--build-input-hash", - build_input_hash, - "--pov-policy", - str(POV_POLICY_PATH.relative_to(REPO_ROOT)), - ] - ) - boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) - else: - admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") - coastline = next(source for source in sources if source["kind"] == "coastline") - claim_lines = next(source for source in sources if source["kind"] == "claim-lines") - build_result = await self._run_step( - [ - "scripts/build_earth_boundary_china_pov_geojson.py", - "--admin0-source", - admin0["path"], - "--coastline-source", - coastline["path"], - "--claims-source", - claim_lines["path"], - "--output-dir", - str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)), - "--build-input-hash", - build_input_hash, - ] - ) - boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) - boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR) - pmtiles_exists = PMTILES_ARTIFACT_PATH.exists() - pmtiles_size = PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0 - await self.update_phase_progress( - current=2, - total=3, - unit="steps", - message=( - "边界源未变化,已跳过瓦片重建" - if build_skipped - else f"已生成 {boundary_manifest.get('tileProvider') or 'boundary'} 国界产物" - ), - progress=80, - commit=True, - force=True, - ) - - await self.set_phase("indexing_artifacts", message="正在登记边界瓦片产物") - tile_counts = boundary_manifest.get("tiles", {}).get("countsByZoom", {}) - records = [ - { - "id": "source-manifest", - "name": "Earth boundary source manifest", - "description": "Offline source collection manifest for Earth boundary tiles", - "value": len(source_manifest.get("sources", [])), - "unit": "sources", - "metadata": { - "manifest_path": str(SOURCE_MANIFEST_PATH.relative_to(REPO_ROOT)), - "manifest": source_manifest, - "collector_result": { - "status": "loaded_from_collected_data", - "sources": [source["id"] for source in sources], - }, - }, - }, - { - "id": "production-readiness", - "name": "Earth boundary production readiness", - "description": "Checks whether all source artifacts and boundary build tooling are available", - "value": 1 if readiness_result.get("returncode") == 0 else 0, - "unit": "ready", - "metadata": { - "result": readiness_result.get("result"), - "returncode": readiness_result.get("returncode"), - }, - }, - { - "id": "boundary-manifest", - "name": "Earth boundary tile manifest", - "description": "Versioned static vector tile manifest for Earth country boundaries", - "value": boundary_stats["file_count"], - "unit": "files", - "metadata": { - "manifest_path": str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)), - "output_dir": str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)), - "size_bytes": boundary_stats["size_bytes"], - "manifest": boundary_manifest, - "collector_result": build_result.get("result"), - "build_skipped": build_skipped, - "production_target": BUILD_CONFIG["production_target"], - "pmtiles_artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), - "pmtiles_exists": pmtiles_exists, - }, - }, - ] - if pmtiles_exists: - records.append( - { - "id": "pmtiles-artifact", - "name": "Earth boundary PMTiles artifact", - "description": "Single-file PMTiles/MVT artifact for Earth boundaries", - "value": pmtiles_size, - "unit": "bytes", - "metadata": { - "path": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)), - "exists": pmtiles_exists, - "provider": "pmtiles-mvt", - }, - } - ) - for zoom, count in sorted(tile_counts.items(), key=lambda item: int(item[0])): - records.append( - { - "id": f"tile-z{zoom}", - "name": f"Earth boundary tiles z{zoom}", - "description": f"Generated Earth boundary tile count for zoom {zoom}", - "value": int(count), - "unit": "tiles", - "metadata": { - "zoom": int(zoom), - "tile_count": int(count), - "output_dir": str((BOUNDARY_OUTPUT_DIR / str(zoom)).relative_to(REPO_ROOT)), - }, - } - ) - - await self.update_phase_progress( - current=3, - total=3, - unit="steps", - message="边界瓦片产物已登记", - progress=100, - commit=True, - force=True, - ) - return records diff --git a/backend/app/services/credential_guides.py b/backend/app/services/credential_guides.py index c17d4cf0..73b334ba 100644 --- a/backend/app/services/credential_guides.py +++ b/backend/app/services/credential_guides.py @@ -7,6 +7,7 @@ from typing import Any from sqlalchemy import select +from app.ai_tasks.prompts import get_effective_prompt from app.models.system_setting import SystemSetting from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient @@ -15,6 +16,7 @@ from app.services.ai_tools.web_search import WebSearchClient, WebSearchError CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides" +CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide" @dataclass(frozen=True) @@ -240,14 +242,12 @@ async def generate_credential_guide( guide["sources"] = [] return guide + prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY) response = await ai_client.analyze( SituationalAnalysisRequest( title=f"Generate credential guide for {provider}", - objective=( - default.prompt - + "\n只能根据 context.search_evidence 中的来源生成教程;" - + "如果证据不足,明确说明需要以官方页面为准。" - ), + objective=f"{default.prompt}\n{prompt.prompt}", + system_prompt=prompt.system_prompt or None, context={ "provider": provider, "current_default_guide": default.markdown, diff --git a/backend/app/services/datasource_mapping.py b/backend/app/services/datasource_mapping.py index 90821629..ec9b2d11 100644 --- a/backend/app/services/datasource_mapping.py +++ b/backend/app/services/datasource_mapping.py @@ -301,52 +301,6 @@ async def persist_mapped_records( transport: str | None = None, ) -> int: """Persist validated mapped records to the destination for a target schema.""" - if target_schema == "earth_boundary_source": - from app.models.collected_data import CollectedData - - now = datetime.now(UTC) - written_count = 0 - for index, record in enumerate(records): - source_id = record.get("source_id") or record.get("sha256") or str(index) - entity_key = f"{datasource_name}:{source_id}" - previous_result = await db.execute( - select(CollectedData) - .where(CollectedData.source == datasource_name) - .where(CollectedData.entity_key == entity_key) - .where(CollectedData.is_current.is_(True)) - .order_by(CollectedData.id.desc()) - .limit(1) - ) - previous = previous_result.scalar_one_or_none() - if previous is not None: - previous.is_current = False - db.add( - CollectedData( - source=datasource_name, - source_id=str(source_id), - entity_key=entity_key, - data_type=target_schema, - name=record.get("name") or str(source_id), - description=f"Earth boundary source artifact: {record.get('source_kind')}", - extra_data={ - **record, - "datasource_config_id": datasource_config_id, - "mapping_version": mapping_version, - "delivery_mode": delivery_mode or "polling", - "transport": transport or "http", - }, - collected_at=now, - is_valid=1, - is_current=True, - previous_record_id=previous.id if previous else None, - change_type="updated" if previous else "created", - change_summary={}, - ) - ) - written_count += 1 - await db.commit() - return written_count - if target_schema == "vessel_ais": from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster diff --git a/backend/app/services/earth_boundaries.py b/backend/app/services/earth_boundaries.py new file mode 100644 index 00000000..7a5f42db --- /dev/null +++ b/backend/app/services/earth_boundaries.py @@ -0,0 +1,671 @@ +"""Earth boundary static asset service.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import shutil +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import httpx + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources" +SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json" +BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json" +BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json" +BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1" +BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json" +PMTILES_ARTIFACT_PATH = ( + REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles" +) +LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson" +POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json" +LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json" +EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json" + +BOUNDARY_SOURCE_KINDS = { + "earth_admin0_boundaries": "admin0-boundaries", + "earth_coastline": "coastline", + "earth_claim_lines": "claim-lines", +} + +DEFAULT_PUBLIC_BOUNDARY_SOURCES = { + "earth_admin0_boundaries": { + "displayName": "Natural Earth Admin-0 Countries", + "sourceKind": "admin0-boundaries", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "license": "Natural Earth public domain", + }, + "earth_coastline": { + "displayName": "Natural Earth Coastline", + "sourceKind": "coastline", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "license": "Natural Earth public domain", + }, + "earth_claim_lines": { + "displayName": "Natural Earth Disputed Boundaries", + "sourceKind": "claim-lines", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson", + "method": "GET", + "headers": {}, + "auth_type": "none", + "license": "Natural Earth public domain", + }, +} + +BUILD_CONFIG = { + "builder": "scripts/build_earth_boundary_pmtiles.py", + "format": "pmtiles+mvt", + "production_target": "pmtiles-mvt", +} + + +class EarthBoundaryBuildError(RuntimeError): + def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None: + super().__init__(message) + self.code = code + self.details = details + + +_build_job_lock = asyncio.Lock() +_build_task: asyncio.Task | None = None +_build_job_state: dict[str, Any] = {} + + +def _utc_now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def _public_job_state() -> dict[str, Any]: + if _build_job_state: + return dict(_build_job_state) + return _read_json(BUILD_JOB_PATH) + + +def get_boundary_build_status() -> dict[str, Any]: + return {"job": _public_job_state()} + + +def _set_job_state(**updates: Any) -> dict[str, Any]: + global _build_job_state + current = dict(_build_job_state) + current.update(updates) + current["updated_at"] = _utc_now_iso() + _build_job_state = current + _write_json(BUILD_JOB_PATH, current) + return current + + +def _append_job_log(message: str) -> None: + logs = list(_build_job_state.get("logs") or []) + logs.append({"time": _utc_now_iso(), "message": message}) + _set_job_state(logs=logs[-40:]) + + +def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None: + bounded_progress = max(0, min(100, int(round(progress)))) + _set_job_state( + status="running", + progress=bounded_progress, + phase=phase, + message=message, + **extra, + ) + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as f: + payload = json.load(f) + return payload if isinstance(payload, dict) else {} + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + f.write("\n") + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _stable_json_hash(payload: Any) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str: + suffix = Path(endpoint.split("?", 1)[0]).suffix.lower() + if suffix in {".json", ".geojson", ".zip", ".pbf"}: + return suffix + if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]: + return ".geojson" + if "json" in content_type: + return ".json" + return ".dat" + + +def _json_feature_count(payload: Any) -> int: + if isinstance(payload, dict) and isinstance(payload.get("features"), list): + return len(payload["features"]) + if isinstance(payload, list): + return len(payload) + return 1 if payload else 0 + + +def _directory_stats(path: Path) -> dict[str, int]: + if not path.exists(): + return {"file_count": 0, "size_bytes": 0} + files = [item for item in path.rglob("*") if item.is_file()] + return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)} + + +def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]: + path = REPO_ROOT / source["path"] + payload = _read_json(path) + features = payload.get("features") if isinstance(payload, dict) else None + return { + "type": "FeatureCollection", + "features": features if isinstance(features, list) else [], + } + + +def _write_high_precision_geojson_manifest( + sources: list[dict[str, Any]], + build_input_hash: str, + missing_tools: list[str], +) -> dict[str, Any]: + BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") + coastline = next(source for source in sources if source["kind"] == "coastline") + claim_lines = next(source for source in sources if source["kind"] == "claim-lines") + + admin0_payload = _load_source_feature_collection(admin0) + coastline_payload = _load_source_feature_collection(coastline) + claim_payload = _load_source_feature_collection(claim_lines) + for feature in coastline_payload["features"]: + props = feature.setdefault("properties", {}) + if isinstance(props, dict): + props["PLANET_LAYER"] = "coastline" + + base_payload = { + "type": "FeatureCollection", + "features": [*admin0_payload["features"], *coastline_payload["features"]], + } + base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson" + hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson" + claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson" + _write_json(base_path, base_payload) + _write_json(hover_path, admin0_payload) + _write_json(claim_path, claim_payload) + + manifest = { + "version": "natural-earth-v1", + "builtAt": _utc_now_iso(), + "tileProvider": "geojson-high-precision", + "format": "geojson-directory", + "buildInputHash": build_input_hash, + "base": base_path.name, + "hoverIndex": hover_path.name, + "claimLine": claim_path.name, + "sourceFeatureCount": { + "admin0": len(admin0_payload["features"]), + "coastline": len(coastline_payload["features"]), + "claimLines": len(claim_payload["features"]), + }, + "pmtiles": None, + "missingTools": missing_tools, + } + _write_json(BOUNDARY_MANIFEST_PATH, manifest) + return manifest + + +def _relative(path: Path) -> str: + return str(path.relative_to(REPO_ROOT)) + + +def load_boundary_config() -> tuple[dict[str, Any], str]: + if LOCAL_CONFIG_PATH.exists(): + return _read_json(LOCAL_CONFIG_PATH), "local" + return _read_json(EXAMPLE_CONFIG_PATH), "example" + + +def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config") + _write_json(LOCAL_CONFIG_PATH, payload) + return get_boundary_status() + + +def _source_configs(payload: dict[str, Any]) -> dict[str, Any]: + raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {} + return raw_sources if isinstance(raw_sources, dict) else {} + + +def _is_placeholder_endpoint(endpoint: Any) -> bool: + value = str(endpoint or "").strip() + return not value or "example.com" in value + + +def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]: + raw_sources = _source_configs(payload) + merged: dict[str, Any] = {} + for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items(): + configured = raw_sources.get(source_key) + if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")): + merged[source_key] = dict(default_config) + else: + merged[source_key] = {**default_config, **configured} + for source_key, source_config in raw_sources.items(): + if source_key not in merged: + merged[source_key] = source_config + return merged + + +def _build_input_hash(source_manifest: dict[str, Any]) -> str: + return _stable_json_hash( + { + "source_manifest_schema": source_manifest.get("schema"), + "sources": [ + { + "id": source.get("id"), + "sha256": source.get("sha256"), + "kind": source.get("kind"), + } + for source in source_manifest.get("sources", []) + ], + "pov_policy": source_manifest.get("povPolicy"), + "build_config": BUILD_CONFIG, + } + ) + + +def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool: + return ( + bool(boundary_manifest) + and boundary_manifest.get("buildInputHash") == build_input_hash + and boundary_manifest.get("tileProvider") == "pmtiles-mvt" + and PMTILES_ARTIFACT_PATH.exists() + ) + + +def get_boundary_status() -> dict[str, Any]: + config_payload, config_source = load_boundary_config() + effective_source_configs = _source_configs_with_defaults(config_payload) + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + pmtiles_exists = PMTILES_ARTIFACT_PATH.exists() + manifest_exists = BOUNDARY_MANIFEST_PATH.exists() + high_precision_ready = ( + manifest_exists + and ( + ( + boundary_manifest.get("tileProvider") == "pmtiles-mvt" + and pmtiles_exists + ) + or boundary_manifest.get("tileProvider") == "geojson-high-precision" + ) + ) + legacy_exists = LEGACY_GEOJSON_PATH.exists() + provider = ( + boundary_manifest.get("tileProvider") + if high_precision_ready + else "legacy-geojson" if legacy_exists else "missing" + ) + return { + "provider": provider, + "high_precision_ready": high_precision_ready, + "fallback_available": legacy_exists, + "config_source": config_source, + "config_path": _relative(LOCAL_CONFIG_PATH), + "config_exists": LOCAL_CONFIG_PATH.exists(), + "config": config_payload, + "effective_default_sources": [ + source_key + for source_key, source_config in effective_source_configs.items() + if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES + and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"] + ], + "manifest": { + "path": _relative(BOUNDARY_MANIFEST_PATH), + "exists": manifest_exists, + "tileProvider": boundary_manifest.get("tileProvider"), + "buildInputHash": boundary_manifest.get("buildInputHash"), + "builtAt": boundary_manifest.get("builtAt"), + }, + "pmtiles": { + "path": _relative(PMTILES_ARTIFACT_PATH), + "exists": pmtiles_exists, + "size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0, + }, + "legacy": { + "path": _relative(LEGACY_GEOJSON_PATH), + "exists": legacy_exists, + "size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0, + }, + "source_manifest": { + "path": _relative(SOURCE_MANIFEST_PATH), + "exists": SOURCE_MANIFEST_PATH.exists(), + }, + "last_build": _read_json(BUILD_RESULT_PATH), + "current_job": _public_job_state(), + } + + +async def _download_source( + source_key: str, + source_config: dict[str, Any], + progress_callback: Any = None, +) -> dict[str, Any]: + endpoint = str(source_config.get("endpoint") or "").strip() + if _is_placeholder_endpoint(endpoint): + raise EarthBoundaryBuildError( + f"{source_key} endpoint is not configured", + code="source_not_configured", + details={"source": source_key}, + ) + method = str(source_config.get("method") or "GET").upper() + if method not in {"GET", "POST"}: + raise EarthBoundaryBuildError( + f"{source_key} method must be GET or POST", + code="invalid_config", + details={"source": source_key, "method": method}, + ) + + if endpoint.startswith("file://") or Path(endpoint).expanduser().exists(): + payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes() + content_type = "application/octet-stream" + if progress_callback: + progress_callback(1, len(payload), len(payload)) + else: + timeout = float(source_config.get("timeout") or 120) + headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {} + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + async with client.stream(method, endpoint, headers=headers) as response: + response.raise_for_status() + content_type = response.headers.get("content-type", "") + total = int(response.headers.get("content-length") or 0) + chunks = [] + downloaded = 0 + async for chunk in response.aiter_bytes(): + if not chunk: + continue + chunks.append(chunk) + downloaded += len(chunk) + if progress_callback: + progress_callback( + (downloaded / total) if total else None, + downloaded, + total, + ) + payload = b"".join(chunks) + + extension = _artifact_extension(endpoint, content_type, payload) + parsed: Any = None + if extension in {".json", ".geojson"}: + parsed = json.loads(payload.decode("utf-8")) + feature_count = _json_feature_count(parsed) + if feature_count <= 0: + raise EarthBoundaryBuildError( + f"{source_key} downloaded payload contains no features", + code="empty_source", + details={"source": source_key}, + ) + + sha256 = _sha256_bytes(payload) + source_dir = SOURCE_OUTPUT_DIR / source_key + source_dir.mkdir(parents=True, exist_ok=True) + artifact_path = source_dir / f"{sha256}{extension}" + artifact_path.write_bytes(payload) + return { + "id": source_key, + "kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key], + "path": _relative(artifact_path), + "sha256": sha256, + "featureCount": feature_count, + "license": source_config.get("license"), + } + + +async def _run_step(args: list[str]) -> dict[str, Any]: + process = await asyncio.create_subprocess_exec( + sys.executable, + *args, + cwd=REPO_ROOT, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_bytes, stderr_bytes = await process.communicate() + stdout = stdout_bytes.decode("utf-8", errors="replace").strip() + stderr = stderr_bytes.decode("utf-8", errors="replace").strip() + payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode} + last_line = stdout.splitlines()[-1:] or [] + if last_line: + try: + payload["result"] = json.loads(last_line[0]) + except json.JSONDecodeError: + payload["result"] = last_line[0] + if process.returncode != 0: + raise EarthBoundaryBuildError( + stderr or stdout or f"command failed: {' '.join(args)}", + code="build_command_failed", + details=payload, + ) + return payload + + +async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]: + config_payload, config_source = load_boundary_config() + + source_configs = _source_configs_with_defaults(config_payload) + missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs] + if missing: + raise EarthBoundaryBuildError( + f"Missing Earth boundary source configs: {', '.join(missing)}", + code="missing_sources", + details={"missing": missing}, + ) + + sources = [] + source_keys = list(BOUNDARY_SOURCE_KINDS) + for index, source_key in enumerate(source_keys): + source_config = source_configs[source_key] + if not isinstance(source_config, dict): + raise EarthBoundaryBuildError( + f"{source_key} config must be an object", + code="invalid_config", + details={"source": source_key}, + ) + source_start = 8 + index * 18 + source_end = source_start + 18 + if progress_callback: + progress_callback(source_start, "download", f"正在下载 {source_key}") + + def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None: + if not progress_callback: + return + if ratio is None: + progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes") + return + progress_callback( + source_start + (source_end - source_start) * ratio, + "download", + f"{source_key} 下载 {int(ratio * 100)}%", + downloaded_bytes=downloaded, + total_bytes=total, + ) + + sources.append(await _download_source(source_key, source_config, report_download_progress)) + + source_manifest = { + "schema": "planet-earth-boundary-sources/v2", + "sources": sources, + "povPolicy": _read_json(POV_POLICY_PATH), + } + if progress_callback: + progress_callback(65, "manifest", "正在写入边界源 manifest") + _write_json(SOURCE_MANIFEST_PATH, source_manifest) + build_input_hash = _build_input_hash(source_manifest) + + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash) + missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None] + if missing_tools and not build_skipped: + if progress_callback: + progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包") + boundary_manifest = _write_high_precision_geojson_manifest( + sources, + build_input_hash, + missing_tools, + ) + result = { + "status": "built_geojson_fallback", + "code": "missing_tools", + "missing_tools": missing_tools, + "sources": sources, + "boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH), + "manifest": boundary_manifest, + } + _write_json(BUILD_RESULT_PATH, result) + if progress_callback: + progress_callback(96, "finalize", "GeoJSON 高清国界包已生成") + return {**get_boundary_status(), "build": result} + + if build_skipped: + if progress_callback: + progress_callback(96, "unchanged", "高精国界已是最新") + build_result = { + "status": "unchanged", + "reason": "source manifest and build config hash unchanged", + "buildInputHash": build_input_hash, + } + else: + if progress_callback: + progress_callback(72, "build", "正在构建 PMTiles/MVT") + admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries") + coastline = next(source for source in sources if source["kind"] == "coastline") + claim_lines = next(source for source in sources if source["kind"] == "claim-lines") + build_result = await _run_step( + [ + "scripts/build_earth_boundary_pmtiles.py", + "--admin0-source", + admin0["path"], + "--coastline-source", + coastline["path"], + "--claims-source", + claim_lines["path"], + "--output", + _relative(PMTILES_ARTIFACT_PATH), + "--manifest", + _relative(BOUNDARY_MANIFEST_PATH), + "--build-input-hash", + build_input_hash, + "--pov-policy", + _relative(POV_POLICY_PATH), + ] + ) + if progress_callback: + progress_callback(95, "finalize", "正在校验构建产物") + + boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH) + boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR) + result = { + "status": "unchanged" if build_skipped else "built", + "sources": sources, + "source_manifest": _relative(SOURCE_MANIFEST_PATH), + "boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH), + "pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH), + "pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(), + "boundary_stats": boundary_stats, + "manifest": boundary_manifest, + "build_result": build_result, + } + _write_json(BUILD_RESULT_PATH, result) + return {**get_boundary_status(), "build": result} + + +async def _run_boundary_build_job(job_id: str) -> None: + def report(progress: float, phase: str, message: str, **extra: Any) -> None: + if _build_job_state.get("id") != job_id: + return + _update_job_progress(progress, phase, message, **extra) + + try: + report(3, "prepare", "正在准备高精国界构建") + result = await build_boundary_assets(report) + _set_job_state( + id=job_id, + status="succeeded", + progress=100, + phase="complete", + message="高精国界构建完成", + finished_at=_utc_now_iso(), + result={ + "provider": result.get("provider"), + "high_precision_ready": result.get("high_precision_ready"), + "pmtiles": result.get("pmtiles"), + "manifest": result.get("manifest"), + }, + ) + _append_job_log("高精国界构建完成") + except EarthBoundaryBuildError as exc: + _set_job_state( + id=job_id, + status="failed", + progress=_build_job_state.get("progress", 0), + phase="failed", + message=str(exc), + code=exc.code, + details=exc.details, + finished_at=_utc_now_iso(), + ) + _append_job_log(str(exc)) + except Exception as exc: # pragma: no cover - defensive guard for background task + _set_job_state( + id=job_id, + status="failed", + progress=_build_job_state.get("progress", 0), + phase="failed", + message=str(exc), + code="build_failed", + finished_at=_utc_now_iso(), + ) + _append_job_log(str(exc)) + + +async def start_boundary_build_job() -> dict[str, Any]: + global _build_task + async with _build_job_lock: + if _build_task and not _build_task.done(): + return {"accepted": False, "job": _public_job_state()} + job_id = uuid4().hex + _set_job_state( + id=job_id, + status="queued", + progress=0, + phase="queued", + message="高精国界构建已加入队列", + logs=[], + started_at=_utc_now_iso(), + finished_at=None, + code=None, + details=None, + ) + _append_job_log("高精国界构建已启动") + _build_task = asyncio.create_task(_run_boundary_build_job(job_id)) + return {"accepted": True, "job": _public_job_state()} diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py index 55d322b5..db6ce557 100644 --- a/backend/app/services/earth_news.py +++ b/backend/app/services/earth_news.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime from email.utils import parsedate_to_datetime import hashlib @@ -18,6 +18,7 @@ from bs4 import BeautifulSoup from sqlalchemy.ext.asyncio import AsyncSession from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country +from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder @@ -31,6 +32,8 @@ STALE_CACHE_MAX_AGE_SECONDS = 60 * 45 RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS MAX_TARGET_INFERENCE_CONCURRENCY = 3 TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0 +DEFAULT_NEWS_LOCALE = "zh-CN" +NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich" @dataclass(frozen=True) @@ -82,6 +85,11 @@ class ParsedNewsItem: feed_region: str homepage_url: str published_at: datetime | None + content_language: str = "en" + localizations: dict[str, dict[str, str]] = field(default_factory=dict) + enrichment_status: str = "pending" + enrichment_error: str | None = None + enriched_at: datetime | None = None target_location: NewsTargetLocation | None = None target_resolution_stage: str = "unresolved" target_ai_attempted: bool = False @@ -365,6 +373,65 @@ def _first_json_object(text: str) -> dict[str, Any] | None: return None +def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]: + if not isinstance(value, dict): + return {} + normalized: dict[str, dict[str, str]] = {} + for locale, payload in value.items(): + locale_key = _coerce_str(locale) + if not locale_key or not isinstance(payload, dict): + continue + title = _coerce_str(payload.get("title")) + summary = _coerce_str(payload.get("summary")) + entry: dict[str, str] = {} + if title: + entry["title"] = title + if summary: + entry["summary"] = summary + if entry: + normalized[locale_key] = entry + return normalized + + +def _get_locale_text( + item: ParsedNewsItem, + key: str, + *, + locale: str = DEFAULT_NEWS_LOCALE, +) -> str: + localized = item.localizations.get(locale) + if isinstance(localized, dict): + value = _coerce_str(localized.get(key)) + if value: + return value + return "" + + +def _has_default_localization(item: ParsedNewsItem) -> bool: + localized = item.localizations.get(DEFAULT_NEWS_LOCALE) + if not isinstance(localized, dict): + return False + return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary"))) + + +def apply_enrichment_patch_to_item( + item: ParsedNewsItem, + patch: dict[str, Any], +) -> ParsedNewsItem: + item.location_patch = patch + if "content_language" in patch: + item.content_language = _coerce_str(patch.get("content_language")) or item.content_language + if "localizations" in patch: + item.localizations = _normalize_localizations(patch.get("localizations")) + if "enrichment_status" in patch: + item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status + if "enrichment_error" in patch: + item.enrichment_error = _coerce_str(patch.get("enrichment_error")) + if "enriched_at" in patch: + item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at"))) + return item + + async def _geocode_target_location(query: str) -> dict[str, Any] | None: return await asyncio.to_thread(_news_target_geocode, query) @@ -513,42 +580,60 @@ async def _infer_news_target_location( item: ParsedNewsItem, *, provider_client: AIProviderClient | None, + prompt: EffectiveAIPrompt | None = None, ) -> NewsTargetLocation | None: + target, _localizations = await _infer_news_enrichment( + item, + provider_client=provider_client, + prompt=prompt, + ) + return target + + +async def _infer_news_enrichment( + item: ParsedNewsItem, + *, + provider_client: AIProviderClient | None, + prompt: EffectiveAIPrompt | None = None, +) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]: text_hint = await _extract_target_location_from_text(item) + content_error: str | None = None if text_hint is not None and text_hint.city: item.target_resolution_stage = text_hint.source item.target_ai_attempted = False item.target_ai_status = "skipped_text_hint" item.target_ai_error = None item.target_debug_note = f"text hint matched {text_hint.label}" - return text_hint + localizations: dict[str, dict[str, str]] = {} if provider_client is None: - item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" - item.target_ai_attempted = False - item.target_ai_status = "unavailable" - item.target_ai_error = "AI provider is not configured or unavailable for earth-feed." + if text_hint is None or not text_hint.city: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_attempted = False + item.target_ai_status = "unavailable" + item.target_ai_error = "AI provider is not configured or unavailable for earth-feed." + item.target_debug_note = ( + f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched" + ) + item.enrichment_status = "unavailable" + item.enrichment_error = "AI provider is not configured or unavailable for earth-feed." + return text_hint, localizations + + if text_hint is None or not text_hint.city: + item.target_ai_attempted = True + item.target_ai_status = "attempted" + item.target_ai_error = None item.target_debug_note = ( f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched" ) - return text_hint - - item.target_ai_attempted = True - item.target_ai_status = "attempted" - item.target_ai_error = None - item.target_debug_note = ( - f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched" - ) + item.enrichment_status = "attempted" + item.enrichment_error = None + prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY) request = SituationalAnalysisRequest( - title="Infer likely event location for Earth news cruise", - objective=( - "Return exactly one strict JSON object for the most likely physical " - "location the news event is about. Prefer the host city when a state " - "visit, summit, meeting, attack, or disaster is clearly centered in a " - "known city. Fall back to the best-supported country only when a city " - "cannot be inferred." - ), + title="Enrich Earth news item with event location and zh-CN content", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, context={ "news_item": { "title": item.title, @@ -564,17 +649,28 @@ async def _infer_news_target_location( ), }, "required_json_schema": { - "country": "string|null", - "city": "string|null", - "matched_location_name": "string|null", - "latitude": "number|null", - "longitude": "number|null", - "confidence": "number from 0 to 1", - "reasoning_summary": "short string", + "location": { + "country": "string|null", + "city": "string|null", + "matched_location_name": "string|null", + "latitude": "number|null", + "longitude": "number|null", + "confidence": "number from 0 to 1", + "reasoning_summary": "short string", + }, + "localizations": { + "zh-CN": { + "title": "faithful Simplified Chinese title", + "summary": "1-2 sentence faithful Simplified Chinese summary", + } + }, }, }, constraints=[ "Return only strict JSON. Do not wrap it in markdown.", + "For localizations, do not add facts that are absent from the RSS headline, description, source, or date.", + "If the RSS description is thin, write a conservative summary that says only what is supported.", + "Keep zh-CN summary concise, factual, and non-promotional.", "Prefer the event location, not the newsroom or publisher headquarters.", "When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.", "Use null for unknown fields instead of inventing details.", @@ -584,40 +680,68 @@ async def _infer_news_target_location( try: response = await provider_client.analyze(request) except Exception as exc: - item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" - item.target_ai_status = "provider_error" - item.target_ai_error = str(exc) - return text_hint + if text_hint is None or not text_hint.city: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "provider_error" + item.target_ai_error = str(exc) + item.enrichment_status = "provider_error" + item.enrichment_error = str(exc) + return text_hint, localizations payload = _first_json_object(response.content) if not isinstance(payload, dict): - item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" - item.target_ai_status = "parse_error" - item.target_ai_error = "AI response did not contain a parseable JSON object." - return text_hint + if text_hint is None or not text_hint.city: + item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" + item.target_ai_status = "parse_error" + item.target_ai_error = "AI response did not contain a parseable JSON object." + item.enrichment_status = "parse_error" + item.enrichment_error = "AI response did not contain a parseable JSON object." + return text_hint, localizations - target = await _build_target_location_from_payload(payload) + localizations = _normalize_localizations(payload.get("localizations")) + if not localizations: + content_error = "AI returned no usable localizations." + + location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload + if text_hint is not None and text_hint.city: + target = text_hint + else: + target = await _build_target_location_from_payload(location_payload) if target is None: item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" item.target_ai_status = "no_result" item.target_ai_error = "AI returned no usable target coordinates or geocodeable location." - return text_hint - if target.confidence is not None and target.confidence < 0.45: + target = text_hint + elif target.confidence is not None and target.confidence < 0.45: item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved" item.target_ai_status = "low_confidence" item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}" - return text_hint - item.target_resolution_stage = target.source - item.target_ai_status = "success" - item.target_ai_error = None - item.target_debug_note = f"ai inferred {target.label}" - return target + target = text_hint + else: + item.target_resolution_stage = target.source + item.target_ai_status = "success" + item.target_ai_error = None + item.target_debug_note = f"ai inferred {target.label}" + + item.localizations = localizations + if localizations and item.target_ai_status in {"success", "skipped_text_hint"}: + item.enrichment_status = "success" + item.enrichment_error = None + elif localizations: + item.enrichment_status = "content_only" + item.enrichment_error = item.target_ai_error + else: + item.enrichment_status = "location_only" if target is not None else "no_result" + item.enrichment_error = content_error or item.target_ai_error + item.enriched_at = datetime.now(UTC) if localizations else None + return target, localizations async def _enrich_items_with_target_locations( items: list[ParsedNewsItem], *, provider_client: AIProviderClient | None, + prompt: EffectiveAIPrompt | None = None, ) -> list[ParsedNewsItem]: if not items: return items @@ -626,7 +750,11 @@ async def _enrich_items_with_target_locations( async def enrich(item: ParsedNewsItem) -> ParsedNewsItem: async with semaphore: - target = await _infer_news_target_location(item, provider_client=provider_client) + target = await _infer_news_target_location( + item, + provider_client=provider_client, + prompt=prompt, + ) item.target_location = target return item @@ -783,6 +911,20 @@ def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | Non } +def _serialize_enriched_at(value: datetime | None) -> str | None: + return value.isoformat().replace("+00:00", "Z") if value else None + + +def _content_patch(item: ParsedNewsItem) -> dict[str, Any]: + return { + "content_language": item.content_language, + "localizations": item.localizations, + "enrichment_status": item.enrichment_status, + "enrichment_error": item.enrichment_error, + "enriched_at": _serialize_enriched_at(item.enriched_at), + } + + def build_anchor_location_patch( item: ParsedNewsItem, *, @@ -798,6 +940,9 @@ def build_anchor_location_patch( resolution_stage = item.target_resolution_stage ai_status = item.target_ai_status debug_note = item.target_debug_note + content_patch = _content_patch(item) + if queued and content_patch["enrichment_status"] == "pending": + content_patch["enrichment_status"] = "queued" return { "latitude": anchor.latitude, "longitude": anchor.longitude, @@ -814,6 +959,7 @@ def build_anchor_location_patch( "target": None, "anchor": _serialize_anchor(anchor), }, + **content_patch, } @@ -836,6 +982,7 @@ def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation "target": _serialize_target(target), "anchor": _serialize_anchor(anchor), }, + **_content_patch(item), } @@ -845,6 +992,11 @@ def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]: "id": item.id, "title": item.title, "summary": item.summary, + "content_language": item.content_language, + "localizations": item.localizations, + "enrichment_status": item.enrichment_status, + "enrichment_error": item.enrichment_error, + "enriched_at": _serialize_enriched_at(item.enriched_at), "url": item.url, "source": item.source, "feed_name": item.feed_name, @@ -859,6 +1011,11 @@ def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem id=str(payload.get("id") or ""), title=str(payload.get("title") or ""), summary=str(payload.get("summary") or ""), + content_language=str(payload.get("content_language") or "en"), + localizations=_normalize_localizations(payload.get("localizations")), + enrichment_status=str(payload.get("enrichment_status") or "pending"), + enrichment_error=_coerce_str(payload.get("enrichment_error")), + enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))), url=str(payload.get("url") or ""), source=str(payload.get("source") or ""), feed_name=str(payload.get("feed_name") or ""), @@ -875,10 +1032,15 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An "id": item.id, "title": item.title, "summary": item.summary, + "content_language": item.content_language, + "localizations": item.localizations, + "display_title": _get_locale_text(item, "title"), + "display_summary": _get_locale_text(item, "summary"), "url": item.url, "source": item.source, "feed_name": item.feed_name, "region": item.feed_region, + "display_region": get_region_anchor(item.feed_region).label, "homepage_url": item.homepage_url, "published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None, "latitude": location_patch["latitude"], @@ -887,6 +1049,9 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An "location_source": location_patch["location_source"], "verified": location_patch["verified"], "location_meta": location_patch["location_meta"], + "enrichment_status": item.enrichment_status, + "enrichment_error": item.enrichment_error, + "enriched_at": _serialize_enriched_at(item.enriched_at), "is_focus_match": item.feed_region == active_region, } @@ -911,6 +1076,7 @@ def _build_payload( "lon": lon, "region": active_region, "label": profile.label, + "display_region": get_region_anchor(active_region).label, "accent": profile.accent, }, "sources": _serialize_sources(sources), @@ -966,13 +1132,27 @@ async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> li get_cached_target_location_patch, ) + async def enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool: + return await enqueue_target_location_job(build_target_location_job_payload(item), force=force) + async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem: cached_patch = await get_cached_target_location_patch(item.id) if cached_patch: - item.location_patch = cached_patch + apply_enrichment_patch_to_item(item, cached_patch) + if not _has_default_localization(item): + queued = await enqueue_item(item, force=True) + if queued and item.enrichment_status in { + "pending", + "unavailable", + "provider_error", + "parse_error", + "no_result", + "location_only", + }: + item.enrichment_status = "queued" return item - queued = await enqueue_target_location_job(build_target_location_job_payload(item)) + queued = await enqueue_item(item) item.location_patch = build_anchor_location_patch( item, queued=queued, @@ -991,9 +1171,16 @@ async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None: await asyncio.gather( *( - enqueue_target_location_job(build_target_location_job_payload(item)) + enqueue_target_location_job( + build_target_location_job_payload(item), + force=not _has_default_localization(item), + ) for item in items - if item.location_patch is None or item.location_patch.get("verified") is False + if ( + item.location_patch is None + or item.location_patch.get("verified") is False + or not _has_default_localization(item) + ) ) ) diff --git a/backend/app/services/earth_news_queue.py b/backend/app/services/earth_news_queue.py index d3d6b1de..c4a0e213 100644 --- a/backend/app/services/earth_news_queue.py +++ b/backend/app/services/earth_news_queue.py @@ -32,7 +32,7 @@ class NewsTargetLocationMessage: class NewsTargetLocationQueue(Protocol): - async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool: + async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool: ... async def consume_batch( @@ -91,9 +91,11 @@ class RedisStreamsNewsTargetLocationQueue: raise self._group_ready = True - async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool: + async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool: await self._ensure_group() - if await self.client.exists(_result_key(item_id)): + if force: + await self.client.delete(_result_key(item_id), _queued_key(item_id)) + elif await self.client.exists(_result_key(item_id)): return False queued = await self.client.set( _queued_key(item_id), @@ -187,13 +189,13 @@ def get_news_target_location_queue() -> NewsTargetLocationQueue: return RedisStreamsNewsTargetLocationQueue() -async def enqueue_target_location_job(payload: dict[str, Any]) -> bool: +async def enqueue_target_location_job(payload: dict[str, Any], *, force: bool = False) -> bool: item_id = str(payload.get("id") or "") if not item_id: return False try: queue = get_news_target_location_queue() - return await queue.enqueue(item_id=item_id, payload=payload) + return await queue.enqueue(item_id=item_id, payload=payload, force=force) except Exception as exc: logger.warning_event( "Failed to enqueue Earth news target location job", diff --git a/backend/app/services/earth_news_store.py b/backend/app/services/earth_news_store.py index 63d8537a..910d7791 100644 --- a/backend/app/services/earth_news_store.py +++ b/backend/app/services/earth_news_store.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.earth_news import EarthNewsItem from app.services.earth_news import ( ParsedNewsItem, + apply_enrichment_patch_to_item, build_anchor_location_patch, ) @@ -33,7 +34,7 @@ def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]: def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem: - return ParsedNewsItem( + item = ParsedNewsItem( id=record.id, title=record.title, summary=record.summary or "", @@ -43,8 +44,13 @@ def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem: feed_region=record.region or "global", homepage_url=record.homepage_url or "", published_at=_coerce_datetime(record.published_at), - location_patch=_location_patch_from_record(record), + content_language=record.content_language or "en", + localizations=dict(record.localizations or {}), + enrichment_status=record.enrichment_status or "pending", + enrichment_error=record.enrichment_error, + enriched_at=_coerce_datetime(record.enriched_at), ) + return apply_enrichment_patch_to_item(item, _location_patch_from_record(record)) def _query_sort_key(active_region: str): @@ -108,6 +114,8 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) id=item.id, title=item.title, summary=item.summary, + content_language=item.content_language, + localizations=dict(item.localizations or {}), url=item.url, source=item.source, feed_name=item.feed_name, @@ -122,6 +130,9 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) location_meta=patch["location_meta"], first_seen_at=now, last_seen_at=now, + enrichment_status=item.enrichment_status, + enrichment_error=item.enrichment_error, + enriched_at=item.enriched_at, ) db.add(record) changed += 1 @@ -136,6 +147,12 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) record.homepage_url = item.homepage_url record.published_at = item.published_at record.last_seen_at = now + if item.localizations: + record.content_language = item.content_language + record.localizations = dict(item.localizations or {}) + record.enrichment_status = item.enrichment_status + record.enrichment_error = item.enrichment_error + record.enriched_at = item.enriched_at changed += 1 await db.flush() return changed @@ -161,6 +178,45 @@ async def update_earth_news_item_location( return True +async def update_earth_news_item_enrichment( + db: AsyncSession, + *, + item_id: str, + patch: dict[str, Any], +) -> bool: + record = await db.get(EarthNewsItem, item_id) + if record is None: + return False + if "latitude" in patch: + record.latitude = float(patch["latitude"]) + record.longitude = float(patch["longitude"]) + record.location_label = str(patch["location_label"]) + record.location_source = str(patch["location_source"]) + record.verified = bool(patch["verified"]) + record.location_meta = dict(patch.get("location_meta") or {}) + record.resolved_at = datetime.now(UTC) if record.verified else None + if "content_language" in patch: + record.content_language = str(patch.get("content_language") or "en") + if "localizations" in patch: + record.localizations = dict(patch.get("localizations") or {}) + if "enrichment_status" in patch: + record.enrichment_status = str(patch.get("enrichment_status") or "pending") + if "enrichment_error" in patch: + record.enrichment_error = patch.get("enrichment_error") + if patch.get("enriched_at"): + try: + parsed_enriched_at = datetime.fromisoformat( + str(patch["enriched_at"]).replace("Z", "+00:00") + ) + except ValueError: + parsed_enriched_at = datetime.now(UTC) + record.enriched_at = _coerce_datetime(parsed_enriched_at) + elif patch.get("localizations"): + record.enriched_at = datetime.now(UTC) + await db.flush() + return True + + async def list_unverified_earth_news_items( db: AsyncSession, *, diff --git a/backend/app/services/earth_news_worker.py b/backend/app/services/earth_news_worker.py index ca4c7204..b53442f3 100644 --- a/backend/app/services/earth_news_worker.py +++ b/backend/app/services/earth_news_worker.py @@ -9,8 +9,10 @@ from app.core.logging import get_logger from app.core.websocket.broadcaster import broadcaster from app.db.session import async_session_factory from app.services.ai_client import AIProviderClient +from app.ai_tasks.prompts import get_effective_prompt from app.services.earth_news import ( - _infer_news_target_location, + NEWS_ENRICH_PROMPT_KEY, + _infer_news_enrichment, build_target_location_patch, parsed_news_item_from_job_payload, ) @@ -19,7 +21,7 @@ from app.services.earth_news_queue import ( get_news_target_location_queue, save_target_location_patch, ) -from app.services.earth_news_store import update_earth_news_item_location +from app.services.earth_news_store import update_earth_news_item_enrichment as update_earth_news_item_location logger = get_logger(__name__, service="earth_news") @@ -59,8 +61,15 @@ async def process_target_location_message( provider_client: AIProviderClient | None, ) -> dict[str, Any]: item = parsed_news_item_from_job_payload(message.payload) - target = await _infer_news_target_location(item, provider_client=provider_client) + async with async_session_factory() as session: + prompt = await get_effective_prompt(session, NEWS_ENRICH_PROMPT_KEY) + target, localizations = await _infer_news_enrichment( + item, + provider_client=provider_client, + prompt=prompt, + ) item.target_location = target + item.localizations = localizations or item.localizations patch = build_target_location_patch(item, target) await save_target_location_patch(item.id, patch) async with async_session_factory() as session: diff --git a/backend/app/services/location/llm_fallback.py b/backend/app/services/location/llm_fallback.py index 812c4523..c523d889 100644 --- a/backend/app/services/location/llm_fallback.py +++ b/backend/app/services/location/llm_fallback.py @@ -7,8 +7,11 @@ import re from dataclasses import dataclass from typing import Any, Iterable +from sqlalchemy.ext.asyncio import AsyncSession + from app.core.countries import COUNTRY_ENTRIES, normalize_country from app.schemas.ai import SituationalAnalysisRequest +from app.ai_tasks.prompts import get_effective_prompt from app.services.ai_client import AIProviderClient from app.services.ai_tools.evidence_store import normalize_search_evidence from app.services.ai_tools.web_search import WebSearchClient, WebSearchError @@ -23,6 +26,8 @@ from app.services.location.text import ( VALID_LLM_PRECISIONS = {"precise", "site", "city"} DEFAULT_MIN_CONFIDENCE = 0.55 +LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize" +LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve" MODEL_CONFIDENCE_WEIGHT = 0.25 _geocode_llm_city = build_default_nominatim_geocoder() _LLM_LOCATION_NAME_KEYS = ( @@ -876,6 +881,7 @@ async def _repair_location_payload_from_text( raw_text: str, query: LocationQuery, entity_type: str, + db: AsyncSession | None = None, ) -> dict[str, Any] | None: """Second-pass structure repair for models that answer in prose. @@ -884,12 +890,11 @@ async def _repair_location_payload_from_text( """ if not coerce_str(raw_text): return None + prompt = await get_effective_prompt(db, LOCATION_NORMALIZE_PROMPT_KEY) request = SituationalAnalysisRequest( title=f"Normalize location factcheck for {entity_type}", - objective=( - "Convert the supplied location factcheck text into exactly one strict " - "JSON object. Extract only facts present in the text or original query." - ), + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, context={ "entity_type": entity_type, "location_query": _query_context(query), @@ -929,6 +934,7 @@ async def collect_llm_location_fallback_candidate( provider_client: AIProviderClient, query: LocationQuery, entity_type: str, + db: AsyncSession | None = None, attempted_queries: Iterable[str] = (), search_evidence: list[dict[str, Any]] | None = None, min_confidence: float = DEFAULT_MIN_CONFIDENCE, @@ -946,13 +952,11 @@ async def collect_llm_location_fallback_candidate( attempted_queries=[attempt], failure_reason="LLM location factcheck skipped: no WebSearch evidence.", ) + prompt = await get_effective_prompt(db, LOCATION_RESOLVE_PROMPT_KEY) request = SituationalAnalysisRequest( title=f"Location factcheck fallback for {entity_type}", - objective=( - "Return exactly one JSON object for the most likely physical location. " - "Use only fact-checkable public knowledge; return null fields rather " - "than guessing when evidence is weak." - ), + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, context={ "entity_type": entity_type, "location_query": _query_context(query), @@ -1001,6 +1005,7 @@ async def collect_llm_location_fallback_candidate( raw_text=response.content, query=query, entity_type=entity_type, + db=db, ) if payload is None: payload = _payload_from_free_text(response.content, query=query) diff --git a/backend/app/services/situational_alert_ai_brief.py b/backend/app/services/situational_alert_ai_brief.py index e672669f..ab71d563 100644 --- a/backend/app/services/situational_alert_ai_brief.py +++ b/backend/app/services/situational_alert_ai_brief.py @@ -10,8 +10,11 @@ from app.models.alert import Alert, AlertSeverity, AlertStatus from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.schemas.ai import SituationalAnalysisRequest +from app.ai_tasks.prompts import get_effective_prompt from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record +SITUATIONAL_ALERT_BRIEF_PROMPT_KEY = "alerts.situational.brief" + def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str: if not pairs: @@ -158,10 +161,12 @@ async def build_situational_alert_brief_request( "latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None, "latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None, } + prompt = await get_effective_prompt(db, SITUATIONAL_ALERT_BRIEF_PROMPT_KEY) request = SituationalAnalysisRequest( title="态势告警 AI 简报", - objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。", + objective=prompt.prompt, + system_prompt=prompt.system_prompt or None, observations=facts, constraints=[ "明确区分事实、推断与建议。", diff --git a/backend/tests/test_earth_boundaries.py b/backend/tests/test_earth_boundaries.py new file mode 100644 index 00000000..4fca1c1a --- /dev/null +++ b/backend/tests/test_earth_boundaries.py @@ -0,0 +1,177 @@ +import json + +import pytest + +from app.core.datasource_defaults import DEFAULT_DATASOURCES +from app.services.collectors.registry import collector_registry +from app.services import earth_boundaries + + +def write_geojson(path, name="Test"): + path.write_text( + json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {"name": name}, + "geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + ], + } + ), + encoding="utf-8", + ) + + +def patch_paths(monkeypatch, tmp_path): + repo = tmp_path + source_dir = repo / "data/earth-boundary-sources" + boundary_dir = repo / "frontend/public/earth/data/boundaries/v1" + pmtiles = repo / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles" + legacy = repo / "frontend/public/earth/data/countries-admin0.min.geojson" + config = repo / "config/earth-boundary-sources.local.json" + example = repo / "config/earth-boundary-sources.example.json" + policy = repo / "config/earth-boundary-pov-policy.china-v1.json" + for path in (source_dir, boundary_dir, pmtiles.parent, legacy.parent, config.parent): + path.mkdir(parents=True, exist_ok=True) + policy.write_text('{"productionTileFormat":"pmtiles+mvt"}\n', encoding="utf-8") + example.write_text('{"collectorConfigs":{}}\n', encoding="utf-8") + monkeypatch.setattr(earth_boundaries, "REPO_ROOT", repo) + monkeypatch.setattr(earth_boundaries, "SOURCE_OUTPUT_DIR", source_dir) + monkeypatch.setattr(earth_boundaries, "SOURCE_MANIFEST_PATH", source_dir / "manifest.json") + monkeypatch.setattr(earth_boundaries, "BUILD_RESULT_PATH", source_dir / "build-result.json") + monkeypatch.setattr(earth_boundaries, "BUILD_JOB_PATH", source_dir / "build-job.json") + monkeypatch.setattr(earth_boundaries, "BOUNDARY_OUTPUT_DIR", boundary_dir) + monkeypatch.setattr(earth_boundaries, "BOUNDARY_MANIFEST_PATH", boundary_dir / "manifest.json") + monkeypatch.setattr(earth_boundaries, "PMTILES_ARTIFACT_PATH", pmtiles) + monkeypatch.setattr(earth_boundaries, "LEGACY_GEOJSON_PATH", legacy) + monkeypatch.setattr(earth_boundaries, "LOCAL_CONFIG_PATH", config) + monkeypatch.setattr(earth_boundaries, "EXAMPLE_CONFIG_PATH", example) + monkeypatch.setattr(earth_boundaries, "POV_POLICY_PATH", policy) + return { + "repo": repo, + "config": config, + "legacy": legacy, + "pmtiles": pmtiles, + "manifest": boundary_dir / "manifest.json", + } + + +def test_boundary_status_uses_legacy_provider_when_pmtiles_missing(monkeypatch, tmp_path): + paths = patch_paths(monkeypatch, tmp_path) + write_geojson(paths["legacy"]) + + status = earth_boundaries.get_boundary_status() + + assert status["provider"] == "legacy-geojson" + assert status["fallback_available"] is True + assert status["high_precision_ready"] is False + + +def test_boundary_status_prefers_high_precision_when_manifest_and_pmtiles_exist(monkeypatch, tmp_path): + paths = patch_paths(monkeypatch, tmp_path) + write_geojson(paths["legacy"]) + paths["pmtiles"].write_bytes(b"pmtiles") + paths["manifest"].write_text('{"tileProvider":"pmtiles-mvt"}\n', encoding="utf-8") + + status = earth_boundaries.get_boundary_status() + + assert status["provider"] == "pmtiles-mvt" + assert status["high_precision_ready"] is True + + +def test_save_boundary_config_writes_local_config(monkeypatch, tmp_path): + paths = patch_paths(monkeypatch, tmp_path) + payload = {"collectorConfigs": {"earth_admin0_boundaries": {"endpoint": "file:///tmp/a.geojson"}}} + + status = earth_boundaries.save_boundary_config(payload) + + assert paths["config"].exists() + assert status["config_source"] == "local" + assert status["config"] == payload + + +@pytest.mark.asyncio +async def test_build_reports_missing_tools_after_source_artifacts(monkeypatch, tmp_path): + paths = patch_paths(monkeypatch, tmp_path) + source_files = {} + for source in earth_boundaries.BOUNDARY_SOURCE_KINDS: + source_path = paths["repo"] / f"{source}.geojson" + write_geojson(source_path, name=source) + source_files[source] = source_path + paths["config"].write_text( + json.dumps( + { + "collectorConfigs": { + source: { + "sourceKind": kind, + "endpoint": str(source_files[source]), + "method": "GET", + } + for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items() + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None) + progress_events = [] + + status = await earth_boundaries.build_boundary_assets( + lambda progress, phase, message, **_extra: progress_events.append((progress, phase, message)) + ) + + assert status["provider"] == "geojson-high-precision" + assert status["high_precision_ready"] is True + assert (paths["repo"] / "data/earth-boundary-sources/manifest.json").exists() + assert paths["manifest"].exists() + assert any(phase == "download" for _progress, phase, _message in progress_events) + + +@pytest.mark.asyncio +async def test_start_boundary_build_job_records_geojson_fallback_success(monkeypatch, tmp_path): + paths = patch_paths(monkeypatch, tmp_path) + monkeypatch.setattr(earth_boundaries, "_build_task", None) + monkeypatch.setattr(earth_boundaries, "_build_job_state", {}) + source_files = {} + for source in earth_boundaries.BOUNDARY_SOURCE_KINDS: + source_path = paths["repo"] / f"{source}.geojson" + write_geojson(source_path, name=source) + source_files[source] = source_path + paths["config"].write_text( + json.dumps( + { + "collectorConfigs": { + source: { + "sourceKind": kind, + "endpoint": str(source_files[source]), + "method": "GET", + } + for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items() + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None) + + response = await earth_boundaries.start_boundary_build_job() + await earth_boundaries._build_task + status = earth_boundaries.get_boundary_build_status() + + assert response["accepted"] is True + assert status["job"]["status"] == "succeeded" + assert status["job"]["result"]["provider"] == "geojson-high-precision" + + +def test_earth_boundary_collectors_are_not_registered_as_datasources(): + removed = set(earth_boundaries.BOUNDARY_SOURCE_KINDS) | {"earth_boundary_tiles"} + + assert removed.isdisjoint(DEFAULT_DATASOURCES) + for source in removed: + assert collector_registry.get(source) is None diff --git a/backend/tests/test_earth_news.py b/backend/tests/test_earth_news.py index 0921c084..5ca5b8ef 100644 --- a/backend/tests/test_earth_news.py +++ b/backend/tests/test_earth_news.py @@ -158,6 +158,56 @@ async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatc assert enriched[0].target_ai_error is None +@pytest.mark.asyncio +async def test_enrich_items_with_target_locations_adds_localizations(monkeypatch): + item = ParsedNewsItem( + id="global-scan:localized", + title="Global leaders meet to discuss energy security", + summary="Officials said the talks focused on supply chains and grid resilience.", + url="https://example.com/energy-security", + source="Example Source", + feed_name="Global Monitor / World", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC), + ) + + async def fake_geocode(_query: str): + return { + "lat": "50.1109", + "lon": "8.6821", + "display_name": "Frankfurt am Main, Germany", + } + + class FakeProviderClient: + async def analyze(self, _request): + class Response: + content = ( + '{"location":{"country":"Germany","city":"Frankfurt",' + '"matched_location_name":"Frankfurt, Germany",' + '"latitude":null,"longitude":null,"confidence":0.77},' + '"localizations":{"zh-CN":{"title":"全球领导人讨论能源安全",' + '"summary":"官员表示,会谈聚焦供应链和电网韧性。"}}}' + ) + + return Response() + + monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode) + + enriched = await _enrich_items_with_target_locations( + [item], + provider_client=FakeProviderClient(), + ) + payload = _serialize_item(enriched[0], active_region="global") + + assert payload["title"] == "Global leaders meet to discuss energy security" + assert payload["summary"] == "Officials said the talks focused on supply chains and grid resilience." + assert payload["localizations"]["zh-CN"]["title"] == "全球领导人讨论能源安全" + assert payload["display_title"] == "全球领导人讨论能源安全" + assert payload["display_summary"] == "官员表示,会谈聚焦供应链和电网韧性。" + assert payload["enrichment_status"] == "success" + + @pytest.mark.asyncio async def test_extract_target_location_from_text_uses_country_hint(monkeypatch): item = ParsedNewsItem( @@ -241,7 +291,7 @@ async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job enqueued_payloads = [] - async def fake_enqueue_target_location_job(payload): + async def fake_enqueue_target_location_job(payload, **_kwargs): enqueued_payloads.append(payload) return True @@ -260,6 +310,8 @@ async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job assert len(payload["items"]) == 1 assert payload["items"][0]["id"] == "test-feed:timeout" + assert payload["items"][0]["display_title"] == "" + assert payload["items"][0]["display_summary"] == "" assert payload["items"][0]["latitude"] == 20.0 assert payload["items"][0]["longitude"] == 0.0 assert payload["items"][0]["location_source"] == "region_anchor" @@ -359,7 +411,7 @@ async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatc async def fake_list_earth_news_items(_db, *, active_region, limit): return [item] - async def fake_enqueue_target_location_job(payload): + async def fake_enqueue_target_location_job(payload, **_kwargs): enqueued.append(payload) return True @@ -415,7 +467,7 @@ async def test_earth_news_payload_supplements_stale_database_items(monkeypatch): async def fake_list_earth_news_items(_db, *, active_region, limit): return [old_item] - async def fake_enqueue_target_location_job(_payload): + async def fake_enqueue_target_location_job(_payload, **_kwargs): return True monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness) @@ -473,8 +525,11 @@ async def test_earth_news_payload_merges_cached_location_patch(monkeypatch): async def fake_get_cached_target_location_patch(_item_id): return cached_patch - async def fake_enqueue_target_location_job(_payload): - raise AssertionError("cached items should not be enqueued") + enqueued = [] + + async def fake_enqueue_target_location_job(payload, **_kwargs): + enqueued.append(payload) + return True monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source]) monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source) @@ -493,6 +548,70 @@ async def test_earth_news_payload_merges_cached_location_patch(monkeypatch): assert payload["items"][0]["longitude"] == 116.3912972 assert payload["items"][0]["verified"] is True assert payload["items"][0]["location_source"] == "headline_location_hint" + assert enqueued[0]["id"] == "test-feed:cached" + + +@pytest.mark.asyncio +async def test_earth_news_payload_requeues_cached_failed_localization(monkeypatch): + source = NewsFeedSource( + id="test-feed", + name="Test Feed", + region="global", + homepage_url="https://example.com", + feed_url="https://example.com/rss.xml", + ) + item = ParsedNewsItem( + id="test-feed:failed-localization", + title="Failed localization story", + summary="English source summary.", + url="https://example.com/failed-localization", + source="Test Feed", + feed_name="Test Feed", + feed_region="global", + homepage_url="https://example.com", + published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), + ) + cached_patch = { + "latitude": 20.0, + "longitude": 0.0, + "location_label": "全球", + "location_source": "region_anchor", + "verified": False, + "location_meta": {"target": None, "anchor": {"region": "global"}}, + "content_language": "en", + "localizations": {}, + "enrichment_status": "parse_error", + "enrichment_error": "AI response did not contain a parseable JSON object.", + "enriched_at": None, + } + enqueued = [] + + async def fake_fetch_source(_client, feed_source): + return feed_source, [item], None + + async def fake_get_cached_target_location_patch(_item_id): + return cached_patch + + async def fake_enqueue_target_location_job(payload, **_kwargs): + enqueued.append(payload) + return True + + monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source]) + monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source) + monkeypatch.setattr( + "app.services.earth_news_queue.get_cached_target_location_patch", + fake_get_cached_target_location_patch, + ) + monkeypatch.setattr( + "app.services.earth_news_queue.enqueue_target_location_job", + fake_enqueue_target_location_job, + ) + + payload = await get_earth_news_payload(provider_client=None) + + assert enqueued[0]["id"] == "test-feed:failed-localization" + assert payload["items"][0]["display_title"] == "" + assert payload["items"][0]["enrichment_status"] == "queued" @pytest.mark.asyncio @@ -598,6 +717,11 @@ async def test_media_news_archive_collector_maps_news_items(monkeypatch): location_source="headline_location_hint", verified=True, location_meta={"target": {"country": "中国", "city": "Beijing"}}, + content_language="en", + localizations={"zh-CN": {"title": "归档新闻", "summary": "归档概要"}}, + enrichment_status="success", + enrichment_error=None, + enriched_at=datetime(2026, 5, 15, 3, 6, tzinfo=UTC), first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC), last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC), resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC), @@ -619,3 +743,5 @@ async def test_media_news_archive_collector_maps_news_items(monkeypatch): assert items[0]["city"] == "Beijing" assert items[0]["latitude"] == 39.9057136 assert items[0]["metadata"]["verified"] is True + assert "localizations" not in items[0]["metadata"] + assert "enrichment_status" not in items[0]["metadata"] diff --git a/backend/tests/test_settings_ai_prompts.py b/backend/tests/test_settings_ai_prompts.py new file mode 100644 index 00000000..3ffc21c2 --- /dev/null +++ b/backend/tests/test_settings_ai_prompts.py @@ -0,0 +1,92 @@ +from types import SimpleNamespace + +import pytest + +from app.ai_tasks.prompts import ( + get_effective_prompt, + list_effective_prompts, + reset_prompt_override, + save_prompt_override, +) + + +class _ScalarResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class _PromptSettingsDB: + def __init__(self, payload=None): + self.record = SimpleNamespace(category="ai_prompts", payload=payload) if payload is not None else None + self.added = None + self.commits = 0 + + async def execute(self, _statement): + return _ScalarResult(self.record) + + def add(self, record): + self.record = record + self.added = record + + async def commit(self): + self.commits += 1 + + +@pytest.mark.asyncio +async def test_prompt_defaults_are_loaded_without_override(): + db = _PromptSettingsDB() + + prompt = await get_effective_prompt(db, "earth.news.enrich") + + assert prompt.key == "earth.news.enrich" + assert prompt.is_custom is False + assert "strict JSON" in prompt.prompt + + +@pytest.mark.asyncio +async def test_prompt_override_save_and_reset(): + db = _PromptSettingsDB() + + saved = await save_prompt_override( + db, + "alerts.brief", + system_prompt="system custom", + prompt="prompt custom", + ) + + assert saved.is_custom is True + assert saved.system_prompt == "system custom" + assert saved.prompt == "prompt custom" + assert db.commits == 1 + + effective = await get_effective_prompt(db, "alerts.brief") + assert effective.prompt == "prompt custom" + + reset = await reset_prompt_override(db, "alerts.brief") + assert reset.is_custom is False + assert reset.prompt != "prompt custom" + + +@pytest.mark.asyncio +async def test_prompt_list_marks_custom_items(): + db = _PromptSettingsDB( + { + "overrides": { + "bgp.brief": { + "system_prompt": "", + "prompt": "custom bgp prompt", + "updated_at": "2026-05-16T00:00:00Z", + } + } + } + ) + + prompts = await list_effective_prompts(db) + by_key = {prompt.key: prompt for prompt in prompts} + + assert by_key["bgp.brief"].is_custom is True + assert by_key["bgp.brief"].prompt == "custom bgp prompt" + assert by_key["earth.news.enrich"].is_custom is False diff --git a/config/earth-boundary-sources.example.json b/config/earth-boundary-sources.example.json index ba2ecc30..cb27f603 100644 --- a/config/earth-boundary-sources.example.json +++ b/config/earth-boundary-sources.example.json @@ -5,18 +5,17 @@ "povPolicyPath": "config/earth-boundary-pov-policy.china-v1.json", "productionTileFormat": "pmtiles+mvt", "debugTileFormat": "geojson-directory", - "description": "Example Collector Settings payloads for audited Earth boundary source ingestion. Replace every endpoint, license, and checksum note before production." + "description": "Default Earth boundary update sources. These public Natural Earth endpoints make local high-precision boundary download work out of the box; replace with audited internal sources for production if needed." }, "collectorConfigs": { "earth_admin0_boundaries": { "displayName": "Earth Admin-0 国界源", "sourceKind": "admin0-boundaries", - "endpoint": "https://example.com/admin0-boundaries.geojson", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson", "method": "GET", "headers": {}, "auth_type": "none", - "target_schema": "earth_boundary_source", - "license": "REPLACE_WITH_SOURCE_LICENSE", + "license": "Natural Earth public domain", "mapping_json": { "source": { "items_path": "$.features[*]" @@ -44,12 +43,11 @@ "earth_coastline": { "displayName": "Earth 海岸线源", "sourceKind": "coastline", - "endpoint": "https://example.com/coastline.geojson", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson", "method": "GET", "headers": {}, "auth_type": "none", - "target_schema": "earth_boundary_source", - "license": "REPLACE_WITH_SOURCE_LICENSE", + "license": "Natural Earth public domain", "mapping_json": { "source": { "items_path": "$.features[*]" @@ -77,12 +75,11 @@ "earth_claim_lines": { "displayName": "Earth 主张线源", "sourceKind": "claim-lines", - "endpoint": "https://example.com/claim-lines.geojson", + "endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson", "method": "GET", "headers": {}, "auth_type": "none", - "target_schema": "earth_boundary_source", - "license": "REPLACE_WITH_SOURCE_LICENSE", + "license": "Natural Earth public domain", "mapping_json": { "source": { "items_path": "$.features[*]" @@ -109,7 +106,7 @@ } }, "notes": [ - "Use the actual Collector Settings page as the source of truth for runtime endpoints.", - "The repository seed GeoJSON may be used only for smoke tests and cannot satisfy production high precision." + "Earth can download these sources directly from the toolbar settings when no local source override exists.", + "If tippecanoe/pmtiles are unavailable, the backend generates a GeoJSON high-precision package so the feature remains usable." ] } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4676bcd4..e151d739 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.59.0] — 2026-05-16 + +Released: 2026-05-16 + +### Highlights +- 将 Earth 国界从采集器体系迁移为 Earth 静态资产,恢复低精 GeoJSON fallback,并新增 Earth 工具栏高精国界下载/构建进度与热应用。 +- 重组后台“运维与配置”:新增 Earth 内容与采集管理二级入口,电视直播、国界精度、采集器、采集调度各归其位,未接入模块以占位页呈现。 +- 新增 AI task prompt 覆盖管理,按稳定 task key 管理新闻汉化、告警研判、BGP 简报等业务提示词,避免全局 prompt 污染。 + +### Added / Fixed / Improved +- Earth 新闻锚点链路增加队列化 enrichment 状态、Redis Streams 后台精修和 WebSocket patch 语义,前端汉化/锚点策略更稳定。 +- 国界 hover 与 interactable tooltip 解耦,鼠标位于国家 polygon 内时保持国界高亮,同时卫星/船只/BGP 等对象仍可显示自身信息。 +- 新增 `/api/v1/earth/boundaries/*` 状态、配置、构建和进度接口,并在启动初始化中清理旧 boundary datasource/task/snapshot 历史入口。 +- 补齐中英文用户手册、FAQ、quickstart、运维手册和开发者上下文文档,明确用户 UI、运维操作和开发者稳定边界。 + +--- + ## [0.58.0] — 2026-05-15 Released: 2026-05-15 diff --git a/docs/plans/ai-prompt-settings-task-registry-plan.md b/docs/plans/ai-prompt-settings-task-registry-plan.md new file mode 100644 index 00000000..8fa426ed --- /dev/null +++ b/docs/plans/ai-prompt-settings-task-registry-plan.md @@ -0,0 +1,52 @@ +# AI Prompt Settings and Task Registry Plan + +## Summary + +Add an AI prompt settings tab under the operations AI settings page. Operators can select a business AI task from a dropdown, edit its prompt, save the override, and reset it back to the shipped default. Runtime LLM calls must resolve prompts through a task registry instead of embedding large prompt blocks at each call site. + +Default prompts are shipped as versioned resource data, not scattered business-code literals. Business services reference stable task keys, and the runtime resolves the effective prompt from the database override first, then the shipped default resource. + +## Key Changes + +- Add a backend task prompt registry with stable keys, labels, groups, versions, default system prompts, and default task prompts. +- Store operator overrides in the existing `SystemSetting` table under an `ai_prompts` category. Store only custom overrides; defaults remain in the versioned prompt resource. +- Add settings APIs: + - `GET /api/v1/settings/ai-prompts` + - `PUT /api/v1/settings/ai-prompts/{task_key}` + - `POST /api/v1/settings/ai-prompts/{task_key}/reset` +- Migrate business LLM entrypoints to resolve prompts by task key. `aiprovider` remains a pure model adapter and does not inject business prompts. +- Add a “提示词” tab to `/ai`. The tab shows a grouped task dropdown, current/default prompt status, editable prompt fields, save, and reset-to-default controls. + +## Initial Tasks + +- `earth.news.enrich` — Earth news localization and location enrichment. +- `alerts.brief` — system alert AI brief. +- `alerts.situational.brief` — situational alert AI brief. +- `bgp.brief` — BGP AI brief. +- `location.factcheck.normalize` — location factcheck normalization. +- `location.factcheck.resolve` — location factcheck fallback resolution. +- `datasource.mapping` — datasource mapping DSL generation. +- `credential.guide` — credential guide generation. +- `ai.connection_test` — AI provider connection test. + +Playground and public free-form analyze endpoints stay caller-controlled and are not shown in the prompt settings dropdown. + +## Test Plan + +- Backend uses `uv`: + - `uv run pytest tests/test_settings_ai_prompts.py` + - `uv run pytest tests/test_earth_news.py` + - `uv run pytest tests/test_api.py` +- Frontend uses `bun`: + - `cd frontend && bun run build` +- Manual checks: + - Prompt dropdown switches task content correctly. + - Save persists an override and runtime calls use it. + - Reset deletes the override and restores the shipped default. + - Alert prompts do not leak into news, BGP, datasource, or location tasks. + +## Assumptions + +- This iteration does not add prompt history, approval workflows, A/B testing, or per-user prompt variants. +- Strict JSON tasks may fail validation if an operator edits away the output contract; existing task-specific failure and retry behavior remains responsible for recovery. +- The UI stays Chinese-only in this iteration. diff --git a/docs/plans/docs-audience-split-plan.md b/docs/plans/docs-audience-split-plan.md index 299ce690..ddd23b83 100644 --- a/docs/plans/docs-audience-split-plan.md +++ b/docs/plans/docs-audience-split-plan.md @@ -31,7 +31,7 @@ 3. **登录与找回密码** — 登录页、忘记密码流程 4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出 5. **Console 总览** — 左侧菜单结构、各路由用途 -6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子 +6. **配置数据采集器** — `/collection-management?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子 7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR) 8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件) 9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组 @@ -48,7 +48,7 @@ - 打开管理员给你的 URL - 注册账号 + 邮箱验证 -- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型) +- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型) - 看 Earth 部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。 diff --git a/docs/plans/earth-high-precision-boundary-tiles-plan.md b/docs/plans/earth-high-precision-boundary-tiles-plan.md index 661a8b8d..1de80317 100644 --- a/docs/plans/earth-high-precision-boundary-tiles-plan.md +++ b/docs/plans/earth-high-precision-boundary-tiles-plan.md @@ -2,7 +2,11 @@ ## Status -Current implementation: +Superseded status: + +This plan originally treated boundaries as collector-managed source records. The current implementation has moved country boundaries out of the datasource / collector lifecycle. Boundaries are now Earth static rendering assets managed by `Operations and Configuration -> Earth Content -> Boundary Precision` and `/api/v1/earth/boundaries/*`. The bundled low-precision GeoJSON is the default fallback, and high precision is an opt-in local PMTiles build. + +Historical implementation notes below are retained only as context and must not be used as the current architecture: - Three standard source collectors now handle real source ingestion: `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`. - Each source collector reads endpoint / headers / auth / `target_schema=earth_boundary_source` from Collector Settings, downloads the configured payload, stores the full artifact under `data/earth-boundary-sources//.*`, and writes a hash / feature-count / artifact-path record to `CollectedData`. @@ -21,11 +25,11 @@ Still required before claiming true one-to-one high precision: The Earth boundary layer should use one static PMTiles archive containing MVT tiles instead of thousands of loose GeoJSON files. The artifact is POV-specific: `earth-boundaries-china-pov-v1.pmtiles` has China POV baked in during offline source preparation, and the browser never patches political boundaries at runtime. -Production must serve a single PMTiles artifact through static hosting and HTTP range requests. Missing PMTiles is a hard boundary-layer error, not a silent low-precision fallback. +Production should serve a single PMTiles artifact through static hosting and HTTP range requests. In development or on machines that have not opted into high precision, missing PMTiles falls back to the bundled low-precision GeoJSON so the Earth base remains usable. ## Key Implementation Rules -- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Collector Settings using the `earth_boundary_source` target schema; `config/earth-boundary-sources.example.json` remains the versioned example template. +- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Earth Content boundary precision settings; `config/earth-boundary-sources.example.json` remains the versioned example template. - China POV geometry is applied before tiling: - Zangnan and Aksai Chin are unioned into China and subtracted from India. - Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, and South China Sea islands are China hover/country features. @@ -36,9 +40,9 @@ Production must serve a single PMTiles artifact through static hosting and HTTP - `boundary_disputed_internal` - `coastline` - `claim_line` -- The frontend provider is selected from the boundary manifest: +- The frontend provider is selected from local high-precision preference plus the boundary manifest: - `tileProvider: "pmtiles-mvt"` reads the PMTiles artifact. - - Any other provider, missing manifest, or missing PMTiles artifact is treated as an error. + - Missing high-precision preference, missing manifest, or missing PMTiles artifact falls back to low-precision GeoJSON. - Redis is not part of v1. Static PMTiles plus browser/CDN range caching is the default performance model. ## Cleanup And Documentation @@ -46,11 +50,11 @@ Production must serve a single PMTiles artifact through static hosting and HTTP - Do not commit generated loose tiles under `frontend/public/earth/data/boundaries/` or source downloads under `data/earth-boundary-sources/`. - Remove stale generated debug data before production builds; regenerate it only when smoke testing the debug path. - Keep the high-level plan, backend collector docs, layer style docs, and ops runbook aligned whenever the provider contract changes. -- After implementation changes, provide user-facing operation steps covering source configuration, source collection, artifact build/deploy, page verification, and fallback troubleshooting. +- After implementation changes, provide user-facing operation steps covering source configuration, artifact build/deploy, page verification, and fallback troubleshooting. ## Verification -- The PMTiles builder fails as not ready when any of `earth_admin0_boundaries`, `earth_coastline`, or `earth_claim_lines` has not produced a current artifact record. +- The Earth boundary build API reports missing source configuration or missing tools clearly, without creating datasource collection records. - The PMTiles builder fails as not ready when source artifacts exist but `tippecanoe` / `pmtiles` are missing. - Running the PMTiles builder twice returns `unchanged` on the second run when inputs are stable. - `git add . --dry-run` does not include generated loose boundary tiles or source downloads. @@ -58,10 +62,10 @@ Production must serve a single PMTiles artifact through static hosting and HTTP - Manual Earth checks confirm: - PMTiles range requests are issued only for visible tiles. - Boundary toggle, hover tooltip, and country highlight still work. - - PMTiles failure reports a boundary-layer error; the frontend must not draw low-precision legacy boundaries. + - PMTiles failure reports a high-precision boundary error; machines without high-precision enabled continue drawing low-precision fallback boundaries. ## Assumptions - "One-to-one" means source-faithful to the selected audited vector source, not hand-tuned to a screenshot. - The China POV artifact is static and versioned; no runtime region-based POV switching is planned. -- The removed repository seed file cannot be used as a runtime fallback for country boundaries. +- The repository low-precision seed file is retained as the runtime fallback for country boundaries. diff --git a/docs/technical/en/agents-aiprovider.md b/docs/technical/en/agents-aiprovider.md index f5c9d4f5..50fee714 100644 --- a/docs/technical/en/agents-aiprovider.md +++ b/docs/technical/en/agents-aiprovider.md @@ -95,9 +95,18 @@ The AI settings page uses: - `POST /api/v1/settings/integrations/ai-provider/connect` - `GET /api/v1/settings/integrations/ai-provider/secrets` - `GET /api/v1/settings/integrations/ai-provider/presets` +- `GET /api/v1/settings/ai-prompts` +- `PUT /api/v1/settings/ai-prompts/{task_key}` +- `POST /api/v1/settings/ai-prompts/{task_key}/reset` These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview. +The `ai-prompts` endpoints back the Prompts tab in AI settings. Shipped defaults come from versioned backend resources, while business code references stable task keys. The API stores only operator overrides. Resetting a prompt removes the override and falls back to the current shipped default. + +### Prompt Boundary + +`aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls. + ### AI provider internal API Internal-only endpoints: @@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0 AI_PROVIDER_SERVICE_TOKEN=change_me AI_TIMEOUT_SECONDS=60 AI_HTTP_RETRY_ATTEMPTS=2 -AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 ``` Optional provider-specific keys: diff --git a/docs/technical/en/backend-collectors.md b/docs/technical/en/backend-collectors.md index 5c793504..30b55c7e 100644 --- a/docs/technical/en/backend-collectors.md +++ b/docs/technical/en/backend-collectors.md @@ -86,16 +86,10 @@ async def run(self, db): | TeleGeography | submarine_cable | Submarine cable information | 7 days | | BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings | | AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings | -| Earth Admin-0 Boundaries | earth_admin0_boundaries | Downloads the configured country-boundary source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | -| Earth Coastline | earth_coastline | Downloads the configured coastline source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | -| Earth Claim Lines | earth_claim_lines | Downloads the configured claim-line source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings | -| Earth PMTiles Builder | earth_boundary_tiles | Reads the three Earth boundary source records and builds / registers the PMTiles artifact | Collector settings | AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table. -Earth boundaries are now split into three real source collectors plus one downstream builder. `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines` read endpoint, headers, auth, and `config.target_schema=earth_boundary_source` from Collector Settings. Triggering them requests the configured endpoint, writes the full response to `data/earth-boundary-sources//.*`, and stores sha256, feature count, license, artifact path, sample properties, and mapping metadata in `CollectedData`. - -`earth_boundary_tiles` no longer means source-data collection. It reads the latest successful records from those three source collectors; if any source is missing, the task fails as "not ready" and does not register "4 high-precision tile" records. Once all sources exist, it uses `tippecanoe` / `pmtiles` to build `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`; if those tools are missing, the task fails with the missing-tool message. There is no legacy low-precision fallback for country boundaries. +Earth boundaries are no longer data collectors. They are Earth static rendering assets: the Earth Assets settings panel owns source configuration, and `/api/v1/earth/boundaries/*` builds `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`. When no high-precision PMTiles artifact is available locally, the frontend uses the bundled low-precision GeoJSON fallback and does not write boundary records to `CollectedData`. TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh. @@ -306,7 +300,7 @@ State semantics: - `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting. - `stopped` / `cancelled`: stopped by a test limit or user action. -AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it. +AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Collection Management -> Collectors -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it. The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters: @@ -374,9 +368,9 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l `/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper. -## X. Collector Settings And Connectivity Validation +## X. Collectors And Connectivity Validation -The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling: +The console "Collectors" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling: - endpoint - auth type @@ -397,7 +391,7 @@ POST /api/v1/settings/credential-guides/{provider}/generate POST /api/v1/settings/credential-guides/{provider}/reset ``` -See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow. +See [Collectors and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow. ## XI. Data Usage diff --git a/docs/technical/en/datasource-collector-settings-connectivity.md b/docs/technical/en/datasource-collector-settings-connectivity.md index 7ccf2a5d..cbfb18cd 100644 --- a/docs/technical/en/datasource-collector-settings-connectivity.md +++ b/docs/technical/en/datasource-collector-settings-connectivity.md @@ -8,12 +8,12 @@ The console now separates the "data source catalog" from "collector configuratio - Lists all data sources, including built-in and custom sources. - Clicking a name only opens an information drawer. - Focuses on status, manual collection, and running collection tasks. -- `/settings?tab=collector_credentials` - - Displays as "Collector Settings". +- `/collection-management?tab=collector_credentials` + - Displays as "Collectors". - Owns endpoint, headers, base parameters, and credentials. - Every collector exposes a connection button for health checks. -This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to collector settings instead of being scattered across the data source list and system settings. +This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to Collectors instead of being scattered across the data source list and system settings. ## User-Facing Rules @@ -53,7 +53,7 @@ Current behavior: `data-source-bulk-toolbar__running-pill` is the styling entry point for the "Collecting" pill. It is aligned with other status tags, while hover treatment, arrow affordance, and blue outline indicate interactivity. -### Collector Settings +### Collectors File: @@ -61,7 +61,7 @@ File: Current behavior: -- The `collector_credentials` tab is displayed as "Collector Settings". +- The `collector_credentials` tab is displayed as "Collectors" under `/collection-management`. - A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data. - The only button beside the select is a plug icon for health checks. - Status tags below the select show: @@ -307,7 +307,7 @@ Files: Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel. -Earth high-precision boundaries use the same target-schema mechanism. `earth_boundary_source` receives mapped records for `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`; full GeoJSON / JSON payloads are stored as artifacts, while the database only keeps source kind, sha256, feature count, license, artifact path, and sample properties so large geometries do not land in a single row. +Earth high-precision boundaries no longer use custom-source target schemas. Boundaries are Earth static assets: the Earth Assets settings panel saves local source configuration and triggers PMTiles builds without writing records to `CollectedData`. ### Configuration Semantics @@ -318,7 +318,7 @@ Important fields: - `auth_type`: `none`, `bearer`, `api_key`, or `basic`. - `headers`: static request headers. - `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query. -- `config.target_schema`: for example `vessel_ais` or `earth_boundary_source`. +- `config.target_schema`: for example `vessel_ais`, `geo_points`, or `generic_records`. - `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`. - `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`. diff --git a/docs/technical/en/earth-frontend-context.md b/docs/technical/en/earth-frontend-context.md index a84bc3b8..beaea38c 100644 --- a/docs/technical/en/earth-frontend-context.md +++ b/docs/technical/en/earth-frontend-context.md @@ -216,6 +216,8 @@ The real satellite altitude preference is persisted by `controls.js`, while the SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude. +Boundary precision is stored separately by `country-boundaries.js` under `planet.earth.boundaries.highPrecisionEnabled`. When high precision is off, Earth keeps using the bundled low-precision `countries-admin0.min.geojson` fallback even if high-precision manifest/PMTiles files exist locally. When high precision is on but the artifact is missing, the Earth toolbar settings call `/api/v1/earth/boundaries/build` and poll progress. After success, `reloadCountryBoundaries()` hot-swaps the boundary layer without refreshing the page. Boundary hover is independent from interactable hover: a country polygon remains highlighted whenever the surface coordinate is inside it, while the tooltip can still prioritize a satellite, vessel, BGP marker, or other interactable. + ## Current Terrain Pipeline 1. `terrain.js` creates a sphere geometry with enough segments diff --git a/docs/technical/en/earth-layer-style-reference.md b/docs/technical/en/earth-layer-style-reference.md index 8c8c374e..525fb46c 100644 --- a/docs/technical/en/earth-layer-style-reference.md +++ b/docs/technical/en/earth-layer-style-reference.md @@ -78,9 +78,10 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor | Name | Variable | Current Value | Location / Notes | | --- | --- | --- | --- | -| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | Required production PMTiles manifest; missing manifest is an error | -| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | Only PMTiles/MVT is accepted for country boundaries | +| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | High-precision PMTiles manifest; missing manifest uses the low-precision fallback | +| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | Prefer high-precision PMTiles/MVT, then fall back to legacy GeoJSON | | PMTiles artifact path | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | Production single-file PMTiles/MVT artifact | +| Low-precision fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | Default land/ocean base and hover data when no high-precision boundary asset has been built locally | | MVT layer names | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | Fixed layer names decoded by the PMTiles provider | | Boundary tile base path | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest base path | | Boundary tile zoom thresholds | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | Production PMTiles zoom selection | diff --git a/docs/technical/en/earth-news-live-streams-collector-format.md b/docs/technical/en/earth-news-live-streams-collector-format.md index 1d64b635..a3c3f380 100644 --- a/docs/technical/en/earth-news-live-streams-collector-format.md +++ b/docs/technical/en/earth-news-live-streams-collector-format.md @@ -103,7 +103,7 @@ Goals: ## Collector Configuration -`news_live_streams` does not need a separate new page; it reuses Collector Settings under `/settings`: +`news_live_streams` does not need a separate new page; it reuses Collectors under `/collection-management`: - `endpoint` - Channel directory JSON API URL diff --git a/docs/technical/en/faq.md b/docs/technical/en/faq.md index 4bf4d5f5..d16aef97 100644 --- a/docs/technical/en/faq.md +++ b/docs/technical/en/faq.md @@ -294,7 +294,7 @@ If only AI Provider is unhealthy, restart just that service: ### Connectivity validation passes, but collection cannot read credentials. Why? -Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Settings -> Collector Settings, especially for AISStream's long-lived WebSocket collector. +Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Collection Management -> Collectors, especially for AISStream's long-lived WebSocket collector. If `AISSTREAM_API_KEY` only lives in `~/.zshrc`, confirm the backend process actually inherited it. Otherwise validation may pass while the collector runtime has no key. @@ -308,7 +308,7 @@ export BARENTSWATCH_CLIENT_ID="..." export BARENTSWATCH_CLIENT_SECRET="..." ``` -For stable operation, save credentials in Collector Settings so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration. +For stable operation, save credentials in Collectors so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration. ## Docs / Permissions @@ -323,6 +323,17 @@ Docs visibility is controlled by Gatekeeper groups: ## Earth Common Tasks +### Why does Earth say the boundary endpoint is not configured, or only show low precision boundaries? + +Country boundaries have moved out of the collector system. They are no longer generated by datasource collection tasks. The low-precision boundary file is bundled with the frontend and is the expected fallback when no local high-precision PMTiles artifact exists. + +There are two high-precision entry points: + +- Earth page settings gear -> Boundary Precision: switching to High Precision starts the first background download/build, shows percentage progress, and applies the result automatically. +- Console `Operations and Configuration -> Earth Content -> Boundary Precision`: use this to inspect provider, manifest, PMTiles, fallback state, edit source JSON, or rebuild manually. + +If the UI says the update source is incomplete, save the source configuration from `Earth Content -> Boundary Precision`. The private local config is written to `config/earth-boundary-sources.local.json`; do not commit it. Falling back to low precision is normal when no high-precision artifact has been built. + ### Why did collecting a location candidate not write anything? Collecting and saving are two separate actions. Candidates can be previewed on Earth first. A candidate is written only after clicking Save or using the unresolved list's one-click adopt flow. diff --git a/docs/technical/en/frontend-admin-frontend-context.md b/docs/technical/en/frontend-admin-frontend-context.md index dcc87602..50a65113 100644 --- a/docs/technical/en/frontend-admin-frontend-context.md +++ b/docs/technical/en/frontend-admin-frontend-context.md @@ -33,6 +33,8 @@ Current admin-related routes: - `/alerts/situational` - `/bgp` - `/ai` +- `/earth-content` +- `/collection-management` - `/settings` `/earth` is a standalone display page and is not part of the console shell. @@ -277,6 +279,42 @@ Constraints: - Do not let tables blow out the full page - New table areas should reuse `TableScrollRegion` / `ScrollbarOverlay` +### Datasource Directory Page + +[DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) is the datasource directory and collection operation page. It should not grow back into a configuration editor. + +Current page boundary: + +- Built-in and custom sources are merged as `UnifiedDataSource`. +- The list shows type, state, last run, collection progress, and actions. +- Clicking a name opens a read-only drawer. +- Endpoint, headers, and config are displayed here, not edited. +- Credential-bearing collectors point users to `Collection Management -> Collectors`. + +Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`. + +### Collectors Page + +[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`. + +Current boundary: + +- The dropdown selects built-in collectors. +- The plug icon beside the dropdown runs the health check. +- Credential-bearing collectors place credential forms above base config. +- Free collectors show endpoint, default endpoint, headers, timeout, and retry. +- BarentsWatch AIS keeps its dedicated credential form. + +### Earth Content Page + +`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), but its ownership is separate from System Settings: + +- `TV Livestream` owns the Earth media-panel source configuration. +- `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. + +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`. + ### 3. Complex Workspace Pages Examples: diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 731d7a5a..92ffb4fe 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -76,15 +76,17 @@ The console at `http://localhost:3000/admin` is built with React + Ant Design. T | BGP Alerts | `/alerts/bgp` | BGP-related alerts | | Situational Alerts | `/alerts/situational` | Situational analysis alerts | | AI | `/ai` | Model providers, tools, testbench | +| Earth Content | `/earth-content` | TV livestreams, boundary precision, base-map and layer-resource entry points | +| Collection Management | `/collection-management` | Collectors, scheduling, collection history entry points | | Logs | `/logs` | Usually visible only to super admin | | Users | `/users` | Create/delete users, change roles/groups | -| Settings | `/settings` | System, SMTP, TV, collectors | +| System Settings | `/settings` | Display, notification, security, SMTP | Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups. ## Configure Data Collectors -`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials. +`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`. Steps: @@ -125,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to Steps: -1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels` +1. Open `/collection-management?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels` 2. Fill the AISStream API Key 3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream` 4. Click the plug icon to test; confirm it reports `Reachable` @@ -139,10 +141,11 @@ Steps: ## Configure AI Credentials -`/ai?tab=providers` is the AI management entry. Two key sub-tabs: +`/ai?tab=providers` is the AI management entry. Three key sub-tabs: - `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test - `Tools`: a dropdown for specific tools — currently WebSearch and OCR +- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default ### Model Providers @@ -163,6 +166,10 @@ The plug icon at the end of the Base URL input runs a connection test. A passing - **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out - **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format +### Prompts + +After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task. + The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`. ## System Settings @@ -173,8 +180,26 @@ The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`. - **Notifications**: alert email switch, recipient, critical/warning/daily summary - **Security**: session timeout, max login attempts, password policy - **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only) -- **TV Livestream**: TV source management -- **AI / WebSearch / OCR**: see above + +TV livestreams and boundary precision moved to `/earth-content`; collectors and scheduling moved to `/collection-management`; AI Provider / WebSearch / OCR live at `/ai`. + +### Earth Content + +`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend: + +- **TV Livestream**: manages sources shown in the Earth media panel. +- **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. + +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. + +### Collection Management + +`/collection-management` is also under Operations and Configuration and owns the collection lifecycle: + +- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks. +- **Collection Scheduling**: the existing scheduling configuration. +- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing. ### SMTP Email Settings @@ -204,7 +229,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or ## Data Exploration -- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list +- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list - `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" - `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth - `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts diff --git a/docs/technical/en/ops-runbook.md b/docs/technical/en/ops-runbook.md index 45723cb4..1751acdf 100644 --- a/docs/technical/en/ops-runbook.md +++ b/docs/technical/en/ops-runbook.md @@ -243,13 +243,12 @@ uv run pytest backend/tests/test_otp_service.py ## Earth Boundary PMTiles Operations -1. In Collector Settings, configure endpoints, headers/auth, `config.target_schema=earth_boundary_source`, license, and mapping for `Earth Admin-0 Boundaries`, `Earth Coastline`, and `Earth Claim Lines`. -2. In the data-source console, collect those three sources first. Each successful source writes the full artifact to `data/earth-boundary-sources//.*` and stores sha256, feature count, artifact path, and sample properties. -3. After all three sources succeed, collect `Earth PMTiles Builder`. If any source is missing, it fails as "not ready" and does not update Earth boundaries. -4. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools fail the task with a clear message. -5. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest. -6. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy. -7. If PMTiles loading fails, Earth reports a boundary-layer error and does not draw legacy low-precision borders. Troubleshoot in this order: browser Network range requests for PMTiles, manifest `tileProvider: "pmtiles-mvt"`, Nginx static serving for `.pmtiles`, and artifact path / sha256 consistency with the manifest. +1. In the console, open `Operations and Configuration -> Earth Content -> Boundary Precision` to save boundary source configuration. The local config is written to `config/earth-boundary-sources.local.json`; do not commit it. +2. Click "Build high precision boundaries", or switch the Earth toolbar settings gear to High Precision for the first build. The backend downloads the three source packages to `data/earth-boundary-sources/`, writes the source manifest, and invokes the PMTiles build script. +3. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools return a clear API error and do not write data-source collection records. +4. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest. +5. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy. +6. If no high-precision manifest/PMTiles exists locally, Earth uses the bundled `frontend/public/earth/data/countries-admin0.min.geojson` fallback. If high-precision assets exist but tile requests fail, troubleshoot PMTiles range requests, manifest provider, Nginx `.pmtiles` static serving, and sha256 consistency. ## Related Docs diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 6065a404..8318c02d 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -11,7 +11,7 @@ Open the URL your administrator gave you, e.g. `http://planet.example.com`. A lo Entry points are split in two: - Public: `/earth` (3D situational view), `/docs` (public documentation) -- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration) +- Login required: `/admin` (console), `/ai` (AI), `/settings` (system settings), `/earth-content` (Earth content), `/collection-management` (collection management) ## 2. Register @@ -32,7 +32,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col After landing on the `/admin` dashboard, here's a recommended walk-through: -1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first +1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first 2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional 3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters 4. `/alerts/system`: verify system alerts look right diff --git a/docs/technical/zh/agents-aiprovider.md b/docs/technical/zh/agents-aiprovider.md index 9fa6800a..47b55fb9 100644 --- a/docs/technical/zh/agents-aiprovider.md +++ b/docs/technical/zh/agents-aiprovider.md @@ -95,9 +95,18 @@ AI 配置页使用的接口: - `POST /api/v1/settings/integrations/ai-provider/connect` - `GET /api/v1/settings/integrations/ai-provider/secrets` - `GET /api/v1/settings/integrations/ai-provider/presets` +- `GET /api/v1/settings/ai-prompts` +- `PUT /api/v1/settings/ai-prompts/{task_key}` +- `POST /api/v1/settings/ai-prompts/{task_key}/reset` 这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。 +`ai-prompts` 接口用于运维配置页的“提示词”Tab。默认提示词来自后端随发布包携带的版本化资源,业务代码只引用稳定 task key;接口只保存运维覆盖值。重置时删除覆盖值并恢复当前发布包中的缺省提示词。 + +### 提示词边界 + +`aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。 + ### AI Provider 内部 API 仅供内部调用的接口: @@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0 AI_PROVIDER_SERVICE_TOKEN=change_me AI_TIMEOUT_SECONDS=60 AI_HTTP_RETRY_ATTEMPTS=2 -AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 ``` 可选 provider 专属 key: diff --git a/docs/technical/zh/backend-collectors.md b/docs/technical/zh/backend-collectors.md index 1ae59d1d..48ed0bd4 100644 --- a/docs/technical/zh/backend-collectors.md +++ b/docs/technical/zh/backend-collectors.md @@ -87,16 +87,10 @@ async def run(self, db): | Space-Track TLE | satellite_tle | 卫星轨道 TLE 数据 | 依采集器配置 | | BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 | | AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 | -| Earth Admin-0 Boundaries | earth_admin0_boundaries | 从配置 endpoint 下载国家级边界源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | -| Earth Coastline | earth_coastline | 从配置 endpoint 下载海岸线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | -| Earth Claim Lines | earth_claim_lines | 从配置 endpoint 下载主张线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 | -| Earth PMTiles Builder | earth_boundary_tiles | 读取三类 Earth 边界源采集结果并构建 / 登记 PMTiles 产物 | 依采集器配置 | AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。 -Earth 边界现在拆成三个真实源采集器和一个下游构建器。`earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 都读取后台 Collector Settings 里的 endpoint、headers、auth 和 `config.target_schema=earth_boundary_source`,点击采集时会真实请求 endpoint,保存完整响应到 `data/earth-boundary-sources//.*`,并在 `CollectedData` 中写入 sha256、feature count、license、artifact path、sample properties 和 mapping 信息。 - -`earth_boundary_tiles` 不再代表源数据采集。它只读取上述三类源的最新成功记录;缺任一源时任务失败并显示“未就绪”,不会登记“4 条高精度瓦片”。三类源齐全后,它会调用 `tippecanoe` / `pmtiles` 生成 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`;本机缺少这些工具时任务失败并说明缺失工具。国界不再有旧低精度兜底。 +Earth 国界不再属于采集器体系。它是 Earth 静态渲染资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。 TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。 @@ -232,7 +226,7 @@ if datasource.last_status == "success": ) ``` -这个记录用于控制台“采集器设置”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。 +这个记录用于控制台“采集管理 -> 采集器”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。 相关实现见: @@ -273,8 +267,8 @@ backend/app/models/ | 采集器 | credential provider | 凭证来源 | | --- | --- | --- | -| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` | -| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) | +| `barentswatch_vessels` | `barentswatch` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc` | +| `aisstream_vessels` | `aisstream` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到“采集管理 -> 采集器”或注入后端环境) | | `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` | ### BarentsWatch AIS @@ -333,7 +327,7 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默 - `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。 - `stopped` / `cancelled`:任务被测试上限或用户停止。 -AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。 +AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“采集管理 -> 采集器 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。 控制台通过 `/datasources -> 实时流` 管理 AISStream,而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数: @@ -403,7 +397,7 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l ## 十、采集器设置与连接验证 -控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum: +控制台的“采集管理 -> 采集器”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum: - endpoint - auth type diff --git a/docs/technical/zh/datasource-collector-settings-connectivity.md b/docs/technical/zh/datasource-collector-settings-connectivity.md index fd56747c..3535006e 100644 --- a/docs/technical/zh/datasource-collector-settings-connectivity.md +++ b/docs/technical/zh/datasource-collector-settings-connectivity.md @@ -8,12 +8,12 @@ - 展示所有数据源,包括内置和自定义。 - 点击名称只打开信息抽屉。 - 负责查看状态、触发采集和查看采集中任务。 -- `/settings?tab=collector_credentials` - - 显示为“采集器设置”。 +- `/collection-management?tab=collector_credentials` + - 显示为“采集器”。 - 负责 endpoint、请求头、基础参数和凭证配置。 - 所有采集器都提供连接按钮,用于健康检查。 -这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器设置”,而不是散落在数据源列表和系统设置多个入口里。 +这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器”,而不是散落在数据源列表和系统设置多个入口里。 ## 用户侧规则 @@ -53,7 +53,7 @@ `data-source-bulk-toolbar__running-pill` 是“采集中”标签的样式入口。它和其他状态标签同排,但通过 hover、箭头和蓝色描边表达可交互性。 -### 采集器设置 +### 采集器 文件: @@ -61,7 +61,7 @@ 当前行为: -- `collector_credentials` tab 展示为“采集器设置”。 +- `collector_credentials` tab 在 `/collection-management` 下展示为“采集器”。 - 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。 - 下拉框右侧只有一个插头图标按钮,用于健康检查。 - 下拉框下方用状态标签展示: @@ -309,7 +309,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit= 自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。 -Earth 高精度边界使用同一套目标 schema 机制。`earth_boundary_source` 承接 `earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 三类源的映射结果;完整 GeoJSON / JSON 原文保存为 artifact,数据库只保存 source kind、sha256、feature count、license、artifact path 和 sample properties,避免把大型几何塞进单行记录。 +Earth 高精度边界不再使用自定义源目标 schema。国界是 Earth 静态资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`。 ### 配置语义 @@ -320,7 +320,7 @@ Earth 高精度边界使用同一套目标 schema 机制。`earth_boundary_sourc - `auth_type`:`none`、`bearer`、`api_key`、`basic`。 - `headers`:静态请求头。 - `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。 -- `config.target_schema`:例如 `vessel_ais` 或 `earth_boundary_source`。 +- `config.target_schema`:例如 `vessel_ais`、`geo_points` 或 `generic_records`。 - `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。 - `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。 diff --git a/docs/technical/zh/earth-frontend-context.md b/docs/technical/zh/earth-frontend-context.md index caaf9674..5c9d304b 100644 --- a/docs/technical/zh/earth-frontend-context.md +++ b/docs/technical/zh/earth-frontend-context.md @@ -426,6 +426,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/ - 图层控制开关:`地形 / 卫星 / 海缆 / BGP` - 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示` - 地表 hover 提示偏好:`国家 / 位置 / 完整` +- 国界精度偏好:低精 fallback / 高精 PMTiles - 地形透明度 也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。 @@ -436,6 +437,8 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/ SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。 +国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,Earth 工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。 + ## 当前地形链路 真实地形首次启用会慢,原因不只是一个: diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 76ab61ca..5c031168 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -84,9 +84,10 @@ | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | -| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 必需的生产 PMTiles manifest;缺失即报错 | -| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | 国界只接受 PMTiles/MVT | +| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 高精 PMTiles manifest;缺失时使用低精度 fallback | +| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | 优先高精 PMTiles/MVT,缺失时降级到 legacy GeoJSON | | PMTiles 产物路径 | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | 生产单文件 PMTiles/MVT artifact | +| 低精度 fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | 本地未构建高精国界时的缺省海陆基座和 hover 数据 | | MVT 图层名 | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | PMTiles provider 解码时读取的固定 layer 名 | | 国界瓦片基础路径 | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest 基础路径 | | 国界瓦片缩放阈值 | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | 生产 PMTiles zoom 选择 | diff --git a/docs/technical/zh/earth-news-live-streams-collector-format.md b/docs/technical/zh/earth-news-live-streams-collector-format.md index fbfae585..c6a03526 100644 --- a/docs/technical/zh/earth-news-live-streams-collector-format.md +++ b/docs/technical/zh/earth-news-live-streams-collector-format.md @@ -103,7 +103,7 @@ ## 采集器配置方式 -`news_live_streams` 不需要单独新页面,直接复用控制台 `/settings` 的“采集器设置”: +`news_live_streams` 不需要单独新页面,直接复用控制台 `/collection-management` 的“采集器”配置: - `endpoint` - 频道目录 JSON API 地址 diff --git a/docs/technical/zh/faq.md b/docs/technical/zh/faq.md index 60a1db5b..54145c6f 100644 --- a/docs/technical/zh/faq.md +++ b/docs/technical/zh/faq.md @@ -296,7 +296,7 @@ USB 摄像头透传到 WSL 属于高级路径;脚本不会默认把无摄像 ### 采集器连接验证通过,但正式采集拿不到凭证怎么办? -连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“设置 -> 采集器设置”,尤其是 AISStream 这类长连接 collector。 +连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“采集管理 -> 采集器”,尤其是 AISStream 这类长连接 collector。 如果只把 `AISSTREAM_API_KEY` 放在 `~/.zshrc`,需要确认后端进程实际继承了该变量。否则可能出现连接验证可用,但 collector 运行时没有 key 的情况。 @@ -310,7 +310,7 @@ export BARENTSWATCH_CLIENT_ID="..." export BARENTSWATCH_CLIENT_SECRET="..." ``` -稳定运行时,优先在控制台采集器设置中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。 +稳定运行时,优先在控制台“采集管理 -> 采集器”中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。 ## Docs / 权限 @@ -325,6 +325,17 @@ Docs 按 Gatekeeper 权限组控制可见性: ## Earth 常见操作 +### 为什么国界提示“未配置 endpoint”或只能看到低精度? + +国界已经从采集器体系移出,不再通过数据源采集任务生成。低精度国界是随前端打包的兜底资产,本地未构建高精 PMTiles 时会自动使用它。 + +要启用高精国界,有两条入口: + +- Earth 页面齿轮设置里的“国界精度”:切到“高精”会启动首次后台下载/构建,并显示百分比,完成后自动应用。 +- 控制台 `运维与配置 -> Earth 内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON,或手动重建。 + +如果看到“更新源未配置完整”,先到 `Earth 内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。 + ### Earth 位置候选采集后没有写入怎么办? “采集候选”和“保存候选”是两步。候选可以先在 Earth 上预览,只有点击保存或使用待定位列表中的“一键采用”后,才会写入维表并刷新图层。 diff --git a/docs/technical/zh/frontend-admin-frontend-context.md b/docs/technical/zh/frontend-admin-frontend-context.md index 1bb61b47..4c8f2b9c 100644 --- a/docs/technical/zh/frontend-admin-frontend-context.md +++ b/docs/technical/zh/frontend-admin-frontend-context.md @@ -33,6 +33,8 @@ - `/alerts/situational` - `/bgp` - `/ai` +- `/earth-content` +- `/collection-management` - `/settings` `/earth` 是独立展示页,不属于控制台骨架。 @@ -288,9 +290,9 @@ - 点击名称打开只读抽屉。 - 抽屉中明确显示“内置数据源”或“自定义数据源”。 - endpoint、headers、config 只展示,不在这里编辑。 -- 需要凭证的采集器提示用户到“设置 -> 采集器设置”维护。 +- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。 -这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/settings?tab=collector_credentials`。 +这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`。 页面顶部的总进度区域新增 `采集中 N` 标签: @@ -303,7 +305,7 @@ ### 采集器设置页 -[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 中的 `collector_credentials` tab 当前显示为“采集器设置”。 +[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是 Earth 内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。 当前页面边界: @@ -334,6 +336,16 @@ - [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md) +### Earth 内容页 + +`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 的单屏 tab 容器,但页面责任与系统设置分离: + +- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。 +- `国界精度` 管理 Earth 静态国界资产:provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。 +- `地球底图`、`图层资源`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。 + +系统级 `/settings` 不应再新增 Earth 体验资源或采集生命周期 tab;采集相关入口属于 `/collection-management`,Earth 展示资源属于 `/earth-content`。 + ### 3. 复杂工作区页面 例如: diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 82ed6438..438ee7ad 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -76,15 +76,17 @@ | BGP 告警 | `/alerts/bgp` | BGP 相关告警 | | 态势告警 | `/alerts/situational` | 态势研判告警 | | AI | `/ai` | 模型供应商、工具、测试台 | +| Earth 内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 | +| 采集管理 | `/collection-management` | 采集器、采集调度、采集历史入口 | | 系统日志 | `/logs` | 通常仅 super admin 可见 | | 用户管理 | `/users` | 创建/删除/改角色/调权限组 | -| 系统配置 | `/settings` | 系统、SMTP、TV、采集器设置 | +| 系统设置 | `/settings` | 系统显示、通知、安全、SMTP | 权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。 ## 配置数据采集器 -`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。 +`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`。 操作步骤: @@ -128,7 +130,7 @@ 操作步骤: -1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels` +1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels` 2. 在 `AISStream 凭证` 填入 API Key 3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream` 4. 点击插头图标进行连接测试,确认显示 `可用` @@ -142,10 +144,11 @@ ## 配置 AI 凭证 -`/ai?tab=providers` 是 AI 模型管理入口。包含两个核心子 tab: +`/ai?tab=providers` 是 AI 模型管理入口。包含三个核心子 tab: - `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试 - `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR +- `提示词`:通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省 ### 模型供应商 @@ -166,6 +169,10 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会 - **WebSearch**:provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰 - **OCR**:provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式 +### 提示词 + +选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。 + 旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。 ## 系统设置 @@ -176,8 +183,26 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会 - **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知 - **安全策略**:会话超时、最大登录尝试、密码策略 - **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见) -- **电视直播**:电视直播源管理 -- **AI / WebSearch / OCR**:见上节 + +电视直播和国界精度已经移到 `/earth-content`,采集器和采集调度已经移到 `/collection-management`,AI Provider / WebSearch / OCR 在 `/ai`。 + +### Earth 内容 + +`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源: + +- **电视直播**:维护 Earth 媒体面板里的直播源。 +- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。 +- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。 + +Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。 + +### 采集管理 + +`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期: + +- **采集器**:维护 endpoint、请求头、凭证、timeout、retry,并运行连接检查。 +- **采集调度**:维护原有调度相关设置。 +- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。 ### SMTP 邮件设置 @@ -207,7 +232,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会 ## 数据探索 -- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 +- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 - `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" - `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 - `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警 diff --git a/docs/technical/zh/ops-runbook.md b/docs/technical/zh/ops-runbook.md index f63621b3..93d5e6f6 100644 --- a/docs/technical/zh/ops-runbook.md +++ b/docs/technical/zh/ops-runbook.md @@ -243,13 +243,12 @@ uv run pytest backend/tests/test_otp_service.py ## Earth 国界 PMTiles 操作步骤 -1. 在后台 Collector Settings 中分别配置 `Earth Admin-0 国界源`、`Earth 海岸线源`、`Earth 主张线源` 的 endpoint、headers/auth、`config.target_schema=earth_boundary_source`、license 和 mapping。 -2. 在数据源页依次采集这三个源。每个源成功后会保存完整 artifact 到 `data/earth-boundary-sources//.*`,并写入 sha256、feature count、artifact path、sample properties。 -3. 三个源都成功后,再采集 `Earth PMTiles 构建器`。缺任一源时它会失败为“未就绪”,不会更新 Earth 国界。 -4. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时任务失败并显示缺失工具。 -5. 生产构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。 -6. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。 -7. 如果 PMTiles 加载失败,Earth 会报国界图层错误并且不绘制旧低精度国界。排查顺序是:浏览器 Network 是否有 PMTiles range 请求、manifest 的 `tileProvider` 是否为 `pmtiles-mvt`、Nginx 是否能静态返回 `.pmtiles`、artifact 路径和 sha256 是否与 manifest 一致。 +1. 在控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。 +2. 点击“构建高精国界”,或在 Earth 页面工具栏齿轮中切到“高精”触发首次构建。后端会下载三类源到 `data/earth-boundary-sources/`,生成 source manifest,并调用 PMTiles 构建脚本。 +3. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时接口返回明确错误,不会写入数据源采集记录。 +4. 构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。 +5. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。 +6. 如果本地没有高精 manifest/PMTiles,Earth 会使用 `frontend/public/earth/data/countries-admin0.min.geojson` 低精度 fallback;如果高精产物存在但瓦片请求失败,按 PMTiles range 请求、manifest provider、Nginx `.pmtiles` 静态返回和 sha256 一致性排查。 ## 相关文档 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 8d855711..ab2f9443 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -11,7 +11,7 @@ 入口分两类: - 公开页:`/earth`(3D 态势)、`/docs`(公共文档) -- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统配置) +- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统设置)、`/earth-content`(Earth 内容)、`/collection-management`(采集管理) ## 2. 注册账号 @@ -32,7 +32,7 @@ 进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台: -1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret +1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret 2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数 4. `/alerts/system`:看系统告警是否正常 diff --git a/docs/version-history.md b/docs/version-history.md index 5aed737b..decc6d0e 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.58.0` +- `dev` 当前开发分支历史推导到:`0.59.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback,新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 | | `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 | | `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay,保持 localhost 与局域网同用 3000/8000,并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 | | `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 | diff --git a/frontend/package.json b/frontend/package.json index 859ce931..e658db93 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.58.0", + "version": "0.59.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 5d7261e2..a3127761 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -1309,23 +1309,44 @@ } .earth-mobile-settings-segmented { - display: inline-flex; - flex-wrap: wrap; - gap: 8px; + --item-count: 2; + --active-index: 0; + position: relative; + display: inline-grid; + grid-template-columns: repeat(var(--item-count), minmax(0, 1fr)); + padding: 4px; + border: 1px solid rgba(212, 227, 244, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.earth-mobile-settings-segmented::before { + content: ""; + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + width: calc((100% - 8px) / var(--item-count)); + border-radius: 999px; + background: rgba(122, 180, 255, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + transform: translateX(calc(var(--active-index) * 100%)); + transition: transform 180ms ease; } .earth-mobile-settings-pill { - border: 1px solid rgba(212, 227, 244, 0.1); + position: relative; + z-index: 1; + border: 0; border-radius: 999px; - background: rgba(255, 255, 255, 0.04); + background: transparent; color: var(--hud-text-soft); padding: 10px 14px; } .earth-mobile-settings-pill.is-active { color: var(--hud-title); - border-color: rgba(122, 180, 255, 0.24); - background: rgba(122, 180, 255, 0.14); } .earth-mobile-settings-chip-group { @@ -2256,7 +2277,11 @@ label.is-disabled.earth-mobile-settings-card { } .earth-settings-segmented { - display: inline-flex; + --item-count: 2; + --active-index: 0; + position: relative; + display: inline-grid; + grid-template-columns: repeat(var(--item-count), minmax(0, 1fr)); align-self: flex-start; padding: 4px; border-radius: 999px; @@ -2265,10 +2290,30 @@ label.is-disabled.earth-mobile-settings-card { rgba(255, 255, 255, 0.03); border: 1px solid rgba(212, 227, 244, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - gap: 4px; + overflow: hidden; +} + +.earth-settings-segmented::before { + content: ""; + position: absolute; + top: 4px; + bottom: 4px; + left: 4px; + width: calc((100% - 8px) / var(--item-count)); + border-radius: 999px; + background: + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%), + linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 18px rgba(0, 0, 0, 0.2); + transform: translateX(calc(var(--active-index) * 100%)); + transition: transform 180ms ease, opacity 180ms ease; } .earth-settings-segmented-btn { + position: relative; + z-index: 1; border: 0; background: transparent; color: var(--hud-text-soft); @@ -2280,9 +2325,7 @@ label.is-disabled.earth-mobile-settings-card { letter-spacing: 0.02em; cursor: pointer; transition: - background 0.18s ease, color 0.18s ease, - box-shadow 0.18s ease, transform 0.18s ease; } @@ -2293,12 +2336,73 @@ label.is-disabled.earth-mobile-settings-card { .earth-settings-segmented-btn.is-active { color: var(--hud-title); +} + +.earth-settings-segmented-btn:disabled { + cursor: default; + opacity: 0.54; + transform: none; +} + +.earth-settings-boundary-action-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.earth-settings-segmented--boundary { + flex: 0 1 auto; + margin-right: auto; +} + +.earth-settings-reload-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(30px * var(--hud-scale)); + height: calc(30px * var(--hud-scale)); + border: 1px solid rgba(122, 180, 255, 0.24); + border-radius: 999px; background: - radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%), - linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 8px 18px rgba(0, 0, 0, 0.2); + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%), + linear-gradient(180deg, rgba(121, 159, 207, 0.18), rgba(72, 101, 139, 0.24)); + color: var(--hud-title); + cursor: pointer; + vertical-align: middle; + transition: + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease, + opacity 0.18s ease; +} + +.earth-settings-reload-action--text { + width: auto; + min-width: calc(46px * var(--hud-scale)); + padding: 0 calc(12px * var(--hud-scale)); + font: inherit; + font-size: calc(0.7rem * var(--hud-scale)); + font-weight: 600; +} + +.earth-settings-reload-action[hidden] { + display: none; +} + +.earth-settings-reload-action:hover { + transform: translateY(-1px); + border-color: rgba(125, 197, 255, 0.46); +} + +.earth-settings-reload-action:disabled { + cursor: default; + opacity: 0.52; + transform: none; +} + +.earth-settings-reload-action .material-symbols-rounded { + font-size: calc(1rem * var(--hud-scale)); } .earth-settings-chip-group { @@ -2432,6 +2536,41 @@ label.is-disabled.earth-mobile-settings-card { font-variant-numeric: tabular-nums; } +.earth-boundary-progress { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + width: 100%; +} + +.earth-boundary-progress[hidden] { + display: none; +} + +.earth-boundary-progress__track { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: rgba(135, 162, 190, 0.26); +} + +.earth-boundary-progress__bar { + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #55d6be, #7cb7ff); + transition: width 180ms ease; +} + +.earth-boundary-progress__value { + min-width: 40px; + color: var(--hud-text-soft); + font-size: calc(0.66rem * var(--hud-scale)); + text-align: right; + font-variant-numeric: tabular-nums; +} + .earth-settings-copy { display: flex; flex-direction: column; diff --git a/frontend/public/earth/data/countries-admin0.min.geojson b/frontend/public/earth/data/countries-admin0.min.geojson new file mode 100644 index 00000000..e8273b89 --- /dev/null +++ b/frontend/public/earth/data/countries-admin0.min.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"ADMIN":"Fiji","NAME_EN":"Fiji","CONTINENT":"Oceania","ADM0_A3":"FJI","ISO_A3":"FJI","NAME":"Fiji","ISO_A2":"FJ","NAME_ZH":"斐济"},"geometry":{"type":"MultiPolygon","coordinates":[[[[180,-16.067133],[180,-16.555217],[179.364143,-16.801354],[178.725059,-17.012042],[178.596839,-16.63915],[179.096609,-16.433984],[179.413509,-16.379054],[180,-16.067133]]],[[[178.12557,-17.50481],[178.3736,-17.33992],[178.71806,-17.62846],[178.55271,-18.15059],[177.93266,-18.28799],[177.38146,-18.16432],[177.28504,-17.72465],[177.67087,-17.38114],[178.12557,-17.50481]]],[[[-179.79332,-16.020882],[-179.917369,-16.501783],[-180,-16.555217],[-180,-16.067133],[-179.79332,-16.020882]]]]}},{"type":"Feature","properties":{"ADMIN":"United Republic of Tanzania","NAME_EN":"Tanzania","CONTINENT":"Africa","ADM0_A3":"TZA","ISO_A3":"TZA","NAME":"Tanzania","ISO_A2":"TZ","NAME_ZH":"坦桑尼亚"},"geometry":{"type":"Polygon","coordinates":[[[33.903711,-0.95],[34.07262,-1.05982],[37.69869,-3.09699],[37.7669,-3.67712],[39.20222,-4.67677],[38.74054,-5.90895],[38.79977,-6.47566],[39.44,-6.84],[39.47,-7.1],[39.19469,-7.7039],[39.25203,-8.00781],[39.18652,-8.48551],[39.53574,-9.11237],[39.9496,-10.0984],[40.316586,-10.317098],[40.31659,-10.3171],[39.521,-10.89688],[38.427557,-11.285202],[37.82764,-11.26879],[37.47129,-11.56876],[36.775151,-11.594537],[36.514082,-11.720938],[35.312398,-11.439146],[34.559989,-11.52002],[34.28,-10.16],[33.940838,-9.693674],[33.73972,-9.41715],[32.759375,-9.230599],[32.191865,-8.930359],[31.556348,-8.762049],[31.157751,-8.594579],[30.74001,-8.340006],[30.740015,-8.340007],[30.199997,-7.079981],[29.620032,-6.520015],[29.419993,-5.939999],[29.519987,-5.419979],[29.339998,-4.499983],[29.753512,-4.452389],[30.11632,-4.09012],[30.50554,-3.56858],[30.75224,-3.35931],[30.74301,-3.03431],[30.52766,-2.80762],[30.469674,-2.413855],[30.46967,-2.41383],[30.758309,-2.28725],[30.816135,-1.698914],[30.419105,-1.134659],[30.76986,-1.01455],[31.86617,-1.02736],[33.903711,-0.95]]]}},{"type":"Feature","properties":{"ADMIN":"Western Sahara","NAME_EN":"Western Sahara","CONTINENT":"Africa","ADM0_A3":"SAH","ISO_A3":"ESH","NAME":"W. Sahara","ISO_A2":"EH","NAME_ZH":"西撒哈拉"},"geometry":{"type":"Polygon","coordinates":[[[-8.66559,27.656426],[-8.665124,27.589479],[-8.6844,27.395744],[-8.687294,25.881056],[-11.969419,25.933353],[-11.937224,23.374594],[-12.874222,23.284832],[-13.118754,22.77122],[-12.929102,21.327071],[-16.845194,21.333323],[-17.063423,20.999752],[-17.020428,21.42231],[-17.002962,21.420734],[-14.750955,21.5006],[-14.630833,21.86094],[-14.221168,22.310163],[-13.89111,23.691009],[-12.500963,24.770116],[-12.030759,26.030866],[-11.71822,26.104092],[-11.392555,26.883424],[-10.551263,26.990808],[-10.189424,26.860945],[-9.735343,26.860945],[-9.413037,27.088476],[-8.794884,27.120696],[-8.817828,27.656426],[-8.66559,27.656426]]]}},{"type":"Feature","properties":{"ADMIN":"Canada","NAME_EN":"Canada","CONTINENT":"North America","ADM0_A3":"CAN","ISO_A3":"CAN","NAME":"Canada","ISO_A2":"CA","NAME_ZH":"加拿大"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-122.97421,49.002538],[-124.91024,49.98456],[-125.62461,50.41656],[-127.43561,50.83061],[-127.99276,51.71583],[-127.85032,52.32961],[-129.12979,52.75538],[-129.30523,53.56159],[-130.51497,54.28757],[-130.536109,54.802754],[-130.53611,54.80278],[-129.98,55.285],[-130.00778,55.91583],[-131.70781,56.55212],[-132.73042,57.69289],[-133.35556,58.41028],[-134.27111,58.86111],[-134.945,59.27056],[-135.47583,59.78778],[-136.47972,59.46389],[-137.4525,58.905],[-138.34089,59.56211],[-139.039,60],[-140.013,60.27682],[-140.99778,60.30639],[-140.9925,66.00003],[-140.986,69.712],[-140.985988,69.711998],[-139.12052,69.47102],[-137.54636,68.99002],[-136.50358,68.89804],[-135.62576,69.31512],[-134.41464,69.62743],[-132.92925,69.50534],[-131.43136,69.94451],[-129.79471,70.19369],[-129.10773,69.77927],[-128.36156,70.01286],[-128.13817,70.48384],[-127.44712,70.37721],[-125.75632,69.48058],[-124.42483,70.1584],[-124.28968,69.39969],[-123.06108,69.56372],[-122.6835,69.85553],[-121.47226,69.79778],[-119.94288,69.37786],[-117.60268,69.01128],[-116.22643,68.84151],[-115.2469,68.90591],[-113.89794,68.3989],[-115.30489,67.90261],[-113.49727,67.68815],[-110.798,67.80612],[-109.94619,67.98104],[-108.8802,67.38144],[-107.79239,67.88736],[-108.81299,68.31164],[-108.16721,68.65392],[-106.95,68.7],[-106.15,68.8],[-105.34282,68.56122],[-104.33791,68.018],[-103.22115,68.09775],[-101.45433,67.64689],[-99.90195,67.80566],[-98.4432,67.78165],[-98.5586,68.40394],[-97.66948,68.57864],[-96.11991,68.23939],[-96.12588,67.29338],[-95.48943,68.0907],[-94.685,68.06383],[-94.23282,69.06903],[-95.30408,69.68571],[-96.47131,70.08976],[-96.39115,71.19482],[-95.2088,71.92053],[-93.88997,71.76015],[-92.87818,71.31869],[-91.51964,70.19129],[-92.40692,69.69997],[-90.5471,69.49766],[-90.55151,68.47499],[-89.21515,69.25873],[-88.01966,68.61508],[-88.31749,67.87338],[-87.35017,67.19872],[-86.30607,67.92146],[-85.57664,68.78456],[-85.52197,69.88211],[-84.10081,69.80539],[-82.62258,69.65826],[-81.28043,69.16202],[-81.2202,68.66567],[-81.96436,68.13253],[-81.25928,67.59716],[-81.38653,67.11078],[-83.34456,66.41154],[-84.73542,66.2573],[-85.76943,66.55833],[-86.0676,66.05625],[-87.03143,65.21297],[-87.32324,64.77563],[-88.48296,64.09897],[-89.91444,64.03273],[-90.70398,63.61017],[-90.77004,62.96021],[-91.93342,62.83508],[-93.15698,62.02469],[-94.24153,60.89865],[-94.62931,60.11021],[-94.6846,58.94882],[-93.21502,58.78212],[-92.76462,57.84571],[-92.29703,57.08709],[-90.89769,57.28468],[-89.03953,56.85172],[-88.03978,56.47162],[-87.32421,55.99914],[-86.07121,55.72383],[-85.01181,55.3026],[-83.36055,55.24489],[-82.27285,55.14832],[-82.4362,54.28227],[-82.12502,53.27703],[-81.40075,52.15788],[-79.91289,51.20842],[-79.14301,51.53393],[-78.60191,52.56208],[-79.12421,54.14145],[-79.82958,54.66772],[-78.22874,55.13645],[-77.0956,55.83741],[-76.54137,56.53423],[-76.62319,57.20263],[-77.30226,58.05209],[-78.51688,58.80458],[-77.33676,59.85261],[-77.77272,60.75788],[-78.10687,62.31964],[-77.41067,62.55053],[-75.69621,62.2784],[-74.6682,62.18111],[-73.83988,62.4438],[-72.90853,62.10507],[-71.67708,61.52535],[-71.37369,61.13717],[-69.59042,61.06141],[-69.62033,60.22125],[-69.2879,58.95736],[-68.37455,58.80106],[-67.64976,58.21206],[-66.20178,58.76731],[-65.24517,59.87071],[-64.58352,60.33558],[-63.80475,59.4426],[-62.50236,58.16708],[-61.39655,56.96745],[-61.79866,56.33945],[-60.46853,55.77548],[-59.56962,55.20407],[-57.97508,54.94549],[-57.3332,54.6265],[-56.93689,53.78032],[-56.15811,53.64749],[-55.75632,53.27036],[-55.68338,52.14664],[-56.40916,51.7707],[-57.12691,51.41972],[-58.77482,51.0643],[-60.03309,50.24277],[-61.72366,50.08046],[-63.86251,50.29099],[-65.36331,50.2982],[-66.39905,50.22897],[-67.23631,49.51156],[-68.51114,49.06836],[-69.95362,47.74488],[-71.10458,46.82171],[-70.25522,46.98606],[-68.65,48.3],[-66.55243,49.1331],[-65.05626,49.23278],[-64.17099,48.74248],[-65.11545,48.07085],[-64.79854,46.99297],[-64.47219,46.23849],[-63.17329,45.73902],[-61.52072,45.88377],[-60.51815,47.00793],[-60.4486,46.28264],[-59.80287,45.9204],[-61.03988,45.26525],[-63.25471,44.67014],[-64.24656,44.26553],[-65.36406,43.54523],[-66.1234,43.61867],[-66.16173,44.46512],[-64.42549,45.29204],[-66.02605,45.25931],[-67.13741,45.13753],[-67.79134,45.70281],[-67.79046,47.06636],[-68.23444,47.35486],[-68.905,47.185],[-69.237216,47.447781],[-69.99997,46.69307],[-70.305,45.915],[-70.66,45.46],[-71.08482,45.30524],[-71.405,45.255],[-71.50506,45.0082],[-73.34783,45.00738],[-74.867,45.00048],[-75.31821,44.81645],[-76.375,44.09631],[-76.5,44.018459],[-76.820034,43.628784],[-77.737885,43.629056],[-78.72028,43.625089],[-79.171674,43.466339],[-79.01,43.27],[-78.92,42.965],[-78.939362,42.863611],[-80.247448,42.3662],[-81.277747,42.209026],[-82.439278,41.675105],[-82.690089,41.675105],[-83.02981,41.832796],[-83.142,41.975681],[-83.12,42.08],[-82.9,42.43],[-82.43,42.98],[-82.137642,43.571088],[-82.337763,44.44],[-82.550925,45.347517],[-83.592851,45.816894],[-83.469551,45.994686],[-83.616131,46.116927],[-83.890765,46.116927],[-84.091851,46.275419],[-84.14212,46.512226],[-84.3367,46.40877],[-84.6049,46.4396],[-84.543749,46.538684],[-84.779238,46.637102],[-84.87608,46.900083],[-85.652363,47.220219],[-86.461991,47.553338],[-87.439793,47.94],[-88.378114,48.302918],[-89.272917,48.019808],[-89.6,48.01],[-90.83,48.27],[-91.64,48.14],[-92.61,48.45],[-93.63087,48.60926],[-94.32914,48.67074],[-94.64,48.84],[-94.81758,49.38905],[-95.15609,49.38425],[-95.15907,49],[-97.22872,49.0007],[-100.65,49],[-104.04826,48.99986],[-107.05,49],[-110.05,49],[-113,49],[-116.04818,49],[-117.03121,49],[-120,49],[-122.84,49]]],[[[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528]]],[[[-79.775833,72.802902],[-80.876099,73.333183],[-80.833885,73.693184],[-80.353058,73.75972],[-78.064438,73.651932],[-76.34,73.102685],[-76.251404,72.826385],[-77.314438,72.855545],[-78.39167,72.876656],[-79.486252,72.742203],[-79.775833,72.802902]]],[[[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565]]],[[[-93.612756,74.979997],[-94.156909,74.592347],[-95.608681,74.666864],[-96.820932,74.927623],[-96.288587,75.377828],[-94.85082,75.647218],[-93.977747,75.29649],[-93.612756,74.979997]]],[[[-93.840003,77.519997],[-94.295608,77.491343],[-96.169654,77.555111],[-96.436304,77.834629],[-94.422577,77.820005],[-93.720656,77.634331],[-93.840003,77.519997]]],[[[-96.754399,78.765813],[-95.559278,78.418315],[-95.830295,78.056941],[-97.309843,77.850597],[-98.124289,78.082857],[-98.552868,78.458105],[-98.631984,78.87193],[-97.337231,78.831984],[-96.754399,78.765813]]],[[[-88.15035,74.392307],[-89.764722,74.515555],[-92.422441,74.837758],[-92.768285,75.38682],[-92.889906,75.882655],[-93.893824,76.319244],[-95.962457,76.441381],[-97.121379,76.751078],[-96.745123,77.161389],[-94.684086,77.097878],[-93.573921,76.776296],[-91.605023,76.778518],[-90.741846,76.449597],[-90.969661,76.074013],[-89.822238,75.847774],[-89.187083,75.610166],[-87.838276,75.566189],[-86.379192,75.482421],[-84.789625,75.699204],[-82.753445,75.784315],[-81.128531,75.713983],[-80.057511,75.336849],[-79.833933,74.923127],[-80.457771,74.657304],[-81.948843,74.442459],[-83.228894,74.564028],[-86.097452,74.410032],[-88.15035,74.392307]]],[[[-111.264443,78.152956],[-109.854452,77.996325],[-110.186938,77.697015],[-112.051191,77.409229],[-113.534279,77.732207],[-112.724587,78.05105],[-111.264443,78.152956]]],[[[-110.963661,78.804441],[-109.663146,78.601973],[-110.881314,78.40692],[-112.542091,78.407902],[-112.525891,78.550555],[-111.50001,78.849994],[-110.963661,78.804441]]],[[[-55.600218,51.317075],[-56.134036,50.68701],[-56.795882,49.812309],[-56.143105,50.150117],[-55.471492,49.935815],[-55.822401,49.587129],[-54.935143,49.313011],[-54.473775,49.556691],[-53.476549,49.249139],[-53.786014,48.516781],[-53.086134,48.687804],[-52.958648,48.157164],[-52.648099,47.535548],[-53.069158,46.655499],[-53.521456,46.618292],[-54.178936,46.807066],[-53.961869,47.625207],[-54.240482,47.752279],[-55.400773,46.884994],[-55.997481,46.91972],[-55.291219,47.389562],[-56.250799,47.632545],[-57.325229,47.572807],[-59.266015,47.603348],[-59.419494,47.899454],[-58.796586,48.251525],[-59.231625,48.523188],[-58.391805,49.125581],[-57.35869,50.718274],[-56.73865,51.287438],[-55.870977,51.632094],[-55.406974,51.588273],[-55.600218,51.317075]]],[[[-83.882626,65.109618],[-82.787577,64.766693],[-81.642014,64.455136],[-81.55344,63.979609],[-80.817361,64.057486],[-80.103451,63.725981],[-80.99102,63.411246],[-82.547178,63.651722],[-83.108798,64.101876],[-84.100417,63.569712],[-85.523405,63.052379],[-85.866769,63.637253],[-87.221983,63.541238],[-86.35276,64.035833],[-86.224886,64.822917],[-85.883848,65.738778],[-85.161308,65.657285],[-84.975764,65.217518],[-84.464012,65.371772],[-83.882626,65.109618]]],[[[-78.770639,72.352173],[-77.824624,72.749617],[-75.605845,72.243678],[-74.228616,71.767144],[-74.099141,71.33084],[-72.242226,71.556925],[-71.200015,70.920013],[-68.786054,70.525024],[-67.91497,70.121948],[-66.969033,69.186087],[-68.805123,68.720198],[-66.449866,68.067163],[-64.862314,67.847539],[-63.424934,66.928473],[-61.851981,66.862121],[-62.163177,66.160251],[-63.918444,64.998669],[-65.14886,65.426033],[-66.721219,66.388041],[-68.015016,66.262726],[-68.141287,65.689789],[-67.089646,65.108455],[-65.73208,64.648406],[-65.320168,64.382737],[-64.669406,63.392927],[-65.013804,62.674185],[-66.275045,62.945099],[-68.783186,63.74567],[-67.369681,62.883966],[-66.328297,62.280075],[-66.165568,61.930897],[-68.877367,62.330149],[-71.023437,62.910708],[-72.235379,63.397836],[-71.886278,63.679989],[-73.378306,64.193963],[-74.834419,64.679076],[-74.818503,64.389093],[-77.70998,64.229542],[-78.555949,64.572906],[-77.897281,65.309192],[-76.018274,65.326969],[-73.959795,65.454765],[-74.293883,65.811771],[-73.944912,66.310578],[-72.651167,67.284576],[-72.92606,67.726926],[-73.311618,68.069437],[-74.843307,68.554627],[-76.869101,68.894736],[-76.228649,69.147769],[-77.28737,69.76954],[-78.168634,69.826488],[-78.957242,70.16688],[-79.492455,69.871808],[-81.305471,69.743185],[-84.944706,69.966634],[-87.060003,70.260001],[-88.681713,70.410741],[-89.51342,70.762038],[-88.467721,71.218186],[-89.888151,71.222552],[-90.20516,72.235074],[-89.436577,73.129464],[-88.408242,73.537889],[-85.826151,73.803816],[-86.562179,73.157447],[-85.774371,72.534126],[-84.850112,73.340278],[-82.31559,73.750951],[-80.600088,72.716544],[-80.748942,72.061907],[-78.770639,72.352173]]],[[[-94.503658,74.134907],[-92.420012,74.100025],[-90.509793,73.856732],[-92.003965,72.966244],[-93.196296,72.771992],[-94.269047,72.024596],[-95.409856,72.061881],[-96.033745,72.940277],[-96.018268,73.43743],[-95.495793,73.862417],[-94.503658,74.134907]]],[[[-122.854924,76.116543],[-122.854925,76.116543],[-121.157535,76.864508],[-119.103939,77.51222],[-117.570131,77.498319],[-116.198587,77.645287],[-116.335813,76.876962],[-117.106051,76.530032],[-118.040412,76.481172],[-119.899318,76.053213],[-121.499995,75.900019],[-122.854924,76.116543]]],[[[-132.710008,54.040009],[-131.74999,54.120004],[-132.04948,52.984621],[-131.179043,52.180433],[-131.57783,52.182371],[-132.180428,52.639707],[-132.549992,53.100015],[-133.054611,53.411469],[-133.239664,53.85108],[-133.180004,54.169975],[-132.710008,54.040009]]],[[[-105.492289,79.301594],[-103.529282,79.165349],[-100.825158,78.800462],[-100.060192,78.324754],[-99.670939,77.907545],[-101.30394,78.018985],[-102.949809,78.343229],[-105.176133,78.380332],[-104.210429,78.67742],[-105.41958,78.918336],[-105.492289,79.301594]]],[[[-123.510002,48.510011],[-124.012891,48.370846],[-125.655013,48.825005],[-125.954994,49.179996],[-126.850004,49.53],[-127.029993,49.814996],[-128.059336,49.994959],[-128.444584,50.539138],[-128.358414,50.770648],[-127.308581,50.552574],[-126.695001,50.400903],[-125.755007,50.295018],[-125.415002,49.950001],[-124.920768,49.475275],[-123.922509,49.062484],[-123.510002,48.510011]]],[[[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.383602],[-123.09219,70.90164],[-123.62,71.34],[-125.928949,71.868688],[-125.5,72.292261],[-124.80729,73.02256],[-123.94,73.68],[-124.91775,74.29275],[-121.53788,74.44893]]],[[[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168],[-107.81943,75.84552]]],[[[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984],[-104.46476,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447],[-102.73116,69.50402],[-102.09329,69.11962],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18],[-107.12254,69.11922],[-109,68.78],[-111.534149,68.630059],[-113.3132,68.53554],[-113.85496,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459],[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.9554],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601]]],[[[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588]]],[[[-106.6,73.6],[-105.26,73.64],[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6]]],[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]],[[[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.509322],[-87.81,80.32],[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233]]],[[[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.652273],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.172059],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.169781],[-68.5,83.106322],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.430162],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.970222],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.408696,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429]]],[[[-75.21597,67.44425],[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425]]],[[[-96.257401,69.49003],[-95.647681,69.10769],[-96.269521,68.75704],[-97.617401,69.06003],[-98.431801,68.9507],[-99.797401,69.40003],[-98.917401,69.71003],[-98.218261,70.14354],[-97.157401,69.86003],[-96.557401,69.68003],[-96.257401,69.49003]]],[[[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304]]],[[[-64.01486,47.03601],[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601]]]]}},{"type":"Feature","properties":{"ADMIN":"United States of America","NAME_EN":"United States of America","CONTINENT":"North America","ADM0_A3":"USA","ISO_A3":"USA","NAME":"United States of America","ISO_A2":"US","NAME_ZH":"美国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-120,49],[-117.03121,49],[-116.04818,49],[-113,49],[-110.05,49],[-107.05,49],[-104.04826,48.99986],[-100.65,49],[-97.22872,49.0007],[-95.15907,49],[-95.15609,49.38425],[-94.81758,49.38905],[-94.64,48.84],[-94.32914,48.67074],[-93.63087,48.60926],[-92.61,48.45],[-91.64,48.14],[-90.83,48.27],[-89.6,48.01],[-89.272917,48.019808],[-88.378114,48.302918],[-87.439793,47.94],[-86.461991,47.553338],[-85.652363,47.220219],[-84.87608,46.900083],[-84.779238,46.637102],[-84.543749,46.538684],[-84.6049,46.4396],[-84.3367,46.40877],[-84.14212,46.512226],[-84.091851,46.275419],[-83.890765,46.116927],[-83.616131,46.116927],[-83.469551,45.994686],[-83.592851,45.816894],[-82.550925,45.347517],[-82.337763,44.44],[-82.137642,43.571088],[-82.43,42.98],[-82.9,42.43],[-83.12,42.08],[-83.142,41.975681],[-83.02981,41.832796],[-82.690089,41.675105],[-82.439278,41.675105],[-81.277747,42.209026],[-80.247448,42.3662],[-78.939362,42.863611],[-78.92,42.965],[-79.01,43.27],[-79.171674,43.466339],[-78.72028,43.625089],[-77.737885,43.629056],[-76.820034,43.628784],[-76.5,44.018459],[-76.375,44.09631],[-75.31821,44.81645],[-74.867,45.00048],[-73.34783,45.00738],[-71.50506,45.0082],[-71.405,45.255],[-71.08482,45.30524],[-70.66,45.46],[-70.305,45.915],[-69.99997,46.69307],[-69.237216,47.447781],[-68.905,47.185],[-68.23444,47.35486],[-67.79046,47.06636],[-67.79134,45.70281],[-67.13741,45.13753],[-66.96466,44.8097],[-68.03252,44.3252],[-69.06,43.98],[-70.11617,43.68405],[-70.645476,43.090238],[-70.81489,42.8653],[-70.825,42.335],[-70.495,41.805],[-70.08,41.78],[-70.185,42.145],[-69.88497,41.92283],[-69.96503,41.63717],[-70.64,41.475],[-71.12039,41.49445],[-71.86,41.32],[-72.295,41.27],[-72.87643,41.22065],[-73.71,40.931102],[-72.24126,41.11948],[-71.945,40.93],[-73.345,40.63],[-73.982,40.628],[-73.952325,40.75075],[-74.25671,40.47351],[-73.96244,40.42763],[-74.17838,39.70926],[-74.90604,38.93954],[-74.98041,39.1964],[-75.20002,39.24845],[-75.52805,39.4985],[-75.32,38.96],[-75.071835,38.782032],[-75.05673,38.40412],[-75.37747,38.01551],[-75.94023,37.21689],[-76.03127,37.2566],[-75.72205,37.93705],[-76.23287,38.319215],[-76.35,39.15],[-76.542725,38.717615],[-76.32933,38.08326],[-76.989998,38.239992],[-76.30162,37.917945],[-76.25874,36.9664],[-75.9718,36.89726],[-75.86804,36.55125],[-75.72749,35.55074],[-76.36318,34.80854],[-77.397635,34.51201],[-78.05496,33.92547],[-78.55435,33.86133],[-79.06067,33.49395],[-79.20357,33.15839],[-80.301325,32.509355],[-80.86498,32.0333],[-81.33629,31.44049],[-81.49042,30.72999],[-81.31371,30.03552],[-80.98,29.18],[-80.535585,28.47213],[-80.53,28.04],[-80.056539,26.88],[-80.088015,26.205765],[-80.13156,25.816775],[-80.38103,25.20616],[-80.68,25.08],[-81.17213,25.20126],[-81.33,25.64],[-81.71,25.87],[-82.24,26.73],[-82.70515,27.49504],[-82.85526,27.88624],[-82.65,28.55],[-82.93,29.1],[-83.70959,29.93656],[-84.1,30.09],[-85.10882,29.63615],[-85.28784,29.68612],[-85.7731,30.15261],[-86.4,30.4],[-87.53036,30.27433],[-88.41782,30.3849],[-89.18049,30.31598],[-89.593831,30.159994],[-89.413735,29.89419],[-89.43,29.48864],[-89.21767,29.29108],[-89.40823,29.15961],[-89.77928,29.30714],[-90.15463,29.11743],[-90.880225,29.148535],[-91.626785,29.677],[-92.49906,29.5523],[-93.22637,29.78375],[-93.84842,29.71363],[-94.69,29.48],[-95.60026,28.73863],[-96.59404,28.30748],[-97.14,27.83],[-97.37,27.38],[-97.38,26.69],[-97.33,26.21],[-97.14,25.87],[-97.53,25.84],[-98.24,26.06],[-99.02,26.37],[-99.3,26.84],[-99.52,27.54],[-100.11,28.11],[-100.45584,28.69612],[-100.9576,29.38071],[-101.6624,29.7793],[-102.48,29.76],[-103.11,28.97],[-103.94,29.27],[-104.45697,29.57196],[-104.70575,30.12173],[-105.03737,30.64402],[-105.63159,31.08383],[-106.1429,31.39995],[-106.50759,31.75452],[-108.24,31.754854],[-108.24194,31.34222],[-109.035,31.34194],[-111.02361,31.33472],[-113.30498,32.03914],[-114.815,32.52528],[-114.72139,32.72083],[-115.99135,32.61239],[-117.12776,32.53534],[-117.295938,33.046225],[-117.944,33.621236],[-118.410602,33.740909],[-118.519895,34.027782],[-119.081,34.078],[-119.438841,34.348477],[-120.36778,34.44711],[-120.62286,34.60855],[-120.74433,35.15686],[-121.71457,36.16153],[-122.54747,37.55176],[-122.51201,37.78339],[-122.95319,38.11371],[-123.7272,38.95166],[-123.86517,39.76699],[-124.39807,40.3132],[-124.17886,41.14202],[-124.2137,41.99964],[-124.53284,42.76599],[-124.14214,43.70838],[-124.020535,44.615895],[-123.89893,45.52341],[-124.079635,46.86475],[-124.39567,47.72017],[-124.68721,48.184433],[-124.566101,48.379715],[-123.12,48.04],[-122.58736,47.096],[-122.34,47.36],[-122.5,48.18],[-122.84,49]]],[[[-155.40214,20.07975],[-155.22452,19.99302],[-155.06226,19.8591],[-154.80741,19.50871],[-154.83147,19.45328],[-155.22217,19.23972],[-155.54211,19.08348],[-155.68817,18.91619],[-155.93665,19.05939],[-155.90806,19.33888],[-156.07347,19.70294],[-156.02368,19.81422],[-155.85008,19.97729],[-155.91907,20.17395],[-155.86108,20.26721],[-155.78505,20.2487],[-155.40214,20.07975]]],[[[-155.99566,20.76404],[-156.07926,20.64397],[-156.41445,20.57241],[-156.58673,20.783],[-156.70167,20.8643],[-156.71055,20.92676],[-156.61258,21.01249],[-156.25711,20.91745],[-155.99566,20.76404]]],[[[-156.75824,21.17684],[-156.78933,21.06873],[-157.32521,21.09777],[-157.25027,21.21958],[-156.75824,21.17684]]],[[[-158.0252,21.71696],[-157.94161,21.65272],[-157.65283,21.32217],[-157.70703,21.26442],[-157.7786,21.27729],[-158.12667,21.31244],[-158.2538,21.53919],[-158.29265,21.57912],[-158.0252,21.71696]]],[[[-159.36569,22.21494],[-159.34512,21.982],[-159.46372,21.88299],[-159.80051,22.06533],[-159.74877,22.1382],[-159.5962,22.23618],[-159.36569,22.21494]]],[[[-166.467792,60.38417],[-165.67443,60.293607],[-165.579164,59.909987],[-166.19277,59.754441],[-166.848337,59.941406],[-167.455277,60.213069],[-166.467792,60.38417]]],[[[-153.228729,57.968968],[-152.564791,57.901427],[-152.141147,57.591059],[-153.006314,57.115842],[-154.00509,56.734677],[-154.516403,56.992749],[-154.670993,57.461196],[-153.76278,57.816575],[-153.228729,57.968968]]],[[[-140.985988,69.711998],[-140.986,69.712],[-140.9925,66.00003],[-140.99778,60.30639],[-140.013,60.27682],[-139.039,60],[-138.34089,59.56211],[-137.4525,58.905],[-136.47972,59.46389],[-135.47583,59.78778],[-134.945,59.27056],[-134.27111,58.86111],[-133.35556,58.41028],[-132.73042,57.69289],[-131.70781,56.55212],[-130.00778,55.91583],[-129.98,55.285],[-130.53611,54.80278],[-130.536109,54.802754],[-130.53611,54.802753],[-131.085818,55.178906],[-131.967211,55.497776],[-132.250011,56.369996],[-133.539181,57.178887],[-134.078063,58.123068],[-135.038211,58.187715],[-136.628062,58.212209],[-137.800006,58.499995],[-139.867787,59.537762],[-140.825274,59.727517],[-142.574444,60.084447],[-143.958881,59.99918],[-145.925557,60.45861],[-147.114374,60.884656],[-148.224306,60.672989],[-148.018066,59.978329],[-148.570823,59.914173],[-149.727858,59.705658],[-150.608243,59.368211],[-151.716393,59.155821],[-151.859433,59.744984],[-151.409719,60.725803],[-150.346941,61.033588],[-150.621111,61.284425],[-151.895839,60.727198],[-152.57833,60.061657],[-154.019172,59.350279],[-153.287511,58.864728],[-154.232492,58.146374],[-155.307491,57.727795],[-156.308335,57.422774],[-156.556097,56.979985],[-158.117217,56.463608],[-158.433321,55.994154],[-159.603327,55.566686],[-160.28972,55.643581],[-161.223048,55.364735],[-162.237766,55.024187],[-163.069447,54.689737],[-164.785569,54.404173],[-164.942226,54.572225],[-163.84834,55.039431],[-162.870001,55.348043],[-161.804175,55.894986],[-160.563605,56.008055],[-160.07056,56.418055],[-158.684443,57.016675],[-158.461097,57.216921],[-157.72277,57.570001],[-157.550274,58.328326],[-157.041675,58.918885],[-158.194731,58.615802],[-158.517218,58.787781],[-159.058606,58.424186],[-159.711667,58.93139],[-159.981289,58.572549],[-160.355271,59.071123],[-161.355003,58.670838],[-161.968894,58.671665],[-162.054987,59.266925],[-161.874171,59.633621],[-162.518059,59.989724],[-163.818341,59.798056],[-164.662218,60.267484],[-165.346388,60.507496],[-165.350832,61.073895],[-166.121379,61.500019],[-165.734452,62.074997],[-164.919179,62.633076],[-164.562508,63.146378],[-163.753332,63.219449],[-163.067224,63.059459],[-162.260555,63.541936],[-161.53445,63.455817],[-160.772507,63.766108],[-160.958335,64.222799],[-161.518068,64.402788],[-160.777778,64.788604],[-161.391926,64.777235],[-162.45305,64.559445],[-162.757786,64.338605],[-163.546394,64.55916],[-164.96083,64.446945],[-166.425288,64.686672],[-166.845004,65.088896],[-168.11056,65.669997],[-166.705271,66.088318],[-164.47471,66.57666],[-163.652512,66.57666],[-163.788602,66.077207],[-161.677774,66.11612],[-162.489715,66.735565],[-163.719717,67.116395],[-164.430991,67.616338],[-165.390287,68.042772],[-166.764441,68.358877],[-166.204707,68.883031],[-164.430811,68.915535],[-163.168614,69.371115],[-162.930566,69.858062],[-161.908897,70.33333],[-160.934797,70.44769],[-159.039176,70.891642],[-158.119723,70.824721],[-156.580825,71.357764],[-155.06779,71.147776],[-154.344165,70.696409],[-153.900006,70.889989],[-152.210006,70.829992],[-152.270002,70.600006],[-150.739992,70.430017],[-149.720003,70.53001],[-147.613362,70.214035],[-145.68999,70.12001],[-144.920011,69.989992],[-143.589446,70.152514],[-142.07251,69.851938],[-140.985988,69.711998],[-140.985988,69.711998]]],[[[-171.731657,63.782515],[-171.114434,63.592191],[-170.491112,63.694975],[-169.682505,63.431116],[-168.689439,63.297506],[-168.771941,63.188598],[-169.52944,62.976931],[-170.290556,63.194438],[-170.671386,63.375822],[-171.553063,63.317789],[-171.791111,63.405846],[-171.731657,63.782515]]]]}},{"type":"Feature","properties":{"ADMIN":"Kazakhstan","NAME_EN":"Kazakhstan","CONTINENT":"Asia","ADM0_A3":"KAZ","ISO_A3":"KAZ","NAME":"Kazakhstan","ISO_A2":"KZ","NAME_ZH":"哈萨克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[87.35997,49.214981],[86.598776,48.549182],[85.768233,48.455751],[85.720484,47.452969],[85.16429,47.000956],[83.180484,47.330031],[82.458926,45.53965],[81.947071,45.317027],[79.966106,44.917517],[80.866206,43.180362],[80.18015,42.920068],[80.25999,42.349999],[79.643645,42.496683],[79.142177,42.856092],[77.658392,42.960686],[76.000354,42.988022],[75.636965,42.8779],[74.212866,43.298339],[73.645304,43.091272],[73.489758,42.500894],[71.844638,42.845395],[71.186281,42.704293],[70.962315,42.266154],[70.388965,42.081308],[69.070027,41.384244],[68.632483,40.668681],[68.259896,40.662325],[67.985856,41.135991],[66.714047,41.168444],[66.510649,41.987644],[66.023392,41.994646],[66.098012,42.99766],[64.900824,43.728081],[63.185787,43.650075],[62.0133,43.504477],[61.05832,44.405817],[60.239972,44.784037],[58.689989,45.500014],[58.503127,45.586804],[55.928917,44.995858],[55.968191,41.308642],[55.455251,41.259859],[54.755345,42.043971],[54.079418,42.324109],[52.944293,42.116034],[52.50246,41.783316],[52.446339,42.027151],[52.692112,42.443895],[52.501426,42.792298],[51.342427,43.132975],[50.891292,44.031034],[50.339129,44.284016],[50.305643,44.609836],[51.278503,44.514854],[51.316899,45.245998],[52.16739,45.408391],[53.040876,45.259047],[53.220866,46.234646],[53.042737,46.853006],[52.042023,46.804637],[51.191945,47.048705],[50.034083,46.60899],[49.10116,46.39933],[48.59325,46.56104],[48.694734,47.075628],[48.05725,47.74377],[47.31524,47.71585],[46.466446,48.394152],[47.043672,49.152039],[46.751596,49.356006],[47.54948,50.454698],[48.577841,49.87476],[48.702382,50.605128],[50.766648,51.692762],[52.328724,51.718652],[54.532878,51.02624],[55.71694,50.62171],[56.77798,51.04355],[58.36332,51.06364],[59.642282,50.545442],[59.932807,50.842194],[61.337424,50.79907],[61.588003,51.272659],[59.967534,51.96042],[60.927269,52.447548],[60.739993,52.719986],[61.699986,52.979996],[60.978066,53.664993],[61.4366,54.00625],[65.178534,54.354228],[65.66687,54.60125],[68.1691,54.970392],[69.068167,55.38525],[70.865267,55.169734],[71.180131,54.133285],[72.22415,54.376655],[73.508516,54.035617],[73.425679,53.48981],[74.38482,53.54685],[76.8911,54.490524],[76.525179,54.177003],[77.800916,53.404415],[80.03556,50.864751],[80.568447,51.388336],[81.945986,50.812196],[83.383004,51.069183],[83.935115,50.889246],[84.416377,50.3114],[85.11556,50.117303],[85.54127,49.692859],[86.829357,49.826675],[87.35997,49.214981]]]}},{"type":"Feature","properties":{"ADMIN":"Uzbekistan","NAME_EN":"Uzbekistan","CONTINENT":"Asia","ADM0_A3":"UZB","ISO_A3":"UZB","NAME":"Uzbekistan","ISO_A2":"UZ","NAME_ZH":"乌兹别克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[55.968191,41.308642],[55.928917,44.995858],[58.503127,45.586804],[58.689989,45.500014],[60.239972,44.784037],[61.05832,44.405817],[62.0133,43.504477],[63.185787,43.650075],[64.900824,43.728081],[66.098012,42.99766],[66.023392,41.994646],[66.510649,41.987644],[66.714047,41.168444],[67.985856,41.135991],[68.259896,40.662325],[68.632483,40.668681],[69.070027,41.384244],[70.388965,42.081308],[70.962315,42.266154],[71.259248,42.167711],[70.420022,41.519998],[71.157859,41.143587],[71.870115,41.3929],[73.055417,40.866033],[71.774875,40.145844],[71.014198,40.244366],[70.601407,40.218527],[70.45816,40.496495],[70.666622,40.960213],[69.329495,40.727824],[69.011633,40.086158],[68.536416,39.533453],[67.701429,39.580478],[67.44222,39.140144],[68.176025,38.901553],[68.392033,38.157025],[67.83,37.144994],[67.075782,37.356144],[66.518607,37.362784],[66.54615,37.974685],[65.215999,38.402695],[64.170223,38.892407],[63.518015,39.363257],[62.37426,40.053886],[61.882714,41.084857],[61.547179,41.26637],[60.465953,41.220327],[60.083341,41.425146],[59.976422,42.223082],[58.629011,42.751551],[57.78653,42.170553],[56.932215,41.826026],[57.096391,41.32231],[55.968191,41.308642]]]}},{"type":"Feature","properties":{"ADMIN":"Papua New Guinea","NAME_EN":"Papua New Guinea","CONTINENT":"Oceania","ADM0_A3":"PNG","ISO_A3":"PNG","NAME":"Papua New Guinea","ISO_A2":"PG","NAME_ZH":"巴布亚新几内亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.00021,-2.600151],[142.735247,-3.289153],[144.583971,-3.861418],[145.27318,-4.373738],[145.829786,-4.876498],[145.981922,-5.465609],[147.648073,-6.083659],[147.891108,-6.614015],[146.970905,-6.721657],[147.191874,-7.388024],[148.084636,-8.044108],[148.734105,-9.104664],[149.306835,-9.071436],[149.266631,-9.514406],[150.038728,-9.684318],[149.738798,-9.872937],[150.801628,-10.293687],[150.690575,-10.582713],[150.028393,-10.652476],[149.78231,-10.393267],[148.923138,-10.280923],[147.913018,-10.130441],[147.135443,-9.492444],[146.567881,-8.942555],[146.048481,-8.067414],[144.744168,-7.630128],[143.897088,-7.91533],[143.286376,-8.245491],[143.413913,-8.983069],[142.628431,-9.326821],[142.068259,-9.159596],[141.033852,-9.117893],[141.017057,-5.859022],[141.00021,-2.600151]]],[[[152.640017,-3.659983],[153.019994,-3.980015],[153.140038,-4.499983],[152.827292,-4.766427],[152.638673,-4.176127],[152.406026,-3.789743],[151.953237,-3.462062],[151.384279,-3.035422],[150.66205,-2.741486],[150.939965,-2.500002],[151.479984,-2.779985],[151.820015,-2.999972],[152.239989,-3.240009],[152.640017,-3.659983]]],[[[151.30139,-5.840728],[150.754447,-6.083763],[150.241197,-6.317754],[149.709963,-6.316513],[148.890065,-6.02604],[148.318937,-5.747142],[148.401826,-5.437756],[149.298412,-5.583742],[149.845562,-5.505503],[149.99625,-5.026101],[150.139756,-5.001348],[150.236908,-5.53222],[150.807467,-5.455842],[151.089672,-5.113693],[151.647881,-4.757074],[151.537862,-4.167807],[152.136792,-4.14879],[152.338743,-4.312966],[152.318693,-4.867661],[151.982796,-5.478063],[151.459107,-5.56028],[151.30139,-5.840728]]],[[[154.759991,-5.339984],[155.062918,-5.566792],[155.547746,-6.200655],[156.019965,-6.540014],[155.880026,-6.819997],[155.599991,-6.919991],[155.166994,-6.535931],[154.729192,-5.900828],[154.514114,-5.139118],[154.652504,-5.042431],[154.759991,-5.339984]]]]}},{"type":"Feature","properties":{"ADMIN":"Indonesia","NAME_EN":"Indonesia","CONTINENT":"Asia","ADM0_A3":"IDN","ISO_A3":"IDN","NAME":"Indonesia","ISO_A2":"ID","NAME_ZH":"印度尼西亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.00021,-2.600151],[141.017057,-5.859022],[141.033852,-9.117893],[140.143415,-8.297168],[139.127767,-8.096043],[138.881477,-8.380935],[137.614474,-8.411683],[138.039099,-7.597882],[138.668621,-7.320225],[138.407914,-6.232849],[137.92784,-5.393366],[135.98925,-4.546544],[135.164598,-4.462931],[133.66288,-3.538853],[133.367705,-4.024819],[132.983956,-4.112979],[132.756941,-3.746283],[132.753789,-3.311787],[131.989804,-2.820551],[133.066845,-2.460418],[133.780031,-2.479848],[133.696212,-2.214542],[132.232373,-2.212526],[131.836222,-1.617162],[130.94284,-1.432522],[130.519558,-0.93772],[131.867538,-0.695461],[132.380116,-0.369538],[133.985548,-0.78021],[134.143368,-1.151867],[134.422627,-2.769185],[135.457603,-3.367753],[136.293314,-2.307042],[137.440738,-1.703513],[138.329727,-1.702686],[139.184921,-2.051296],[139.926684,-2.409052],[141.00021,-2.600151]]],[[[124.968682,-8.89279],[125.07002,-9.089987],[125.08852,-9.393173],[124.43595,-10.140001],[123.579982,-10.359987],[123.459989,-10.239995],[123.550009,-9.900016],[123.980009,-9.290027],[124.968682,-8.89279]]],[[[134.210134,-6.895238],[134.112776,-6.142467],[134.290336,-5.783058],[134.499625,-5.445042],[134.727002,-5.737582],[134.724624,-6.214401],[134.210134,-6.895238]]],[[[117.882035,4.137551],[117.313232,3.234428],[118.04833,2.28769],[117.875627,1.827641],[118.996747,0.902219],[117.811858,0.784242],[117.478339,0.102475],[117.521644,-0.803723],[116.560048,-1.487661],[116.533797,-2.483517],[116.148084,-4.012726],[116.000858,-3.657037],[114.864803,-4.106984],[114.468652,-3.495704],[113.755672,-3.43917],[113.256994,-3.118776],[112.068126,-3.478392],[111.703291,-2.994442],[111.04824,-3.049426],[110.223846,-2.934032],[110.070936,-1.592874],[109.571948,-1.314907],[109.091874,-0.459507],[108.952658,0.415375],[109.069136,1.341934],[109.66326,2.006467],[109.830227,1.338136],[110.514061,0.773131],[111.159138,0.976478],[111.797548,0.904441],[112.380252,1.410121],[112.859809,1.49779],[113.80585,1.217549],[114.621355,1.430688],[115.134037,2.821482],[115.519078,3.169238],[115.865517,4.306559],[117.015214,4.306094],[117.882035,4.137551]]],[[[129.370998,-2.802154],[130.471344,-3.093764],[130.834836,-3.858472],[129.990547,-3.446301],[129.155249,-3.362637],[128.590684,-3.428679],[127.898891,-3.393436],[128.135879,-2.84365],[129.370998,-2.802154]]],[[[126.874923,-3.790983],[126.183802,-3.607376],[125.989034,-3.177273],[127.000651,-3.129318],[127.249215,-3.459065],[126.874923,-3.790983]]],[[[127.932378,2.174596],[128.004156,1.628531],[128.594559,1.540811],[128.688249,1.132386],[128.635952,0.258486],[128.12017,0.356413],[127.968034,-0.252077],[128.379999,-0.780004],[128.100016,-0.899996],[127.696475,-0.266598],[127.39949,1.011722],[127.600512,1.810691],[127.932378,2.174596]]],[[[122.927567,0.875192],[124.077522,0.917102],[125.065989,1.643259],[125.240501,1.419836],[124.437035,0.427881],[123.685505,0.235593],[122.723083,0.431137],[121.056725,0.381217],[120.183083,0.237247],[120.04087,-0.519658],[120.935905,-1.408906],[121.475821,-0.955962],[123.340565,-0.615673],[123.258399,-1.076213],[122.822715,-0.930951],[122.38853,-1.516858],[121.508274,-1.904483],[122.454572,-3.186058],[122.271896,-3.5295],[123.170963,-4.683693],[123.162333,-5.340604],[122.628515,-5.634591],[122.236394,-5.282933],[122.719569,-4.464172],[121.738234,-4.851331],[121.489463,-4.574553],[121.619171,-4.188478],[120.898182,-3.602105],[120.972389,-2.627643],[120.305453,-2.931604],[120.390047,-4.097579],[120.430717,-5.528241],[119.796543,-5.6734],[119.366906,-5.379878],[119.653606,-4.459417],[119.498835,-3.494412],[119.078344,-3.487022],[118.767769,-2.801999],[119.180974,-2.147104],[119.323394,-1.353147],[119.825999,0.154254],[120.035702,0.566477],[120.885779,1.309223],[121.666817,1.013944],[122.927567,0.875192]]],[[[120.295014,-10.25865],[118.967808,-9.557969],[119.90031,-9.36134],[120.425756,-9.665921],[120.775502,-9.969675],[120.715609,-10.239581],[120.295014,-10.25865]]],[[[121.341669,-8.53674],[122.007365,-8.46062],[122.903537,-8.094234],[122.756983,-8.649808],[121.254491,-8.933666],[119.924391,-8.810418],[119.920929,-8.444859],[120.715092,-8.236965],[121.341669,-8.53674]]],[[[118.260616,-8.362383],[118.87846,-8.280683],[119.126507,-8.705825],[117.970402,-8.906639],[117.277731,-9.040895],[116.740141,-9.032937],[117.083737,-8.457158],[117.632024,-8.449303],[117.900018,-8.095681],[118.260616,-8.362383]]],[[[108.486846,-6.421985],[108.623479,-6.777674],[110.539227,-6.877358],[110.759576,-6.465186],[112.614811,-6.946036],[112.978768,-7.594213],[114.478935,-7.776528],[115.705527,-8.370807],[114.564511,-8.751817],[113.464734,-8.348947],[112.559672,-8.376181],[111.522061,-8.302129],[110.58615,-8.122605],[109.427667,-7.740664],[108.693655,-7.6416],[108.277763,-7.766657],[106.454102,-7.3549],[106.280624,-6.9249],[105.365486,-6.851416],[106.051646,-5.895919],[107.265009,-5.954985],[108.072091,-6.345762],[108.486846,-6.421985]]],[[[104.369991,-1.084843],[104.53949,-1.782372],[104.887893,-2.340425],[105.622111,-2.428844],[106.108593,-3.061777],[105.857446,-4.305525],[105.817655,-5.852356],[104.710384,-5.873285],[103.868213,-5.037315],[102.584261,-4.220259],[102.156173,-3.614146],[101.399113,-2.799777],[100.902503,-2.050262],[100.141981,-0.650348],[99.26374,0.183142],[98.970011,1.042882],[98.601351,1.823507],[97.699598,2.453184],[97.176942,3.308791],[96.424017,3.86886],[95.380876,4.970782],[95.293026,5.479821],[95.936863,5.439513],[97.484882,5.246321],[98.369169,4.26837],[99.142559,3.59035],[99.693998,3.174329],[100.641434,2.099381],[101.658012,2.083697],[102.498271,1.3987],[103.07684,0.561361],[103.838396,0.104542],[103.437645,-0.711946],[104.010789,-1.059212],[104.369991,-1.084843]]]]}},{"type":"Feature","properties":{"ADMIN":"Argentina","NAME_EN":"Argentina","CONTINENT":"South America","ADM0_A3":"ARG","ISO_A3":"ARG","NAME":"Argentina","ISO_A2":"AR","NAME_ZH":"阿根廷"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.25,-53.1],[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2],[-66.45,-55.25],[-66.95992,-54.89681],[-67.56244,-54.87001],[-68.63335,-54.8695],[-68.63401,-52.63637]]],[[[-57.625133,-30.216295],[-57.874937,-31.016556],[-58.14244,-32.044504],[-58.132648,-33.040567],[-58.349611,-33.263189],[-58.427074,-33.909454],[-58.495442,-34.43149],[-57.22583,-35.288027],[-57.362359,-35.97739],[-56.737487,-36.413126],[-56.788285,-36.901572],[-57.749157,-38.183871],[-59.231857,-38.72022],[-61.237445,-38.928425],[-62.335957,-38.827707],[-62.125763,-39.424105],[-62.330531,-40.172586],[-62.145994,-40.676897],[-62.745803,-41.028761],[-63.770495,-41.166789],[-64.73209,-40.802677],[-65.118035,-41.064315],[-64.978561,-42.058001],[-64.303408,-42.359016],[-63.755948,-42.043687],[-63.458059,-42.563138],[-64.378804,-42.873558],[-65.181804,-43.495381],[-65.328823,-44.501366],[-65.565269,-45.036786],[-66.509966,-45.039628],[-67.293794,-45.551896],[-67.580546,-46.301773],[-66.597066,-47.033925],[-65.641027,-47.236135],[-65.985088,-48.133289],[-67.166179,-48.697337],[-67.816088,-49.869669],[-68.728745,-50.264218],[-69.138539,-50.73251],[-68.815561,-51.771104],[-68.149995,-52.349983],[-68.571545,-52.299444],[-69.498362,-52.142761],[-71.914804,-52.009022],[-72.329404,-51.425956],[-72.309974,-50.67701],[-72.975747,-50.74145],[-73.328051,-50.378785],[-73.415436,-49.318436],[-72.648247,-48.878618],[-72.331161,-48.244238],[-72.447355,-47.738533],[-71.917258,-46.884838],[-71.552009,-45.560733],[-71.659316,-44.973689],[-71.222779,-44.784243],[-71.329801,-44.407522],[-71.793623,-44.207172],[-71.464056,-43.787611],[-71.915424,-43.408565],[-72.148898,-42.254888],[-71.746804,-42.051386],[-71.915734,-40.832339],[-71.680761,-39.808164],[-71.413517,-38.916022],[-70.814664,-38.552995],[-71.118625,-37.576827],[-71.121881,-36.658124],[-70.364769,-36.005089],[-70.388049,-35.169688],[-69.817309,-34.193571],[-69.814777,-33.273886],[-70.074399,-33.09121],[-70.535069,-31.36501],[-69.919008,-30.336339],[-70.01355,-29.367923],[-69.65613,-28.459141],[-69.001235,-27.521214],[-68.295542,-26.89934],[-68.5948,-26.506909],[-68.386001,-26.185016],[-68.417653,-24.518555],[-67.328443,-24.025303],[-66.985234,-22.986349],[-67.106674,-22.735925],[-66.273339,-21.83231],[-64.964892,-22.075862],[-64.377021,-22.798091],[-63.986838,-21.993644],[-62.846468,-22.034985],[-62.685057,-22.249029],[-60.846565,-23.880713],[-60.028966,-24.032796],[-58.807128,-24.771459],[-57.777217,-25.16234],[-57.63366,-25.603657],[-58.618174,-27.123719],[-57.60976,-27.395899],[-56.486702,-27.548499],[-55.695846,-27.387837],[-54.788795,-26.621786],[-54.625291,-25.739255],[-54.13005,-25.547639],[-53.628349,-26.124865],[-53.648735,-26.923473],[-54.490725,-27.474757],[-55.162286,-27.881915],[-56.2909,-28.852761],[-57.625133,-30.216295]]]]}},{"type":"Feature","properties":{"ADMIN":"Chile","NAME_EN":"Chile","CONTINENT":"South America","ADM0_A3":"CHL","ISO_A3":"CHL","NAME":"Chile","ISO_A2":"CL","NAME_ZH":"智利"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.63335,-54.8695],[-67.56244,-54.87001],[-66.95992,-54.89681],[-67.29103,-55.30124],[-68.14863,-55.61183],[-68.639991,-55.580018],[-69.2321,-55.49906],[-69.95809,-55.19843],[-71.00568,-55.05383],[-72.2639,-54.49514],[-73.2852,-53.95752],[-74.66253,-52.83749],[-73.8381,-53.04743],[-72.43418,-53.7154],[-71.10773,-54.07433],[-70.59178,-53.61583],[-70.26748,-52.93123],[-69.34565,-52.5183],[-68.63401,-52.63637]]],[[[-69.590424,-17.580012],[-69.100247,-18.260125],[-68.966818,-18.981683],[-68.442225,-19.405068],[-68.757167,-20.372658],[-68.219913,-21.494347],[-67.82818,-22.872919],[-67.106674,-22.735925],[-66.985234,-22.986349],[-67.328443,-24.025303],[-68.417653,-24.518555],[-68.386001,-26.185016],[-68.5948,-26.506909],[-68.295542,-26.89934],[-69.001235,-27.521214],[-69.65613,-28.459141],[-70.01355,-29.367923],[-69.919008,-30.336339],[-70.535069,-31.36501],[-70.074399,-33.09121],[-69.814777,-33.273886],[-69.817309,-34.193571],[-70.388049,-35.169688],[-70.364769,-36.005089],[-71.121881,-36.658124],[-71.118625,-37.576827],[-70.814664,-38.552995],[-71.413517,-38.916022],[-71.680761,-39.808164],[-71.915734,-40.832339],[-71.746804,-42.051386],[-72.148898,-42.254888],[-71.915424,-43.408565],[-71.464056,-43.787611],[-71.793623,-44.207172],[-71.329801,-44.407522],[-71.222779,-44.784243],[-71.659316,-44.973689],[-71.552009,-45.560733],[-71.917258,-46.884838],[-72.447355,-47.738533],[-72.331161,-48.244238],[-72.648247,-48.878618],[-73.415436,-49.318436],[-73.328051,-50.378785],[-72.975747,-50.74145],[-72.309974,-50.67701],[-72.329404,-51.425956],[-71.914804,-52.009022],[-69.498362,-52.142761],[-68.571545,-52.299444],[-69.461284,-52.291951],[-69.94278,-52.537931],[-70.845102,-52.899201],[-71.006332,-53.833252],[-71.429795,-53.856455],[-72.557943,-53.53141],[-73.702757,-52.835069],[-73.702757,-52.83507],[-74.946763,-52.262754],[-75.260026,-51.629355],[-74.976632,-51.043396],[-75.479754,-50.378372],[-75.608015,-48.673773],[-75.18277,-47.711919],[-74.126581,-46.939253],[-75.644395,-46.647643],[-74.692154,-45.763976],[-74.351709,-44.103044],[-73.240356,-44.454961],[-72.717804,-42.383356],[-73.3889,-42.117532],[-73.701336,-43.365776],[-74.331943,-43.224958],[-74.017957,-41.794813],[-73.677099,-39.942213],[-73.217593,-39.258689],[-73.505559,-38.282883],[-73.588061,-37.156285],[-73.166717,-37.12378],[-72.553137,-35.50884],[-71.861732,-33.909093],[-71.43845,-32.418899],[-71.668721,-30.920645],[-71.370083,-30.095682],[-71.489894,-28.861442],[-70.905124,-27.64038],[-70.724954,-25.705924],[-70.403966,-23.628997],[-70.091246,-21.393319],[-70.16442,-19.756468],[-70.372572,-18.347975],[-69.858444,-18.092694],[-69.590424,-17.580012]]]]}},{"type":"Feature","properties":{"ADMIN":"Democratic Republic of the Congo","NAME_EN":"Democratic Republic of the Congo","CONTINENT":"Africa","ADM0_A3":"COD","ISO_A3":"COD","NAME":"Dem. Rep. Congo","ISO_A2":"CD","NAME_ZH":"刚果民主共和国"},"geometry":{"type":"Polygon","coordinates":[[[29.339998,-4.499983],[29.519987,-5.419979],[29.419993,-5.939999],[29.620032,-6.520015],[30.199997,-7.079981],[30.740015,-8.340007],[30.74001,-8.340006],[30.346086,-8.238257],[29.002912,-8.407032],[28.734867,-8.526559],[28.449871,-9.164918],[28.673682,-9.605925],[28.49607,-10.789884],[28.372253,-11.793647],[28.642417,-11.971569],[29.341548,-12.360744],[29.616001,-12.178895],[29.699614,-13.257227],[28.934286,-13.248958],[28.523562,-12.698604],[28.155109,-12.272481],[27.388799,-12.132747],[27.16442,-11.608748],[26.553088,-11.92444],[25.75231,-11.784965],[25.418118,-11.330936],[24.78317,-11.238694],[24.314516,-11.262826],[24.257155,-10.951993],[23.912215,-10.926826],[23.456791,-10.867863],[22.837345,-11.017622],[22.402798,-10.993075],[22.155268,-11.084801],[22.208753,-9.894796],[21.875182,-9.523708],[21.801801,-8.908707],[21.949131,-8.305901],[21.746456,-7.920085],[21.728111,-7.290872],[20.514748,-7.299606],[20.601823,-6.939318],[20.091622,-6.94309],[20.037723,-7.116361],[19.417502,-7.155429],[19.166613,-7.738184],[19.016752,-7.988246],[18.464176,-7.847014],[18.134222,-7.987678],[17.47297,-8.068551],[17.089996,-7.545689],[16.860191,-7.222298],[16.57318,-6.622645],[16.326528,-5.87747],[13.375597,-5.864241],[13.024869,-5.984389],[12.735171,-5.965682],[12.322432,-6.100092],[12.182337,-5.789931],[12.436688,-5.684304],[12.468004,-5.248362],[12.631612,-4.991271],[12.995517,-4.781103],[13.25824,-4.882957],[13.600235,-4.500138],[14.144956,-4.510009],[14.209035,-4.793092],[14.582604,-4.970239],[15.170992,-4.343507],[15.75354,-3.855165],[16.00629,-3.535133],[15.972803,-2.712392],[16.407092,-1.740927],[16.865307,-1.225816],[17.523716,-0.74383],[17.638645,-0.424832],[17.663553,-0.058084],[17.82654,0.288923],[17.774192,0.855659],[17.898835,1.741832],[18.094276,2.365722],[18.393792,2.900443],[18.453065,3.504386],[18.542982,4.201785],[18.932312,4.709506],[19.467784,5.031528],[20.290679,4.691678],[20.927591,4.322786],[21.659123,4.224342],[22.405124,4.02916],[22.704124,4.633051],[22.84148,4.710126],[23.297214,4.609693],[24.410531,5.108784],[24.805029,4.897247],[25.128833,4.927245],[25.278798,5.170408],[25.650455,5.256088],[26.402761,5.150875],[27.044065,5.127853],[27.374226,5.233944],[27.979977,4.408413],[28.428994,4.287155],[28.696678,4.455077],[29.159078,4.389267],[29.715995,4.600805],[29.9535,4.173699],[30.833852,3.509172],[30.83386,3.509166],[30.773347,2.339883],[31.174149,2.204465],[30.85267,1.849396],[30.468508,1.583805],[30.086154,1.062313],[29.875779,0.59738],[29.819503,-0.20531],[29.587838,-0.587406],[29.579466,-1.341313],[29.291887,-1.620056],[29.254835,-2.21511],[29.117479,-2.292211],[29.024926,-2.839258],[29.276384,-3.293907],[29.339998,-4.499983]]]}},{"type":"Feature","properties":{"ADMIN":"Somalia","NAME_EN":"Somalia","CONTINENT":"Africa","ADM0_A3":"SOM","ISO_A3":"SOM","NAME":"Somalia","ISO_A2":"SO","NAME_ZH":"索马里"},"geometry":{"type":"Polygon","coordinates":[[[41.58513,-1.68325],[40.993,-0.85829],[40.98105,2.78452],[41.855083,3.918912],[42.12861,4.23413],[42.76967,4.25259],[43.66087,4.95755],[44.9636,5.00162],[47.78942,8.003],[48.486736,8.837626],[48.93813,9.451749],[48.938233,9.9735],[48.938491,10.982327],[48.942005,11.394266],[48.948205,11.410617],[48.948205,11.410617],[49.26776,11.43033],[49.72862,11.5789],[50.25878,11.67957],[50.73202,12.0219],[51.1112,12.02464],[51.13387,11.74815],[51.04153,11.16651],[51.04531,10.6409],[50.83418,10.27972],[50.55239,9.19874],[50.07092,8.08173],[49.4527,6.80466],[48.59455,5.33911],[47.74079,4.2194],[46.56476,2.85529],[45.56399,2.04576],[44.06815,1.05283],[43.13597,0.2922],[42.04157,-0.91916],[41.81095,-1.44647],[41.58513,-1.68325]]]}},{"type":"Feature","properties":{"ADMIN":"Kenya","NAME_EN":"Kenya","CONTINENT":"Africa","ADM0_A3":"KEN","ISO_A3":"KEN","NAME":"Kenya","ISO_A2":"KE","NAME_ZH":"肯尼亚"},"geometry":{"type":"Polygon","coordinates":[[[39.20222,-4.67677],[37.7669,-3.67712],[37.69869,-3.09699],[34.07262,-1.05982],[33.903711,-0.95],[33.893569,0.109814],[34.18,0.515],[34.6721,1.17694],[35.03599,1.90584],[34.59607,3.05374],[34.47913,3.5556],[34.005,4.249885],[34.620196,4.847123],[35.298007,5.506],[35.817448,5.338232],[35.817448,4.776966],[36.159079,4.447864],[36.855093,4.447864],[38.120915,3.598605],[38.43697,3.58851],[38.67114,3.61607],[38.89251,3.50074],[39.559384,3.42206],[39.85494,3.83879],[40.76848,4.25702],[41.1718,3.91909],[41.855083,3.918912],[40.98105,2.78452],[40.993,-0.85829],[41.58513,-1.68325],[40.88477,-2.08255],[40.63785,-2.49979],[40.26304,-2.57309],[40.12119,-3.27768],[39.80006,-3.68116],[39.60489,-4.34653],[39.20222,-4.67677]]]}},{"type":"Feature","properties":{"ADMIN":"Sudan","NAME_EN":"Sudan","CONTINENT":"Africa","ADM0_A3":"SDN","ISO_A3":"SDN","NAME":"Sudan","ISO_A2":"SD","NAME_ZH":"苏丹"},"geometry":{"type":"Polygon","coordinates":[[[24.567369,8.229188],[23.805813,8.666319],[23.459013,8.954286],[23.394779,9.265068],[23.55725,9.681218],[23.554304,10.089255],[22.977544,10.714463],[22.864165,11.142395],[22.87622,11.38461],[22.50869,11.67936],[22.49762,12.26024],[22.28801,12.64605],[21.93681,12.58818],[22.03759,12.95546],[22.29658,13.37232],[22.18329,13.78648],[22.51202,14.09318],[22.30351,14.32682],[22.56795,14.94429],[23.02459,15.68072],[23.88689,15.61084],[23.83766,19.58047],[23.85,20],[25,20.00304],[25,22],[29.02,22],[32.9,22],[36.86623,22],[37.18872,21.01885],[36.96941,20.83744],[37.1147,19.80796],[37.48179,18.61409],[37.86276,18.36786],[38.41009,17.998307],[37.904,17.42754],[37.16747,17.26314],[36.85253,16.95655],[36.75389,16.29186],[36.32322,14.82249],[36.42951,14.42211],[36.27022,13.56333],[35.86363,12.57828],[35.26049,12.08286],[34.83163,11.31896],[34.73115,10.91017],[34.25745,10.63009],[33.96162,9.58358],[33.97498,8.68456],[33.963393,9.464285],[33.824963,9.484061],[33.842131,9.981915],[33.721959,10.325262],[33.206938,10.720112],[33.086766,11.441141],[33.206938,12.179338],[32.743419,12.248008],[32.67475,12.024832],[32.073892,11.97333],[32.314235,11.681484],[32.400072,11.080626],[31.850716,10.531271],[31.352862,9.810241],[30.837841,9.707237],[29.996639,10.290927],[29.618957,10.084919],[29.515953,9.793074],[29.000932,9.604232],[28.966597,9.398224],[27.97089,9.398224],[27.833551,9.604232],[27.112521,9.638567],[26.752006,9.466893],[26.477328,9.55273],[25.962307,10.136421],[25.790633,10.411099],[25.069604,10.27376],[24.794926,9.810241],[24.537415,8.917538],[24.194068,8.728696],[23.88698,8.61973],[24.567369,8.229188]]]}},{"type":"Feature","properties":{"ADMIN":"Chad","NAME_EN":"Chad","CONTINENT":"Africa","ADM0_A3":"TCD","ISO_A3":"TCD","NAME":"Chad","ISO_A2":"TD","NAME_ZH":"乍得"},"geometry":{"type":"Polygon","coordinates":[[[23.83766,19.58047],[23.88689,15.61084],[23.02459,15.68072],[22.56795,14.94429],[22.30351,14.32682],[22.51202,14.09318],[22.18329,13.78648],[22.29658,13.37232],[22.03759,12.95546],[21.93681,12.58818],[22.28801,12.64605],[22.49762,12.26024],[22.50869,11.67936],[22.87622,11.38461],[22.864165,11.142395],[22.231129,10.971889],[21.723822,10.567056],[21.000868,9.475985],[20.059685,9.012706],[19.094008,9.074847],[18.81201,8.982915],[18.911022,8.630895],[18.389555,8.281304],[17.96493,7.890914],[16.705988,7.508328],[16.456185,7.734774],[16.290562,7.754307],[16.106232,7.497088],[15.27946,7.421925],[15.436092,7.692812],[15.120866,8.38215],[14.979996,8.796104],[14.544467,8.965861],[13.954218,9.549495],[14.171466,10.021378],[14.627201,9.920919],[14.909354,9.992129],[15.467873,9.982337],[14.923565,10.891325],[14.960152,11.555574],[14.89336,12.21905],[14.495787,12.859396],[14.595781,13.330427],[13.954477,13.353449],[13.956699,13.996691],[13.540394,14.367134],[13.97217,15.68437],[15.247731,16.627306],[15.300441,17.92795],[15.685741,19.95718],[15.903247,20.387619],[15.487148,20.730415],[15.47106,21.04845],[15.096888,21.308519],[14.8513,22.86295],[15.86085,23.40972],[19.84926,21.49509],[23.83766,19.58047]]]}},{"type":"Feature","properties":{"ADMIN":"Haiti","NAME_EN":"Haiti","CONTINENT":"North America","ADM0_A3":"HTI","ISO_A3":"HTI","NAME":"Haiti","ISO_A2":"HT","NAME_ZH":"海地"},"geometry":{"type":"Polygon","coordinates":[[[-71.712361,19.714456],[-71.624873,19.169838],[-71.701303,18.785417],[-71.945112,18.6169],[-71.687738,18.31666],[-71.708305,18.044997],[-72.372476,18.214961],[-72.844411,18.145611],[-73.454555,18.217906],[-73.922433,18.030993],[-74.458034,18.34255],[-74.369925,18.664908],[-73.449542,18.526053],[-72.694937,18.445799],[-72.334882,18.668422],[-72.79165,19.101625],[-72.784105,19.483591],[-73.415022,19.639551],[-73.189791,19.915684],[-72.579673,19.871501],[-71.712361,19.714456]]]}},{"type":"Feature","properties":{"ADMIN":"Dominican Republic","NAME_EN":"Dominican Republic","CONTINENT":"North America","ADM0_A3":"DOM","ISO_A3":"DOM","NAME":"Dominican Rep.","ISO_A2":"DO","NAME_ZH":"多米尼加"},"geometry":{"type":"Polygon","coordinates":[[[-71.708305,18.044997],[-71.687738,18.31666],[-71.945112,18.6169],[-71.701303,18.785417],[-71.624873,19.169838],[-71.712361,19.714456],[-71.587304,19.884911],[-70.806706,19.880286],[-70.214365,19.622885],[-69.950815,19.648],[-69.76925,19.293267],[-69.222126,19.313214],[-69.254346,19.015196],[-68.809412,18.979074],[-68.317943,18.612198],[-68.689316,18.205142],[-69.164946,18.422648],[-69.623988,18.380713],[-69.952934,18.428307],[-70.133233,18.245915],[-70.517137,18.184291],[-70.669298,18.426886],[-70.99995,18.283329],[-71.40021,17.598564],[-71.657662,17.757573],[-71.708305,18.044997]]]}},{"type":"Feature","properties":{"ADMIN":"Russia","NAME_EN":"Russia","CONTINENT":"Europe","ADM0_A3":"RUS","ISO_A3":"RUS","NAME":"Russia","ISO_A2":"RU","NAME_ZH":"俄罗斯"},"geometry":{"type":"MultiPolygon","coordinates":[[[[178.7253,71.0988],[180,71.515714],[180,70.832199],[178.903425,70.78114],[178.7253,71.0988]]],[[[49.10116,46.39933],[48.64541,45.80629],[47.67591,45.64149],[46.68201,44.6092],[47.59094,43.66016],[47.49252,42.98658],[48.58437,41.80888],[48.584353,41.808869],[47.987283,41.405819],[47.815666,41.151416],[47.373315,41.219732],[46.686071,41.827137],[46.404951,41.860675],[45.7764,42.09244],[45.470279,42.502781],[44.537623,42.711993],[43.93121,42.55496],[43.75599,42.74083],[42.3944,43.2203],[40.92219,43.38215],[40.076965,43.553104],[39.955009,43.434998],[38.68,44.28],[37.53912,44.65721],[36.67546,45.24469],[37.40317,45.40451],[38.23295,46.24087],[37.67372,46.63657],[39.14767,47.04475],[39.1212,47.26336],[38.223538,47.10219],[38.255112,47.5464],[38.77057,47.82562],[39.738278,47.898937],[39.89562,48.23241],[39.67465,48.78382],[40.080789,49.30743],[40.06904,49.60105],[38.594988,49.926462],[38.010631,49.915662],[37.39346,50.383953],[36.626168,50.225591],[35.356116,50.577197],[35.37791,50.77394],[35.022183,51.207572],[34.224816,51.255993],[34.141978,51.566413],[34.391731,51.768882],[33.7527,52.335075],[32.715761,52.238465],[32.412058,52.288695],[32.15944,52.06125],[31.785992,52.101678],[31.78597,52.10168],[31.540018,52.742052],[31.305201,53.073996],[31.49764,53.16743],[32.304519,53.132726],[32.693643,53.351421],[32.405599,53.618045],[31.731273,53.794029],[31.791424,53.974639],[31.384472,54.157056],[30.757534,54.811771],[30.971836,55.081548],[30.873909,55.550976],[29.896294,55.789463],[29.371572,55.670091],[29.229513,55.918344],[28.176709,56.16913],[27.855282,56.759326],[27.770016,57.244258],[27.288185,57.474528],[27.716686,57.791899],[27.42015,58.72457],[28.131699,59.300825],[27.98112,59.47537],[27.981127,59.475373],[29.1177,60.02805],[28.070002,60.503519],[28.07,60.50352],[30.211107,61.780028],[31.139991,62.357693],[31.516092,62.867687],[30.035872,63.552814],[30.444685,64.204453],[29.54443,64.948672],[30.21765,65.80598],[29.054589,66.944286],[29.977426,67.698297],[28.445944,68.364613],[28.59193,69.064777],[29.39955,69.15692],[31.101042,69.558101],[31.10108,69.55811],[32.13272,69.90595],[33.77547,69.30142],[36.51396,69.06342],[40.29234,67.9324],[41.05987,67.45713],[41.12595,66.79158],[40.01583,66.26618],[38.38295,65.99953],[33.91871,66.75961],[33.18444,66.63253],[34.81477,65.90015],[34.878574,65.436213],[34.94391,64.41437],[36.23129,64.10945],[37.01273,63.84983],[37.14197,64.33471],[36.539579,64.76446],[37.17604,65.14322],[39.59345,64.52079],[40.4356,64.76446],[39.7626,65.49682],[42.09309,66.47623],[43.01604,66.41858],[43.94975,66.06908],[44.53226,66.75634],[43.69839,67.35245],[44.18795,67.95051],[43.45282,68.57079],[46.25,68.25],[46.82134,67.68997],[45.55517,67.56652],[45.56202,67.01005],[46.34915,66.66767],[47.89416,66.88455],[48.13876,67.52238],[50.22766,67.99867],[53.71743,68.85738],[54.47171,68.80815],[53.48582,68.20131],[54.72628,68.09702],[55.44268,68.43866],[57.31702,68.46628],[58.802,68.88082],[59.94142,68.27844],[61.07784,68.94069],[60.03,69.52],[60.55,69.85],[63.504,69.54739],[64.888115,69.234835],[68.51216,68.09233],[69.18068,68.61563],[68.16444,69.14436],[68.13522,69.35649],[66.93008,69.45461],[67.25976,69.92873],[66.72492,70.70889],[66.69466,71.02897],[68.54006,71.9345],[69.19636,72.84336],[69.94,73.04],[72.58754,72.77629],[72.79603,72.22006],[71.84811,71.40898],[72.47011,71.09019],[72.79188,70.39114],[72.5647,69.02085],[73.66787,68.4079],[73.2387,67.7404],[71.28,66.32],[72.42301,66.17267],[72.82077,66.53267],[73.92099,66.78946],[74.18651,67.28429],[75.052,67.76047],[74.46926,68.32899],[74.93584,68.98918],[73.84236,69.07146],[73.60187,69.62763],[74.3998,70.63175],[73.1011,71.44717],[74.89082,72.12119],[74.65926,72.83227],[75.15801,72.85497],[75.68351,72.30056],[75.28898,71.33556],[76.35911,71.15287],[75.90313,71.87401],[77.57665,72.26717],[79.65202,72.32011],[81.5,71.75],[80.61071,72.58285],[80.51109,73.6482],[82.25,73.85],[84.65526,73.80591],[86.8223,73.93688],[86.00956,74.45967],[87.16682,75.11643],[88.31571,75.14393],[90.26,75.64],[92.90058,75.77333],[93.23421,76.0472],[95.86,76.14],[96.67821,75.91548],[98.92254,76.44689],[100.75967,76.43028],[101.03532,76.86189],[101.99084,77.28754],[104.3516,77.69792],[106.06664,77.37389],[104.705,77.1274],[106.97013,76.97419],[107.24,76.48],[108.1538,76.72335],[111.07726,76.71],[113.33151,76.22224],[114.13417,75.84764],[113.88539,75.32779],[112.77918,75.03186],[110.15125,74.47673],[109.4,74.18],[110.64,74.04],[112.11919,73.78774],[113.01954,73.97693],[113.52958,73.33505],[113.96881,73.59488],[115.56782,73.75285],[118.77633,73.58772],[119.02,73.12],[123.20066,72.97122],[123.25777,73.73503],[125.38,73.56],[126.97644,73.56549],[128.59126,73.03871],[129.05157,72.39872],[128.46,71.98],[129.71599,71.19304],[131.28858,70.78699],[132.2535,71.8363],[133.85766,71.38642],[135.56193,71.65525],[137.49755,71.34763],[138.23409,71.62803],[139.86983,71.48783],[139.14791,72.41619],[140.46817,72.84941],[149.5,72.2],[150.35118,71.60643],[152.9689,70.84222],[157.00688,71.03141],[158.99779,70.86672],[159.83031,70.45324],[159.70866,69.72198],[160.94053,69.43728],[162.27907,69.64204],[164.05248,69.66823],[165.94037,69.47199],[167.83567,69.58269],[169.57763,68.6938],[170.81688,69.01363],[170.0082,69.65276],[170.45345,70.09703],[173.64391,69.81743],[175.72403,69.87725],[178.6,69.4],[180,68.963636],[180,64.979709],[179.99281,64.97433],[178.7072,64.53493],[177.41128,64.60821],[178.313,64.07593],[178.90825,63.25197],[179.37034,62.98262],[179.48636,62.56894],[179.22825,62.3041],[177.3643,62.5219],[174.56929,61.76915],[173.68013,61.65261],[172.15,60.95],[170.6985,60.33618],[170.33085,59.88177],[168.90046,60.57355],[166.29498,59.78855],[165.84,60.16],[164.87674,59.7316],[163.53929,59.86871],[163.21711,59.21101],[162.01733,58.24328],[162.05297,57.83912],[163.19191,57.61503],[163.05794,56.15924],[162.12958,56.12219],[161.70146,55.28568],[162.11749,54.85514],[160.36877,54.34433],[160.02173,53.20257],[158.53094,52.95868],[158.23118,51.94269],[156.78979,51.01105],[156.42,51.7],[155.99182,53.15895],[155.43366,55.38103],[155.91442,56.76792],[156.75815,57.3647],[156.81035,57.83204],[158.36433,58.05575],[160.15064,59.31477],[161.87204,60.343],[163.66969,61.1409],[164.47355,62.55061],[163.25842,62.46627],[162.65791,61.6425],[160.12148,60.54423],[159.30232,61.77396],[156.72068,61.43442],[154.21806,59.75818],[155.04375,59.14495],[152.81185,58.88385],[151.26573,58.78089],[151.33815,59.50396],[149.78371,59.65573],[148.54481,59.16448],[145.48722,59.33637],[142.19782,59.03998],[138.95848,57.08805],[135.12619,54.72959],[136.70171,54.60355],[137.19342,53.97732],[138.1647,53.75501],[138.80463,54.25455],[139.90151,54.18968],[141.34531,53.08957],[141.37923,52.23877],[140.59742,51.23967],[140.51308,50.04553],[140.06193,48.44671],[138.55472,46.99965],[138.21971,46.30795],[136.86232,45.1435],[135.51535,43.989],[134.86939,43.39821],[133.53687,42.81147],[132.90627,42.79849],[132.27807,43.28456],[130.93587,42.55274],[130.780005,42.22001],[130.780004,42.220008],[130.78,42.22],[130.779992,42.22001],[130.64,42.395],[130.64,42.395024],[130.633866,42.903015],[131.144688,42.92999],[131.288555,44.11152],[131.02519,44.96796],[131.883454,45.321162],[133.09712,45.14409],[133.769644,46.116927],[134.11235,47.21248],[134.50081,47.57845],[135.026311,48.47823],[133.373596,48.183442],[132.50669,47.78896],[130.98726,47.79013],[130.582293,48.729687],[129.397818,49.4406],[127.6574,49.76027],[127.287456,50.739797],[126.939157,51.353894],[126.564399,51.784255],[125.946349,52.792799],[125.068211,53.161045],[123.57147,53.4588],[122.245748,53.431726],[121.003085,53.251401],[120.177089,52.753886],[120.725789,52.516226],[120.7382,51.96411],[120.18208,51.64355],[119.27939,50.58292],[119.288461,50.142883],[117.879244,49.510983],[116.678801,49.888531],[115.485695,49.805177],[114.96211,50.140247],[114.362456,50.248303],[112.89774,49.543565],[111.581231,49.377968],[110.662011,49.130128],[109.402449,49.292961],[108.475167,49.282548],[107.868176,49.793705],[106.888804,50.274296],[105.886591,50.406019],[104.62158,50.27532],[103.676545,50.089966],[102.25589,50.51056],[102.06521,51.25991],[100.88948,51.516856],[99.981732,51.634006],[98.861491,52.047366],[97.82574,51.010995],[98.231762,50.422401],[97.25976,49.72605],[95.81402,49.97746],[94.815949,50.013433],[94.147566,50.480537],[93.10421,50.49529],[92.234712,50.802171],[90.713667,50.331812],[88.805567,49.470521],[87.751264,49.297198],[87.35997,49.214981],[86.829357,49.826675],[85.54127,49.692859],[85.11556,50.117303],[84.416377,50.3114],[83.935115,50.889246],[83.383004,51.069183],[81.945986,50.812196],[80.568447,51.388336],[80.03556,50.864751],[77.800916,53.404415],[76.525179,54.177003],[76.8911,54.490524],[74.38482,53.54685],[73.425679,53.48981],[73.508516,54.035617],[72.22415,54.376655],[71.180131,54.133285],[70.865267,55.169734],[69.068167,55.38525],[68.1691,54.970392],[65.66687,54.60125],[65.178534,54.354228],[61.4366,54.00625],[60.978066,53.664993],[61.699986,52.979996],[60.739993,52.719986],[60.927269,52.447548],[59.967534,51.96042],[61.588003,51.272659],[61.337424,50.79907],[59.932807,50.842194],[59.642282,50.545442],[58.36332,51.06364],[56.77798,51.04355],[55.71694,50.62171],[54.532878,51.02624],[52.328724,51.718652],[50.766648,51.692762],[48.702382,50.605128],[48.577841,49.87476],[47.54948,50.454698],[46.751596,49.356006],[47.043672,49.152039],[46.466446,48.394152],[47.31524,47.71585],[48.05725,47.74377],[48.694734,47.075628],[48.59325,46.56104],[49.10116,46.39933]]],[[[93.77766,81.0246],[95.940895,81.2504],[97.88385,80.746975],[100.186655,79.780135],[99.93976,78.88094],[97.75794,78.7562],[94.97259,79.044745],[93.31288,79.4265],[92.5454,80.14379],[91.18107,80.34146],[93.77766,81.0246]]],[[[102.837815,79.28129],[105.37243,78.71334],[105.07547,78.30689],[99.43814,77.921],[101.2649,79.23399],[102.08635,79.34641],[102.837815,79.28129]]],[[[138.831075,76.13676],[141.471615,76.09289],[145.086285,75.562625],[144.3,74.82],[140.61381,74.84768],[138.95544,74.61148],[136.97439,75.26167],[137.51176,75.94917],[138.831075,76.13676]]],[[[148.22223,75.345845],[150.73167,75.08406],[149.575925,74.68892],[147.977465,74.778355],[146.11919,75.17298],[146.358485,75.49682],[148.22223,75.345845]]],[[[139.86312,73.36983],[140.81171,73.76506],[142.06207,73.85758],[143.48283,73.47525],[143.60385,73.21244],[142.08763,73.20544],[140.038155,73.31692],[139.86312,73.36983]]],[[[44.846958,80.58981],[46.799139,80.771918],[48.318477,80.78401],[48.522806,80.514569],[49.09719,80.753986],[50.039768,80.918885],[51.522933,80.699726],[51.136187,80.54728],[49.793685,80.415428],[48.894411,80.339567],[48.754937,80.175468],[47.586119,80.010181],[46.502826,80.247247],[47.072455,80.559424],[44.846958,80.58981]]],[[[22.731099,54.327537],[20.892245,54.312525],[19.66064,54.426084],[19.888481,54.86616],[21.268449,55.190482],[22.315724,55.015299],[22.757764,54.856574],[22.651052,54.582741],[22.731099,54.327537]]],[[[53.50829,73.749814],[55.902459,74.627486],[55.631933,75.081412],[57.868644,75.60939],[61.170044,76.251883],[64.498368,76.439055],[66.210977,76.809782],[68.15706,76.939697],[68.852211,76.544811],[68.180573,76.233642],[64.637326,75.737755],[61.583508,75.260885],[58.477082,74.309056],[56.986786,73.333044],[55.419336,72.371268],[55.622838,71.540595],[57.535693,70.720464],[56.944979,70.632743],[53.677375,70.762658],[53.412017,71.206662],[51.601895,71.474759],[51.455754,72.014881],[52.478275,72.229442],[52.444169,72.774731],[54.427614,73.627548],[53.50829,73.749814]]],[[[142.914616,53.704578],[143.260848,52.74076],[143.235268,51.75666],[143.648007,50.7476],[144.654148,48.976391],[143.173928,49.306551],[142.558668,47.861575],[143.533492,46.836728],[143.505277,46.137908],[142.747701,46.740765],[142.09203,45.966755],[141.906925,46.805929],[142.018443,47.780133],[141.904445,48.859189],[142.1358,49.615163],[142.179983,50.952342],[141.594076,51.935435],[141.682546,53.301966],[142.606934,53.762145],[142.209749,54.225476],[142.654786,54.365881],[142.914616,53.704578]]],[[[-174.92825,67.20589],[-175.01425,66.58435],[-174.33983,66.33556],[-174.57182,67.06219],[-171.85731,66.91308],[-169.89958,65.97724],[-170.89107,65.54139],[-172.53025,65.43791],[-172.555,64.46079],[-172.95533,64.25269],[-173.89184,64.2826],[-174.65392,64.63125],[-175.98353,64.92288],[-176.20716,65.35667],[-177.22266,65.52024],[-178.35993,65.39052],[-178.90332,65.74044],[-178.68611,66.11211],[-179.88377,65.87456],[-179.43268,65.40411],[-180,64.979709],[-180,68.963636],[-177.55,68.2],[-174.92825,67.20589]]],[[[-178.69378,70.89302],[-180,70.832199],[-180,71.515714],[-179.871875,71.55762],[-179.02433,71.55553],[-177.577945,71.26948],[-177.663575,71.13277],[-178.69378,70.89302]]],[[[33.435988,45.971917],[33.699462,46.219573],[34.410402,46.005162],[34.732017,45.965666],[34.861792,45.768182],[35.012659,45.737725],[35.020788,45.651219],[35.510009,45.409993],[36.529998,45.46999],[36.334713,45.113216],[35.239999,44.939996],[33.882511,44.361479],[33.326421,44.564877],[33.546924,45.034771],[32.454174,45.327466],[32.630804,45.519186],[33.588162,45.851569],[33.435988,45.971917]]]]}},{"type":"Feature","properties":{"ADMIN":"The Bahamas","NAME_EN":"The Bahamas","CONTINENT":"North America","ADM0_A3":"BHS","ISO_A3":"BHS","NAME":"Bahamas","ISO_A2":"BS","NAME_ZH":"巴哈马"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58],[-78.91,26.42],[-78.98,26.79]]],[[[-77.79,27.04],[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04]]],[[[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103]]]]}},{"type":"Feature","properties":{"ADMIN":"Falkland Islands","NAME_EN":"Falkland Islands","CONTINENT":"South America","ADM0_A3":"FLK","ISO_A3":"FLK","NAME":"Falkland Is.","ISO_A2":"FK","NAME_ZH":"福克兰群岛"},"geometry":{"type":"Polygon","coordinates":[[[-61.2,-51.85],[-60,-51.25],[-59.15,-51.5],[-58.55,-51.1],[-57.75,-51.55],[-58.05,-51.9],[-59.4,-52.2],[-59.85,-51.85],[-60.7,-52.3],[-61.2,-51.85]]]}},{"type":"Feature","properties":{"ADMIN":"Norway","NAME_EN":"Norway","CONTINENT":"Europe","ADM0_A3":"NOR","ISO_A3":"-99","NAME":"Norway","ISO_A2":"-99","NAME_ZH":"挪威"},"geometry":{"type":"MultiPolygon","coordinates":[[[[15.14282,79.67431],[15.52255,80.01608],[16.99085,80.05086],[18.25183,79.70175],[21.54383,78.95611],[19.02737,78.5626],[18.47172,77.82669],[17.59441,77.63796],[17.1182,76.80941],[15.91315,76.77045],[13.76259,77.38035],[14.66956,77.73565],[13.1706,78.02493],[11.22231,78.8693],[10.44453,79.65239],[13.17077,80.01046],[13.71852,79.66039],[15.14282,79.67431]]],[[[31.101042,69.558101],[29.39955,69.15692],[28.59193,69.064777],[29.015573,69.766491],[27.732292,70.164193],[26.179622,69.825299],[25.689213,69.092114],[24.735679,68.649557],[23.66205,68.891247],[22.356238,68.841741],[21.244936,69.370443],[20.645593,69.106247],[20.025269,69.065139],[19.87856,68.407194],[17.993868,68.567391],[17.729182,68.010552],[16.768879,68.013937],[16.108712,67.302456],[15.108411,66.193867],[13.55569,64.787028],[13.919905,64.445421],[13.571916,64.049114],[12.579935,64.066219],[11.930569,63.128318],[11.992064,61.800362],[12.631147,61.293572],[12.300366,60.117933],[11.468272,59.432393],[11.027369,58.856149],[10.356557,59.469807],[8.382,58.313288],[7.048748,58.078884],[5.665835,58.588155],[5.308234,59.663232],[4.992078,61.970998],[5.9129,62.614473],[8.553411,63.454008],[10.527709,64.486038],[12.358347,65.879726],[14.761146,67.810642],[16.435927,68.563205],[19.184028,69.817444],[21.378416,70.255169],[23.023742,70.202072],[24.546543,71.030497],[26.37005,70.986262],[28.165547,71.185474],[31.293418,70.453788],[30.005435,70.186259],[31.101042,69.558101]]],[[[27.407506,80.056406],[25.924651,79.517834],[23.024466,79.400012],[20.075188,79.566823],[19.897266,79.842362],[18.462264,79.85988],[17.368015,80.318896],[20.455992,80.598156],[21.907945,80.357679],[22.919253,80.657144],[25.447625,80.40734],[27.407506,80.056406]]],[[[24.72412,77.85385],[22.49032,77.44493],[20.72601,77.67704],[21.41611,77.93504],[20.8119,78.25463],[22.88426,78.45494],[23.28134,78.07954],[24.72412,77.85385]]]]}},{"type":"Feature","properties":{"ADMIN":"Greenland","NAME_EN":"Greenland","CONTINENT":"North America","ADM0_A3":"GRL","ISO_A3":"GRL","NAME":"Greenland","ISO_A2":"GL","NAME_ZH":"格陵兰"},"geometry":{"type":"Polygon","coordinates":[[[-46.76379,82.62796],[-43.40644,83.22516],[-39.89753,83.18018],[-38.62214,83.54905],[-35.08787,83.64513],[-27.10046,83.51966],[-20.84539,82.72669],[-22.69182,82.34165],[-26.51753,82.29765],[-31.9,82.2],[-31.39646,82.02154],[-27.85666,82.13178],[-24.84448,81.78697],[-22.90328,82.09317],[-22.07175,81.73449],[-23.16961,81.15271],[-20.62363,81.52462],[-15.76818,81.91245],[-12.77018,81.71885],[-12.20855,81.29154],[-16.28533,80.58004],[-16.85,80.35],[-20.04624,80.17708],[-17.73035,80.12912],[-18.9,79.4],[-19.70499,78.75128],[-19.67353,77.63859],[-18.47285,76.98565],[-20.03503,76.94434],[-21.67944,76.62795],[-19.83407,76.09808],[-19.59896,75.24838],[-20.66818,75.15585],[-19.37281,74.29561],[-21.59422,74.22382],[-20.43454,73.81713],[-20.76234,73.46436],[-22.17221,73.30955],[-23.56593,73.30663],[-22.31311,72.62928],[-22.29954,72.18409],[-24.27834,72.59788],[-24.79296,72.3302],[-23.44296,72.08016],[-22.13281,71.46898],[-21.75356,70.66369],[-23.53603,70.471],[-24.30702,70.85649],[-25.54341,71.43094],[-25.20135,70.75226],[-26.36276,70.22646],[-23.72742,70.18401],[-22.34902,70.12946],[-25.02927,69.2588],[-27.74737,68.47046],[-30.67371,68.12503],[-31.77665,68.12078],[-32.81105,67.73547],[-34.20196,66.67974],[-36.35284,65.9789],[-37.04378,65.93768],[-38.37505,65.69213],[-39.81222,65.45848],[-40.66899,64.83997],[-40.68281,64.13902],[-41.1887,63.48246],[-42.81938,62.68233],[-42.41666,61.90093],[-42.86619,61.07404],[-43.3784,60.09772],[-44.7875,60.03676],[-46.26364,60.85328],[-48.26294,60.85843],[-49.23308,61.40681],[-49.90039,62.38336],[-51.63325,63.62691],[-52.14014,64.27842],[-52.27659,65.1767],[-53.66166,66.09957],[-53.30161,66.8365],[-53.96911,67.18899],[-52.9804,68.35759],[-51.47536,68.72958],[-51.08041,69.14781],[-50.87122,69.9291],[-52.013585,69.574925],[-52.55792,69.42616],[-53.45629,69.283625],[-54.68336,69.61003],[-54.75001,70.28932],[-54.35884,70.821315],[-53.431315,70.835755],[-51.39014,70.56978],[-53.10937,71.20485],[-54.00422,71.54719],[-55,71.406537],[-55.83468,71.65444],[-54.71819,72.58625],[-55.32634,72.95861],[-56.12003,73.64977],[-57.32363,74.71026],[-58.59679,75.09861],[-58.58516,75.51727],[-61.26861,76.10238],[-63.39165,76.1752],[-66.06427,76.13486],[-68.50438,76.06141],[-69.66485,76.37975],[-71.40257,77.00857],[-68.77671,77.32312],[-66.76397,77.37595],[-71.04293,77.63595],[-73.297,78.04419],[-73.15938,78.43271],[-69.37345,78.91388],[-65.7107,79.39436],[-65.3239,79.75814],[-68.02298,80.11721],[-67.15129,80.51582],[-63.68925,81.21396],[-62.23444,81.3211],[-62.65116,81.77042],[-60.28249,82.03363],[-57.20744,82.19074],[-54.13442,82.19962],[-53.04328,81.88833],[-50.39061,82.43883],[-48.00386,82.06481],[-46.59984,81.985945],[-44.523,81.6607],[-46.9007,82.19979],[-46.76379,82.62796]]]}},{"type":"Feature","properties":{"ADMIN":"French Southern and Antarctic Lands","NAME_EN":"French Southern and Antarctic Lands","CONTINENT":"Seven seas (open ocean)","ADM0_A3":"ATF","ISO_A3":"ATF","NAME":"Fr. S. Antarctic Lands","ISO_A2":"TF","NAME_ZH":"法属南部和南极领地"},"geometry":{"type":"Polygon","coordinates":[[[68.935,-48.625],[69.58,-48.94],[70.525,-49.065],[70.56,-49.255],[70.28,-49.71],[68.745,-49.775],[68.72,-49.2425],[68.8675,-48.83],[68.935,-48.625]]]}},{"type":"Feature","properties":{"ADMIN":"East Timor","NAME_EN":"East Timor","CONTINENT":"Asia","ADM0_A3":"TLS","ISO_A3":"TLS","NAME":"Timor-Leste","ISO_A2":"TL","NAME_ZH":"东帝汶"},"geometry":{"type":"Polygon","coordinates":[[[124.968682,-8.89279],[125.086246,-8.656887],[125.947072,-8.432095],[126.644704,-8.398247],[126.957243,-8.273345],[127.335928,-8.397317],[126.967992,-8.668256],[125.925885,-9.106007],[125.08852,-9.393173],[125.07002,-9.089987],[124.968682,-8.89279]]]}},{"type":"Feature","properties":{"ADMIN":"South Africa","NAME_EN":"South Africa","CONTINENT":"Africa","ADM0_A3":"ZAF","ISO_A3":"ZAF","NAME":"South Africa","ISO_A2":"ZA","NAME_ZH":"南非"},"geometry":{"type":"Polygon","coordinates":[[[16.344977,-28.576705],[16.824017,-28.082162],[17.218929,-28.355943],[17.387497,-28.783514],[17.836152,-28.856378],[18.464899,-29.045462],[19.002127,-28.972443],[19.894734,-28.461105],[19.895768,-24.76779],[20.165726,-24.917962],[20.758609,-25.868136],[20.66647,-26.477453],[20.889609,-26.828543],[21.605896,-26.726534],[22.105969,-26.280256],[22.579532,-25.979448],[22.824271,-25.500459],[23.312097,-25.26869],[23.73357,-25.390129],[24.211267,-25.670216],[25.025171,-25.71967],[25.664666,-25.486816],[25.765849,-25.174845],[25.941652,-24.696373],[26.485753,-24.616327],[26.786407,-24.240691],[27.11941,-23.574323],[28.017236,-22.827754],[29.432188,-22.091313],[29.839037,-22.102216],[30.322883,-22.271612],[30.659865,-22.151567],[31.191409,-22.25151],[31.670398,-23.658969],[31.930589,-24.369417],[31.752408,-25.484284],[31.837778,-25.843332],[31.333158,-25.660191],[31.04408,-25.731452],[30.949667,-26.022649],[30.676609,-26.398078],[30.685962,-26.743845],[31.282773,-27.285879],[31.86806,-27.177927],[32.071665,-26.73382],[32.83012,-26.742192],[32.580265,-27.470158],[32.462133,-28.301011],[32.203389,-28.752405],[31.521001,-29.257387],[31.325561,-29.401978],[30.901763,-29.909957],[30.622813,-30.423776],[30.055716,-31.140269],[28.925553,-32.172041],[28.219756,-32.771953],[27.464608,-33.226964],[26.419452,-33.61495],[25.909664,-33.66704],[25.780628,-33.944646],[25.172862,-33.796851],[24.677853,-33.987176],[23.594043,-33.794474],[22.988189,-33.916431],[22.574157,-33.864083],[21.542799,-34.258839],[20.689053,-34.417175],[20.071261,-34.795137],[19.616405,-34.819166],[19.193278,-34.462599],[18.855315,-34.444306],[18.424643,-33.997873],[18.377411,-34.136521],[18.244499,-33.867752],[18.25008,-33.281431],[17.92519,-32.611291],[18.24791,-32.429131],[18.221762,-31.661633],[17.566918,-30.725721],[17.064416,-29.878641],[17.062918,-29.875954],[16.344977,-28.576705]],[[28.978263,-28.955597],[28.5417,-28.647502],[28.074338,-28.851469],[27.532511,-29.242711],[26.999262,-29.875954],[27.749397,-30.645106],[28.107205,-30.545732],[28.291069,-30.226217],[28.8484,-30.070051],[29.018415,-29.743766],[29.325166,-29.257387],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"ADMIN":"Lesotho","NAME_EN":"Lesotho","CONTINENT":"Africa","ADM0_A3":"LSO","ISO_A3":"LSO","NAME":"Lesotho","ISO_A2":"LS","NAME_ZH":"莱索托"},"geometry":{"type":"Polygon","coordinates":[[[28.978263,-28.955597],[29.325166,-29.257387],[29.018415,-29.743766],[28.8484,-30.070051],[28.291069,-30.226217],[28.107205,-30.545732],[27.749397,-30.645106],[26.999262,-29.875954],[27.532511,-29.242711],[28.074338,-28.851469],[28.5417,-28.647502],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"ADMIN":"Mexico","NAME_EN":"Mexico","CONTINENT":"North America","ADM0_A3":"MEX","ISO_A3":"MEX","NAME":"Mexico","ISO_A2":"MX","NAME_ZH":"墨西哥"},"geometry":{"type":"Polygon","coordinates":[[[-117.12776,32.53534],[-115.99135,32.61239],[-114.72139,32.72083],[-114.815,32.52528],[-113.30498,32.03914],[-111.02361,31.33472],[-109.035,31.34194],[-108.24194,31.34222],[-108.24,31.754854],[-106.50759,31.75452],[-106.1429,31.39995],[-105.63159,31.08383],[-105.03737,30.64402],[-104.70575,30.12173],[-104.45697,29.57196],[-103.94,29.27],[-103.11,28.97],[-102.48,29.76],[-101.6624,29.7793],[-100.9576,29.38071],[-100.45584,28.69612],[-100.11,28.11],[-99.52,27.54],[-99.3,26.84],[-99.02,26.37],[-98.24,26.06],[-97.53,25.84],[-97.140008,25.869997],[-97.528072,24.992144],[-97.702946,24.272343],[-97.776042,22.93258],[-97.872367,22.444212],[-97.699044,21.898689],[-97.38896,21.411019],[-97.189333,20.635433],[-96.525576,19.890931],[-96.292127,19.320371],[-95.900885,18.828024],[-94.839063,18.562717],[-94.42573,18.144371],[-93.548651,18.423837],[-92.786114,18.524839],[-92.037348,18.704569],[-91.407903,18.876083],[-90.77187,19.28412],[-90.53359,19.867418],[-90.451476,20.707522],[-90.278618,20.999855],[-89.601321,21.261726],[-88.543866,21.493675],[-87.658417,21.458846],[-87.05189,21.543543],[-86.811982,21.331515],[-86.845908,20.849865],[-87.383291,20.255405],[-87.621054,19.646553],[-87.43675,19.472403],[-87.58656,19.04013],[-87.837191,18.259816],[-88.090664,18.516648],[-88.300031,18.499982],[-88.490123,18.486831],[-88.848344,17.883198],[-89.029857,18.001511],[-89.150909,17.955468],[-89.14308,17.808319],[-90.067934,17.819326],[-91.00152,17.817595],[-91.002269,17.254658],[-91.453921,17.252177],[-91.08167,16.918477],[-90.711822,16.687483],[-90.600847,16.470778],[-90.438867,16.41011],[-90.464473,16.069562],[-91.74796,16.066565],[-92.229249,15.251447],[-92.087216,15.064585],[-92.20323,14.830103],[-92.22775,14.538829],[-93.359464,15.61543],[-93.875169,15.940164],[-94.691656,16.200975],[-95.250227,16.128318],[-96.053382,15.752088],[-96.557434,15.653515],[-97.263592,15.917065],[-98.01303,16.107312],[-98.947676,16.566043],[-99.697397,16.706164],[-100.829499,17.171071],[-101.666089,17.649026],[-101.918528,17.91609],[-102.478132,17.975751],[-103.50099,18.292295],[-103.917527,18.748572],[-104.99201,19.316134],[-105.493038,19.946767],[-105.731396,20.434102],[-105.397773,20.531719],[-105.500661,20.816895],[-105.270752,21.076285],[-105.265817,21.422104],[-105.603161,21.871146],[-105.693414,22.26908],[-106.028716,22.773752],[-106.90998,23.767774],[-107.915449,24.548915],[-108.401905,25.172314],[-109.260199,25.580609],[-109.444089,25.824884],[-109.291644,26.442934],[-109.801458,26.676176],[-110.391732,27.162115],[-110.641019,27.859876],[-111.178919,27.941241],[-111.759607,28.467953],[-112.228235,28.954409],[-112.271824,29.266844],[-112.809594,30.021114],[-113.163811,30.786881],[-113.148669,31.170966],[-113.871881,31.567608],[-114.205737,31.524045],[-114.776451,31.799532],[-114.9367,31.393485],[-114.771232,30.913617],[-114.673899,30.162681],[-114.330974,29.750432],[-113.588875,29.061611],[-113.424053,28.826174],[-113.271969,28.754783],[-113.140039,28.411289],[-112.962298,28.42519],[-112.761587,27.780217],[-112.457911,27.525814],[-112.244952,27.171727],[-111.616489,26.662817],[-111.284675,25.73259],[-110.987819,25.294606],[-110.710007,24.826004],[-110.655049,24.298595],[-110.172856,24.265548],[-109.771847,23.811183],[-109.409104,23.364672],[-109.433392,23.185588],[-109.854219,22.818272],[-110.031392,22.823078],[-110.295071,23.430973],[-110.949501,24.000964],[-111.670568,24.484423],[-112.182036,24.738413],[-112.148989,25.470125],[-112.300711,26.012004],[-112.777297,26.32196],[-113.464671,26.768186],[-113.59673,26.63946],[-113.848937,26.900064],[-114.465747,27.14209],[-115.055142,27.722727],[-114.982253,27.7982],[-114.570366,27.741485],[-114.199329,28.115003],[-114.162018,28.566112],[-114.931842,29.279479],[-115.518654,29.556362],[-115.887365,30.180794],[-116.25835,30.836464],[-116.721526,31.635744],[-117.12776,32.53534]]]}},{"type":"Feature","properties":{"ADMIN":"Uruguay","NAME_EN":"Uruguay","CONTINENT":"South America","ADM0_A3":"URY","ISO_A3":"URY","NAME":"Uruguay","ISO_A2":"UY","NAME_ZH":"乌拉圭"},"geometry":{"type":"Polygon","coordinates":[[[-57.625133,-30.216295],[-56.976026,-30.109686],[-55.973245,-30.883076],[-55.60151,-30.853879],[-54.572452,-31.494511],[-53.787952,-32.047243],[-53.209589,-32.727666],[-53.650544,-33.202004],[-53.373662,-33.768378],[-53.806426,-34.396815],[-54.935866,-34.952647],[-55.67409,-34.752659],[-56.215297,-34.859836],[-57.139685,-34.430456],[-57.817861,-34.462547],[-58.427074,-33.909454],[-58.349611,-33.263189],[-58.132648,-33.040567],[-58.14244,-32.044504],[-57.874937,-31.016556],[-57.625133,-30.216295]]]}},{"type":"Feature","properties":{"ADMIN":"Brazil","NAME_EN":"Brazil","CONTINENT":"South America","ADM0_A3":"BRA","ISO_A3":"BRA","NAME":"Brazil","ISO_A2":"BR","NAME_ZH":"巴西"},"geometry":{"type":"Polygon","coordinates":[[[-53.373662,-33.768378],[-53.650544,-33.202004],[-53.209589,-32.727666],[-53.787952,-32.047243],[-54.572452,-31.494511],[-55.60151,-30.853879],[-55.973245,-30.883076],[-56.976026,-30.109686],[-57.625133,-30.216295],[-56.2909,-28.852761],[-55.162286,-27.881915],[-54.490725,-27.474757],[-53.648735,-26.923473],[-53.628349,-26.124865],[-54.13005,-25.547639],[-54.625291,-25.739255],[-54.428946,-25.162185],[-54.293476,-24.5708],[-54.29296,-24.021014],[-54.652834,-23.839578],[-55.027902,-24.001274],[-55.400747,-23.956935],[-55.517639,-23.571998],[-55.610683,-22.655619],[-55.797958,-22.35693],[-56.473317,-22.0863],[-56.88151,-22.282154],[-57.937156,-22.090176],[-57.870674,-20.732688],[-58.166392,-20.176701],[-57.853802,-19.969995],[-57.949997,-19.400004],[-57.676009,-18.96184],[-57.498371,-18.174188],[-57.734558,-17.552468],[-58.280804,-17.27171],[-58.388058,-16.877109],[-58.24122,-16.299573],[-60.15839,-16.258284],[-60.542966,-15.09391],[-60.251149,-15.077219],[-60.264326,-14.645979],[-60.459198,-14.354007],[-60.503304,-13.775955],[-61.084121,-13.479384],[-61.713204,-13.489202],[-62.127081,-13.198781],[-62.80306,-13.000653],[-63.196499,-12.627033],[-64.316353,-12.461978],[-65.402281,-11.56627],[-65.321899,-10.895872],[-65.444837,-10.511451],[-65.338435,-9.761988],[-66.646908,-9.931331],[-67.173801,-10.306812],[-68.048192,-10.712059],[-68.271254,-11.014521],[-68.786158,-11.03638],[-69.529678,-10.951734],[-70.093752,-11.123972],[-70.548686,-11.009147],[-70.481894,-9.490118],[-71.302412,-10.079436],[-72.184891,-10.053598],[-72.563033,-9.520194],[-73.226713,-9.462213],[-73.015383,-9.032833],[-73.571059,-8.424447],[-73.987235,-7.52383],[-73.723401,-7.340999],[-73.724487,-6.918595],[-73.120027,-6.629931],[-73.219711,-6.089189],[-72.964507,-5.741251],[-72.891928,-5.274561],[-71.748406,-4.593983],[-70.928843,-4.401591],[-70.794769,-4.251265],[-69.893635,-4.298187],[-69.444102,-1.556287],[-69.420486,-1.122619],[-69.577065,-0.549992],[-70.020656,-0.185156],[-70.015566,0.541414],[-69.452396,0.706159],[-69.252434,0.602651],[-69.218638,0.985677],[-69.804597,1.089081],[-69.816973,1.714805],[-67.868565,1.692455],[-67.53781,2.037163],[-67.259998,1.719999],[-67.065048,1.130112],[-66.876326,1.253361],[-66.325765,0.724452],[-65.548267,0.789254],[-65.354713,1.095282],[-64.611012,1.328731],[-64.199306,1.492855],[-64.083085,1.916369],[-63.368788,2.2009],[-63.422867,2.411068],[-64.269999,2.497006],[-64.408828,3.126786],[-64.368494,3.79721],[-64.816064,4.056445],[-64.628659,4.148481],[-63.888343,4.02053],[-63.093198,3.770571],[-62.804533,4.006965],[-62.08543,4.162124],[-60.966893,4.536468],[-60.601179,4.918098],[-60.733574,5.200277],[-60.213683,5.244486],[-59.980959,5.014061],[-60.111002,4.574967],[-59.767406,4.423503],[-59.53804,3.958803],[-59.815413,3.606499],[-59.974525,2.755233],[-59.718546,2.24963],[-59.646044,1.786894],[-59.030862,1.317698],[-58.540013,1.268088],[-58.429477,1.463942],[-58.11345,1.507195],[-57.660971,1.682585],[-57.335823,1.948538],[-56.782704,1.863711],[-56.539386,1.899523],[-55.995698,1.817667],[-55.9056,2.021996],[-56.073342,2.220795],[-55.973322,2.510364],[-55.569755,2.421506],[-55.097587,2.523748],[-54.524754,2.311849],[-54.088063,2.105557],[-53.778521,2.376703],[-53.554839,2.334897],[-53.418465,2.053389],[-52.939657,2.124858],[-52.556425,2.504705],[-52.249338,3.241094],[-51.657797,4.156232],[-51.317146,4.203491],[-51.069771,3.650398],[-50.508875,1.901564],[-49.974076,1.736483],[-49.947101,1.04619],[-50.699251,0.222984],[-50.388211,-0.078445],[-48.620567,-0.235489],[-48.584497,-1.237805],[-47.824956,-0.581618],[-46.566584,-0.941028],[-44.905703,-1.55174],[-44.417619,-2.13775],[-44.581589,-2.691308],[-43.418791,-2.38311],[-41.472657,-2.912018],[-39.978665,-2.873054],[-38.500383,-3.700652],[-37.223252,-4.820946],[-36.452937,-5.109404],[-35.597796,-5.149504],[-35.235389,-5.464937],[-34.89603,-6.738193],[-34.729993,-7.343221],[-35.128212,-8.996401],[-35.636967,-9.649282],[-37.046519,-11.040721],[-37.683612,-12.171195],[-38.423877,-13.038119],[-38.673887,-13.057652],[-38.953276,-13.79337],[-38.882298,-15.667054],[-39.161092,-17.208407],[-39.267339,-17.867746],[-39.583521,-18.262296],[-39.760823,-19.599113],[-40.774741,-20.904512],[-40.944756,-21.937317],[-41.754164,-22.370676],[-41.988284,-22.97007],[-43.074704,-22.967693],[-44.647812,-23.351959],[-45.352136,-23.796842],[-46.472093,-24.088969],[-47.648972,-24.885199],[-48.495458,-25.877025],[-48.641005,-26.623698],[-48.474736,-27.175912],[-48.66152,-28.186135],[-48.888457,-28.674115],[-49.587329,-29.224469],[-50.696874,-30.984465],[-51.576226,-31.777698],[-52.256081,-32.24537],[-52.7121,-33.196578],[-53.373662,-33.768378]]]}},{"type":"Feature","properties":{"ADMIN":"Bolivia","NAME_EN":"Bolivia","CONTINENT":"South America","ADM0_A3":"BOL","ISO_A3":"BOL","NAME":"Bolivia","ISO_A2":"BO","NAME_ZH":"玻利维亚"},"geometry":{"type":"Polygon","coordinates":[[[-69.529678,-10.951734],[-68.786158,-11.03638],[-68.271254,-11.014521],[-68.048192,-10.712059],[-67.173801,-10.306812],[-66.646908,-9.931331],[-65.338435,-9.761988],[-65.444837,-10.511451],[-65.321899,-10.895872],[-65.402281,-11.56627],[-64.316353,-12.461978],[-63.196499,-12.627033],[-62.80306,-13.000653],[-62.127081,-13.198781],[-61.713204,-13.489202],[-61.084121,-13.479384],[-60.503304,-13.775955],[-60.459198,-14.354007],[-60.264326,-14.645979],[-60.251149,-15.077219],[-60.542966,-15.09391],[-60.15839,-16.258284],[-58.24122,-16.299573],[-58.388058,-16.877109],[-58.280804,-17.27171],[-57.734558,-17.552468],[-57.498371,-18.174188],[-57.676009,-18.96184],[-57.949997,-19.400004],[-57.853802,-19.969995],[-58.166392,-20.176701],[-58.183471,-19.868399],[-59.115042,-19.356906],[-60.043565,-19.342747],[-61.786326,-19.633737],[-62.265961,-20.513735],[-62.291179,-21.051635],[-62.685057,-22.249029],[-62.846468,-22.034985],[-63.986838,-21.993644],[-64.377021,-22.798091],[-64.964892,-22.075862],[-66.273339,-21.83231],[-67.106674,-22.735925],[-67.82818,-22.872919],[-68.219913,-21.494347],[-68.757167,-20.372658],[-68.442225,-19.405068],[-68.966818,-18.981683],[-69.100247,-18.260125],[-69.590424,-17.580012],[-68.959635,-16.500698],[-69.389764,-15.660129],[-69.160347,-15.323974],[-69.339535,-14.953195],[-68.948887,-14.453639],[-68.929224,-13.602684],[-68.88008,-12.899729],[-68.66508,-12.5613],[-69.529678,-10.951734]]]}},{"type":"Feature","properties":{"ADMIN":"Peru","NAME_EN":"Peru","CONTINENT":"South America","ADM0_A3":"PER","ISO_A3":"PER","NAME":"Peru","ISO_A2":"PE","NAME_ZH":"秘鲁"},"geometry":{"type":"Polygon","coordinates":[[[-69.893635,-4.298187],[-70.794769,-4.251265],[-70.928843,-4.401591],[-71.748406,-4.593983],[-72.891928,-5.274561],[-72.964507,-5.741251],[-73.219711,-6.089189],[-73.120027,-6.629931],[-73.724487,-6.918595],[-73.723401,-7.340999],[-73.987235,-7.52383],[-73.571059,-8.424447],[-73.015383,-9.032833],[-73.226713,-9.462213],[-72.563033,-9.520194],[-72.184891,-10.053598],[-71.302412,-10.079436],[-70.481894,-9.490118],[-70.548686,-11.009147],[-70.093752,-11.123972],[-69.529678,-10.951734],[-68.66508,-12.5613],[-68.88008,-12.899729],[-68.929224,-13.602684],[-68.948887,-14.453639],[-69.339535,-14.953195],[-69.160347,-15.323974],[-69.389764,-15.660129],[-68.959635,-16.500698],[-69.590424,-17.580012],[-69.858444,-18.092694],[-70.372572,-18.347975],[-71.37525,-17.773799],[-71.462041,-17.363488],[-73.44453,-16.359363],[-75.237883,-15.265683],[-76.009205,-14.649286],[-76.423469,-13.823187],[-76.259242,-13.535039],[-77.106192,-12.222716],[-78.092153,-10.377712],[-79.036953,-8.386568],[-79.44592,-7.930833],[-79.760578,-7.194341],[-80.537482,-6.541668],[-81.249996,-6.136834],[-80.926347,-5.690557],[-81.410943,-4.736765],[-81.09967,-4.036394],[-80.302561,-3.404856],[-80.184015,-3.821162],[-80.469295,-4.059287],[-80.442242,-4.425724],[-80.028908,-4.346091],[-79.624979,-4.454198],[-79.205289,-4.959129],[-78.639897,-4.547784],[-78.450684,-3.873097],[-77.837905,-3.003021],[-76.635394,-2.608678],[-75.544996,-1.56161],[-75.233723,-0.911417],[-75.373223,-0.152032],[-75.106625,-0.057205],[-74.441601,-0.53082],[-74.122395,-1.002833],[-73.659504,-1.260491],[-73.070392,-2.308954],[-72.325787,-2.434218],[-71.774761,-2.16979],[-71.413646,-2.342802],[-70.813476,-2.256865],[-70.047709,-2.725156],[-70.692682,-3.742872],[-70.394044,-3.766591],[-69.893635,-4.298187]]]}},{"type":"Feature","properties":{"ADMIN":"Colombia","NAME_EN":"Colombia","CONTINENT":"South America","ADM0_A3":"COL","ISO_A3":"COL","NAME":"Colombia","ISO_A2":"CO","NAME_ZH":"哥伦比亚"},"geometry":{"type":"Polygon","coordinates":[[[-66.876326,1.253361],[-67.065048,1.130112],[-67.259998,1.719999],[-67.53781,2.037163],[-67.868565,1.692455],[-69.816973,1.714805],[-69.804597,1.089081],[-69.218638,0.985677],[-69.252434,0.602651],[-69.452396,0.706159],[-70.015566,0.541414],[-70.020656,-0.185156],[-69.577065,-0.549992],[-69.420486,-1.122619],[-69.444102,-1.556287],[-69.893635,-4.298187],[-70.394044,-3.766591],[-70.692682,-3.742872],[-70.047709,-2.725156],[-70.813476,-2.256865],[-71.413646,-2.342802],[-71.774761,-2.16979],[-72.325787,-2.434218],[-73.070392,-2.308954],[-73.659504,-1.260491],[-74.122395,-1.002833],[-74.441601,-0.53082],[-75.106625,-0.057205],[-75.373223,-0.152032],[-75.801466,0.084801],[-76.292314,0.416047],[-76.57638,0.256936],[-77.424984,0.395687],[-77.668613,0.825893],[-77.855061,0.809925],[-78.855259,1.380924],[-78.990935,1.69137],[-78.617831,1.766404],[-78.662118,2.267355],[-78.42761,2.629556],[-77.931543,2.696606],[-77.510431,3.325017],[-77.12769,3.849636],[-77.496272,4.087606],[-77.307601,4.667984],[-77.533221,5.582812],[-77.318815,5.845354],[-77.476661,6.691116],[-77.881571,7.223771],[-77.753414,7.70984],[-77.431108,7.638061],[-77.242566,7.935278],[-77.474723,8.524286],[-77.353361,8.670505],[-76.836674,8.638749],[-76.086384,9.336821],[-75.6746,9.443248],[-75.664704,9.774003],[-75.480426,10.61899],[-74.906895,11.083045],[-74.276753,11.102036],[-74.197223,11.310473],[-73.414764,11.227015],[-72.627835,11.731972],[-72.238195,11.95555],[-71.75409,12.437303],[-71.399822,12.376041],[-71.137461,12.112982],[-71.331584,11.776284],[-71.973922,11.608672],[-72.227575,11.108702],[-72.614658,10.821975],[-72.905286,10.450344],[-73.027604,9.73677],[-73.304952,9.152],[-72.78873,9.085027],[-72.660495,8.625288],[-72.439862,8.405275],[-72.360901,8.002638],[-72.479679,7.632506],[-72.444487,7.423785],[-72.198352,7.340431],[-71.960176,6.991615],[-70.674234,7.087785],[-70.093313,6.960376],[-69.38948,6.099861],[-68.985319,6.206805],[-68.265052,6.153268],[-67.695087,6.267318],[-67.34144,6.095468],[-67.521532,5.55687],[-67.744697,5.221129],[-67.823012,4.503937],[-67.621836,3.839482],[-67.337564,3.542342],[-67.303173,3.318454],[-67.809938,2.820655],[-67.447092,2.600281],[-67.181294,2.250638],[-66.876326,1.253361]]]}},{"type":"Feature","properties":{"ADMIN":"Panama","NAME_EN":"Panama","CONTINENT":"North America","ADM0_A3":"PAN","ISO_A3":"PAN","NAME":"Panama","ISO_A2":"PA","NAME_ZH":"巴拿马"},"geometry":{"type":"Polygon","coordinates":[[[-77.353361,8.670505],[-77.474723,8.524286],[-77.242566,7.935278],[-77.431108,7.638061],[-77.753414,7.70984],[-77.881571,7.223771],[-78.214936,7.512255],[-78.429161,8.052041],[-78.182096,8.319182],[-78.435465,8.387705],[-78.622121,8.718124],[-79.120307,8.996092],[-79.557877,8.932375],[-79.760578,8.584515],[-80.164481,8.333316],[-80.382659,8.298409],[-80.480689,8.090308],[-80.00369,7.547524],[-80.276671,7.419754],[-80.421158,7.271572],[-80.886401,7.220541],[-81.059543,7.817921],[-81.189716,7.647906],[-81.519515,7.70661],[-81.721311,8.108963],[-82.131441,8.175393],[-82.390934,8.292362],[-82.820081,8.290864],[-82.850958,8.073823],[-82.965783,8.225028],[-82.913176,8.423517],[-82.829771,8.626295],[-82.868657,8.807266],[-82.719183,8.925709],[-82.927155,9.07433],[-82.932891,9.476812],[-82.546196,9.566135],[-82.187123,9.207449],[-82.207586,8.995575],[-81.808567,8.950617],[-81.714154,9.031955],[-81.439287,8.786234],[-80.947302,8.858504],[-80.521901,9.111072],[-79.9146,9.312765],[-79.573303,9.61161],[-79.021192,9.552931],[-79.05845,9.454565],[-78.500888,9.420459],[-78.055928,9.24773],[-77.729514,8.946844],[-77.353361,8.670505]]]}},{"type":"Feature","properties":{"ADMIN":"Costa Rica","NAME_EN":"Costa Rica","CONTINENT":"North America","ADM0_A3":"CRI","ISO_A3":"CRI","NAME":"Costa Rica","ISO_A2":"CR","NAME_ZH":"哥斯达黎加"},"geometry":{"type":"Polygon","coordinates":[[[-82.546196,9.566135],[-82.932891,9.476812],[-82.927155,9.07433],[-82.719183,8.925709],[-82.868657,8.807266],[-82.829771,8.626295],[-82.913176,8.423517],[-82.965783,8.225028],[-83.508437,8.446927],[-83.711474,8.656836],[-83.596313,8.830443],[-83.632642,9.051386],[-83.909886,9.290803],[-84.303402,9.487354],[-84.647644,9.615537],[-84.713351,9.908052],[-84.97566,10.086723],[-84.911375,9.795992],[-85.110923,9.55704],[-85.339488,9.834542],[-85.660787,9.933347],[-85.797445,10.134886],[-85.791709,10.439337],[-85.659314,10.754331],[-85.941725,10.895278],[-85.71254,11.088445],[-85.561852,11.217119],[-84.903003,10.952303],[-84.673069,11.082657],[-84.355931,10.999226],[-84.190179,10.79345],[-83.895054,10.726839],[-83.655612,10.938764],[-83.40232,10.395438],[-83.015677,9.992982],[-82.546196,9.566135]]]}},{"type":"Feature","properties":{"ADMIN":"Nicaragua","NAME_EN":"Nicaragua","CONTINENT":"North America","ADM0_A3":"NIC","ISO_A3":"NIC","NAME":"Nicaragua","ISO_A2":"NI","NAME_ZH":"尼加拉瓜"},"geometry":{"type":"Polygon","coordinates":[[[-83.655612,10.938764],[-83.895054,10.726839],[-84.190179,10.79345],[-84.355931,10.999226],[-84.673069,11.082657],[-84.903003,10.952303],[-85.561852,11.217119],[-85.71254,11.088445],[-86.058488,11.403439],[-86.52585,11.806877],[-86.745992,12.143962],[-87.167516,12.458258],[-87.668493,12.90991],[-87.557467,13.064552],[-87.392386,12.914018],[-87.316654,12.984686],[-87.005769,13.025794],[-86.880557,13.254204],[-86.733822,13.263093],[-86.755087,13.754845],[-86.520708,13.778487],[-86.312142,13.771356],[-86.096264,14.038187],[-85.801295,13.836055],[-85.698665,13.960078],[-85.514413,14.079012],[-85.165365,14.35437],[-85.148751,14.560197],[-85.052787,14.551541],[-84.924501,14.790493],[-84.820037,14.819587],[-84.649582,14.666805],[-84.449336,14.621614],[-84.228342,14.748764],[-83.975721,14.749436],[-83.628585,14.880074],[-83.489989,15.016267],[-83.147219,14.995829],[-83.233234,14.899866],[-83.284162,14.676624],[-83.182126,14.310703],[-83.4125,13.970078],[-83.519832,13.567699],[-83.552207,13.127054],[-83.498515,12.869292],[-83.473323,12.419087],[-83.626104,12.32085],[-83.719613,11.893124],[-83.650858,11.629032],[-83.85547,11.373311],[-83.808936,11.103044],[-83.655612,10.938764]]]}},{"type":"Feature","properties":{"ADMIN":"Honduras","NAME_EN":"Honduras","CONTINENT":"North America","ADM0_A3":"HND","ISO_A3":"HND","NAME":"Honduras","ISO_A2":"HN","NAME_ZH":"洪都拉斯"},"geometry":{"type":"Polygon","coordinates":[[[-83.147219,14.995829],[-83.489989,15.016267],[-83.628585,14.880074],[-83.975721,14.749436],[-84.228342,14.748764],[-84.449336,14.621614],[-84.649582,14.666805],[-84.820037,14.819587],[-84.924501,14.790493],[-85.052787,14.551541],[-85.148751,14.560197],[-85.165365,14.35437],[-85.514413,14.079012],[-85.698665,13.960078],[-85.801295,13.836055],[-86.096264,14.038187],[-86.312142,13.771356],[-86.520708,13.778487],[-86.755087,13.754845],[-86.733822,13.263093],[-86.880557,13.254204],[-87.005769,13.025794],[-87.316654,12.984686],[-87.489409,13.297535],[-87.793111,13.38448],[-87.723503,13.78505],[-87.859515,13.893312],[-88.065343,13.964626],[-88.503998,13.845486],[-88.541231,13.980155],[-88.843073,14.140507],[-89.058512,14.340029],[-89.353326,14.424133],[-89.145535,14.678019],[-89.22522,14.874286],[-89.154811,15.066419],[-88.68068,15.346247],[-88.225023,15.727722],[-88.121153,15.688655],[-87.901813,15.864458],[-87.61568,15.878799],[-87.522921,15.797279],[-87.367762,15.84694],[-86.903191,15.756713],[-86.440946,15.782835],[-86.119234,15.893449],[-86.001954,16.005406],[-85.683317,15.953652],[-85.444004,15.885749],[-85.182444,15.909158],[-84.983722,15.995923],[-84.52698,15.857224],[-84.368256,15.835158],[-84.063055,15.648244],[-83.773977,15.424072],[-83.410381,15.270903],[-83.147219,14.995829]]]}},{"type":"Feature","properties":{"ADMIN":"El Salvador","NAME_EN":"El Salvador","CONTINENT":"North America","ADM0_A3":"SLV","ISO_A3":"SLV","NAME":"El Salvador","ISO_A2":"SV","NAME_ZH":"萨尔瓦多"},"geometry":{"type":"Polygon","coordinates":[[[-89.353326,14.424133],[-89.058512,14.340029],[-88.843073,14.140507],[-88.541231,13.980155],[-88.503998,13.845486],[-88.065343,13.964626],[-87.859515,13.893312],[-87.723503,13.78505],[-87.793111,13.38448],[-87.904112,13.149017],[-88.483302,13.163951],[-88.843228,13.259734],[-89.256743,13.458533],[-89.812394,13.520622],[-90.095555,13.735338],[-90.064678,13.88197],[-89.721934,14.134228],[-89.534219,14.244816],[-89.587343,14.362586],[-89.353326,14.424133]]]}},{"type":"Feature","properties":{"ADMIN":"Guatemala","NAME_EN":"Guatemala","CONTINENT":"North America","ADM0_A3":"GTM","ISO_A3":"GTM","NAME":"Guatemala","ISO_A2":"GT","NAME_ZH":"危地马拉"},"geometry":{"type":"Polygon","coordinates":[[[-92.22775,14.538829],[-92.20323,14.830103],[-92.087216,15.064585],[-92.229249,15.251447],[-91.74796,16.066565],[-90.464473,16.069562],[-90.438867,16.41011],[-90.600847,16.470778],[-90.711822,16.687483],[-91.08167,16.918477],[-91.453921,17.252177],[-91.002269,17.254658],[-91.00152,17.817595],[-90.067934,17.819326],[-89.14308,17.808319],[-89.150806,17.015577],[-89.229122,15.886938],[-88.930613,15.887273],[-88.604586,15.70638],[-88.518364,15.855389],[-88.225023,15.727722],[-88.68068,15.346247],[-89.154811,15.066419],[-89.22522,14.874286],[-89.145535,14.678019],[-89.353326,14.424133],[-89.587343,14.362586],[-89.534219,14.244816],[-89.721934,14.134228],[-90.064678,13.88197],[-90.095555,13.735338],[-90.608624,13.909771],[-91.23241,13.927832],[-91.689747,14.126218],[-92.22775,14.538829]]]}},{"type":"Feature","properties":{"ADMIN":"Belize","NAME_EN":"Belize","CONTINENT":"North America","ADM0_A3":"BLZ","ISO_A3":"BLZ","NAME":"Belize","ISO_A2":"BZ","NAME_ZH":"伯利兹"},"geometry":{"type":"Polygon","coordinates":[[[-89.14308,17.808319],[-89.150909,17.955468],[-89.029857,18.001511],[-88.848344,17.883198],[-88.490123,18.486831],[-88.300031,18.499982],[-88.296336,18.353273],[-88.106813,18.348674],[-88.123479,18.076675],[-88.285355,17.644143],[-88.197867,17.489475],[-88.302641,17.131694],[-88.239518,17.036066],[-88.355428,16.530774],[-88.551825,16.265467],[-88.732434,16.233635],[-88.930613,15.887273],[-89.229122,15.886938],[-89.150806,17.015577],[-89.14308,17.808319]]]}},{"type":"Feature","properties":{"ADMIN":"Venezuela","NAME_EN":"Venezuela","CONTINENT":"South America","ADM0_A3":"VEN","ISO_A3":"VEN","NAME":"Venezuela","ISO_A2":"VE","NAME_ZH":"委内瑞拉"},"geometry":{"type":"Polygon","coordinates":[[[-60.733574,5.200277],[-60.601179,4.918098],[-60.966893,4.536468],[-62.08543,4.162124],[-62.804533,4.006965],[-63.093198,3.770571],[-63.888343,4.02053],[-64.628659,4.148481],[-64.816064,4.056445],[-64.368494,3.79721],[-64.408828,3.126786],[-64.269999,2.497006],[-63.422867,2.411068],[-63.368788,2.2009],[-64.083085,1.916369],[-64.199306,1.492855],[-64.611012,1.328731],[-65.354713,1.095282],[-65.548267,0.789254],[-66.325765,0.724452],[-66.876326,1.253361],[-67.181294,2.250638],[-67.447092,2.600281],[-67.809938,2.820655],[-67.303173,3.318454],[-67.337564,3.542342],[-67.621836,3.839482],[-67.823012,4.503937],[-67.744697,5.221129],[-67.521532,5.55687],[-67.34144,6.095468],[-67.695087,6.267318],[-68.265052,6.153268],[-68.985319,6.206805],[-69.38948,6.099861],[-70.093313,6.960376],[-70.674234,7.087785],[-71.960176,6.991615],[-72.198352,7.340431],[-72.444487,7.423785],[-72.479679,7.632506],[-72.360901,8.002638],[-72.439862,8.405275],[-72.660495,8.625288],[-72.78873,9.085027],[-73.304952,9.152],[-73.027604,9.73677],[-72.905286,10.450344],[-72.614658,10.821975],[-72.227575,11.108702],[-71.973922,11.608672],[-71.331584,11.776284],[-71.360006,11.539994],[-71.94705,11.423282],[-71.620868,10.96946],[-71.633064,10.446494],[-72.074174,9.865651],[-71.695644,9.072263],[-71.264559,9.137195],[-71.039999,9.859993],[-71.350084,10.211935],[-71.400623,10.968969],[-70.155299,11.375482],[-70.293843,11.846822],[-69.943245,12.162307],[-69.5843,11.459611],[-68.882999,11.443385],[-68.233271,10.885744],[-68.194127,10.554653],[-67.296249,10.545868],[-66.227864,10.648627],[-65.655238,10.200799],[-64.890452,10.077215],[-64.329479,10.389599],[-64.318007,10.641418],[-63.079322,10.701724],[-61.880946,10.715625],[-62.730119,10.420269],[-62.388512,9.948204],[-61.588767,9.873067],[-60.830597,9.38134],[-60.671252,8.580174],[-60.150096,8.602757],[-59.758285,8.367035],[-60.550588,7.779603],[-60.637973,7.415],[-60.295668,7.043911],[-60.543999,6.856584],[-61.159336,6.696077],[-61.139415,6.234297],[-61.410303,5.959068],[-60.733574,5.200277]]]}},{"type":"Feature","properties":{"ADMIN":"Guyana","NAME_EN":"Guyana","CONTINENT":"South America","ADM0_A3":"GUY","ISO_A3":"GUY","NAME":"Guyana","ISO_A2":"GY","NAME_ZH":"圭亚那"},"geometry":{"type":"Polygon","coordinates":[[[-56.539386,1.899523],[-56.782704,1.863711],[-57.335823,1.948538],[-57.660971,1.682585],[-58.11345,1.507195],[-58.429477,1.463942],[-58.540013,1.268088],[-59.030862,1.317698],[-59.646044,1.786894],[-59.718546,2.24963],[-59.974525,2.755233],[-59.815413,3.606499],[-59.53804,3.958803],[-59.767406,4.423503],[-60.111002,4.574967],[-59.980959,5.014061],[-60.213683,5.244486],[-60.733574,5.200277],[-61.410303,5.959068],[-61.139415,6.234297],[-61.159336,6.696077],[-60.543999,6.856584],[-60.295668,7.043911],[-60.637973,7.415],[-60.550588,7.779603],[-59.758285,8.367035],[-59.101684,7.999202],[-58.482962,7.347691],[-58.454876,6.832787],[-58.078103,6.809094],[-57.542219,6.321268],[-57.147436,5.97315],[-57.307246,5.073567],[-57.914289,4.812626],[-57.86021,4.576801],[-58.044694,4.060864],[-57.601569,3.334655],[-57.281433,3.333492],[-57.150098,2.768927],[-56.539386,1.899523]]]}},{"type":"Feature","properties":{"ADMIN":"Suriname","NAME_EN":"Suriname","CONTINENT":"South America","ADM0_A3":"SUR","ISO_A3":"SUR","NAME":"Suriname","ISO_A2":"SR","NAME_ZH":"苏里南"},"geometry":{"type":"Polygon","coordinates":[[[-54.524754,2.311849],[-55.097587,2.523748],[-55.569755,2.421506],[-55.973322,2.510364],[-56.073342,2.220795],[-55.9056,2.021996],[-55.995698,1.817667],[-56.539386,1.899523],[-57.150098,2.768927],[-57.281433,3.333492],[-57.601569,3.334655],[-58.044694,4.060864],[-57.86021,4.576801],[-57.914289,4.812626],[-57.307246,5.073567],[-57.147436,5.97315],[-55.949318,5.772878],[-55.84178,5.953125],[-55.03325,6.025291],[-53.958045,5.756548],[-54.478633,4.896756],[-54.399542,4.212611],[-54.006931,3.620038],[-54.181726,3.18978],[-54.269705,2.732392],[-54.524754,2.311849]]]}},{"type":"Feature","properties":{"ADMIN":"France","NAME_EN":"France","CONTINENT":"Europe","ADM0_A3":"FRA","ISO_A3":"-99","NAME":"France","ISO_A2":"-99","NAME_ZH":"法国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-51.657797,4.156232],[-52.249338,3.241094],[-52.556425,2.504705],[-52.939657,2.124858],[-53.418465,2.053389],[-53.554839,2.334897],[-53.778521,2.376703],[-54.088063,2.105557],[-54.524754,2.311849],[-54.269705,2.732392],[-54.181726,3.18978],[-54.006931,3.620038],[-54.399542,4.212611],[-54.478633,4.896756],[-53.958045,5.756548],[-53.618453,5.646529],[-52.882141,5.409851],[-51.823343,4.565768],[-51.657797,4.156232]]],[[[6.18632,49.463803],[6.65823,49.201958],[8.099279,49.017784],[7.593676,48.333019],[7.466759,47.620582],[7.192202,47.449766],[6.736571,47.541801],[6.768714,47.287708],[6.037389,46.725779],[6.022609,46.27299],[6.5001,46.429673],[6.843593,45.991147],[6.802355,45.70858],[7.096652,45.333099],[6.749955,45.028518],[7.007562,44.254767],[7.549596,44.127901],[7.435185,43.693845],[6.529245,43.128892],[4.556963,43.399651],[3.100411,43.075201],[2.985999,42.473015],[1.826793,42.343385],[0.701591,42.795734],[0.338047,42.579546],[-1.502771,43.034014],[-1.901351,43.422802],[-1.384225,44.02261],[-1.193798,46.014918],[-2.225724,47.064363],[-2.963276,47.570327],[-4.491555,47.954954],[-4.59235,48.68416],[-3.295814,48.901692],[-1.616511,48.644421],[-1.933494,49.776342],[-0.989469,49.347376],[1.338761,50.127173],[1.639001,50.946606],[2.513573,51.148506],[2.658422,50.796848],[3.123252,50.780363],[3.588184,50.378992],[4.286023,49.907497],[4.799222,49.985373],[5.674052,49.529484],[5.897759,49.442667],[6.18632,49.463803]]],[[[8.746009,42.628122],[9.390001,43.009985],[9.560016,42.152492],[9.229752,41.380007],[8.775723,41.583612],[8.544213,42.256517],[8.746009,42.628122]]]]}},{"type":"Feature","properties":{"ADMIN":"Ecuador","NAME_EN":"Ecuador","CONTINENT":"South America","ADM0_A3":"ECU","ISO_A3":"ECU","NAME":"Ecuador","ISO_A2":"EC","NAME_ZH":"厄瓜多尔"},"geometry":{"type":"Polygon","coordinates":[[[-75.373223,-0.152032],[-75.233723,-0.911417],[-75.544996,-1.56161],[-76.635394,-2.608678],[-77.837905,-3.003021],[-78.450684,-3.873097],[-78.639897,-4.547784],[-79.205289,-4.959129],[-79.624979,-4.454198],[-80.028908,-4.346091],[-80.442242,-4.425724],[-80.469295,-4.059287],[-80.184015,-3.821162],[-80.302561,-3.404856],[-79.770293,-2.657512],[-79.986559,-2.220794],[-80.368784,-2.685159],[-80.967765,-2.246943],[-80.764806,-1.965048],[-80.933659,-1.057455],[-80.58337,-0.906663],[-80.399325,-0.283703],[-80.020898,0.36034],[-80.09061,0.768429],[-79.542762,0.982938],[-78.855259,1.380924],[-77.855061,0.809925],[-77.668613,0.825893],[-77.424984,0.395687],[-76.57638,0.256936],[-76.292314,0.416047],[-75.801466,0.084801],[-75.373223,-0.152032]]]}},{"type":"Feature","properties":{"ADMIN":"Puerto Rico","NAME_EN":"Puerto Rico","CONTINENT":"North America","ADM0_A3":"PRI","ISO_A3":"PRI","NAME":"Puerto Rico","ISO_A2":"PR","NAME_ZH":"波多黎各"},"geometry":{"type":"Polygon","coordinates":[[[-66.282434,18.514762],[-65.771303,18.426679],[-65.591004,18.228035],[-65.847164,17.975906],[-66.599934,17.981823],[-67.184162,17.946553],[-67.242428,18.37446],[-67.100679,18.520601],[-66.282434,18.514762]]]}},{"type":"Feature","properties":{"ADMIN":"Jamaica","NAME_EN":"Jamaica","CONTINENT":"North America","ADM0_A3":"JAM","ISO_A3":"JAM","NAME":"Jamaica","ISO_A2":"JM","NAME_ZH":"牙买加"},"geometry":{"type":"Polygon","coordinates":[[[-77.569601,18.490525],[-76.896619,18.400867],[-76.365359,18.160701],[-76.199659,17.886867],[-76.902561,17.868238],[-77.206341,17.701116],[-77.766023,17.861597],[-78.337719,18.225968],[-78.217727,18.454533],[-77.797365,18.524218],[-77.569601,18.490525]]]}},{"type":"Feature","properties":{"ADMIN":"Cuba","NAME_EN":"Cuba","CONTINENT":"North America","ADM0_A3":"CUB","ISO_A3":"CUB","NAME":"Cuba","ISO_A2":"CU","NAME_ZH":"古巴"},"geometry":{"type":"Polygon","coordinates":[[[-82.268151,23.188611],[-81.404457,23.117271],[-80.618769,23.10598],[-79.679524,22.765303],[-79.281486,22.399202],[-78.347434,22.512166],[-77.993296,22.277194],[-77.146422,21.657851],[-76.523825,21.20682],[-76.19462,21.220565],[-75.598222,21.016624],[-75.67106,20.735091],[-74.933896,20.693905],[-74.178025,20.284628],[-74.296648,20.050379],[-74.961595,19.923435],[-75.63468,19.873774],[-76.323656,19.952891],[-77.755481,19.855481],[-77.085108,20.413354],[-77.492655,20.673105],[-78.137292,20.739949],[-78.482827,21.028613],[-78.719867,21.598114],[-79.285,21.559175],[-80.217475,21.827324],[-80.517535,22.037079],[-81.820943,22.192057],[-82.169992,22.387109],[-81.795002,22.636965],[-82.775898,22.68815],[-83.494459,22.168518],[-83.9088,22.154565],[-84.052151,21.910575],[-84.54703,21.801228],[-84.974911,21.896028],[-84.447062,22.20495],[-84.230357,22.565755],[-83.77824,22.788118],[-83.267548,22.983042],[-82.510436,23.078747],[-82.268151,23.188611]]]}},{"type":"Feature","properties":{"ADMIN":"Zimbabwe","NAME_EN":"Zimbabwe","CONTINENT":"Africa","ADM0_A3":"ZWE","ISO_A3":"ZWE","NAME":"Zimbabwe","ISO_A2":"ZW","NAME_ZH":"津巴布韦"},"geometry":{"type":"Polygon","coordinates":[[[31.191409,-22.25151],[30.659865,-22.151567],[30.322883,-22.271612],[29.839037,-22.102216],[29.432188,-22.091313],[28.794656,-21.639454],[28.02137,-21.485975],[27.727228,-20.851802],[27.724747,-20.499059],[27.296505,-20.39152],[26.164791,-19.293086],[25.850391,-18.714413],[25.649163,-18.536026],[25.264226,-17.73654],[26.381935,-17.846042],[26.706773,-17.961229],[27.044427,-17.938026],[27.598243,-17.290831],[28.467906,-16.4684],[28.825869,-16.389749],[28.947463,-16.043051],[29.516834,-15.644678],[30.274256,-15.507787],[30.338955,-15.880839],[31.173064,-15.860944],[31.636498,-16.07199],[31.852041,-16.319417],[32.328239,-16.392074],[32.847639,-16.713398],[32.849861,-17.979057],[32.654886,-18.67209],[32.611994,-19.419383],[32.772708,-19.715592],[32.659743,-20.30429],[32.508693,-20.395292],[32.244988,-21.116489],[31.191409,-22.25151]]]}},{"type":"Feature","properties":{"ADMIN":"Botswana","NAME_EN":"Botswana","CONTINENT":"Africa","ADM0_A3":"BWA","ISO_A3":"BWA","NAME":"Botswana","ISO_A2":"BW","NAME_ZH":"博茨瓦纳"},"geometry":{"type":"Polygon","coordinates":[[[29.432188,-22.091313],[28.017236,-22.827754],[27.11941,-23.574323],[26.786407,-24.240691],[26.485753,-24.616327],[25.941652,-24.696373],[25.765849,-25.174845],[25.664666,-25.486816],[25.025171,-25.71967],[24.211267,-25.670216],[23.73357,-25.390129],[23.312097,-25.26869],[22.824271,-25.500459],[22.579532,-25.979448],[22.105969,-26.280256],[21.605896,-26.726534],[20.889609,-26.828543],[20.66647,-26.477453],[20.758609,-25.868136],[20.165726,-24.917962],[19.895768,-24.76779],[19.895458,-21.849157],[20.881134,-21.814327],[20.910641,-18.252219],[21.65504,-18.219146],[23.196858,-17.869038],[23.579006,-18.281261],[24.217365,-17.889347],[24.520705,-17.887125],[25.084443,-17.661816],[25.264226,-17.73654],[25.649163,-18.536026],[25.850391,-18.714413],[26.164791,-19.293086],[27.296505,-20.39152],[27.724747,-20.499059],[27.727228,-20.851802],[28.02137,-21.485975],[28.794656,-21.639454],[29.432188,-22.091313]]]}},{"type":"Feature","properties":{"ADMIN":"Namibia","NAME_EN":"Namibia","CONTINENT":"Africa","ADM0_A3":"NAM","ISO_A3":"NAM","NAME":"Namibia","ISO_A2":"NA","NAME_ZH":"纳米比亚"},"geometry":{"type":"Polygon","coordinates":[[[19.895768,-24.76779],[19.894734,-28.461105],[19.002127,-28.972443],[18.464899,-29.045462],[17.836152,-28.856378],[17.387497,-28.783514],[17.218929,-28.355943],[16.824017,-28.082162],[16.344977,-28.576705],[15.601818,-27.821247],[15.210472,-27.090956],[14.989711,-26.117372],[14.743214,-25.39292],[14.408144,-23.853014],[14.385717,-22.656653],[14.257714,-22.111208],[13.868642,-21.699037],[13.352498,-20.872834],[12.826845,-19.673166],[12.608564,-19.045349],[11.794919,-18.069129],[11.734199,-17.301889],[12.215461,-17.111668],[12.814081,-16.941343],[13.462362,-16.971212],[14.058501,-17.423381],[14.209707,-17.353101],[18.263309,-17.309951],[18.956187,-17.789095],[21.377176,-17.930636],[23.215048,-17.523116],[24.033862,-17.295843],[24.682349,-17.353411],[25.07695,-17.578823],[25.084443,-17.661816],[24.520705,-17.887125],[24.217365,-17.889347],[23.579006,-18.281261],[23.196858,-17.869038],[21.65504,-18.219146],[20.910641,-18.252219],[20.881134,-21.814327],[19.895458,-21.849157],[19.895768,-24.76779]]]}},{"type":"Feature","properties":{"ADMIN":"Senegal","NAME_EN":"Senegal","CONTINENT":"Africa","ADM0_A3":"SEN","ISO_A3":"SEN","NAME":"Senegal","ISO_A2":"SN","NAME_ZH":"塞内加尔"},"geometry":{"type":"Polygon","coordinates":[[[-16.713729,13.594959],[-17.126107,14.373516],[-17.625043,14.729541],[-17.185173,14.919477],[-16.700706,15.621527],[-16.463098,16.135036],[-16.12069,16.455663],[-15.623666,16.369337],[-15.135737,16.587282],[-14.577348,16.598264],[-14.099521,16.304302],[-13.435738,16.039383],[-12.830658,15.303692],[-12.17075,14.616834],[-12.124887,13.994727],[-11.927716,13.422075],[-11.553398,13.141214],[-11.467899,12.754519],[-11.513943,12.442988],[-11.658301,12.386583],[-12.203565,12.465648],[-12.278599,12.35444],[-12.499051,12.33209],[-13.217818,12.575874],[-13.700476,12.586183],[-15.548477,12.62817],[-15.816574,12.515567],[-16.147717,12.547762],[-16.677452,12.384852],[-16.841525,13.151394],[-15.931296,13.130284],[-15.691001,13.270353],[-15.511813,13.27857],[-15.141163,13.509512],[-14.712197,13.298207],[-14.277702,13.280585],[-13.844963,13.505042],[-14.046992,13.794068],[-14.376714,13.62568],[-14.687031,13.630357],[-15.081735,13.876492],[-15.39877,13.860369],[-15.624596,13.623587],[-16.713729,13.594959]]]}},{"type":"Feature","properties":{"ADMIN":"Mali","NAME_EN":"Mali","CONTINENT":"Africa","ADM0_A3":"MLI","ISO_A3":"MLI","NAME":"Mali","ISO_A2":"ML","NAME_ZH":"马里"},"geometry":{"type":"Polygon","coordinates":[[[-11.513943,12.442988],[-11.467899,12.754519],[-11.553398,13.141214],[-11.927716,13.422075],[-12.124887,13.994727],[-12.17075,14.616834],[-11.834208,14.799097],[-11.666078,15.388208],[-11.349095,15.411256],[-10.650791,15.132746],[-10.086846,15.330486],[-9.700255,15.264107],[-9.550238,15.486497],[-5.537744,15.50169],[-5.315277,16.201854],[-5.488523,16.325102],[-5.971129,20.640833],[-6.453787,24.956591],[-4.923337,24.974574],[-1.550055,22.792666],[1.823228,20.610809],[2.060991,20.142233],[2.683588,19.85623],[3.146661,19.693579],[3.158133,19.057364],[4.267419,19.155265],[4.27021,16.852227],[3.723422,16.184284],[3.638259,15.56812],[2.749993,15.409525],[1.385528,15.323561],[1.015783,14.968182],[0.374892,14.928908],[-0.266257,14.924309],[-0.515854,15.116158],[-1.066363,14.973815],[-2.001035,14.559008],[-2.191825,14.246418],[-2.967694,13.79815],[-3.103707,13.541267],[-3.522803,13.337662],[-4.006391,13.472485],[-4.280405,13.228444],[-4.427166,12.542646],[-5.220942,11.713859],[-5.197843,11.375146],[-5.470565,10.95127],[-5.404342,10.370737],[-5.816926,10.222555],[-6.050452,10.096361],[-6.205223,10.524061],[-6.493965,10.411303],[-6.666461,10.430811],[-6.850507,10.138994],[-7.622759,10.147236],[-7.89959,10.297382],[-8.029944,10.206535],[-8.335377,10.494812],[-8.282357,10.792597],[-8.407311,10.909257],[-8.620321,10.810891],[-8.581305,11.136246],[-8.376305,11.393646],[-8.786099,11.812561],[-8.905265,12.088358],[-9.127474,12.30806],[-9.327616,12.334286],[-9.567912,12.194243],[-9.890993,12.060479],[-10.165214,11.844084],[-10.593224,11.923975],[-10.87083,12.177887],[-11.036556,12.211245],[-11.297574,12.077971],[-11.456169,12.076834],[-11.513943,12.442988]]]}},{"type":"Feature","properties":{"ADMIN":"Mauritania","NAME_EN":"Mauritania","CONTINENT":"Africa","ADM0_A3":"MRT","ISO_A3":"MRT","NAME":"Mauritania","ISO_A2":"MR","NAME_ZH":"毛里塔尼亚"},"geometry":{"type":"Polygon","coordinates":[[[-17.063423,20.999752],[-16.845194,21.333323],[-12.929102,21.327071],[-13.118754,22.77122],[-12.874222,23.284832],[-11.937224,23.374594],[-11.969419,25.933353],[-8.687294,25.881056],[-8.6844,27.395744],[-4.923337,24.974574],[-6.453787,24.956591],[-5.971129,20.640833],[-5.488523,16.325102],[-5.315277,16.201854],[-5.537744,15.50169],[-9.550238,15.486497],[-9.700255,15.264107],[-10.086846,15.330486],[-10.650791,15.132746],[-11.349095,15.411256],[-11.666078,15.388208],[-11.834208,14.799097],[-12.17075,14.616834],[-12.830658,15.303692],[-13.435738,16.039383],[-14.099521,16.304302],[-14.577348,16.598264],[-15.135737,16.587282],[-15.623666,16.369337],[-16.12069,16.455663],[-16.463098,16.135036],[-16.549708,16.673892],[-16.270552,17.166963],[-16.146347,18.108482],[-16.256883,19.096716],[-16.377651,19.593817],[-16.277838,20.092521],[-16.536324,20.567866],[-17.063423,20.999752]]]}},{"type":"Feature","properties":{"ADMIN":"Benin","NAME_EN":"Benin","CONTINENT":"Africa","ADM0_A3":"BEN","ISO_A3":"BEN","NAME":"Benin","ISO_A2":"BJ","NAME_ZH":"贝宁"},"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[1.865241,6.142158],[1.618951,6.832038],[1.664478,9.12859],[1.463043,9.334624],[1.425061,9.825395],[1.077795,10.175607],[0.772336,10.470808],[0.899563,10.997339],[1.24347,11.110511],[1.447178,11.547719],[1.935986,11.64115],[2.154474,11.94015],[2.490164,12.233052],[2.848643,12.235636],[3.61118,11.660167],[3.572216,11.327939],[3.797112,10.734746],[3.60007,10.332186],[3.705438,10.06321],[3.220352,9.444153],[2.912308,9.137608],[2.723793,8.506845],[2.749063,7.870734],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"ADMIN":"Niger","NAME_EN":"Niger","CONTINENT":"Africa","ADM0_A3":"NER","ISO_A3":"NER","NAME":"Niger","ISO_A2":"NE","NAME_ZH":"尼日尔"},"geometry":{"type":"Polygon","coordinates":[[[14.8513,22.86295],[15.096888,21.308519],[15.47106,21.04845],[15.487148,20.730415],[15.903247,20.387619],[15.685741,19.95718],[15.300441,17.92795],[15.247731,16.627306],[13.97217,15.68437],[13.540394,14.367134],[13.956699,13.996691],[13.954477,13.353449],[14.595781,13.330427],[14.495787,12.859396],[14.213531,12.802035],[14.181336,12.483657],[13.995353,12.461565],[13.318702,13.556356],[13.083987,13.596147],[12.302071,13.037189],[11.527803,13.32898],[10.989593,13.387323],[10.701032,13.246918],[10.114814,13.277252],[9.524928,12.851102],[9.014933,12.826659],[7.804671,13.343527],[7.330747,13.098038],[6.820442,13.115091],[6.445426,13.492768],[5.443058,13.865924],[4.368344,13.747482],[4.107946,13.531216],[3.967283,12.956109],[3.680634,12.552903],[3.61118,11.660167],[2.848643,12.235636],[2.490164,12.233052],[2.154474,11.94015],[2.177108,12.625018],[1.024103,12.851826],[0.993046,13.33575],[0.429928,13.988733],[0.295646,14.444235],[0.374892,14.928908],[1.015783,14.968182],[1.385528,15.323561],[2.749993,15.409525],[3.638259,15.56812],[3.723422,16.184284],[4.27021,16.852227],[4.267419,19.155265],[5.677566,19.601207],[8.572893,21.565661],[11.999506,23.471668],[13.581425,23.040506],[14.143871,22.491289],[14.8513,22.86295]]]}},{"type":"Feature","properties":{"ADMIN":"Nigeria","NAME_EN":"Nigeria","CONTINENT":"Africa","ADM0_A3":"NGA","ISO_A3":"NGA","NAME":"Nigeria","ISO_A2":"NG","NAME_ZH":"尼日利亚"},"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[2.749063,7.870734],[2.723793,8.506845],[2.912308,9.137608],[3.220352,9.444153],[3.705438,10.06321],[3.60007,10.332186],[3.797112,10.734746],[3.572216,11.327939],[3.61118,11.660167],[3.680634,12.552903],[3.967283,12.956109],[4.107946,13.531216],[4.368344,13.747482],[5.443058,13.865924],[6.445426,13.492768],[6.820442,13.115091],[7.330747,13.098038],[7.804671,13.343527],[9.014933,12.826659],[9.524928,12.851102],[10.114814,13.277252],[10.701032,13.246918],[10.989593,13.387323],[11.527803,13.32898],[12.302071,13.037189],[13.083987,13.596147],[13.318702,13.556356],[13.995353,12.461565],[14.181336,12.483657],[14.577178,12.085361],[14.468192,11.904752],[14.415379,11.572369],[13.57295,10.798566],[13.308676,10.160362],[13.1676,9.640626],[12.955468,9.417772],[12.753672,8.717763],[12.218872,8.305824],[12.063946,7.799808],[11.839309,7.397042],[11.745774,6.981383],[11.058788,6.644427],[10.497375,7.055358],[10.118277,7.03877],[9.522706,6.453482],[9.233163,6.444491],[8.757533,5.479666],[8.500288,4.771983],[7.462108,4.412108],[7.082596,4.464689],[6.698072,4.240594],[5.898173,4.262453],[5.362805,4.887971],[5.033574,5.611802],[4.325607,6.270651],[3.57418,6.2583],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"ADMIN":"Cameroon","NAME_EN":"Cameroon","CONTINENT":"Africa","ADM0_A3":"CMR","ISO_A3":"CMR","NAME":"Cameroon","ISO_A2":"CM","NAME_ZH":"喀麦隆"},"geometry":{"type":"Polygon","coordinates":[[[14.495787,12.859396],[14.89336,12.21905],[14.960152,11.555574],[14.923565,10.891325],[15.467873,9.982337],[14.909354,9.992129],[14.627201,9.920919],[14.171466,10.021378],[13.954218,9.549495],[14.544467,8.965861],[14.979996,8.796104],[15.120866,8.38215],[15.436092,7.692812],[15.27946,7.421925],[14.776545,6.408498],[14.53656,6.226959],[14.459407,5.451761],[14.558936,5.030598],[14.478372,4.732605],[14.950953,4.210389],[15.03622,3.851367],[15.405396,3.335301],[15.862732,3.013537],[15.907381,2.557389],[16.012852,2.26764],[15.940919,1.727673],[15.146342,1.964015],[14.337813,2.227875],[13.075822,2.267097],[12.951334,2.321616],[12.35938,2.192812],[11.751665,2.326758],[11.276449,2.261051],[9.649158,2.283866],[9.795196,3.073404],[9.404367,3.734527],[8.948116,3.904129],[8.744924,4.352215],[8.488816,4.495617],[8.500288,4.771983],[8.757533,5.479666],[9.233163,6.444491],[9.522706,6.453482],[10.118277,7.03877],[10.497375,7.055358],[11.058788,6.644427],[11.745774,6.981383],[11.839309,7.397042],[12.063946,7.799808],[12.218872,8.305824],[12.753672,8.717763],[12.955468,9.417772],[13.1676,9.640626],[13.308676,10.160362],[13.57295,10.798566],[14.415379,11.572369],[14.468192,11.904752],[14.577178,12.085361],[14.181336,12.483657],[14.213531,12.802035],[14.495787,12.859396]]]}},{"type":"Feature","properties":{"ADMIN":"Togo","NAME_EN":"Togo","CONTINENT":"Africa","ADM0_A3":"TGO","ISO_A3":"TGO","NAME":"Togo","ISO_A2":"TG","NAME_ZH":"多哥"},"geometry":{"type":"Polygon","coordinates":[[[0.899563,10.997339],[0.772336,10.470808],[1.077795,10.175607],[1.425061,9.825395],[1.463043,9.334624],[1.664478,9.12859],[1.618951,6.832038],[1.865241,6.142158],[1.060122,5.928837],[0.836931,6.279979],[0.570384,6.914359],[0.490957,7.411744],[0.712029,8.312465],[0.461192,8.677223],[0.365901,9.465004],[0.36758,10.191213],[-0.049785,10.706918],[0.023803,11.018682],[0.899563,10.997339]]]}},{"type":"Feature","properties":{"ADMIN":"Ghana","NAME_EN":"Ghana","CONTINENT":"Africa","ADM0_A3":"GHA","ISO_A3":"GHA","NAME":"Ghana","ISO_A2":"GH","NAME_ZH":"加纳"},"geometry":{"type":"Polygon","coordinates":[[[0.023803,11.018682],[-0.049785,10.706918],[0.36758,10.191213],[0.365901,9.465004],[0.461192,8.677223],[0.712029,8.312465],[0.490957,7.411744],[0.570384,6.914359],[0.836931,6.279979],[1.060122,5.928837],[-0.507638,5.343473],[-1.063625,5.000548],[-1.964707,4.710462],[-2.856125,4.994476],[-2.810701,5.389051],[-3.24437,6.250472],[-2.983585,7.379705],[-2.56219,8.219628],[-2.827496,9.642461],[-2.963896,10.395335],[-2.940409,10.96269],[-1.203358,11.009819],[-0.761576,10.93693],[-0.438702,11.098341],[0.023803,11.018682]]]}},{"type":"Feature","properties":{"ADMIN":"Ivory Coast","NAME_EN":"Ivory Coast","CONTINENT":"Africa","ADM0_A3":"CIV","ISO_A3":"CIV","NAME":"Côte d'Ivoire","ISO_A2":"CI","NAME_ZH":"科特迪瓦"},"geometry":{"type":"Polygon","coordinates":[[[-8.029944,10.206535],[-7.89959,10.297382],[-7.622759,10.147236],[-6.850507,10.138994],[-6.666461,10.430811],[-6.493965,10.411303],[-6.205223,10.524061],[-6.050452,10.096361],[-5.816926,10.222555],[-5.404342,10.370737],[-4.954653,10.152714],[-4.779884,9.821985],[-4.330247,9.610835],[-3.980449,9.862344],[-3.511899,9.900326],[-2.827496,9.642461],[-2.56219,8.219628],[-2.983585,7.379705],[-3.24437,6.250472],[-2.810701,5.389051],[-2.856125,4.994476],[-3.311084,4.984296],[-4.00882,5.179813],[-4.649917,5.168264],[-5.834496,4.993701],[-6.528769,4.705088],[-7.518941,4.338288],[-7.712159,4.364566],[-7.635368,5.188159],[-7.539715,5.313345],[-7.570153,5.707352],[-7.993693,6.12619],[-8.311348,6.193033],[-8.60288,6.467564],[-8.385452,6.911801],[-8.485446,7.395208],[-8.439298,7.686043],[-8.280703,7.68718],[-8.221792,8.123329],[-8.299049,8.316444],[-8.203499,8.455453],[-7.8321,8.575704],[-8.079114,9.376224],[-8.309616,9.789532],[-8.229337,10.12902],[-8.029944,10.206535]]]}},{"type":"Feature","properties":{"ADMIN":"Guinea","NAME_EN":"Guinea","CONTINENT":"Africa","ADM0_A3":"GIN","ISO_A3":"GIN","NAME":"Guinea","ISO_A2":"GN","NAME_ZH":"几内亚"},"geometry":{"type":"Polygon","coordinates":[[[-13.700476,12.586183],[-13.217818,12.575874],[-12.499051,12.33209],[-12.278599,12.35444],[-12.203565,12.465648],[-11.658301,12.386583],[-11.513943,12.442988],[-11.456169,12.076834],[-11.297574,12.077971],[-11.036556,12.211245],[-10.87083,12.177887],[-10.593224,11.923975],[-10.165214,11.844084],[-9.890993,12.060479],[-9.567912,12.194243],[-9.327616,12.334286],[-9.127474,12.30806],[-8.905265,12.088358],[-8.786099,11.812561],[-8.376305,11.393646],[-8.581305,11.136246],[-8.620321,10.810891],[-8.407311,10.909257],[-8.282357,10.792597],[-8.335377,10.494812],[-8.029944,10.206535],[-8.229337,10.12902],[-8.309616,9.789532],[-8.079114,9.376224],[-7.8321,8.575704],[-8.203499,8.455453],[-8.299049,8.316444],[-8.221792,8.123329],[-8.280703,7.68718],[-8.439298,7.686043],[-8.722124,7.711674],[-8.926065,7.309037],[-9.208786,7.313921],[-9.403348,7.526905],[-9.33728,7.928534],[-9.755342,8.541055],[-10.016567,8.428504],[-10.230094,8.406206],[-10.505477,8.348896],[-10.494315,8.715541],[-10.65477,8.977178],[-10.622395,9.26791],[-10.839152,9.688246],[-11.117481,10.045873],[-11.917277,10.046984],[-12.150338,9.858572],[-12.425929,9.835834],[-12.596719,9.620188],[-12.711958,9.342712],[-13.24655,8.903049],[-13.685154,9.494744],[-14.074045,9.886167],[-14.330076,10.01572],[-14.579699,10.214467],[-14.693232,10.656301],[-14.839554,10.876572],[-15.130311,11.040412],[-14.685687,11.527824],[-14.382192,11.509272],[-14.121406,11.677117],[-13.9008,11.678719],[-13.743161,11.811269],[-13.828272,12.142644],[-13.718744,12.247186],[-13.700476,12.586183]]]}},{"type":"Feature","properties":{"ADMIN":"Guinea-Bissau","NAME_EN":"Guinea-Bissau","CONTINENT":"Africa","ADM0_A3":"GNB","ISO_A3":"GNB","NAME":"Guinea-Bissau","ISO_A2":"GW","NAME_ZH":"几内亚比绍"},"geometry":{"type":"Polygon","coordinates":[[[-16.677452,12.384852],[-16.147717,12.547762],[-15.816574,12.515567],[-15.548477,12.62817],[-13.700476,12.586183],[-13.718744,12.247186],[-13.828272,12.142644],[-13.743161,11.811269],[-13.9008,11.678719],[-14.121406,11.677117],[-14.382192,11.509272],[-14.685687,11.527824],[-15.130311,11.040412],[-15.66418,11.458474],[-16.085214,11.524594],[-16.314787,11.806515],[-16.308947,11.958702],[-16.613838,12.170911],[-16.677452,12.384852]]]}},{"type":"Feature","properties":{"ADMIN":"Liberia","NAME_EN":"Liberia","CONTINENT":"Africa","ADM0_A3":"LBR","ISO_A3":"LBR","NAME":"Liberia","ISO_A2":"LR","NAME_ZH":"利比里亚"},"geometry":{"type":"Polygon","coordinates":[[[-8.439298,7.686043],[-8.485446,7.395208],[-8.385452,6.911801],[-8.60288,6.467564],[-8.311348,6.193033],[-7.993693,6.12619],[-7.570153,5.707352],[-7.539715,5.313345],[-7.635368,5.188159],[-7.712159,4.364566],[-7.974107,4.355755],[-9.004794,4.832419],[-9.91342,5.593561],[-10.765384,6.140711],[-11.438779,6.785917],[-11.199802,7.105846],[-11.146704,7.396706],[-10.695595,7.939464],[-10.230094,8.406206],[-10.016567,8.428504],[-9.755342,8.541055],[-9.33728,7.928534],[-9.403348,7.526905],[-9.208786,7.313921],[-8.926065,7.309037],[-8.722124,7.711674],[-8.439298,7.686043]]]}},{"type":"Feature","properties":{"ADMIN":"Sierra Leone","NAME_EN":"Sierra Leone","CONTINENT":"Africa","ADM0_A3":"SLE","ISO_A3":"SLE","NAME":"Sierra Leone","ISO_A2":"SL","NAME_ZH":"塞拉利昂"},"geometry":{"type":"Polygon","coordinates":[[[-13.24655,8.903049],[-12.711958,9.342712],[-12.596719,9.620188],[-12.425929,9.835834],[-12.150338,9.858572],[-11.917277,10.046984],[-11.117481,10.045873],[-10.839152,9.688246],[-10.622395,9.26791],[-10.65477,8.977178],[-10.494315,8.715541],[-10.505477,8.348896],[-10.230094,8.406206],[-10.695595,7.939464],[-11.146704,7.396706],[-11.199802,7.105846],[-11.438779,6.785917],[-11.708195,6.860098],[-12.428099,7.262942],[-12.949049,7.798646],[-13.124025,8.163946],[-13.24655,8.903049]]]}},{"type":"Feature","properties":{"ADMIN":"Burkina Faso","NAME_EN":"Burkina Faso","CONTINENT":"Africa","ADM0_A3":"BFA","ISO_A3":"BFA","NAME":"Burkina Faso","ISO_A2":"BF","NAME_ZH":"布基纳法索"},"geometry":{"type":"Polygon","coordinates":[[[-5.404342,10.370737],[-5.470565,10.95127],[-5.197843,11.375146],[-5.220942,11.713859],[-4.427166,12.542646],[-4.280405,13.228444],[-4.006391,13.472485],[-3.522803,13.337662],[-3.103707,13.541267],[-2.967694,13.79815],[-2.191825,14.246418],[-2.001035,14.559008],[-1.066363,14.973815],[-0.515854,15.116158],[-0.266257,14.924309],[0.374892,14.928908],[0.295646,14.444235],[0.429928,13.988733],[0.993046,13.33575],[1.024103,12.851826],[2.177108,12.625018],[2.154474,11.94015],[1.935986,11.64115],[1.447178,11.547719],[1.24347,11.110511],[0.899563,10.997339],[0.023803,11.018682],[-0.438702,11.098341],[-0.761576,10.93693],[-1.203358,11.009819],[-2.940409,10.96269],[-2.963896,10.395335],[-2.827496,9.642461],[-3.511899,9.900326],[-3.980449,9.862344],[-4.330247,9.610835],[-4.779884,9.821985],[-4.954653,10.152714],[-5.404342,10.370737]]]}},{"type":"Feature","properties":{"ADMIN":"Central African Republic","NAME_EN":"Central African Republic","CONTINENT":"Africa","ADM0_A3":"CAF","ISO_A3":"CAF","NAME":"Central African Rep.","ISO_A2":"CF","NAME_ZH":"中非共和国"},"geometry":{"type":"Polygon","coordinates":[[[27.374226,5.233944],[27.044065,5.127853],[26.402761,5.150875],[25.650455,5.256088],[25.278798,5.170408],[25.128833,4.927245],[24.805029,4.897247],[24.410531,5.108784],[23.297214,4.609693],[22.84148,4.710126],[22.704124,4.633051],[22.405124,4.02916],[21.659123,4.224342],[20.927591,4.322786],[20.290679,4.691678],[19.467784,5.031528],[18.932312,4.709506],[18.542982,4.201785],[18.453065,3.504386],[17.8099,3.560196],[17.133042,3.728197],[16.537058,3.198255],[16.012852,2.26764],[15.907381,2.557389],[15.862732,3.013537],[15.405396,3.335301],[15.03622,3.851367],[14.950953,4.210389],[14.478372,4.732605],[14.558936,5.030598],[14.459407,5.451761],[14.53656,6.226959],[14.776545,6.408498],[15.27946,7.421925],[16.106232,7.497088],[16.290562,7.754307],[16.456185,7.734774],[16.705988,7.508328],[17.96493,7.890914],[18.389555,8.281304],[18.911022,8.630895],[18.81201,8.982915],[19.094008,9.074847],[20.059685,9.012706],[21.000868,9.475985],[21.723822,10.567056],[22.231129,10.971889],[22.864165,11.142395],[22.977544,10.714463],[23.554304,10.089255],[23.55725,9.681218],[23.394779,9.265068],[23.459013,8.954286],[23.805813,8.666319],[24.567369,8.229188],[25.114932,7.825104],[25.124131,7.500085],[25.796648,6.979316],[26.213418,6.546603],[26.465909,5.946717],[27.213409,5.550953],[27.374226,5.233944]]]}},{"type":"Feature","properties":{"ADMIN":"Republic of the Congo","NAME_EN":"Republic of the Congo","CONTINENT":"Africa","ADM0_A3":"COG","ISO_A3":"COG","NAME":"Congo","ISO_A2":"CG","NAME_ZH":"刚果共和国"},"geometry":{"type":"Polygon","coordinates":[[[18.453065,3.504386],[18.393792,2.900443],[18.094276,2.365722],[17.898835,1.741832],[17.774192,0.855659],[17.82654,0.288923],[17.663553,-0.058084],[17.638645,-0.424832],[17.523716,-0.74383],[16.865307,-1.225816],[16.407092,-1.740927],[15.972803,-2.712392],[16.00629,-3.535133],[15.75354,-3.855165],[15.170992,-4.343507],[14.582604,-4.970239],[14.209035,-4.793092],[14.144956,-4.510009],[13.600235,-4.500138],[13.25824,-4.882957],[12.995517,-4.781103],[12.62076,-4.438023],[12.318608,-4.60623],[11.914963,-5.037987],[11.093773,-3.978827],[11.855122,-3.426871],[11.478039,-2.765619],[11.820964,-2.514161],[12.495703,-2.391688],[12.575284,-1.948511],[13.109619,-2.42874],[13.992407,-2.470805],[14.29921,-1.998276],[14.425456,-1.333407],[14.316418,-0.552627],[13.843321,0.038758],[14.276266,1.19693],[14.026669,1.395677],[13.282631,1.314184],[13.003114,1.830896],[13.075822,2.267097],[14.337813,2.227875],[15.146342,1.964015],[15.940919,1.727673],[16.012852,2.26764],[16.537058,3.198255],[17.133042,3.728197],[17.8099,3.560196],[18.453065,3.504386]]]}},{"type":"Feature","properties":{"ADMIN":"Gabon","NAME_EN":"Gabon","CONTINENT":"Africa","ADM0_A3":"GAB","ISO_A3":"GAB","NAME":"Gabon","ISO_A2":"GA","NAME_ZH":"加蓬"},"geometry":{"type":"Polygon","coordinates":[[[11.276449,2.261051],[11.751665,2.326758],[12.35938,2.192812],[12.951334,2.321616],[13.075822,2.267097],[13.003114,1.830896],[13.282631,1.314184],[14.026669,1.395677],[14.276266,1.19693],[13.843321,0.038758],[14.316418,-0.552627],[14.425456,-1.333407],[14.29921,-1.998276],[13.992407,-2.470805],[13.109619,-2.42874],[12.575284,-1.948511],[12.495703,-2.391688],[11.820964,-2.514161],[11.478039,-2.765619],[11.855122,-3.426871],[11.093773,-3.978827],[10.066135,-2.969483],[9.405245,-2.144313],[8.797996,-1.111301],[8.830087,-0.779074],[9.04842,-0.459351],[9.291351,0.268666],[9.492889,1.01012],[9.830284,1.067894],[11.285079,1.057662],[11.276449,2.261051]]]}},{"type":"Feature","properties":{"ADMIN":"Equatorial Guinea","NAME_EN":"Equatorial Guinea","CONTINENT":"Africa","ADM0_A3":"GNQ","ISO_A3":"GNQ","NAME":"Eq. Guinea","ISO_A2":"GQ","NAME_ZH":"赤道几内亚"},"geometry":{"type":"Polygon","coordinates":[[[9.649158,2.283866],[11.276449,2.261051],[11.285079,1.057662],[9.830284,1.067894],[9.492889,1.01012],[9.305613,1.160911],[9.649158,2.283866]]]}},{"type":"Feature","properties":{"ADMIN":"Zambia","NAME_EN":"Zambia","CONTINENT":"Africa","ADM0_A3":"ZMB","ISO_A3":"ZMB","NAME":"Zambia","ISO_A2":"ZM","NAME_ZH":"赞比亚"},"geometry":{"type":"Polygon","coordinates":[[[30.74001,-8.340006],[31.157751,-8.594579],[31.556348,-8.762049],[32.191865,-8.930359],[32.759375,-9.230599],[33.231388,-9.676722],[33.485688,-10.525559],[33.31531,-10.79655],[33.114289,-11.607198],[33.306422,-12.435778],[32.991764,-12.783871],[32.688165,-13.712858],[33.214025,-13.97186],[30.179481,-14.796099],[30.274256,-15.507787],[29.516834,-15.644678],[28.947463,-16.043051],[28.825869,-16.389749],[28.467906,-16.4684],[27.598243,-17.290831],[27.044427,-17.938026],[26.706773,-17.961229],[26.381935,-17.846042],[25.264226,-17.73654],[25.084443,-17.661816],[25.07695,-17.578823],[24.682349,-17.353411],[24.033862,-17.295843],[23.215048,-17.523116],[22.562478,-16.898451],[21.887843,-16.08031],[21.933886,-12.898437],[24.016137,-12.911046],[23.930922,-12.565848],[24.079905,-12.191297],[23.904154,-11.722282],[24.017894,-11.237298],[23.912215,-10.926826],[24.257155,-10.951993],[24.314516,-11.262826],[24.78317,-11.238694],[25.418118,-11.330936],[25.75231,-11.784965],[26.553088,-11.92444],[27.16442,-11.608748],[27.388799,-12.132747],[28.155109,-12.272481],[28.523562,-12.698604],[28.934286,-13.248958],[29.699614,-13.257227],[29.616001,-12.178895],[29.341548,-12.360744],[28.642417,-11.971569],[28.372253,-11.793647],[28.49607,-10.789884],[28.673682,-9.605925],[28.449871,-9.164918],[28.734867,-8.526559],[29.002912,-8.407032],[30.346086,-8.238257],[30.74001,-8.340006]]]}},{"type":"Feature","properties":{"ADMIN":"Malawi","NAME_EN":"Malawi","CONTINENT":"Africa","ADM0_A3":"MWI","ISO_A3":"MWI","NAME":"Malawi","ISO_A2":"MW","NAME_ZH":"马拉维"},"geometry":{"type":"Polygon","coordinates":[[[32.759375,-9.230599],[33.73972,-9.41715],[33.940838,-9.693674],[34.28,-10.16],[34.559989,-11.52002],[34.280006,-12.280025],[34.559989,-13.579998],[34.907151,-13.565425],[35.267956,-13.887834],[35.686845,-14.611046],[35.771905,-15.896859],[35.339063,-16.10744],[35.03381,-16.8013],[34.381292,-16.18356],[34.307291,-15.478641],[34.517666,-15.013709],[34.459633,-14.61301],[34.064825,-14.35995],[33.7897,-14.451831],[33.214025,-13.97186],[32.688165,-13.712858],[32.991764,-12.783871],[33.306422,-12.435778],[33.114289,-11.607198],[33.31531,-10.79655],[33.485688,-10.525559],[33.231388,-9.676722],[32.759375,-9.230599]]]}},{"type":"Feature","properties":{"ADMIN":"Mozambique","NAME_EN":"Mozambique","CONTINENT":"Africa","ADM0_A3":"MOZ","ISO_A3":"MOZ","NAME":"Mozambique","ISO_A2":"MZ","NAME_ZH":"莫桑比克"},"geometry":{"type":"Polygon","coordinates":[[[34.559989,-11.52002],[35.312398,-11.439146],[36.514082,-11.720938],[36.775151,-11.594537],[37.47129,-11.56876],[37.82764,-11.26879],[38.427557,-11.285202],[39.521,-10.89688],[40.31659,-10.3171],[40.316586,-10.317098],[40.316589,-10.317096],[40.478387,-10.765441],[40.437253,-11.761711],[40.560811,-12.639177],[40.59962,-14.201975],[40.775475,-14.691764],[40.477251,-15.406294],[40.089264,-16.100774],[39.452559,-16.720891],[38.538351,-17.101023],[37.411133,-17.586368],[36.281279,-18.659688],[35.896497,-18.84226],[35.1984,-19.552811],[34.786383,-19.784012],[34.701893,-20.497043],[35.176127,-21.254361],[35.373428,-21.840837],[35.385848,-22.14],[35.562546,-22.09],[35.533935,-23.070788],[35.371774,-23.535359],[35.60747,-23.706563],[35.458746,-24.12261],[35.040735,-24.478351],[34.215824,-24.816314],[33.01321,-25.357573],[32.574632,-25.727318],[32.660363,-26.148584],[32.915955,-26.215867],[32.83012,-26.742192],[32.071665,-26.73382],[31.985779,-26.29178],[31.837778,-25.843332],[31.752408,-25.484284],[31.930589,-24.369417],[31.670398,-23.658969],[31.191409,-22.25151],[32.244988,-21.116489],[32.508693,-20.395292],[32.659743,-20.30429],[32.772708,-19.715592],[32.611994,-19.419383],[32.654886,-18.67209],[32.849861,-17.979057],[32.847639,-16.713398],[32.328239,-16.392074],[31.852041,-16.319417],[31.636498,-16.07199],[31.173064,-15.860944],[30.338955,-15.880839],[30.274256,-15.507787],[30.179481,-14.796099],[33.214025,-13.97186],[33.7897,-14.451831],[34.064825,-14.35995],[34.459633,-14.61301],[34.517666,-15.013709],[34.307291,-15.478641],[34.381292,-16.18356],[35.03381,-16.8013],[35.339063,-16.10744],[35.771905,-15.896859],[35.686845,-14.611046],[35.267956,-13.887834],[34.907151,-13.565425],[34.559989,-13.579998],[34.280006,-12.280025],[34.559989,-11.52002]]]}},{"type":"Feature","properties":{"ADMIN":"eSwatini","NAME_EN":"Eswatini","CONTINENT":"Africa","ADM0_A3":"SWZ","ISO_A3":"SWZ","NAME":"eSwatini","ISO_A2":"SZ","NAME_ZH":"斯威士兰"},"geometry":{"type":"Polygon","coordinates":[[[32.071665,-26.73382],[31.86806,-27.177927],[31.282773,-27.285879],[30.685962,-26.743845],[30.676609,-26.398078],[30.949667,-26.022649],[31.04408,-25.731452],[31.333158,-25.660191],[31.837778,-25.843332],[31.985779,-26.29178],[32.071665,-26.73382]]]}},{"type":"Feature","properties":{"ADMIN":"Angola","NAME_EN":"Angola","CONTINENT":"Africa","ADM0_A3":"AGO","ISO_A3":"AGO","NAME":"Angola","ISO_A2":"AO","NAME_ZH":"安哥拉"},"geometry":{"type":"MultiPolygon","coordinates":[[[[12.995517,-4.781103],[12.631612,-4.991271],[12.468004,-5.248362],[12.436688,-5.684304],[12.182337,-5.789931],[11.914963,-5.037987],[12.318608,-4.60623],[12.62076,-4.438023],[12.995517,-4.781103]]],[[[12.322432,-6.100092],[12.735171,-5.965682],[13.024869,-5.984389],[13.375597,-5.864241],[16.326528,-5.87747],[16.57318,-6.622645],[16.860191,-7.222298],[17.089996,-7.545689],[17.47297,-8.068551],[18.134222,-7.987678],[18.464176,-7.847014],[19.016752,-7.988246],[19.166613,-7.738184],[19.417502,-7.155429],[20.037723,-7.116361],[20.091622,-6.94309],[20.601823,-6.939318],[20.514748,-7.299606],[21.728111,-7.290872],[21.746456,-7.920085],[21.949131,-8.305901],[21.801801,-8.908707],[21.875182,-9.523708],[22.208753,-9.894796],[22.155268,-11.084801],[22.402798,-10.993075],[22.837345,-11.017622],[23.456791,-10.867863],[23.912215,-10.926826],[24.017894,-11.237298],[23.904154,-11.722282],[24.079905,-12.191297],[23.930922,-12.565848],[24.016137,-12.911046],[21.933886,-12.898437],[21.887843,-16.08031],[22.562478,-16.898451],[23.215048,-17.523116],[21.377176,-17.930636],[18.956187,-17.789095],[18.263309,-17.309951],[14.209707,-17.353101],[14.058501,-17.423381],[13.462362,-16.971212],[12.814081,-16.941343],[12.215461,-17.111668],[11.734199,-17.301889],[11.640096,-16.673142],[11.778537,-15.793816],[12.123581,-14.878316],[12.175619,-14.449144],[12.500095,-13.5477],[12.738479,-13.137906],[13.312914,-12.48363],[13.633721,-12.038645],[13.738728,-11.297863],[13.686379,-10.731076],[13.387328,-10.373578],[13.120988,-9.766897],[12.87537,-9.166934],[12.929061,-8.959091],[13.236433,-8.562629],[12.93304,-7.596539],[12.728298,-6.927122],[12.227347,-6.294448],[12.322432,-6.100092]]]]}},{"type":"Feature","properties":{"ADMIN":"Burundi","NAME_EN":"Burundi","CONTINENT":"Africa","ADM0_A3":"BDI","ISO_A3":"BDI","NAME":"Burundi","ISO_A2":"BI","NAME_ZH":"布隆迪"},"geometry":{"type":"Polygon","coordinates":[[[30.469674,-2.413855],[30.52766,-2.80762],[30.74301,-3.03431],[30.75224,-3.35931],[30.50554,-3.56858],[30.11632,-4.09012],[29.753512,-4.452389],[29.339998,-4.499983],[29.276384,-3.293907],[29.024926,-2.839258],[29.632176,-2.917858],[29.938359,-2.348487],[30.469674,-2.413855]]]}},{"type":"Feature","properties":{"ADMIN":"Palestine","NAME_EN":"Palestine","CONTINENT":"Asia","ADM0_A3":"PSX","ISO_A3":"PSE","NAME":"Palestine","ISO_A2":"PS","NAME_ZH":"巴勒斯坦"},"geometry":{"type":"MultiPolygon","coordinates":[[[[35.397561,31.489086],[34.927408,31.353435],[34.970507,31.616778],[35.225892,31.754341],[34.974641,31.866582],[35.18393,32.532511],[35.545665,32.393992],[35.545252,31.782505],[35.397561,31.489086]]],[[[34.2296,31.2208],[34.222,31.274],[34.222,31.38],[34.222,31.48],[34.232,31.535],[34.259,31.578],[34.308,31.595],[34.488,31.595],[34.556,31.575],[34.56,31.543],[34.55,31.396],[34.54,31.242],[34.456,31.221],[34.2296,31.2208]]]]}},{"type":"Feature","properties":{"ADMIN":"Israel","NAME_EN":"Israel","CONTINENT":"Asia","ADM0_A3":"ISR","ISO_A3":"ISR","NAME":"Israel","ISO_A2":"IL","NAME_ZH":"以色列"},"geometry":{"type":"Polygon","coordinates":[[[35.719918,32.709192],[35.545665,32.393992],[35.18393,32.532511],[34.974641,31.866582],[35.225892,31.754341],[34.970507,31.616778],[34.927408,31.353435],[35.397561,31.489086],[35.420918,31.100066],[34.922603,29.501326],[34.823243,29.761081],[34.26544,31.21936],[34.265435,31.219357],[34.265433,31.219361],[34.556372,31.548824],[34.488107,31.605539],[34.752587,32.072926],[34.955417,32.827376],[35.098457,33.080539],[35.126053,33.0909],[35.460709,33.08904],[35.552797,33.264275],[35.821101,33.277426],[35.836397,32.868123],[35.700798,32.716014],[35.719918,32.709192]]]}},{"type":"Feature","properties":{"ADMIN":"Lebanon","NAME_EN":"Lebanon","CONTINENT":"Asia","ADM0_A3":"LBN","ISO_A3":"LBN","NAME":"Lebanon","ISO_A2":"LB","NAME_ZH":"黎巴嫩"},"geometry":{"type":"Polygon","coordinates":[[[35.821101,33.277426],[35.552797,33.264275],[35.460709,33.08904],[35.126053,33.0909],[35.482207,33.90545],[35.979592,34.610058],[35.998403,34.644914],[36.448194,34.593935],[36.61175,34.201789],[36.06646,33.824912],[35.821101,33.277426]]]}},{"type":"Feature","properties":{"ADMIN":"Madagascar","NAME_EN":"Madagascar","CONTINENT":"Africa","ADM0_A3":"MDG","ISO_A3":"MDG","NAME":"Madagascar","ISO_A2":"MG","NAME_ZH":"马达加斯加"},"geometry":{"type":"Polygon","coordinates":[[[49.543519,-12.469833],[49.808981,-12.895285],[50.056511,-13.555761],[50.217431,-14.758789],[50.476537,-15.226512],[50.377111,-15.706069],[50.200275,-16.000263],[49.860606,-15.414253],[49.672607,-15.710204],[49.863344,-16.451037],[49.774564,-16.875042],[49.498612,-17.106036],[49.435619,-17.953064],[49.041792,-19.118781],[48.548541,-20.496888],[47.930749,-22.391501],[47.547723,-23.781959],[47.095761,-24.94163],[46.282478,-25.178463],[45.409508,-25.601434],[44.833574,-25.346101],[44.03972,-24.988345],[43.763768,-24.460677],[43.697778,-23.574116],[43.345654,-22.776904],[43.254187,-22.057413],[43.433298,-21.336475],[43.893683,-21.163307],[43.89637,-20.830459],[44.374325,-20.072366],[44.464397,-19.435454],[44.232422,-18.961995],[44.042976,-18.331387],[43.963084,-17.409945],[44.312469,-16.850496],[44.446517,-16.216219],[44.944937,-16.179374],[45.502732,-15.974373],[45.872994,-15.793454],[46.312243,-15.780018],[46.882183,-15.210182],[47.70513,-14.594303],[48.005215,-14.091233],[47.869047,-13.663869],[48.293828,-13.784068],[48.84506,-13.089175],[48.863509,-12.487868],[49.194651,-12.040557],[49.543519,-12.469833]]]}},{"type":"Feature","properties":{"ADMIN":"Gambia","NAME_EN":"The Gambia","CONTINENT":"Africa","ADM0_A3":"GMB","ISO_A3":"GMB","NAME":"Gambia","ISO_A2":"GM","NAME_ZH":"冈比亚"},"geometry":{"type":"Polygon","coordinates":[[[-16.713729,13.594959],[-15.624596,13.623587],[-15.39877,13.860369],[-15.081735,13.876492],[-14.687031,13.630357],[-14.376714,13.62568],[-14.046992,13.794068],[-13.844963,13.505042],[-14.277702,13.280585],[-14.712197,13.298207],[-15.141163,13.509512],[-15.511813,13.27857],[-15.691001,13.270353],[-15.931296,13.130284],[-16.841525,13.151394],[-16.713729,13.594959]]]}},{"type":"Feature","properties":{"ADMIN":"Tunisia","NAME_EN":"Tunisia","CONTINENT":"Africa","ADM0_A3":"TUN","ISO_A3":"TUN","NAME":"Tunisia","ISO_A2":"TN","NAME_ZH":"突尼斯"},"geometry":{"type":"Polygon","coordinates":[[[9.48214,30.307556],[9.055603,32.102692],[8.439103,32.506285],[8.430473,32.748337],[7.612642,33.344115],[7.524482,34.097376],[8.140981,34.655146],[8.376368,35.479876],[8.217824,36.433177],[8.420964,36.946427],[9.509994,37.349994],[10.210002,37.230002],[10.18065,36.724038],[11.028867,37.092103],[11.100026,36.899996],[10.600005,36.41],[10.593287,35.947444],[10.939519,35.698984],[10.807847,34.833507],[10.149593,34.330773],[10.339659,33.785742],[10.856836,33.76874],[11.108501,33.293343],[11.488787,33.136996],[11.432253,32.368903],[10.94479,32.081815],[10.636901,31.761421],[9.950225,31.37607],[10.056575,30.961831],[9.970017,30.539325],[9.48214,30.307556]]]}},{"type":"Feature","properties":{"ADMIN":"Algeria","NAME_EN":"Algeria","CONTINENT":"Africa","ADM0_A3":"DZA","ISO_A3":"DZA","NAME":"Algeria","ISO_A2":"DZ","NAME_ZH":"阿尔及利亚"},"geometry":{"type":"Polygon","coordinates":[[[-8.6844,27.395744],[-8.665124,27.589479],[-8.66559,27.656426],[-8.674116,28.841289],[-7.059228,29.579228],[-6.060632,29.7317],[-5.242129,30.000443],[-4.859646,30.501188],[-3.690441,30.896952],[-3.647498,31.637294],[-3.06898,31.724498],[-2.616605,32.094346],[-1.307899,32.262889],[-1.124551,32.651522],[-1.388049,32.864015],[-1.733455,33.919713],[-1.792986,34.527919],[-2.169914,35.168396],[-1.208603,35.714849],[-0.127454,35.888662],[0.503877,36.301273],[1.466919,36.605647],[3.161699,36.783905],[4.815758,36.865037],[5.32012,36.716519],[6.26182,37.110655],[7.330385,37.118381],[7.737078,36.885708],[8.420964,36.946427],[8.217824,36.433177],[8.376368,35.479876],[8.140981,34.655146],[7.524482,34.097376],[7.612642,33.344115],[8.430473,32.748337],[8.439103,32.506285],[9.055603,32.102692],[9.48214,30.307556],[9.805634,29.424638],[9.859998,28.95999],[9.683885,28.144174],[9.756128,27.688259],[9.629056,27.140953],[9.716286,26.512206],[9.319411,26.094325],[9.910693,25.365455],[9.948261,24.936954],[10.303847,24.379313],[10.771364,24.562532],[11.560669,24.097909],[11.999506,23.471668],[8.572893,21.565661],[5.677566,19.601207],[4.267419,19.155265],[3.158133,19.057364],[3.146661,19.693579],[2.683588,19.85623],[2.060991,20.142233],[1.823228,20.610809],[-1.550055,22.792666],[-4.923337,24.974574],[-8.6844,27.395744]]]}},{"type":"Feature","properties":{"ADMIN":"Jordan","NAME_EN":"Jordan","CONTINENT":"Asia","ADM0_A3":"JOR","ISO_A3":"JOR","NAME":"Jordan","ISO_A2":"JO","NAME_ZH":"约旦"},"geometry":{"type":"Polygon","coordinates":[[[35.545665,32.393992],[35.719918,32.709192],[36.834062,32.312938],[38.792341,33.378686],[39.195468,32.161009],[39.004886,32.010217],[37.002166,31.508413],[37.998849,30.5085],[37.66812,30.338665],[37.503582,30.003776],[36.740528,29.865283],[36.501214,29.505254],[36.068941,29.197495],[34.956037,29.356555],[34.922603,29.501326],[35.420918,31.100066],[35.397561,31.489086],[35.545252,31.782505],[35.545665,32.393992]]]}},{"type":"Feature","properties":{"ADMIN":"United Arab Emirates","NAME_EN":"United Arab Emirates","CONTINENT":"Asia","ADM0_A3":"ARE","ISO_A3":"ARE","NAME":"United Arab Emirates","ISO_A2":"AE","NAME_ZH":"阿拉伯联合酋长国"},"geometry":{"type":"Polygon","coordinates":[[[51.579519,24.245497],[51.757441,24.294073],[51.794389,24.019826],[52.577081,24.177439],[53.404007,24.151317],[54.008001,24.121758],[54.693024,24.797892],[55.439025,25.439145],[56.070821,26.055464],[56.261042,25.714606],[56.396847,24.924732],[55.886233,24.920831],[55.804119,24.269604],[55.981214,24.130543],[55.528632,23.933604],[55.525841,23.524869],[55.234489,23.110993],[55.208341,22.70833],[55.006803,22.496948],[52.000733,23.001154],[51.617708,24.014219],[51.579519,24.245497]]]}},{"type":"Feature","properties":{"ADMIN":"Qatar","NAME_EN":"Qatar","CONTINENT":"Asia","ADM0_A3":"QAT","ISO_A3":"QAT","NAME":"Qatar","ISO_A2":"QA","NAME_ZH":"卡塔尔"},"geometry":{"type":"Polygon","coordinates":[[[50.810108,24.754743],[50.743911,25.482424],[51.013352,26.006992],[51.286462,26.114582],[51.589079,25.801113],[51.6067,25.21567],[51.389608,24.627386],[51.112415,24.556331],[50.810108,24.754743]]]}},{"type":"Feature","properties":{"ADMIN":"Kuwait","NAME_EN":"Kuwait","CONTINENT":"Asia","ADM0_A3":"KWT","ISO_A3":"KWT","NAME":"Kuwait","ISO_A2":"KW","NAME_ZH":"科威特"},"geometry":{"type":"Polygon","coordinates":[[[47.974519,29.975819],[48.183189,29.534477],[48.093943,29.306299],[48.416094,28.552004],[47.708851,28.526063],[47.459822,29.002519],[46.568713,29.099025],[47.302622,30.05907],[47.974519,29.975819]]]}},{"type":"Feature","properties":{"ADMIN":"Iraq","NAME_EN":"Iraq","CONTINENT":"Asia","ADM0_A3":"IRQ","ISO_A3":"IRQ","NAME":"Iraq","ISO_A2":"IQ","NAME_ZH":"伊拉克"},"geometry":{"type":"Polygon","coordinates":[[[39.195468,32.161009],[38.792341,33.378686],[41.006159,34.419372],[41.383965,35.628317],[41.289707,36.358815],[41.837064,36.605854],[42.349591,37.229873],[42.779126,37.385264],[43.942259,37.256228],[44.293452,37.001514],[44.772677,37.170437],[45.420618,35.977546],[46.07634,35.677383],[46.151788,35.093259],[45.64846,34.748138],[45.416691,33.967798],[46.109362,33.017287],[47.334661,32.469155],[47.849204,31.709176],[47.685286,30.984853],[48.004698,30.985137],[48.014568,30.452457],[48.567971,29.926778],[47.974519,29.975819],[47.302622,30.05907],[46.568713,29.099025],[44.709499,29.178891],[41.889981,31.190009],[40.399994,31.889992],[39.195468,32.161009]]]}},{"type":"Feature","properties":{"ADMIN":"Oman","NAME_EN":"Oman","CONTINENT":"Asia","ADM0_A3":"OMN","ISO_A3":"OMN","NAME":"Oman","ISO_A2":"OM","NAME_ZH":"阿曼"},"geometry":{"type":"MultiPolygon","coordinates":[[[[55.208341,22.70833],[55.234489,23.110993],[55.525841,23.524869],[55.528632,23.933604],[55.981214,24.130543],[55.804119,24.269604],[55.886233,24.920831],[56.396847,24.924732],[56.84514,24.241673],[57.403453,23.878594],[58.136948,23.747931],[58.729211,23.565668],[59.180502,22.992395],[59.450098,22.660271],[59.80806,22.533612],[59.806148,22.310525],[59.442191,21.714541],[59.282408,21.433886],[58.861141,21.114035],[58.487986,20.428986],[58.034318,20.481437],[57.826373,20.243002],[57.665762,19.736005],[57.7887,19.06757],[57.694391,18.94471],[57.234264,18.947991],[56.609651,18.574267],[56.512189,18.087113],[56.283521,17.876067],[55.661492,17.884128],[55.269939,17.632309],[55.2749,17.228354],[54.791002,16.950697],[54.239253,17.044981],[53.570508,16.707663],[53.108573,16.651051],[52.782184,17.349742],[52.00001,19.000003],[54.999982,19.999994],[55.666659,22.000001],[55.208341,22.70833]]],[[[56.261042,25.714606],[56.070821,26.055464],[56.362017,26.395934],[56.485679,26.309118],[56.391421,25.895991],[56.261042,25.714606]]]]}},{"type":"Feature","properties":{"ADMIN":"Vanuatu","NAME_EN":"Vanuatu","CONTINENT":"Oceania","ADM0_A3":"VUT","ISO_A3":"VUT","NAME":"Vanuatu","ISO_A2":"VU","NAME_ZH":"瓦努阿图"},"geometry":{"type":"MultiPolygon","coordinates":[[[[167.216801,-15.891846],[167.844877,-16.466333],[167.515181,-16.59785],[167.180008,-16.159995],[167.216801,-15.891846]]],[[[166.793158,-15.668811],[166.649859,-15.392704],[166.629137,-14.626497],[167.107712,-14.93392],[167.270028,-15.740021],[167.001207,-15.614602],[166.793158,-15.668811]]]]}},{"type":"Feature","properties":{"ADMIN":"Cambodia","NAME_EN":"Cambodia","CONTINENT":"Asia","ADM0_A3":"KHM","ISO_A3":"KHM","NAME":"Cambodia","ISO_A2":"KH","NAME_ZH":"柬埔寨"},"geometry":{"type":"Polygon","coordinates":[[[102.584932,12.186595],[102.348099,13.394247],[102.988422,14.225721],[104.281418,14.416743],[105.218777,14.273212],[106.043946,13.881091],[106.496373,14.570584],[107.382727,14.202441],[107.614548,13.535531],[107.491403,12.337206],[105.810524,11.567615],[106.24967,10.961812],[105.199915,10.88931],[104.334335,10.486544],[103.49728,10.632555],[103.09069,11.153661],[102.584932,12.186595]]]}},{"type":"Feature","properties":{"ADMIN":"Thailand","NAME_EN":"Thailand","CONTINENT":"Asia","ADM0_A3":"THA","ISO_A3":"THA","NAME":"Thailand","ISO_A2":"TH","NAME_ZH":"泰国"},"geometry":{"type":"Polygon","coordinates":[[[105.218777,14.273212],[104.281418,14.416743],[102.988422,14.225721],[102.348099,13.394247],[102.584932,12.186595],[101.687158,12.64574],[100.83181,12.627085],[100.978467,13.412722],[100.097797,13.406856],[100.018733,12.307001],[99.478921,10.846367],[99.153772,9.963061],[99.222399,9.239255],[99.873832,9.207862],[100.279647,8.295153],[100.459274,7.429573],[101.017328,6.856869],[101.623079,6.740622],[102.141187,6.221636],[101.814282,5.810808],[101.154219,5.691384],[101.075516,6.204867],[100.259596,6.642825],[100.085757,6.464489],[99.690691,6.848213],[99.519642,7.343454],[98.988253,7.907993],[98.503786,8.382305],[98.339662,7.794512],[98.150009,8.350007],[98.25915,8.973923],[98.553551,9.93296],[99.038121,10.960546],[99.587286,11.892763],[99.196354,12.804748],[99.212012,13.269294],[99.097755,13.827503],[98.430819,14.622028],[98.192074,15.123703],[98.537376,15.308497],[98.903348,16.177824],[98.493761,16.837836],[97.859123,17.567946],[97.375896,18.445438],[97.797783,18.62708],[98.253724,19.708203],[98.959676,19.752981],[99.543309,20.186598],[100.115988,20.41785],[100.548881,20.109238],[100.606294,19.508344],[101.282015,19.462585],[101.035931,18.408928],[101.059548,17.512497],[102.113592,18.109102],[102.413005,17.932782],[102.998706,17.961695],[103.200192,18.309632],[103.956477,18.240954],[104.716947,17.428859],[104.779321,16.441865],[105.589039,15.570316],[105.544338,14.723934],[105.218777,14.273212]]]}},{"type":"Feature","properties":{"ADMIN":"Laos","NAME_EN":"Laos","CONTINENT":"Asia","ADM0_A3":"LAO","ISO_A3":"LAO","NAME":"Laos","ISO_A2":"LA","NAME_ZH":"老挝"},"geometry":{"type":"Polygon","coordinates":[[[107.382727,14.202441],[106.496373,14.570584],[106.043946,13.881091],[105.218777,14.273212],[105.544338,14.723934],[105.589039,15.570316],[104.779321,16.441865],[104.716947,17.428859],[103.956477,18.240954],[103.200192,18.309632],[102.998706,17.961695],[102.413005,17.932782],[102.113592,18.109102],[101.059548,17.512497],[101.035931,18.408928],[101.282015,19.462585],[100.606294,19.508344],[100.548881,20.109238],[100.115988,20.41785],[100.329101,20.786122],[101.180005,21.436573],[101.270026,21.201652],[101.80312,21.174367],[101.652018,22.318199],[102.170436,22.464753],[102.754896,21.675137],[103.203861,20.766562],[104.435,20.758733],[104.822574,19.886642],[104.183388,19.624668],[103.896532,19.265181],[105.094598,18.666975],[105.925762,17.485315],[106.556008,16.604284],[107.312706,15.908538],[107.564525,15.202173],[107.382727,14.202441]]]}},{"type":"Feature","properties":{"ADMIN":"Myanmar","NAME_EN":"Myanmar","CONTINENT":"Asia","ADM0_A3":"MMR","ISO_A3":"MMR","NAME":"Myanmar","ISO_A2":"MM","NAME_ZH":"缅甸"},"geometry":{"type":"Polygon","coordinates":[[[100.115988,20.41785],[99.543309,20.186598],[98.959676,19.752981],[98.253724,19.708203],[97.797783,18.62708],[97.375896,18.445438],[97.859123,17.567946],[98.493761,16.837836],[98.903348,16.177824],[98.537376,15.308497],[98.192074,15.123703],[98.430819,14.622028],[99.097755,13.827503],[99.212012,13.269294],[99.196354,12.804748],[99.587286,11.892763],[99.038121,10.960546],[98.553551,9.93296],[98.457174,10.675266],[98.764546,11.441292],[98.428339,12.032987],[98.509574,13.122378],[98.103604,13.64046],[97.777732,14.837286],[97.597072,16.100568],[97.16454,16.928734],[96.505769,16.427241],[95.369352,15.71439],[94.808405,15.803454],[94.188804,16.037936],[94.533486,17.27724],[94.324817,18.213514],[93.540988,19.366493],[93.663255,19.726962],[93.078278,19.855145],[92.368554,20.670883],[92.303234,21.475485],[92.652257,21.324048],[92.672721,22.041239],[93.166128,22.27846],[93.060294,22.703111],[93.286327,23.043658],[93.325188,24.078556],[94.106742,23.850741],[94.552658,24.675238],[94.603249,25.162495],[95.155153,26.001307],[95.124768,26.573572],[96.419366,27.264589],[97.133999,27.083774],[97.051989,27.699059],[97.402561,27.882536],[97.327114,28.261583],[97.911988,28.335945],[98.246231,27.747221],[98.68269,27.508812],[98.712094,26.743536],[98.671838,25.918703],[97.724609,25.083637],[97.60472,23.897405],[98.660262,24.063286],[98.898749,23.142722],[99.531992,22.949039],[99.240899,22.118314],[99.983489,21.742937],[100.416538,21.558839],[101.150033,21.849984],[101.180005,21.436573],[100.329101,20.786122],[100.115988,20.41785]]]}},{"type":"Feature","properties":{"ADMIN":"Vietnam","NAME_EN":"Vietnam","CONTINENT":"Asia","ADM0_A3":"VNM","ISO_A3":"VNM","NAME":"Vietnam","ISO_A2":"VN","NAME_ZH":"越南"},"geometry":{"type":"Polygon","coordinates":[[[104.334335,10.486544],[105.199915,10.88931],[106.24967,10.961812],[105.810524,11.567615],[107.491403,12.337206],[107.614548,13.535531],[107.382727,14.202441],[107.564525,15.202173],[107.312706,15.908538],[106.556008,16.604284],[105.925762,17.485315],[105.094598,18.666975],[103.896532,19.265181],[104.183388,19.624668],[104.822574,19.886642],[104.435,20.758733],[103.203861,20.766562],[102.754896,21.675137],[102.170436,22.464753],[102.706992,22.708795],[103.504515,22.703757],[104.476858,22.81915],[105.329209,23.352063],[105.811247,22.976892],[106.725403,22.794268],[106.567273,22.218205],[107.04342,21.811899],[108.05018,21.55238],[106.715068,20.696851],[105.881682,19.75205],[105.662006,19.058165],[106.426817,18.004121],[107.361954,16.697457],[108.269495,16.079742],[108.877107,15.276691],[109.33527,13.426028],[109.200136,11.666859],[108.36613,11.008321],[107.220929,10.364484],[106.405113,9.53084],[105.158264,8.59976],[104.795185,9.241038],[105.076202,9.918491],[104.334335,10.486544]]]}},{"type":"Feature","properties":{"ADMIN":"North Korea","NAME_EN":"North Korea","CONTINENT":"Asia","ADM0_A3":"PRK","ISO_A3":"PRK","NAME":"North Korea","ISO_A2":"KP","NAME_ZH":"朝鲜民主主义人民共和国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[130.780004,42.220008],[130.780005,42.22001],[130.780007,42.220007],[130.780004,42.220008]]],[[[130.64,42.395024],[130.64,42.395],[130.779992,42.22001],[130.400031,42.280004],[129.965949,41.941368],[129.667362,41.601104],[129.705189,40.882828],[129.188115,40.661808],[129.0104,40.485436],[128.633368,40.189847],[127.967414,40.025413],[127.533436,39.75685],[127.50212,39.323931],[127.385434,39.213472],[127.783343,39.050898],[128.349716,38.612243],[128.205746,38.370397],[127.780035,38.304536],[127.073309,38.256115],[126.68372,37.804773],[126.237339,37.840378],[126.174759,37.749686],[125.689104,37.94001],[125.568439,37.752089],[125.27533,37.669071],[125.240087,37.857224],[124.981033,37.948821],[124.712161,38.108346],[124.985994,38.548474],[125.221949,38.665857],[125.132859,38.848559],[125.38659,39.387958],[125.321116,39.551385],[124.737482,39.660344],[124.265625,39.928493],[125.079942,40.569824],[126.182045,41.107336],[126.869083,41.816569],[127.343783,41.503152],[128.208433,41.466772],[128.052215,41.994285],[129.596669,42.424982],[129.994267,42.985387],[130.64,42.395024]]]]}},{"type":"Feature","properties":{"ADMIN":"South Korea","NAME_EN":"South Korea","CONTINENT":"Asia","ADM0_A3":"KOR","ISO_A3":"KOR","NAME":"South Korea","ISO_A2":"KR","NAME_ZH":"大韩民国"},"geometry":{"type":"Polygon","coordinates":[[[126.174759,37.749686],[126.237339,37.840378],[126.68372,37.804773],[127.073309,38.256115],[127.780035,38.304536],[128.205746,38.370397],[128.349716,38.612243],[129.21292,37.432392],[129.46045,36.784189],[129.468304,35.632141],[129.091377,35.082484],[128.18585,34.890377],[127.386519,34.475674],[126.485748,34.390046],[126.37392,34.93456],[126.559231,35.684541],[126.117398,36.725485],[126.860143,36.893924],[126.174759,37.749686]]]}},{"type":"Feature","properties":{"ADMIN":"Mongolia","NAME_EN":"Mongolia","CONTINENT":"Asia","ADM0_A3":"MNG","ISO_A3":"MNG","NAME":"Mongolia","ISO_A2":"MN","NAME_ZH":"蒙古国"},"geometry":{"type":"Polygon","coordinates":[[[87.751264,49.297198],[88.805567,49.470521],[90.713667,50.331812],[92.234712,50.802171],[93.10421,50.49529],[94.147566,50.480537],[94.815949,50.013433],[95.81402,49.97746],[97.25976,49.72605],[98.231762,50.422401],[97.82574,51.010995],[98.861491,52.047366],[99.981732,51.634006],[100.88948,51.516856],[102.06521,51.25991],[102.25589,50.51056],[103.676545,50.089966],[104.62158,50.27532],[105.886591,50.406019],[106.888804,50.274296],[107.868176,49.793705],[108.475167,49.282548],[109.402449,49.292961],[110.662011,49.130128],[111.581231,49.377968],[112.89774,49.543565],[114.362456,50.248303],[114.96211,50.140247],[115.485695,49.805177],[116.678801,49.888531],[116.191802,49.134598],[115.485282,48.135383],[115.742837,47.726545],[116.308953,47.85341],[117.295507,47.697709],[118.064143,48.06673],[118.866574,47.74706],[119.772824,47.048059],[119.66327,46.69268],[118.874326,46.805412],[117.421701,46.672733],[116.717868,46.388202],[115.985096,45.727235],[114.460332,45.339817],[113.463907,44.808893],[112.436062,45.011646],[111.873306,45.102079],[111.348377,44.457442],[111.667737,44.073176],[111.829588,43.743118],[111.129682,43.406834],[110.412103,42.871234],[109.243596,42.519446],[107.744773,42.481516],[106.129316,42.134328],[104.964994,41.59741],[104.522282,41.908347],[103.312278,41.907468],[101.83304,42.514873],[100.845866,42.663804],[99.515817,42.524691],[97.451757,42.74889],[96.349396,42.725635],[95.762455,43.319449],[95.306875,44.241331],[94.688929,44.352332],[93.480734,44.975472],[92.133891,45.115076],[90.94554,45.286073],[90.585768,45.719716],[90.970809,46.888146],[90.280826,47.693549],[88.854298,48.069082],[88.013832,48.599463],[87.751264,49.297198]]]}},{"type":"Feature","properties":{"ADMIN":"India","NAME_EN":"India","CONTINENT":"Asia","ADM0_A3":"IND","ISO_A3":"IND","NAME":"India","ISO_A2":"IN","NAME_ZH":"印度"},"geometry":{"type":"Polygon","coordinates":[[[97.327114,28.261583],[97.402561,27.882536],[97.051989,27.699059],[97.133999,27.083774],[96.419366,27.264589],[95.124768,26.573572],[95.155153,26.001307],[94.603249,25.162495],[94.552658,24.675238],[94.106742,23.850741],[93.325188,24.078556],[93.286327,23.043658],[93.060294,22.703111],[93.166128,22.27846],[92.672721,22.041239],[92.146035,23.627499],[91.869928,23.624346],[91.706475,22.985264],[91.158963,23.503527],[91.46773,24.072639],[91.915093,24.130414],[92.376202,24.976693],[91.799596,25.147432],[90.872211,25.132601],[89.920693,25.26975],[89.832481,25.965082],[89.355094,26.014407],[88.563049,26.446526],[88.209789,25.768066],[88.931554,25.238692],[88.306373,24.866079],[88.084422,24.501657],[88.69994,24.233715],[88.52977,23.631142],[88.876312,22.879146],[89.031961,22.055708],[88.888766,21.690588],[88.208497,21.703172],[86.975704,21.495562],[87.033169,20.743308],[86.499351,20.151638],[85.060266,19.478579],[83.941006,18.30201],[83.189217,17.671221],[82.192792,17.016636],[82.191242,16.556664],[81.692719,16.310219],[80.791999,15.951972],[80.324896,15.899185],[80.025069,15.136415],[80.233274,13.835771],[80.286294,13.006261],[79.862547,12.056215],[79.857999,10.357275],[79.340512,10.308854],[78.885345,9.546136],[79.18972,9.216544],[78.277941,8.933047],[77.941165,8.252959],[77.539898,7.965535],[76.592979,8.899276],[76.130061,10.29963],[75.746467,11.308251],[75.396101,11.781245],[74.864816,12.741936],[74.616717,13.992583],[74.443859,14.617222],[73.534199,15.990652],[73.119909,17.92857],[72.820909,19.208234],[72.824475,20.419503],[72.630533,21.356009],[71.175273,20.757441],[70.470459,20.877331],[69.16413,22.089298],[69.644928,22.450775],[69.349597,22.84318],[68.176645,23.691965],[68.842599,24.359134],[71.04324,24.356524],[70.844699,25.215102],[70.282873,25.722229],[70.168927,26.491872],[69.514393,26.940966],[70.616496,27.989196],[71.777666,27.91318],[72.823752,28.961592],[73.450638,29.976413],[74.42138,30.979815],[74.405929,31.692639],[75.258642,32.271105],[74.451559,32.7649],[74.104294,33.441473],[73.749948,34.317699],[74.240203,34.748887],[75.757061,34.504923],[76.871722,34.653544],[77.837451,35.49401],[78.912269,34.321936],[78.811086,33.506198],[79.208892,32.994395],[79.176129,32.48378],[78.458446,32.618164],[78.738894,31.515906],[79.721367,30.882715],[81.111256,30.183481],[80.476721,29.729865],[80.088425,28.79447],[81.057203,28.416095],[81.999987,27.925479],[83.304249,27.364506],[84.675018,27.234901],[85.251779,26.726198],[86.024393,26.630985],[87.227472,26.397898],[88.060238,26.414615],[88.174804,26.810405],[88.043133,27.445819],[88.120441,27.876542],[88.730326,28.086865],[88.814248,27.299316],[88.835643,27.098966],[89.744528,26.719403],[90.373275,26.875724],[91.217513,26.808648],[92.033484,26.83831],[92.103712,27.452614],[91.696657,27.771742],[92.503119,27.896876],[93.413348,28.640629],[94.56599,29.277438],[95.404802,29.031717],[96.117679,29.452802],[96.586591,28.83098],[96.248833,28.411031],[97.327114,28.261583]]]}},{"type":"Feature","properties":{"ADMIN":"Bangladesh","NAME_EN":"Bangladesh","CONTINENT":"Asia","ADM0_A3":"BGD","ISO_A3":"BGD","NAME":"Bangladesh","ISO_A2":"BD","NAME_ZH":"孟加拉国"},"geometry":{"type":"Polygon","coordinates":[[[92.672721,22.041239],[92.652257,21.324048],[92.303234,21.475485],[92.368554,20.670883],[92.082886,21.192195],[92.025215,21.70157],[91.834891,22.182936],[91.417087,22.765019],[90.496006,22.805017],[90.586957,22.392794],[90.272971,21.836368],[89.847467,22.039146],[89.70205,21.857116],[89.418863,21.966179],[89.031961,22.055708],[88.876312,22.879146],[88.52977,23.631142],[88.69994,24.233715],[88.084422,24.501657],[88.306373,24.866079],[88.931554,25.238692],[88.209789,25.768066],[88.563049,26.446526],[89.355094,26.014407],[89.832481,25.965082],[89.920693,25.26975],[90.872211,25.132601],[91.799596,25.147432],[92.376202,24.976693],[91.915093,24.130414],[91.46773,24.072639],[91.158963,23.503527],[91.706475,22.985264],[91.869928,23.624346],[92.146035,23.627499],[92.672721,22.041239]]]}},{"type":"Feature","properties":{"ADMIN":"Bhutan","NAME_EN":"Bhutan","CONTINENT":"Asia","ADM0_A3":"BTN","ISO_A3":"BTN","NAME":"Bhutan","ISO_A2":"BT","NAME_ZH":"不丹"},"geometry":{"type":"Polygon","coordinates":[[[91.696657,27.771742],[92.103712,27.452614],[92.033484,26.83831],[91.217513,26.808648],[90.373275,26.875724],[89.744528,26.719403],[88.835643,27.098966],[88.814248,27.299316],[89.47581,28.042759],[90.015829,28.296439],[90.730514,28.064954],[91.258854,28.040614],[91.696657,27.771742]]]}},{"type":"Feature","properties":{"ADMIN":"Nepal","NAME_EN":"Nepal","CONTINENT":"Asia","ADM0_A3":"NPL","ISO_A3":"NPL","NAME":"Nepal","ISO_A2":"NP","NAME_ZH":"尼泊尔"},"geometry":{"type":"Polygon","coordinates":[[[88.120441,27.876542],[88.043133,27.445819],[88.174804,26.810405],[88.060238,26.414615],[87.227472,26.397898],[86.024393,26.630985],[85.251779,26.726198],[84.675018,27.234901],[83.304249,27.364506],[81.999987,27.925479],[81.057203,28.416095],[80.088425,28.79447],[80.476721,29.729865],[81.111256,30.183481],[81.525804,30.422717],[82.327513,30.115268],[83.337115,29.463732],[83.898993,29.320226],[84.23458,28.839894],[85.011638,28.642774],[85.82332,28.203576],[86.954517,27.974262],[88.120441,27.876542]]]}},{"type":"Feature","properties":{"ADMIN":"Pakistan","NAME_EN":"Pakistan","CONTINENT":"Asia","ADM0_A3":"PAK","ISO_A3":"PAK","NAME":"Pakistan","ISO_A2":"PK","NAME_ZH":"巴基斯坦"},"geometry":{"type":"Polygon","coordinates":[[[77.837451,35.49401],[76.871722,34.653544],[75.757061,34.504923],[74.240203,34.748887],[73.749948,34.317699],[74.104294,33.441473],[74.451559,32.7649],[75.258642,32.271105],[74.405929,31.692639],[74.42138,30.979815],[73.450638,29.976413],[72.823752,28.961592],[71.777666,27.91318],[70.616496,27.989196],[69.514393,26.940966],[70.168927,26.491872],[70.282873,25.722229],[70.844699,25.215102],[71.04324,24.356524],[68.842599,24.359134],[68.176645,23.691965],[67.443667,23.944844],[67.145442,24.663611],[66.372828,25.425141],[64.530408,25.237039],[62.905701,25.218409],[61.497363,25.078237],[61.874187,26.239975],[63.316632,26.756532],[63.233898,27.217047],[62.755426,27.378923],[62.72783,28.259645],[61.771868,28.699334],[61.369309,29.303276],[60.874248,29.829239],[62.549857,29.318572],[63.550261,29.468331],[64.148002,29.340819],[64.350419,29.560031],[65.046862,29.472181],[66.346473,29.887943],[66.381458,30.738899],[66.938891,31.304911],[67.683394,31.303154],[67.792689,31.58293],[68.556932,31.71331],[68.926677,31.620189],[69.317764,31.901412],[69.262522,32.501944],[69.687147,33.105499],[70.323594,33.358533],[69.930543,34.02012],[70.881803,33.988856],[71.156773,34.348911],[71.115019,34.733126],[71.613076,35.153203],[71.498768,35.650563],[71.262348,36.074388],[71.846292,36.509942],[72.920025,36.720007],[74.067552,36.836176],[74.575893,37.020841],[75.158028,37.133031],[75.896897,36.666806],[76.192848,35.898403],[77.837451,35.49401]]]}},{"type":"Feature","properties":{"ADMIN":"Afghanistan","NAME_EN":"Afghanistan","CONTINENT":"Asia","ADM0_A3":"AFG","ISO_A3":"AFG","NAME":"Afghanistan","ISO_A2":"AF","NAME_ZH":"阿富汗"},"geometry":{"type":"Polygon","coordinates":[[[66.518607,37.362784],[67.075782,37.356144],[67.83,37.144994],[68.135562,37.023115],[68.859446,37.344336],[69.196273,37.151144],[69.518785,37.608997],[70.116578,37.588223],[70.270574,37.735165],[70.376304,38.138396],[70.806821,38.486282],[71.348131,38.258905],[71.239404,37.953265],[71.541918,37.905774],[71.448693,37.065645],[71.844638,36.738171],[72.193041,36.948288],[72.63689,37.047558],[73.260056,37.495257],[73.948696,37.421566],[74.980002,37.41999],[75.158028,37.133031],[74.575893,37.020841],[74.067552,36.836176],[72.920025,36.720007],[71.846292,36.509942],[71.262348,36.074388],[71.498768,35.650563],[71.613076,35.153203],[71.115019,34.733126],[71.156773,34.348911],[70.881803,33.988856],[69.930543,34.02012],[70.323594,33.358533],[69.687147,33.105499],[69.262522,32.501944],[69.317764,31.901412],[68.926677,31.620189],[68.556932,31.71331],[67.792689,31.58293],[67.683394,31.303154],[66.938891,31.304911],[66.381458,30.738899],[66.346473,29.887943],[65.046862,29.472181],[64.350419,29.560031],[64.148002,29.340819],[63.550261,29.468331],[62.549857,29.318572],[60.874248,29.829239],[61.781222,30.73585],[61.699314,31.379506],[60.941945,31.548075],[60.863655,32.18292],[60.536078,32.981269],[60.9637,33.528832],[60.52843,33.676446],[60.803193,34.404102],[61.210817,35.650072],[62.230651,35.270664],[62.984662,35.404041],[63.193538,35.857166],[63.982896,36.007957],[64.546479,36.312073],[64.746105,37.111818],[65.588948,37.305217],[65.745631,37.661164],[66.217385,37.39379],[66.518607,37.362784]]]}},{"type":"Feature","properties":{"ADMIN":"Tajikistan","NAME_EN":"Tajikistan","CONTINENT":"Asia","ADM0_A3":"TJK","ISO_A3":"TJK","NAME":"Tajikistan","ISO_A2":"TJ","NAME_ZH":"塔吉克斯坦"},"geometry":{"type":"Polygon","coordinates":[[[67.83,37.144994],[68.392033,38.157025],[68.176025,38.901553],[67.44222,39.140144],[67.701429,39.580478],[68.536416,39.533453],[69.011633,40.086158],[69.329495,40.727824],[70.666622,40.960213],[70.45816,40.496495],[70.601407,40.218527],[71.014198,40.244366],[70.648019,39.935754],[69.55961,40.103211],[69.464887,39.526683],[70.549162,39.604198],[71.784694,39.279463],[73.675379,39.431237],[73.928852,38.505815],[74.257514,38.606507],[74.864816,38.378846],[74.829986,37.990007],[74.980002,37.41999],[73.948696,37.421566],[73.260056,37.495257],[72.63689,37.047558],[72.193041,36.948288],[71.844638,36.738171],[71.448693,37.065645],[71.541918,37.905774],[71.239404,37.953265],[71.348131,38.258905],[70.806821,38.486282],[70.376304,38.138396],[70.270574,37.735165],[70.116578,37.588223],[69.518785,37.608997],[69.196273,37.151144],[68.859446,37.344336],[68.135562,37.023115],[67.83,37.144994]]]}},{"type":"Feature","properties":{"ADMIN":"Kyrgyzstan","NAME_EN":"Kyrgyzstan","CONTINENT":"Asia","ADM0_A3":"KGZ","ISO_A3":"KGZ","NAME":"Kyrgyzstan","ISO_A2":"KG","NAME_ZH":"吉尔吉斯斯坦"},"geometry":{"type":"Polygon","coordinates":[[[70.962315,42.266154],[71.186281,42.704293],[71.844638,42.845395],[73.489758,42.500894],[73.645304,43.091272],[74.212866,43.298339],[75.636965,42.8779],[76.000354,42.988022],[77.658392,42.960686],[79.142177,42.856092],[79.643645,42.496683],[80.25999,42.349999],[80.11943,42.123941],[78.543661,41.582243],[78.187197,41.185316],[76.904484,41.066486],[76.526368,40.427946],[75.467828,40.562072],[74.776862,40.366425],[73.822244,39.893973],[73.960013,39.660008],[73.675379,39.431237],[71.784694,39.279463],[70.549162,39.604198],[69.464887,39.526683],[69.55961,40.103211],[70.648019,39.935754],[71.014198,40.244366],[71.774875,40.145844],[73.055417,40.866033],[71.870115,41.3929],[71.157859,41.143587],[70.420022,41.519998],[71.259248,42.167711],[70.962315,42.266154]]]}},{"type":"Feature","properties":{"ADMIN":"Turkmenistan","NAME_EN":"Turkmenistan","CONTINENT":"Asia","ADM0_A3":"TKM","ISO_A3":"TKM","NAME":"Turkmenistan","ISO_A2":"TM","NAME_ZH":"土库曼斯坦"},"geometry":{"type":"Polygon","coordinates":[[[52.50246,41.783316],[52.944293,42.116034],[54.079418,42.324109],[54.755345,42.043971],[55.455251,41.259859],[55.968191,41.308642],[57.096391,41.32231],[56.932215,41.826026],[57.78653,42.170553],[58.629011,42.751551],[59.976422,42.223082],[60.083341,41.425146],[60.465953,41.220327],[61.547179,41.26637],[61.882714,41.084857],[62.37426,40.053886],[63.518015,39.363257],[64.170223,38.892407],[65.215999,38.402695],[66.54615,37.974685],[66.518607,37.362784],[66.217385,37.39379],[65.745631,37.661164],[65.588948,37.305217],[64.746105,37.111818],[64.546479,36.312073],[63.982896,36.007957],[63.193538,35.857166],[62.984662,35.404041],[62.230651,35.270664],[61.210817,35.650072],[61.123071,36.491597],[60.377638,36.527383],[59.234762,37.412988],[58.436154,37.522309],[57.330434,38.029229],[56.619366,38.121394],[56.180375,37.935127],[55.511578,37.964117],[54.800304,37.392421],[53.921598,37.198918],[53.735511,37.906136],[53.880929,38.952093],[53.101028,39.290574],[53.357808,39.975286],[52.693973,40.033629],[52.915251,40.876523],[53.858139,40.631034],[54.736845,40.951015],[54.008311,41.551211],[53.721713,42.123191],[52.91675,41.868117],[52.814689,41.135371],[52.50246,41.783316]]]}},{"type":"Feature","properties":{"ADMIN":"Iran","NAME_EN":"Iran","CONTINENT":"Asia","ADM0_A3":"IRN","ISO_A3":"IRN","NAME":"Iran","ISO_A2":"IR","NAME_ZH":"伊朗"},"geometry":{"type":"Polygon","coordinates":[[[48.567971,29.926778],[48.014568,30.452457],[48.004698,30.985137],[47.685286,30.984853],[47.849204,31.709176],[47.334661,32.469155],[46.109362,33.017287],[45.416691,33.967798],[45.64846,34.748138],[46.151788,35.093259],[46.07634,35.677383],[45.420618,35.977546],[44.772677,37.170437],[44.77267,37.17045],[44.225756,37.971584],[44.421403,38.281281],[44.109225,39.428136],[44.79399,39.713003],[44.952688,39.335765],[45.457722,38.874139],[46.143623,38.741201],[46.50572,38.770605],[47.685079,39.508364],[48.060095,39.582235],[48.355529,39.288765],[48.010744,38.794015],[48.634375,38.270378],[48.883249,38.320245],[49.199612,37.582874],[50.147771,37.374567],[50.842354,36.872814],[52.264025,36.700422],[53.82579,36.965031],[53.921598,37.198918],[54.800304,37.392421],[55.511578,37.964117],[56.180375,37.935127],[56.619366,38.121394],[57.330434,38.029229],[58.436154,37.522309],[59.234762,37.412988],[60.377638,36.527383],[61.123071,36.491597],[61.210817,35.650072],[60.803193,34.404102],[60.52843,33.676446],[60.9637,33.528832],[60.536078,32.981269],[60.863655,32.18292],[60.941945,31.548075],[61.699314,31.379506],[61.781222,30.73585],[60.874248,29.829239],[61.369309,29.303276],[61.771868,28.699334],[62.72783,28.259645],[62.755426,27.378923],[63.233898,27.217047],[63.316632,26.756532],[61.874187,26.239975],[61.497363,25.078237],[59.616134,25.380157],[58.525761,25.609962],[57.397251,25.739902],[56.970766,26.966106],[56.492139,27.143305],[55.72371,26.964633],[54.71509,26.480658],[53.493097,26.812369],[52.483598,27.580849],[51.520763,27.86569],[50.852948,28.814521],[50.115009,30.147773],[49.57685,29.985715],[48.941333,30.31709],[48.567971,29.926778]]]}},{"type":"Feature","properties":{"ADMIN":"Syria","NAME_EN":"Syria","CONTINENT":"Asia","ADM0_A3":"SYR","ISO_A3":"SYR","NAME":"Syria","ISO_A2":"SY","NAME_ZH":"叙利亚"},"geometry":{"type":"Polygon","coordinates":[[[35.719918,32.709192],[35.700798,32.716014],[35.836397,32.868123],[35.821101,33.277426],[36.06646,33.824912],[36.61175,34.201789],[36.448194,34.593935],[35.998403,34.644914],[35.905023,35.410009],[36.149763,35.821535],[36.41755,36.040617],[36.685389,36.259699],[36.739494,36.81752],[37.066761,36.623036],[38.167727,36.90121],[38.699891,36.712927],[39.52258,36.716054],[40.673259,37.091276],[41.212089,37.074352],[42.349591,37.229873],[41.837064,36.605854],[41.289707,36.358815],[41.383965,35.628317],[41.006159,34.419372],[38.792341,33.378686],[36.834062,32.312938],[35.719918,32.709192]]]}},{"type":"Feature","properties":{"ADMIN":"Armenia","NAME_EN":"Armenia","CONTINENT":"Asia","ADM0_A3":"ARM","ISO_A3":"ARM","NAME":"Armenia","ISO_A2":"AM","NAME_ZH":"亚美尼亚"},"geometry":{"type":"Polygon","coordinates":[[[46.50572,38.770605],[46.143623,38.741201],[45.735379,39.319719],[45.739978,39.473999],[45.298145,39.471751],[45.001987,39.740004],[44.79399,39.713003],[44.400009,40.005],[43.656436,40.253564],[43.752658,40.740201],[43.582746,41.092143],[44.97248,41.248129],[45.179496,40.985354],[45.560351,40.81229],[45.359175,40.561504],[45.891907,40.218476],[45.610012,39.899994],[46.034534,39.628021],[46.483499,39.464155],[46.50572,38.770605]]]}},{"type":"Feature","properties":{"ADMIN":"Sweden","NAME_EN":"Sweden","CONTINENT":"Europe","ADM0_A3":"SWE","ISO_A3":"SWE","NAME":"Sweden","ISO_A2":"SE","NAME_ZH":"瑞典"},"geometry":{"type":"Polygon","coordinates":[[[11.027369,58.856149],[11.468272,59.432393],[12.300366,60.117933],[12.631147,61.293572],[11.992064,61.800362],[11.930569,63.128318],[12.579935,64.066219],[13.571916,64.049114],[13.919905,64.445421],[13.55569,64.787028],[15.108411,66.193867],[16.108712,67.302456],[16.768879,68.013937],[17.729182,68.010552],[17.993868,68.567391],[19.87856,68.407194],[20.025269,69.065139],[20.645593,69.106247],[21.978535,68.616846],[23.539473,67.936009],[23.56588,66.396051],[23.903379,66.006927],[22.183173,65.723741],[21.213517,65.026005],[21.369631,64.413588],[19.778876,63.609554],[17.847779,62.7494],[17.119555,61.341166],[17.831346,60.636583],[18.787722,60.081914],[17.869225,58.953766],[16.829185,58.719827],[16.44771,57.041118],[15.879786,56.104302],[14.666681,56.200885],[14.100721,55.407781],[12.942911,55.361737],[12.625101,56.30708],[11.787942,57.441817],[11.027369,58.856149]]]}},{"type":"Feature","properties":{"ADMIN":"Belarus","NAME_EN":"Belarus","CONTINENT":"Europe","ADM0_A3":"BLR","ISO_A3":"BLR","NAME":"Belarus","ISO_A2":"BY","NAME_ZH":"白俄罗斯"},"geometry":{"type":"Polygon","coordinates":[[[28.176709,56.16913],[29.229513,55.918344],[29.371572,55.670091],[29.896294,55.789463],[30.873909,55.550976],[30.971836,55.081548],[30.757534,54.811771],[31.384472,54.157056],[31.791424,53.974639],[31.731273,53.794029],[32.405599,53.618045],[32.693643,53.351421],[32.304519,53.132726],[31.49764,53.16743],[31.305201,53.073996],[31.540018,52.742052],[31.78597,52.10168],[31.785992,52.101678],[30.927549,52.042353],[30.619454,51.822806],[30.555117,51.319503],[30.157364,51.416138],[29.254938,51.368234],[28.992835,51.602044],[28.617613,51.427714],[28.241615,51.572227],[27.454066,51.592303],[26.337959,51.832289],[25.327788,51.910656],[24.553106,51.888461],[24.005078,51.617444],[23.527071,51.578454],[23.508002,52.023647],[23.199494,52.486977],[23.799199,52.691099],[23.804935,53.089731],[23.527536,53.470122],[23.484128,53.912498],[24.450684,53.905702],[25.536354,54.282423],[25.768433,54.846963],[26.588279,55.167176],[26.494331,55.615107],[27.10246,55.783314],[28.176709,56.16913]]]}},{"type":"Feature","properties":{"ADMIN":"Ukraine","NAME_EN":"Ukraine","CONTINENT":"Europe","ADM0_A3":"UKR","ISO_A3":"UKR","NAME":"Ukraine","ISO_A2":"UA","NAME_ZH":"乌克兰"},"geometry":{"type":"Polygon","coordinates":[[[31.785992,52.101678],[32.15944,52.06125],[32.412058,52.288695],[32.715761,52.238465],[33.7527,52.335075],[34.391731,51.768882],[34.141978,51.566413],[34.224816,51.255993],[35.022183,51.207572],[35.37791,50.77394],[35.356116,50.577197],[36.626168,50.225591],[37.39346,50.383953],[38.010631,49.915662],[38.594988,49.926462],[40.06904,49.60105],[40.080789,49.30743],[39.67465,48.78382],[39.89562,48.23241],[39.738278,47.898937],[38.77057,47.82562],[38.255112,47.5464],[38.223538,47.10219],[37.425137,47.022221],[36.759855,46.6987],[35.823685,46.645964],[34.962342,46.273197],[35.012659,45.737725],[34.861792,45.768182],[34.732017,45.965666],[34.410402,46.005162],[33.699462,46.219573],[33.435988,45.971917],[33.298567,46.080598],[31.74414,46.333348],[31.675307,46.706245],[30.748749,46.5831],[30.377609,46.03241],[29.603289,45.293308],[29.149725,45.464925],[28.679779,45.304031],[28.233554,45.488283],[28.485269,45.596907],[28.659987,45.939987],[28.933717,46.25883],[28.862972,46.437889],[29.072107,46.517678],[29.170654,46.379262],[29.759972,46.349988],[30.024659,46.423937],[29.83821,46.525326],[29.908852,46.674361],[29.559674,46.928583],[29.415135,47.346645],[29.050868,47.510227],[29.122698,47.849095],[28.670891,48.118149],[28.259547,48.155562],[27.522537,48.467119],[26.857824,48.368211],[26.619337,48.220726],[26.19745,48.220881],[25.945941,47.987149],[25.207743,47.891056],[24.866317,47.737526],[24.402056,47.981878],[23.760958,47.985598],[23.142236,48.096341],[22.710531,47.882194],[22.64082,48.15024],[22.085608,48.422264],[22.280842,48.825392],[22.558138,49.085738],[22.776419,49.027395],[22.51845,49.476774],[23.426508,50.308506],[23.922757,50.424881],[24.029986,50.705407],[23.527071,51.578454],[24.005078,51.617444],[24.553106,51.888461],[25.327788,51.910656],[26.337959,51.832289],[27.454066,51.592303],[28.241615,51.572227],[28.617613,51.427714],[28.992835,51.602044],[29.254938,51.368234],[30.157364,51.416138],[30.555117,51.319503],[30.619454,51.822806],[30.927549,52.042353],[31.785992,52.101678]]]}},{"type":"Feature","properties":{"ADMIN":"Poland","NAME_EN":"Poland","CONTINENT":"Europe","ADM0_A3":"POL","ISO_A3":"POL","NAME":"Poland","ISO_A2":"PL","NAME_ZH":"波兰"},"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[23.527536,53.470122],[23.804935,53.089731],[23.799199,52.691099],[23.199494,52.486977],[23.508002,52.023647],[23.527071,51.578454],[24.029986,50.705407],[23.922757,50.424881],[23.426508,50.308506],[22.51845,49.476774],[22.776419,49.027395],[22.558138,49.085738],[21.607808,49.470107],[20.887955,49.328772],[20.415839,49.431453],[19.825023,49.217125],[19.320713,49.571574],[18.909575,49.435846],[18.853144,49.49623],[18.392914,49.988629],[17.649445,50.049038],[17.554567,50.362146],[16.868769,50.473974],[16.719476,50.215747],[16.176253,50.422607],[16.238627,50.697733],[15.490972,50.78473],[15.016996,51.106674],[14.607098,51.745188],[14.685026,52.089947],[14.4376,52.62485],[14.074521,52.981263],[14.353315,53.248171],[14.119686,53.757029],[14.8029,54.050706],[16.363477,54.513159],[17.622832,54.851536],[18.620859,54.682606],[18.696255,54.438719],[19.66064,54.426084],[20.892245,54.312525],[22.731099,54.327537],[23.243987,54.220567],[23.484128,53.912498]]]}},{"type":"Feature","properties":{"ADMIN":"Austria","NAME_EN":"Austria","CONTINENT":"Europe","ADM0_A3":"AUT","ISO_A3":"AUT","NAME":"Austria","ISO_A2":"AT","NAME_ZH":"奥地利"},"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.903754,47.714866],[16.340584,47.712902],[16.534268,47.496171],[16.202298,46.852386],[16.011664,46.683611],[15.137092,46.658703],[14.632472,46.431817],[13.806475,46.509306],[12.376485,46.767559],[12.153088,47.115393],[11.164828,46.941579],[11.048556,46.751359],[10.442701,46.893546],[9.932448,46.920728],[9.47997,47.10281],[9.632932,47.347601],[9.594226,47.525058],[9.896068,47.580197],[10.402084,47.302488],[10.544504,47.566399],[11.426414,47.523766],[12.141357,47.703083],[12.62076,47.672388],[12.932627,47.467646],[13.025851,47.637584],[12.884103,48.289146],[13.243357,48.416115],[13.595946,48.877172],[14.338898,48.555305],[14.901447,48.964402],[15.253416,49.039074],[16.029647,48.733899],[16.499283,48.785808],[16.960288,48.596982],[16.879983,48.470013],[16.979667,48.123497]]]}},{"type":"Feature","properties":{"ADMIN":"Hungary","NAME_EN":"Hungary","CONTINENT":"Europe","ADM0_A3":"HUN","ISO_A3":"HUN","NAME":"Hungary","ISO_A2":"HU","NAME_ZH":"匈牙利"},"geometry":{"type":"Polygon","coordinates":[[[22.085608,48.422264],[22.64082,48.15024],[22.710531,47.882194],[22.099768,47.672439],[21.626515,46.994238],[21.021952,46.316088],[20.220192,46.127469],[19.596045,46.17173],[18.829838,45.908878],[18.829825,45.908872],[18.456062,45.759481],[17.630066,45.951769],[16.882515,46.380632],[16.564808,46.503751],[16.370505,46.841327],[16.202298,46.852386],[16.534268,47.496171],[16.340584,47.712902],[16.903754,47.714866],[16.979667,48.123497],[17.488473,47.867466],[17.857133,47.758429],[18.696513,47.880954],[18.777025,48.081768],[19.174365,48.111379],[19.661364,48.266615],[19.769471,48.202691],[20.239054,48.327567],[20.473562,48.56285],[20.801294,48.623854],[21.872236,48.319971],[22.085608,48.422264]]]}},{"type":"Feature","properties":{"ADMIN":"Moldova","NAME_EN":"Moldova","CONTINENT":"Europe","ADM0_A3":"MDA","ISO_A3":"MDA","NAME":"Moldova","ISO_A2":"MD","NAME_ZH":"摩尔多瓦"},"geometry":{"type":"Polygon","coordinates":[[[26.619337,48.220726],[26.857824,48.368211],[27.522537,48.467119],[28.259547,48.155562],[28.670891,48.118149],[29.122698,47.849095],[29.050868,47.510227],[29.415135,47.346645],[29.559674,46.928583],[29.908852,46.674361],[29.83821,46.525326],[30.024659,46.423937],[29.759972,46.349988],[29.170654,46.379262],[29.072107,46.517678],[28.862972,46.437889],[28.933717,46.25883],[28.659987,45.939987],[28.485269,45.596907],[28.233554,45.488283],[28.054443,45.944586],[28.160018,46.371563],[28.12803,46.810476],[27.551166,47.405117],[27.233873,47.826771],[26.924176,48.123264],[26.619337,48.220726]]]}},{"type":"Feature","properties":{"ADMIN":"Romania","NAME_EN":"Romania","CONTINENT":"Europe","ADM0_A3":"ROU","ISO_A3":"ROU","NAME":"Romania","ISO_A2":"RO","NAME_ZH":"罗马尼亚"},"geometry":{"type":"Polygon","coordinates":[[[28.233554,45.488283],[28.679779,45.304031],[29.149725,45.464925],[29.603289,45.293308],[29.626543,45.035391],[29.141612,44.82021],[28.837858,44.913874],[28.558081,43.707462],[27.970107,43.812468],[27.2424,44.175986],[26.065159,43.943494],[25.569272,43.688445],[24.100679,43.741051],[23.332302,43.897011],[22.944832,43.823785],[22.65715,44.234923],[22.474008,44.409228],[22.705726,44.578003],[22.459022,44.702517],[22.145088,44.478422],[21.562023,44.768947],[21.483526,45.18117],[20.874313,45.416375],[20.762175,45.734573],[20.220192,46.127469],[21.021952,46.316088],[21.626515,46.994238],[22.099768,47.672439],[22.710531,47.882194],[23.142236,48.096341],[23.760958,47.985598],[24.402056,47.981878],[24.866317,47.737526],[25.207743,47.891056],[25.945941,47.987149],[26.19745,48.220881],[26.619337,48.220726],[26.924176,48.123264],[27.233873,47.826771],[27.551166,47.405117],[28.12803,46.810476],[28.160018,46.371563],[28.054443,45.944586],[28.233554,45.488283]]]}},{"type":"Feature","properties":{"ADMIN":"Lithuania","NAME_EN":"Lithuania","CONTINENT":"Europe","ADM0_A3":"LTU","ISO_A3":"LTU","NAME":"Lithuania","ISO_A2":"LT","NAME_ZH":"立陶宛"},"geometry":{"type":"Polygon","coordinates":[[[26.494331,55.615107],[26.588279,55.167176],[25.768433,54.846963],[25.536354,54.282423],[24.450684,53.905702],[23.484128,53.912498],[23.243987,54.220567],[22.731099,54.327537],[22.651052,54.582741],[22.757764,54.856574],[22.315724,55.015299],[21.268449,55.190482],[21.0558,56.031076],[22.201157,56.337802],[23.878264,56.273671],[24.860684,56.372528],[25.000934,56.164531],[25.533047,56.100297],[26.494331,55.615107]]]}},{"type":"Feature","properties":{"ADMIN":"Latvia","NAME_EN":"Latvia","CONTINENT":"Europe","ADM0_A3":"LVA","ISO_A3":"LVA","NAME":"Latvia","ISO_A2":"LV","NAME_ZH":"拉脱维亚"},"geometry":{"type":"Polygon","coordinates":[[[27.288185,57.474528],[27.770016,57.244258],[27.855282,56.759326],[28.176709,56.16913],[27.10246,55.783314],[26.494331,55.615107],[25.533047,56.100297],[25.000934,56.164531],[24.860684,56.372528],[23.878264,56.273671],[22.201157,56.337802],[21.0558,56.031076],[21.090424,56.783873],[21.581866,57.411871],[22.524341,57.753374],[23.318453,57.006236],[24.12073,57.025693],[24.312863,57.793424],[25.164594,57.970157],[25.60281,57.847529],[26.463532,57.476389],[27.288185,57.474528]]]}},{"type":"Feature","properties":{"ADMIN":"Estonia","NAME_EN":"Estonia","CONTINENT":"Europe","ADM0_A3":"EST","ISO_A3":"EST","NAME":"Estonia","ISO_A2":"EE","NAME_ZH":"爱沙尼亚"},"geometry":{"type":"Polygon","coordinates":[[[27.981127,59.475373],[27.98112,59.47537],[28.131699,59.300825],[27.42015,58.72457],[27.716686,57.791899],[27.288185,57.474528],[26.463532,57.476389],[25.60281,57.847529],[25.164594,57.970157],[24.312863,57.793424],[24.428928,58.383413],[24.061198,58.257375],[23.42656,58.612753],[23.339795,59.18724],[24.604214,59.465854],[25.864189,59.61109],[26.949136,59.445803],[27.981114,59.475388],[27.981127,59.475373]]]}},{"type":"Feature","properties":{"ADMIN":"Germany","NAME_EN":"Germany","CONTINENT":"Europe","ADM0_A3":"DEU","ISO_A3":"DEU","NAME":"Germany","ISO_A2":"DE","NAME_ZH":"德国"},"geometry":{"type":"Polygon","coordinates":[[[14.119686,53.757029],[14.353315,53.248171],[14.074521,52.981263],[14.4376,52.62485],[14.685026,52.089947],[14.607098,51.745188],[15.016996,51.106674],[14.570718,51.002339],[14.307013,51.117268],[14.056228,50.926918],[13.338132,50.733234],[12.966837,50.484076],[12.240111,50.266338],[12.415191,49.969121],[12.521024,49.547415],[13.031329,49.307068],[13.595946,48.877172],[13.243357,48.416115],[12.884103,48.289146],[13.025851,47.637584],[12.932627,47.467646],[12.62076,47.672388],[12.141357,47.703083],[11.426414,47.523766],[10.544504,47.566399],[10.402084,47.302488],[9.896068,47.580197],[9.594226,47.525058],[8.522612,47.830828],[8.317301,47.61358],[7.466759,47.620582],[7.593676,48.333019],[8.099279,49.017784],[6.65823,49.201958],[6.18632,49.463803],[6.242751,49.902226],[6.043073,50.128052],[6.156658,50.803721],[5.988658,51.851616],[6.589397,51.852029],[6.84287,52.22844],[7.092053,53.144043],[6.90514,53.482162],[7.100425,53.693932],[7.936239,53.748296],[8.121706,53.527792],[8.800734,54.020786],[8.572118,54.395646],[8.526229,54.962744],[9.282049,54.830865],[9.921906,54.983104],[9.93958,54.596642],[10.950112,54.363607],[10.939467,54.008693],[11.956252,54.196486],[12.51844,54.470371],[13.647467,54.075511],[14.119686,53.757029]]]}},{"type":"Feature","properties":{"ADMIN":"Bulgaria","NAME_EN":"Bulgaria","CONTINENT":"Europe","ADM0_A3":"BGR","ISO_A3":"BGR","NAME":"Bulgaria","ISO_A2":"BG","NAME_ZH":"保加利亚"},"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.944832,43.823785],[23.332302,43.897011],[24.100679,43.741051],[25.569272,43.688445],[26.065159,43.943494],[27.2424,44.175986],[27.970107,43.812468],[28.558081,43.707462],[28.039095,43.293172],[27.673898,42.577892],[27.99672,42.007359],[27.135739,42.141485],[26.117042,41.826905],[26.106138,41.328899],[25.197201,41.234486],[24.492645,41.583896],[23.692074,41.309081],[22.952377,41.337994],[22.881374,41.999297],[22.380526,42.32026],[22.545012,42.461362],[22.436595,42.580321],[22.604801,42.898519],[22.986019,43.211161],[22.500157,43.642814],[22.410446,44.008063],[22.65715,44.234923]]]}},{"type":"Feature","properties":{"ADMIN":"Greece","NAME_EN":"Greece","CONTINENT":"Europe","ADM0_A3":"GRC","ISO_A3":"GRC","NAME":"Greece","ISO_A2":"GR","NAME_ZH":"希腊"},"geometry":{"type":"MultiPolygon","coordinates":[[[[26.290003,35.29999],[26.164998,35.004995],[24.724982,34.919988],[24.735007,35.084991],[23.514978,35.279992],[23.69998,35.705004],[24.246665,35.368022],[25.025015,35.424996],[25.769208,35.354018],[25.745023,35.179998],[26.290003,35.29999]]],[[[22.952377,41.337994],[23.692074,41.309081],[24.492645,41.583896],[25.197201,41.234486],[26.106138,41.328899],[26.117042,41.826905],[26.604196,41.562115],[26.294602,40.936261],[26.056942,40.824123],[25.447677,40.852545],[24.925848,40.947062],[23.714811,40.687129],[24.407999,40.124993],[23.899968,39.962006],[23.342999,39.960998],[22.813988,40.476005],[22.626299,40.256561],[22.849748,39.659311],[23.350027,39.190011],[22.973099,38.970903],[23.530016,38.510001],[24.025025,38.219993],[24.040011,37.655015],[23.115003,37.920011],[23.409972,37.409991],[22.774972,37.30501],[23.154225,36.422506],[22.490028,36.41],[21.670026,36.844986],[21.295011,37.644989],[21.120034,38.310323],[20.730032,38.769985],[20.217712,39.340235],[20.150016,39.624998],[20.615,40.110007],[20.674997,40.435],[20.99999,40.580004],[21.02004,40.842727],[21.674161,40.931275],[22.055378,41.149866],[22.597308,41.130487],[22.76177,41.3048],[22.952377,41.337994]]]]}},{"type":"Feature","properties":{"ADMIN":"Turkey","NAME_EN":"Turkey","CONTINENT":"Asia","ADM0_A3":"TUR","ISO_A3":"TUR","NAME":"Turkey","ISO_A2":"TR","NAME_ZH":"土耳其"},"geometry":{"type":"MultiPolygon","coordinates":[[[[44.772677,37.170437],[44.293452,37.001514],[43.942259,37.256228],[42.779126,37.385264],[42.349591,37.229873],[41.212089,37.074352],[40.673259,37.091276],[39.52258,36.716054],[38.699891,36.712927],[38.167727,36.90121],[37.066761,36.623036],[36.739494,36.81752],[36.685389,36.259699],[36.41755,36.040617],[36.149763,35.821535],[35.782085,36.274995],[36.160822,36.650606],[35.550936,36.565443],[34.714553,36.795532],[34.026895,36.21996],[32.509158,36.107564],[31.699595,36.644275],[30.621625,36.677865],[30.391096,36.262981],[29.699976,36.144357],[28.732903,36.676831],[27.641187,36.658822],[27.048768,37.653361],[26.318218,38.208133],[26.8047,38.98576],[26.170785,39.463612],[27.28002,40.420014],[28.819978,40.460011],[29.240004,41.219991],[31.145934,41.087622],[32.347979,41.736264],[33.513283,42.01896],[35.167704,42.040225],[36.913127,41.335358],[38.347665,40.948586],[39.512607,41.102763],[40.373433,41.013673],[41.554084,41.535656],[42.619549,41.583173],[43.582746,41.092143],[43.752658,40.740201],[43.656436,40.253564],[44.400009,40.005],[44.79399,39.713003],[44.109225,39.428136],[44.421403,38.281281],[44.225756,37.971584],[44.77267,37.17045],[44.772677,37.170437]]],[[[26.117042,41.826905],[27.135739,42.141485],[27.99672,42.007359],[28.115525,41.622886],[28.988443,41.299934],[28.806438,41.054962],[27.619017,40.999823],[27.192377,40.690566],[26.358009,40.151994],[26.043351,40.617754],[26.056942,40.824123],[26.294602,40.936261],[26.604196,41.562115],[26.117042,41.826905]]]]}},{"type":"Feature","properties":{"ADMIN":"Albania","NAME_EN":"Albania","CONTINENT":"Europe","ADM0_A3":"ALB","ISO_A3":"ALB","NAME":"Albania","ISO_A2":"AL","NAME_ZH":"阿尔巴尼亚"},"geometry":{"type":"Polygon","coordinates":[[[21.02004,40.842727],[20.99999,40.580004],[20.674997,40.435],[20.615,40.110007],[20.150016,39.624998],[19.98,39.694993],[19.960002,39.915006],[19.406082,40.250773],[19.319059,40.72723],[19.40355,41.409566],[19.540027,41.719986],[19.371769,41.877548],[19.371768,41.877551],[19.304486,42.195745],[19.738051,42.688247],[19.801613,42.500093],[20.0707,42.58863],[20.283755,42.32026],[20.52295,42.21787],[20.590247,41.855409],[20.590247,41.855404],[20.463175,41.515089],[20.605182,41.086226],[21.02004,40.842727]]]}},{"type":"Feature","properties":{"ADMIN":"Croatia","NAME_EN":"Croatia","CONTINENT":"Europe","ADM0_A3":"HRV","ISO_A3":"HRV","NAME":"Croatia","ISO_A2":"HR","NAME_ZH":"克罗地亚"},"geometry":{"type":"Polygon","coordinates":[[[16.564808,46.503751],[16.882515,46.380632],[17.630066,45.951769],[18.456062,45.759481],[18.829825,45.908872],[19.072769,45.521511],[19.390476,45.236516],[19.005485,44.860234],[18.553214,45.08159],[17.861783,45.06774],[17.002146,45.233777],[16.534939,45.211608],[16.318157,45.004127],[15.959367,45.233777],[15.750026,44.818712],[16.23966,44.351143],[16.456443,44.04124],[16.916156,43.667722],[17.297373,43.446341],[17.674922,43.028563],[18.56,42.65],[18.450017,42.479992],[18.450016,42.479991],[17.50997,42.849995],[16.930006,43.209998],[16.015385,43.507215],[15.174454,44.243191],[15.37625,44.317915],[14.920309,44.738484],[14.901602,45.07606],[14.258748,45.233777],[13.952255,44.802124],[13.656976,45.136935],[13.679403,45.484149],[13.71506,45.500324],[14.411968,45.466166],[14.595109,45.634941],[14.935244,45.471695],[15.327675,45.452316],[15.323954,45.731783],[15.67153,45.834154],[15.768733,46.238108],[16.564808,46.503751]]]}},{"type":"Feature","properties":{"ADMIN":"Switzerland","NAME_EN":"Switzerland","CONTINENT":"Europe","ADM0_A3":"CHE","ISO_A3":"CHE","NAME":"Switzerland","ISO_A2":"CH","NAME_ZH":"瑞士"},"geometry":{"type":"Polygon","coordinates":[[[9.594226,47.525058],[9.632932,47.347601],[9.47997,47.10281],[9.932448,46.920728],[10.442701,46.893546],[10.363378,46.483571],[9.922837,46.314899],[9.182882,46.440215],[8.966306,46.036932],[8.489952,46.005151],[8.31663,46.163642],[7.755992,45.82449],[7.273851,45.776948],[6.843593,45.991147],[6.5001,46.429673],[6.022609,46.27299],[6.037389,46.725779],[6.768714,47.287708],[6.736571,47.541801],[7.192202,47.449766],[7.466759,47.620582],[8.317301,47.61358],[8.522612,47.830828],[9.594226,47.525058]]]}},{"type":"Feature","properties":{"ADMIN":"Luxembourg","NAME_EN":"Luxembourg","CONTINENT":"Europe","ADM0_A3":"LUX","ISO_A3":"LUX","NAME":"Luxembourg","ISO_A2":"LU","NAME_ZH":"卢森堡"},"geometry":{"type":"Polygon","coordinates":[[[6.043073,50.128052],[6.242751,49.902226],[6.18632,49.463803],[5.897759,49.442667],[5.674052,49.529484],[5.782417,50.090328],[6.043073,50.128052]]]}},{"type":"Feature","properties":{"ADMIN":"Belgium","NAME_EN":"Belgium","CONTINENT":"Europe","ADM0_A3":"BEL","ISO_A3":"BEL","NAME":"Belgium","ISO_A2":"BE","NAME_ZH":"比利时"},"geometry":{"type":"Polygon","coordinates":[[[6.156658,50.803721],[6.043073,50.128052],[5.782417,50.090328],[5.674052,49.529484],[4.799222,49.985373],[4.286023,49.907497],[3.588184,50.378992],[3.123252,50.780363],[2.658422,50.796848],[2.513573,51.148506],[3.314971,51.345781],[3.315011,51.345777],[3.314971,51.345755],[4.047071,51.267259],[4.973991,51.475024],[5.606976,51.037298],[6.156658,50.803721]]]}},{"type":"Feature","properties":{"ADMIN":"Netherlands","NAME_EN":"Netherlands","CONTINENT":"Europe","ADM0_A3":"NLD","ISO_A3":"NLD","NAME":"Netherlands","ISO_A2":"NL","NAME_ZH":"荷兰"},"geometry":{"type":"Polygon","coordinates":[[[6.90514,53.482162],[7.092053,53.144043],[6.84287,52.22844],[6.589397,51.852029],[5.988658,51.851616],[6.156658,50.803721],[5.606976,51.037298],[4.973991,51.475024],[4.047071,51.267259],[3.314971,51.345755],[3.315011,51.345777],[3.830289,51.620545],[4.705997,53.091798],[6.074183,53.510403],[6.90514,53.482162]]]}},{"type":"Feature","properties":{"ADMIN":"Portugal","NAME_EN":"Portugal","CONTINENT":"Europe","ADM0_A3":"PRT","ISO_A3":"PRT","NAME":"Portugal","ISO_A2":"PT","NAME_ZH":"葡萄牙"},"geometry":{"type":"Polygon","coordinates":[[[-9.034818,41.880571],[-8.671946,42.134689],[-8.263857,42.280469],[-8.013175,41.790886],[-7.422513,41.792075],[-7.251309,41.918346],[-6.668606,41.883387],[-6.389088,41.381815],[-6.851127,41.111083],[-6.86402,40.330872],[-7.026413,40.184524],[-7.066592,39.711892],[-7.498632,39.629571],[-7.098037,39.030073],[-7.374092,38.373059],[-7.029281,38.075764],[-7.166508,37.803894],[-7.537105,37.428904],[-7.453726,37.097788],[-7.855613,36.838269],[-8.382816,36.97888],[-8.898857,36.868809],[-8.746101,37.651346],[-8.839998,38.266243],[-9.287464,38.358486],[-9.526571,38.737429],[-9.446989,39.392066],[-9.048305,39.755093],[-8.977353,40.159306],[-8.768684,40.760639],[-8.790853,41.184334],[-8.990789,41.543459],[-9.034818,41.880571]]]}},{"type":"Feature","properties":{"ADMIN":"Spain","NAME_EN":"Spain","CONTINENT":"Europe","ADM0_A3":"ESP","ISO_A3":"ESP","NAME":"Spain","ISO_A2":"ES","NAME_ZH":"西班牙"},"geometry":{"type":"Polygon","coordinates":[[[-7.453726,37.097788],[-7.537105,37.428904],[-7.166508,37.803894],[-7.029281,38.075764],[-7.374092,38.373059],[-7.098037,39.030073],[-7.498632,39.629571],[-7.066592,39.711892],[-7.026413,40.184524],[-6.86402,40.330872],[-6.851127,41.111083],[-6.389088,41.381815],[-6.668606,41.883387],[-7.251309,41.918346],[-7.422513,41.792075],[-8.013175,41.790886],[-8.263857,42.280469],[-8.671946,42.134689],[-9.034818,41.880571],[-8.984433,42.592775],[-9.392884,43.026625],[-7.97819,43.748338],[-6.754492,43.567909],[-5.411886,43.57424],[-4.347843,43.403449],[-3.517532,43.455901],[-1.901351,43.422802],[-1.502771,43.034014],[0.338047,42.579546],[0.701591,42.795734],[1.826793,42.343385],[2.985999,42.473015],[3.039484,41.89212],[2.091842,41.226089],[0.810525,41.014732],[0.721331,40.678318],[0.106692,40.123934],[-0.278711,39.309978],[0.111291,38.738514],[-0.467124,38.292366],[-0.683389,37.642354],[-1.438382,37.443064],[-2.146453,36.674144],[-3.415781,36.6589],[-4.368901,36.677839],[-4.995219,36.324708],[-5.37716,35.94685],[-5.866432,36.029817],[-6.236694,36.367677],[-6.520191,36.942913],[-7.453726,37.097788]]]}},{"type":"Feature","properties":{"ADMIN":"Ireland","NAME_EN":"Ireland","CONTINENT":"Europe","ADM0_A3":"IRL","ISO_A3":"IRL","NAME":"Ireland","ISO_A2":"IE","NAME_ZH":"爱尔兰"},"geometry":{"type":"Polygon","coordinates":[[[-6.197885,53.867565],[-6.032985,53.153164],[-6.788857,52.260118],[-8.561617,51.669301],[-9.977086,51.820455],[-9.166283,52.864629],[-9.688525,53.881363],[-8.327987,54.664519],[-7.572168,55.131622],[-7.366031,54.595841],[-7.572168,54.059956],[-6.95373,54.073702],[-6.197885,53.867565]]]}},{"type":"Feature","properties":{"ADMIN":"New Caledonia","NAME_EN":"New Caledonia","CONTINENT":"Oceania","ADM0_A3":"NCL","ISO_A3":"NCL","NAME":"New Caledonia","ISO_A2":"NC","NAME_ZH":"新喀里多尼亚"},"geometry":{"type":"Polygon","coordinates":[[[165.77999,-21.080005],[166.599991,-21.700019],[167.120011,-22.159991],[166.740035,-22.399976],[166.189732,-22.129708],[165.474375,-21.679607],[164.829815,-21.14982],[164.167995,-20.444747],[164.029606,-20.105646],[164.459967,-20.120012],[165.020036,-20.459991],[165.460009,-20.800022],[165.77999,-21.080005]]]}},{"type":"Feature","properties":{"ADMIN":"Solomon Islands","NAME_EN":"Solomon Islands","CONTINENT":"Oceania","ADM0_A3":"SLB","ISO_A3":"SLB","NAME":"Solomon Is.","ISO_A2":"SB","NAME_ZH":"所罗门群岛"},"geometry":{"type":"MultiPolygon","coordinates":[[[[162.119025,-10.482719],[162.398646,-10.826367],[161.700032,-10.820011],[161.319797,-10.204751],[161.917383,-10.446701],[162.119025,-10.482719]]],[[[161.679982,-9.599982],[161.529397,-9.784312],[160.788253,-8.917543],[160.579997,-8.320009],[160.920028,-8.320009],[161.280006,-9.120011],[161.679982,-9.599982]]],[[[160.852229,-9.872937],[160.462588,-9.89521],[159.849447,-9.794027],[159.640003,-9.63998],[159.702945,-9.24295],[160.362956,-9.400304],[160.688518,-9.610162],[160.852229,-9.872937]]],[[[159.640003,-8.020027],[159.875027,-8.33732],[159.917402,-8.53829],[159.133677,-8.114181],[158.586114,-7.754824],[158.21115,-7.421872],[158.359978,-7.320018],[158.820001,-7.560003],[159.640003,-8.020027]]],[[[157.14,-7.021638],[157.538426,-7.34782],[157.33942,-7.404767],[156.90203,-7.176874],[156.491358,-6.765943],[156.542828,-6.599338],[157.14,-7.021638]]]]}},{"type":"Feature","properties":{"ADMIN":"New Zealand","NAME_EN":"New Zealand","CONTINENT":"Oceania","ADM0_A3":"NZL","ISO_A3":"NZL","NAME":"New Zealand","ISO_A2":"NZ","NAME_ZH":"新西兰"},"geometry":{"type":"MultiPolygon","coordinates":[[[[176.885824,-40.065978],[176.508017,-40.604808],[176.01244,-41.289624],[175.239567,-41.688308],[175.067898,-41.425895],[174.650973,-41.281821],[175.22763,-40.459236],[174.900157,-39.908933],[173.824047,-39.508854],[173.852262,-39.146602],[174.574802,-38.797683],[174.743474,-38.027808],[174.697017,-37.381129],[174.292028,-36.711092],[174.319004,-36.534824],[173.840997,-36.121981],[173.054171,-35.237125],[172.636005,-34.529107],[173.007042,-34.450662],[173.551298,-35.006183],[174.32939,-35.265496],[174.612009,-36.156397],[175.336616,-37.209098],[175.357596,-36.526194],[175.808887,-36.798942],[175.95849,-37.555382],[176.763195,-37.881253],[177.438813,-37.961248],[178.010354,-37.579825],[178.517094,-37.695373],[178.274731,-38.582813],[177.97046,-39.166343],[177.206993,-39.145776],[176.939981,-39.449736],[177.032946,-39.879943],[176.885824,-40.065978]]],[[[169.667815,-43.555326],[170.52492,-43.031688],[171.12509,-42.512754],[171.569714,-41.767424],[171.948709,-41.514417],[172.097227,-40.956104],[172.79858,-40.493962],[173.020375,-40.919052],[173.247234,-41.331999],[173.958405,-40.926701],[174.247587,-41.349155],[174.248517,-41.770008],[173.876447,-42.233184],[173.22274,-42.970038],[172.711246,-43.372288],[173.080113,-43.853344],[172.308584,-43.865694],[171.452925,-44.242519],[171.185138,-44.897104],[170.616697,-45.908929],[169.831422,-46.355775],[169.332331,-46.641235],[168.411354,-46.619945],[167.763745,-46.290197],[166.676886,-46.219917],[166.509144,-45.852705],[167.046424,-45.110941],[168.303763,-44.123973],[168.949409,-43.935819],[169.667815,-43.555326]]]]}},{"type":"Feature","properties":{"ADMIN":"Australia","NAME_EN":"Australia","CONTINENT":"Oceania","ADM0_A3":"AUS","ISO_A3":"AUS","NAME":"Australia","ISO_A2":"AU","NAME_ZH":"澳大利亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[147.689259,-40.808258],[148.289068,-40.875438],[148.359865,-42.062445],[148.017301,-42.407024],[147.914052,-43.211522],[147.564564,-42.937689],[146.870343,-43.634597],[146.663327,-43.580854],[146.048378,-43.549745],[145.43193,-42.693776],[145.29509,-42.03361],[144.718071,-41.162552],[144.743755,-40.703975],[145.397978,-40.792549],[146.364121,-41.137695],[146.908584,-41.000546],[147.689259,-40.808258]]],[[[126.148714,-32.215966],[125.088623,-32.728751],[124.221648,-32.959487],[124.028947,-33.483847],[123.659667,-33.890179],[122.811036,-33.914467],[122.183064,-34.003402],[121.299191,-33.821036],[120.580268,-33.930177],[119.893695,-33.976065],[119.298899,-34.509366],[119.007341,-34.464149],[118.505718,-34.746819],[118.024972,-35.064733],[117.295507,-35.025459],[116.625109,-35.025097],[115.564347,-34.386428],[115.026809,-34.196517],[115.048616,-33.623425],[115.545123,-33.487258],[115.714674,-33.259572],[115.679379,-32.900369],[115.801645,-32.205062],[115.689611,-31.612437],[115.160909,-30.601594],[114.997043,-30.030725],[115.040038,-29.461095],[114.641974,-28.810231],[114.616498,-28.516399],[114.173579,-28.118077],[114.048884,-27.334765],[113.477498,-26.543134],[113.338953,-26.116545],[113.778358,-26.549025],[113.440962,-25.621278],[113.936901,-25.911235],[114.232852,-26.298446],[114.216161,-25.786281],[113.721255,-24.998939],[113.625344,-24.683971],[113.393523,-24.384764],[113.502044,-23.80635],[113.706993,-23.560215],[113.843418,-23.059987],[113.736552,-22.475475],[114.149756,-21.755881],[114.225307,-22.517488],[114.647762,-21.82952],[115.460167,-21.495173],[115.947373,-21.068688],[116.711615,-20.701682],[117.166316,-20.623599],[117.441545,-20.746899],[118.229559,-20.374208],[118.836085,-20.263311],[118.987807,-20.044203],[119.252494,-19.952942],[119.805225,-19.976506],[120.85622,-19.683708],[121.399856,-19.239756],[121.655138,-18.705318],[122.241665,-18.197649],[122.286624,-17.798603],[122.312772,-17.254967],[123.012574,-16.4052],[123.433789,-17.268558],[123.859345,-17.069035],[123.503242,-16.596506],[123.817073,-16.111316],[124.258287,-16.327944],[124.379726,-15.56706],[124.926153,-15.0751],[125.167275,-14.680396],[125.670087,-14.51007],[125.685796,-14.230656],[126.125149,-14.347341],[126.142823,-14.095987],[126.582589,-13.952791],[127.065867,-13.817968],[127.804633,-14.276906],[128.35969,-14.86917],[128.985543,-14.875991],[129.621473,-14.969784],[129.4096,-14.42067],[129.888641,-13.618703],[130.339466,-13.357376],[130.183506,-13.10752],[130.617795,-12.536392],[131.223495,-12.183649],[131.735091,-12.302453],[132.575298,-12.114041],[132.557212,-11.603012],[131.824698,-11.273782],[132.357224,-11.128519],[133.019561,-11.376411],[133.550846,-11.786515],[134.393068,-12.042365],[134.678632,-11.941183],[135.298491,-12.248606],[135.882693,-11.962267],[136.258381,-12.049342],[136.492475,-11.857209],[136.95162,-12.351959],[136.685125,-12.887223],[136.305407,-13.29123],[135.961758,-13.324509],[136.077617,-13.724278],[135.783836,-14.223989],[135.428664,-14.715432],[135.500184,-14.997741],[136.295175,-15.550265],[137.06536,-15.870762],[137.580471,-16.215082],[138.303217,-16.807604],[138.585164,-16.806622],[139.108543,-17.062679],[139.260575,-17.371601],[140.215245,-17.710805],[140.875463,-17.369069],[141.07111,-16.832047],[141.274095,-16.38887],[141.398222,-15.840532],[141.702183,-15.044921],[141.56338,-14.561333],[141.63552,-14.270395],[141.519869,-13.698078],[141.65092,-12.944688],[141.842691,-12.741548],[141.68699,-12.407614],[141.928629,-11.877466],[142.118488,-11.328042],[142.143706,-11.042737],[142.51526,-10.668186],[142.79731,-11.157355],[142.866763,-11.784707],[143.115947,-11.90563],[143.158632,-12.325656],[143.522124,-12.834358],[143.597158,-13.400422],[143.561811,-13.763656],[143.922099,-14.548311],[144.563714,-14.171176],[144.894908,-14.594458],[145.374724,-14.984976],[145.271991,-15.428205],[145.48526,-16.285672],[145.637033,-16.784918],[145.888904,-16.906926],[146.160309,-17.761655],[146.063674,-18.280073],[146.387478,-18.958274],[147.471082,-19.480723],[148.177602,-19.955939],[148.848414,-20.39121],[148.717465,-20.633469],[149.28942,-21.260511],[149.678337,-22.342512],[150.077382,-22.122784],[150.482939,-22.556142],[150.727265,-22.402405],[150.899554,-23.462237],[151.609175,-24.076256],[152.07354,-24.457887],[152.855197,-25.267501],[153.136162,-26.071173],[153.161949,-26.641319],[153.092909,-27.2603],[153.569469,-28.110067],[153.512108,-28.995077],[153.339095,-29.458202],[153.069241,-30.35024],[153.089602,-30.923642],[152.891578,-31.640446],[152.450002,-32.550003],[151.709117,-33.041342],[151.343972,-33.816023],[151.010555,-34.31036],[150.714139,-35.17346],[150.32822,-35.671879],[150.075212,-36.420206],[149.946124,-37.109052],[149.997284,-37.425261],[149.423882,-37.772681],[148.304622,-37.809061],[147.381733,-38.219217],[146.922123,-38.606532],[146.317922,-39.035757],[145.489652,-38.593768],[144.876976,-38.417448],[145.032212,-37.896188],[144.485682,-38.085324],[143.609974,-38.809465],[142.745427,-38.538268],[142.17833,-38.380034],[141.606582,-38.308514],[140.638579,-38.019333],[139.992158,-37.402936],[139.806588,-36.643603],[139.574148,-36.138362],[139.082808,-35.732754],[138.120748,-35.612296],[138.449462,-35.127261],[138.207564,-34.384723],[137.71917,-35.076825],[136.829406,-35.260535],[137.352371,-34.707339],[137.503886,-34.130268],[137.890116,-33.640479],[137.810328,-32.900007],[136.996837,-33.752771],[136.372069,-34.094766],[135.989043,-34.890118],[135.208213,-34.47867],[135.239218,-33.947953],[134.613417,-33.222778],[134.085904,-32.848072],[134.273903,-32.617234],[132.990777,-32.011224],[132.288081,-31.982647],[131.326331,-31.495803],[129.535794,-31.590423],[128.240938,-31.948489],[127.102867,-32.282267],[126.148714,-32.215966]]]]}},{"type":"Feature","properties":{"ADMIN":"Sri Lanka","NAME_EN":"Sri Lanka","CONTINENT":"Asia","ADM0_A3":"LKA","ISO_A3":"LKA","NAME":"Sri Lanka","ISO_A2":"LK","NAME_ZH":"斯里兰卡"},"geometry":{"type":"Polygon","coordinates":[[[81.787959,7.523055],[81.637322,6.481775],[81.21802,6.197141],[80.348357,5.96837],[79.872469,6.763463],[79.695167,8.200843],[80.147801,9.824078],[80.838818,9.268427],[81.304319,8.564206],[81.787959,7.523055]]]}},{"type":"Feature","properties":{"ADMIN":"China","NAME_EN":"People's Republic of China","CONTINENT":"Asia","ADM0_A3":"CHN","ISO_A3":"CHN","NAME":"China","ISO_A2":"CN","NAME_ZH":"中国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[109.47521,18.197701],[108.655208,18.507682],[108.626217,19.367888],[109.119056,19.821039],[110.211599,20.101254],[110.786551,20.077534],[111.010051,19.69593],[110.570647,19.255879],[110.339188,18.678395],[109.47521,18.197701]]],[[[80.25999,42.349999],[80.18015,42.920068],[80.866206,43.180362],[79.966106,44.917517],[81.947071,45.317027],[82.458926,45.53965],[83.180484,47.330031],[85.16429,47.000956],[85.720484,47.452969],[85.768233,48.455751],[86.598776,48.549182],[87.35997,49.214981],[87.751264,49.297198],[88.013832,48.599463],[88.854298,48.069082],[90.280826,47.693549],[90.970809,46.888146],[90.585768,45.719716],[90.94554,45.286073],[92.133891,45.115076],[93.480734,44.975472],[94.688929,44.352332],[95.306875,44.241331],[95.762455,43.319449],[96.349396,42.725635],[97.451757,42.74889],[99.515817,42.524691],[100.845866,42.663804],[101.83304,42.514873],[103.312278,41.907468],[104.522282,41.908347],[104.964994,41.59741],[106.129316,42.134328],[107.744773,42.481516],[109.243596,42.519446],[110.412103,42.871234],[111.129682,43.406834],[111.829588,43.743118],[111.667737,44.073176],[111.348377,44.457442],[111.873306,45.102079],[112.436062,45.011646],[113.463907,44.808893],[114.460332,45.339817],[115.985096,45.727235],[116.717868,46.388202],[117.421701,46.672733],[118.874326,46.805412],[119.66327,46.69268],[119.772824,47.048059],[118.866574,47.74706],[118.064143,48.06673],[117.295507,47.697709],[116.308953,47.85341],[115.742837,47.726545],[115.485282,48.135383],[116.191802,49.134598],[116.678801,49.888531],[117.879244,49.510983],[119.288461,50.142883],[119.27939,50.58292],[120.18208,51.64355],[120.7382,51.96411],[120.725789,52.516226],[120.177089,52.753886],[121.003085,53.251401],[122.245748,53.431726],[123.57147,53.4588],[125.068211,53.161045],[125.946349,52.792799],[126.564399,51.784255],[126.939157,51.353894],[127.287456,50.739797],[127.6574,49.76027],[129.397818,49.4406],[130.582293,48.729687],[130.98726,47.79013],[132.50669,47.78896],[133.373596,48.183442],[135.026311,48.47823],[134.50081,47.57845],[134.11235,47.21248],[133.769644,46.116927],[133.09712,45.14409],[131.883454,45.321162],[131.02519,44.96796],[131.288555,44.11152],[131.144688,42.92999],[130.633866,42.903015],[130.64,42.395024],[129.994267,42.985387],[129.596669,42.424982],[128.052215,41.994285],[128.208433,41.466772],[127.343783,41.503152],[126.869083,41.816569],[126.182045,41.107336],[125.079942,40.569824],[124.265625,39.928493],[122.86757,39.637788],[122.131388,39.170452],[121.054554,38.897471],[121.585995,39.360854],[121.376757,39.750261],[122.168595,40.422443],[121.640359,40.94639],[120.768629,40.593388],[119.639602,39.898056],[119.023464,39.252333],[118.042749,39.204274],[117.532702,38.737636],[118.059699,38.061476],[118.87815,37.897325],[118.911636,37.448464],[119.702802,37.156389],[120.823457,37.870428],[121.711259,37.481123],[122.357937,37.454484],[122.519995,36.930614],[121.104164,36.651329],[120.637009,36.11144],[119.664562,35.609791],[119.151208,34.909859],[120.227525,34.360332],[120.620369,33.376723],[121.229014,32.460319],[121.908146,31.692174],[121.891919,30.949352],[121.264257,30.676267],[121.503519,30.142915],[122.092114,29.83252],[121.938428,29.018022],[121.684439,28.225513],[121.125661,28.135673],[120.395473,27.053207],[119.585497,25.740781],[118.656871,24.547391],[117.281606,23.624501],[115.890735,22.782873],[114.763827,22.668074],[114.152547,22.22376],[113.80678,22.54834],[113.241078,22.051367],[111.843592,21.550494],[110.785466,21.397144],[110.444039,20.341033],[109.889861,20.282457],[109.627655,21.008227],[109.864488,21.395051],[108.522813,21.715212],[108.05018,21.55238],[107.04342,21.811899],[106.567273,22.218205],[106.725403,22.794268],[105.811247,22.976892],[105.329209,23.352063],[104.476858,22.81915],[103.504515,22.703757],[102.706992,22.708795],[102.170436,22.464753],[101.652018,22.318199],[101.80312,21.174367],[101.270026,21.201652],[101.180005,21.436573],[101.150033,21.849984],[100.416538,21.558839],[99.983489,21.742937],[99.240899,22.118314],[99.531992,22.949039],[98.898749,23.142722],[98.660262,24.063286],[97.60472,23.897405],[97.724609,25.083637],[98.671838,25.918703],[98.712094,26.743536],[98.68269,27.508812],[98.246231,27.747221],[97.911988,28.335945],[97.327114,28.261583],[96.248833,28.411031],[96.586591,28.83098],[96.117679,29.452802],[95.404802,29.031717],[94.56599,29.277438],[93.413348,28.640629],[92.503119,27.896876],[91.696657,27.771742],[91.258854,28.040614],[90.730514,28.064954],[90.015829,28.296439],[89.47581,28.042759],[88.814248,27.299316],[88.730326,28.086865],[88.120441,27.876542],[86.954517,27.974262],[85.82332,28.203576],[85.011638,28.642774],[84.23458,28.839894],[83.898993,29.320226],[83.337115,29.463732],[82.327513,30.115268],[81.525804,30.422717],[81.111256,30.183481],[79.721367,30.882715],[78.738894,31.515906],[78.458446,32.618164],[79.176129,32.48378],[79.208892,32.994395],[78.811086,33.506198],[78.912269,34.321936],[77.837451,35.49401],[76.192848,35.898403],[75.896897,36.666806],[75.158028,37.133031],[74.980002,37.41999],[74.829986,37.990007],[74.864816,38.378846],[74.257514,38.606507],[73.928852,38.505815],[73.675379,39.431237],[73.960013,39.660008],[73.822244,39.893973],[74.776862,40.366425],[75.467828,40.562072],[76.526368,40.427946],[76.904484,41.066486],[78.187197,41.185316],[78.543661,41.582243],[80.11943,42.123941],[80.25999,42.349999]]]]}},{"type":"Feature","properties":{"ADMIN":"Taiwan, China","NAME_EN":"Taiwan, China","CONTINENT":"Asia","ADM0_A3":"TWN","ISO_A3":"TWN","NAME":"Taiwan, China","ISO_A2":"TW","NAME_ZH":"中国(台湾)"},"geometry":{"type":"Polygon","coordinates":[[[121.777818,24.394274],[121.175632,22.790857],[120.74708,21.970571],[120.220083,22.814861],[120.106189,23.556263],[120.69468,24.538451],[121.495044,25.295459],[121.951244,24.997596],[121.777818,24.394274]]]}},{"type":"Feature","properties":{"ADMIN":"Italy","NAME_EN":"Italy","CONTINENT":"Europe","ADM0_A3":"ITA","ISO_A3":"ITA","NAME":"Italy","ISO_A2":"IT","NAME_ZH":"意大利"},"geometry":{"type":"MultiPolygon","coordinates":[[[[10.442701,46.893546],[11.048556,46.751359],[11.164828,46.941579],[12.153088,47.115393],[12.376485,46.767559],[13.806475,46.509306],[13.69811,46.016778],[13.93763,45.591016],[13.141606,45.736692],[12.328581,45.381778],[12.383875,44.885374],[12.261453,44.600482],[12.589237,44.091366],[13.526906,43.587727],[14.029821,42.761008],[15.14257,41.95514],[15.926191,41.961315],[16.169897,41.740295],[15.889346,41.541082],[16.785002,41.179606],[17.519169,40.877143],[18.376687,40.355625],[18.480247,40.168866],[18.293385,39.810774],[17.73838,40.277671],[16.869596,40.442235],[16.448743,39.795401],[17.17149,39.4247],[17.052841,38.902871],[16.635088,38.843572],[16.100961,37.985899],[15.684087,37.908849],[15.687963,38.214593],[15.891981,38.750942],[16.109332,38.964547],[15.718814,39.544072],[15.413613,40.048357],[14.998496,40.172949],[14.703268,40.60455],[14.060672,40.786348],[13.627985,41.188287],[12.888082,41.25309],[12.106683,41.704535],[11.191906,42.355425],[10.511948,42.931463],[10.200029,43.920007],[9.702488,44.036279],[8.888946,44.366336],[8.428561,44.231228],[7.850767,43.767148],[7.435185,43.693845],[7.549596,44.127901],[7.007562,44.254767],[6.749955,45.028518],[7.096652,45.333099],[6.802355,45.70858],[6.843593,45.991147],[7.273851,45.776948],[7.755992,45.82449],[8.31663,46.163642],[8.489952,46.005151],[8.966306,46.036932],[9.182882,46.440215],[9.922837,46.314899],[10.363378,46.483571],[10.442701,46.893546]]],[[[14.761249,38.143874],[15.520376,38.231155],[15.160243,37.444046],[15.309898,37.134219],[15.099988,36.619987],[14.335229,36.996631],[13.826733,37.104531],[12.431004,37.61295],[12.570944,38.126381],[13.741156,38.034966],[14.761249,38.143874]]],[[[8.709991,40.899984],[9.210012,41.209991],[9.809975,40.500009],[9.669519,39.177376],[9.214818,39.240473],[8.806936,38.906618],[8.428302,39.171847],[8.388253,40.378311],[8.159998,40.950007],[8.709991,40.899984]]]]}},{"type":"Feature","properties":{"ADMIN":"Denmark","NAME_EN":"Denmark","CONTINENT":"Europe","ADM0_A3":"DNK","ISO_A3":"DNK","NAME":"Denmark","ISO_A2":"DK","NAME_ZH":"丹麦"},"geometry":{"type":"MultiPolygon","coordinates":[[[[9.921906,54.983104],[9.282049,54.830865],[8.526229,54.962744],[8.120311,55.517723],[8.089977,56.540012],[8.256582,56.809969],[8.543438,57.110003],[9.424469,57.172066],[9.775559,57.447941],[10.580006,57.730017],[10.546106,57.215733],[10.25,56.890016],[10.369993,56.609982],[10.912182,56.458621],[10.667804,56.081383],[10.369993,56.190007],[9.649985,55.469999],[9.921906,54.983104]]],[[[12.370904,56.111407],[12.690006,55.609991],[12.089991,54.800015],[11.043543,55.364864],[10.903914,55.779955],[12.370904,56.111407]]]]}},{"type":"Feature","properties":{"ADMIN":"United Kingdom","NAME_EN":"United Kingdom","CONTINENT":"Europe","ADM0_A3":"GBR","ISO_A3":"GBR","NAME":"United Kingdom","ISO_A2":"GB","NAME_ZH":"英国"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-6.197885,53.867565],[-6.95373,54.073702],[-7.572168,54.059956],[-7.366031,54.595841],[-7.572168,55.131622],[-6.733847,55.17286],[-5.661949,54.554603],[-6.197885,53.867565]]],[[[-3.093831,53.404547],[-3.09208,53.404441],[-2.945009,53.985],[-3.614701,54.600937],[-3.630005,54.615013],[-4.844169,54.790971],[-5.082527,55.061601],[-4.719112,55.508473],[-5.047981,55.783986],[-5.586398,55.311146],[-5.644999,56.275015],[-6.149981,56.78501],[-5.786825,57.818848],[-5.009999,58.630013],[-4.211495,58.550845],[-3.005005,58.635],[-4.073828,57.553025],[-3.055002,57.690019],[-1.959281,57.6848],[-2.219988,56.870017],[-3.119003,55.973793],[-2.085009,55.909998],[-2.005676,55.804903],[-1.114991,54.624986],[-0.430485,54.464376],[0.184981,53.325014],[0.469977,52.929999],[1.681531,52.73952],[1.559988,52.099998],[1.050562,51.806761],[1.449865,51.289428],[0.550334,50.765739],[-0.787517,50.774989],[-2.489998,50.500019],[-2.956274,50.69688],[-3.617448,50.228356],[-4.542508,50.341837],[-5.245023,49.96],[-5.776567,50.159678],[-4.30999,51.210001],[-3.414851,51.426009],[-3.422719,51.426848],[-4.984367,51.593466],[-5.267296,51.9914],[-4.222347,52.301356],[-4.770013,52.840005],[-4.579999,53.495004],[-3.093831,53.404547]]]]}},{"type":"Feature","properties":{"ADMIN":"Iceland","NAME_EN":"Iceland","CONTINENT":"Europe","ADM0_A3":"ISL","ISO_A3":"ISL","NAME":"Iceland","ISO_A2":"IS","NAME_ZH":"冰岛"},"geometry":{"type":"Polygon","coordinates":[[[-14.508695,66.455892],[-14.739637,65.808748],[-13.609732,65.126671],[-14.909834,64.364082],[-17.794438,63.678749],[-18.656246,63.496383],[-19.972755,63.643635],[-22.762972,63.960179],[-21.778484,64.402116],[-23.955044,64.89113],[-22.184403,65.084968],[-22.227423,65.378594],[-24.326184,65.611189],[-23.650515,66.262519],[-22.134922,66.410469],[-20.576284,65.732112],[-19.056842,66.276601],[-17.798624,65.993853],[-16.167819,66.526792],[-14.508695,66.455892]]]}},{"type":"Feature","properties":{"ADMIN":"Azerbaijan","NAME_EN":"Azerbaijan","CONTINENT":"Asia","ADM0_A3":"AZE","ISO_A3":"AZE","NAME":"Azerbaijan","ISO_A2":"AZ","NAME_ZH":"阿塞拜疆"},"geometry":{"type":"MultiPolygon","coordinates":[[[[46.404951,41.860675],[46.686071,41.827137],[47.373315,41.219732],[47.815666,41.151416],[47.987283,41.405819],[48.584353,41.808869],[49.110264,41.282287],[49.618915,40.572924],[50.08483,40.526157],[50.392821,40.256561],[49.569202,40.176101],[49.395259,39.399482],[49.223228,39.049219],[48.856532,38.815486],[48.883249,38.320245],[48.634375,38.270378],[48.010744,38.794015],[48.355529,39.288765],[48.060095,39.582235],[47.685079,39.508364],[46.50572,38.770605],[46.483499,39.464155],[46.034534,39.628021],[45.610012,39.899994],[45.891907,40.218476],[45.359175,40.561504],[45.560351,40.81229],[45.179496,40.985354],[44.97248,41.248129],[45.217426,41.411452],[45.962601,41.123873],[46.501637,41.064445],[46.637908,41.181673],[46.145432,41.722802],[46.404951,41.860675]]],[[[46.143623,38.741201],[45.457722,38.874139],[44.952688,39.335765],[44.79399,39.713003],[45.001987,39.740004],[45.298145,39.471751],[45.739978,39.473999],[45.735379,39.319719],[46.143623,38.741201]]]]}},{"type":"Feature","properties":{"ADMIN":"Georgia","NAME_EN":"Georgia","CONTINENT":"Asia","ADM0_A3":"GEO","ISO_A3":"GEO","NAME":"Georgia","ISO_A2":"GE","NAME_ZH":"格鲁吉亚"},"geometry":{"type":"Polygon","coordinates":[[[39.955009,43.434998],[40.076965,43.553104],[40.92219,43.38215],[42.3944,43.2203],[43.75599,42.74083],[43.93121,42.55496],[44.537623,42.711993],[45.470279,42.502781],[45.7764,42.09244],[46.404951,41.860675],[46.145432,41.722802],[46.637908,41.181673],[46.501637,41.064445],[45.962601,41.123873],[45.217426,41.411452],[44.97248,41.248129],[43.582746,41.092143],[42.619549,41.583173],[41.554084,41.535656],[41.703171,41.962943],[41.45347,42.645123],[40.875469,43.013628],[40.321394,43.128634],[39.955009,43.434998]]]}},{"type":"Feature","properties":{"ADMIN":"Philippines","NAME_EN":"Philippines","CONTINENT":"Asia","ADM0_A3":"PHL","ISO_A3":"PHL","NAME":"Philippines","ISO_A2":"PH","NAME_ZH":"菲律宾"},"geometry":{"type":"MultiPolygon","coordinates":[[[[120.833896,12.704496],[120.323436,13.466413],[121.180128,13.429697],[121.527394,13.06959],[121.26219,12.20556],[120.833896,12.704496]]],[[[122.586089,9.981045],[122.837081,10.261157],[122.947411,10.881868],[123.49885,10.940624],[123.337774,10.267384],[124.077936,11.232726],[123.982438,10.278779],[123.623183,9.950091],[123.309921,9.318269],[122.995883,9.022189],[122.380055,9.713361],[122.586089,9.981045]]],[[[126.376814,8.414706],[126.478513,7.750354],[126.537424,7.189381],[126.196773,6.274294],[125.831421,7.293715],[125.363852,6.786485],[125.683161,6.049657],[125.396512,5.581003],[124.219788,6.161355],[123.93872,6.885136],[124.243662,7.36061],[123.610212,7.833527],[123.296071,7.418876],[122.825506,7.457375],[122.085499,6.899424],[121.919928,7.192119],[122.312359,8.034962],[122.942398,8.316237],[123.487688,8.69301],[123.841154,8.240324],[124.60147,8.514158],[124.764612,8.960409],[125.471391,8.986997],[125.412118,9.760335],[126.222714,9.286074],[126.306637,8.782487],[126.376814,8.414706]]],[[[118.504581,9.316383],[117.174275,8.3675],[117.664477,9.066889],[118.386914,9.6845],[118.987342,10.376292],[119.511496,11.369668],[119.689677,10.554291],[119.029458,10.003653],[118.504581,9.316383]]],[[[122.336957,18.224883],[122.174279,17.810283],[122.515654,17.093505],[122.252311,16.262444],[121.662786,15.931018],[121.50507,15.124814],[121.728829,14.328376],[122.258925,14.218202],[122.701276,14.336541],[123.950295,13.782131],[123.855107,13.237771],[124.181289,12.997527],[124.077419,12.536677],[123.298035,13.027526],[122.928652,13.55292],[122.671355,13.185836],[122.03465,13.784482],[121.126385,13.636687],[120.628637,13.857656],[120.679384,14.271016],[120.991819,14.525393],[120.693336,14.756671],[120.564145,14.396279],[120.070429,14.970869],[119.920929,15.406347],[119.883773,16.363704],[120.286488,16.034629],[120.390047,17.599081],[120.715867,18.505227],[121.321308,18.504065],[121.937601,18.218552],[122.246006,18.47895],[122.336957,18.224883]]],[[[122.03837,11.415841],[121.883548,11.891755],[122.483821,11.582187],[123.120217,11.58366],[123.100838,11.165934],[122.637714,10.741308],[122.00261,10.441017],[121.967367,10.905691],[122.03837,11.415841]]],[[[125.502552,12.162695],[125.783465,11.046122],[125.011884,11.311455],[125.032761,10.975816],[125.277449,10.358722],[124.801819,10.134679],[124.760168,10.837995],[124.459101,10.88993],[124.302522,11.495371],[124.891013,11.415583],[124.87799,11.79419],[124.266762,12.557761],[125.227116,12.535721],[125.502552,12.162695]]]]}},{"type":"Feature","properties":{"ADMIN":"Malaysia","NAME_EN":"Malaysia","CONTINENT":"Asia","ADM0_A3":"MYS","ISO_A3":"MYS","NAME":"Malaysia","ISO_A2":"MY","NAME_ZH":"马来西亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[100.085757,6.464489],[100.259596,6.642825],[101.075516,6.204867],[101.154219,5.691384],[101.814282,5.810808],[102.141187,6.221636],[102.371147,6.128205],[102.961705,5.524495],[103.381215,4.855001],[103.438575,4.181606],[103.332122,3.726698],[103.429429,3.382869],[103.502448,2.791019],[103.854674,2.515454],[104.247932,1.631141],[104.228811,1.293048],[103.519707,1.226334],[102.573615,1.967115],[101.390638,2.760814],[101.27354,3.270292],[100.695435,3.93914],[100.557408,4.76728],[100.196706,5.312493],[100.30626,6.040562],[100.085757,6.464489]]],[[[117.882035,4.137551],[117.015214,4.306094],[115.865517,4.306559],[115.519078,3.169238],[115.134037,2.821482],[114.621355,1.430688],[113.80585,1.217549],[112.859809,1.49779],[112.380252,1.410121],[111.797548,0.904441],[111.159138,0.976478],[110.514061,0.773131],[109.830227,1.338136],[109.66326,2.006467],[110.396135,1.663775],[111.168853,1.850637],[111.370081,2.697303],[111.796928,2.885897],[112.995615,3.102395],[113.712935,3.893509],[114.204017,4.525874],[114.659596,4.007637],[114.869557,4.348314],[115.347461,4.316636],[115.4057,4.955228],[115.45071,5.44773],[116.220741,6.143191],[116.725103,6.924771],[117.129626,6.928053],[117.643393,6.422166],[117.689075,5.98749],[118.347691,5.708696],[119.181904,5.407836],[119.110694,5.016128],[118.439727,4.966519],[118.618321,4.478202],[117.882035,4.137551]]]]}},{"type":"Feature","properties":{"ADMIN":"Brunei","NAME_EN":"Brunei","CONTINENT":"Asia","ADM0_A3":"BRN","ISO_A3":"BRN","NAME":"Brunei","ISO_A2":"BN","NAME_ZH":"文莱"},"geometry":{"type":"Polygon","coordinates":[[[115.45071,5.44773],[115.4057,4.955228],[115.347461,4.316636],[114.869557,4.348314],[114.659596,4.007637],[114.204017,4.525874],[114.599961,4.900011],[115.45071,5.44773]]]}},{"type":"Feature","properties":{"ADMIN":"Slovenia","NAME_EN":"Slovenia","CONTINENT":"Europe","ADM0_A3":"SVN","ISO_A3":"SVN","NAME":"Slovenia","ISO_A2":"SI","NAME_ZH":"斯洛文尼亚"},"geometry":{"type":"Polygon","coordinates":[[[13.806475,46.509306],[14.632472,46.431817],[15.137092,46.658703],[16.011664,46.683611],[16.202298,46.852386],[16.370505,46.841327],[16.564808,46.503751],[15.768733,46.238108],[15.67153,45.834154],[15.323954,45.731783],[15.327675,45.452316],[14.935244,45.471695],[14.595109,45.634941],[14.411968,45.466166],[13.71506,45.500324],[13.93763,45.591016],[13.69811,46.016778],[13.806475,46.509306]]]}},{"type":"Feature","properties":{"ADMIN":"Finland","NAME_EN":"Finland","CONTINENT":"Europe","ADM0_A3":"FIN","ISO_A3":"FIN","NAME":"Finland","ISO_A2":"FI","NAME_ZH":"芬兰"},"geometry":{"type":"Polygon","coordinates":[[[28.59193,69.064777],[28.445944,68.364613],[29.977426,67.698297],[29.054589,66.944286],[30.21765,65.80598],[29.54443,64.948672],[30.444685,64.204453],[30.035872,63.552814],[31.516092,62.867687],[31.139991,62.357693],[30.211107,61.780028],[28.07,60.50352],[28.070002,60.503519],[28.069998,60.503517],[26.255173,60.423961],[24.496624,60.057316],[22.869695,59.846373],[22.290764,60.391921],[21.322244,60.72017],[21.544866,61.705329],[21.059211,62.607393],[21.536029,63.189735],[22.442744,63.81781],[24.730512,64.902344],[25.398068,65.111427],[25.294043,65.534346],[23.903379,66.006927],[23.56588,66.396051],[23.539473,67.936009],[21.978535,68.616846],[20.645593,69.106247],[21.244936,69.370443],[22.356238,68.841741],[23.66205,68.891247],[24.735679,68.649557],[25.689213,69.092114],[26.179622,69.825299],[27.732292,70.164193],[29.015573,69.766491],[28.59193,69.064777]]]}},{"type":"Feature","properties":{"ADMIN":"Slovakia","NAME_EN":"Slovakia","CONTINENT":"Europe","ADM0_A3":"SVK","ISO_A3":"SVK","NAME":"Slovakia","ISO_A2":"SK","NAME_ZH":"斯洛伐克"},"geometry":{"type":"Polygon","coordinates":[[[22.558138,49.085738],[22.280842,48.825392],[22.085608,48.422264],[21.872236,48.319971],[20.801294,48.623854],[20.473562,48.56285],[20.239054,48.327567],[19.769471,48.202691],[19.661364,48.266615],[19.174365,48.111379],[18.777025,48.081768],[18.696513,47.880954],[17.857133,47.758429],[17.488473,47.867466],[16.979667,48.123497],[16.879983,48.470013],[16.960288,48.596982],[17.101985,48.816969],[17.545007,48.800019],[17.886485,48.903475],[17.913512,48.996493],[18.104973,49.043983],[18.170498,49.271515],[18.399994,49.315001],[18.554971,49.495015],[18.853144,49.49623],[18.909575,49.435846],[19.320713,49.571574],[19.825023,49.217125],[20.415839,49.431453],[20.887955,49.328772],[21.607808,49.470107],[22.558138,49.085738]]]}},{"type":"Feature","properties":{"ADMIN":"Czechia","NAME_EN":"Czech Republic","CONTINENT":"Europe","ADM0_A3":"CZE","ISO_A3":"CZE","NAME":"Czechia","ISO_A2":"CZ","NAME_ZH":"捷克"},"geometry":{"type":"Polygon","coordinates":[[[15.016996,51.106674],[15.490972,50.78473],[16.238627,50.697733],[16.176253,50.422607],[16.719476,50.215747],[16.868769,50.473974],[17.554567,50.362146],[17.649445,50.049038],[18.392914,49.988629],[18.853144,49.49623],[18.554971,49.495015],[18.399994,49.315001],[18.170498,49.271515],[18.104973,49.043983],[17.913512,48.996493],[17.886485,48.903475],[17.545007,48.800019],[17.101985,48.816969],[16.960288,48.596982],[16.499283,48.785808],[16.029647,48.733899],[15.253416,49.039074],[14.901447,48.964402],[14.338898,48.555305],[13.595946,48.877172],[13.031329,49.307068],[12.521024,49.547415],[12.415191,49.969121],[12.240111,50.266338],[12.966837,50.484076],[13.338132,50.733234],[14.056228,50.926918],[14.307013,51.117268],[14.570718,51.002339],[15.016996,51.106674]]]}},{"type":"Feature","properties":{"ADMIN":"Eritrea","NAME_EN":"Eritrea","CONTINENT":"Africa","ADM0_A3":"ERI","ISO_A3":"ERI","NAME":"Eritrea","ISO_A2":"ER","NAME_ZH":"厄立特里亚"},"geometry":{"type":"Polygon","coordinates":[[[36.42951,14.42211],[36.32322,14.82249],[36.75389,16.29186],[36.85253,16.95655],[37.16747,17.26314],[37.904,17.42754],[38.41009,17.998307],[38.990623,16.840626],[39.26611,15.922723],[39.814294,15.435647],[41.179275,14.49108],[41.734952,13.921037],[42.276831,13.343992],[42.589576,13.000421],[43.081226,12.699639],[42.779642,12.455416],[42.35156,12.54223],[42.00975,12.86582],[41.59856,13.45209],[41.1552,13.77333],[40.8966,14.11864],[40.02625,14.51959],[39.34061,14.53155],[39.0994,14.74064],[38.51295,14.50547],[37.90607,14.95943],[37.59377,14.2131],[36.42951,14.42211]]]}},{"type":"Feature","properties":{"ADMIN":"Japan","NAME_EN":"Japan","CONTINENT":"Asia","ADM0_A3":"JPN","ISO_A3":"JPN","NAME":"Japan","ISO_A2":"JP","NAME_ZH":"日本"},"geometry":{"type":"MultiPolygon","coordinates":[[[[141.884601,39.180865],[140.959489,38.174001],[140.976388,37.142074],[140.59977,36.343983],[140.774074,35.842877],[140.253279,35.138114],[138.975528,34.6676],[137.217599,34.606286],[135.792983,33.464805],[135.120983,33.849071],[135.079435,34.596545],[133.340316,34.375938],[132.156771,33.904933],[130.986145,33.885761],[132.000036,33.149992],[131.33279,31.450355],[130.686318,31.029579],[130.20242,31.418238],[130.447676,32.319475],[129.814692,32.61031],[129.408463,33.296056],[130.353935,33.604151],[130.878451,34.232743],[131.884229,34.749714],[132.617673,35.433393],[134.608301,35.731618],[135.677538,35.527134],[136.723831,37.304984],[137.390612,36.827391],[138.857602,37.827485],[139.426405,38.215962],[140.05479,39.438807],[139.883379,40.563312],[140.305783,41.195005],[141.368973,41.37856],[141.914263,39.991616],[141.884601,39.180865]]],[[[144.613427,43.960883],[145.320825,44.384733],[145.543137,43.262088],[144.059662,42.988358],[143.18385,41.995215],[141.611491,42.678791],[141.067286,41.584594],[139.955106,41.569556],[139.817544,42.563759],[140.312087,43.333273],[141.380549,43.388825],[141.671952,44.772125],[141.967645,45.551483],[143.14287,44.510358],[143.910162,44.1741],[144.613427,43.960883]]],[[[132.371176,33.463642],[132.924373,34.060299],[133.492968,33.944621],[133.904106,34.364931],[134.638428,34.149234],[134.766379,33.806335],[134.203416,33.201178],[133.79295,33.521985],[133.280268,33.28957],[133.014858,32.704567],[132.363115,32.989382],[132.371176,33.463642]]]]}},{"type":"Feature","properties":{"ADMIN":"Paraguay","NAME_EN":"Paraguay","CONTINENT":"South America","ADM0_A3":"PRY","ISO_A3":"PRY","NAME":"Paraguay","ISO_A2":"PY","NAME_ZH":"巴拉圭"},"geometry":{"type":"Polygon","coordinates":[[[-58.166392,-20.176701],[-57.870674,-20.732688],[-57.937156,-22.090176],[-56.88151,-22.282154],[-56.473317,-22.0863],[-55.797958,-22.35693],[-55.610683,-22.655619],[-55.517639,-23.571998],[-55.400747,-23.956935],[-55.027902,-24.001274],[-54.652834,-23.839578],[-54.29296,-24.021014],[-54.293476,-24.5708],[-54.428946,-25.162185],[-54.625291,-25.739255],[-54.788795,-26.621786],[-55.695846,-27.387837],[-56.486702,-27.548499],[-57.60976,-27.395899],[-58.618174,-27.123719],[-57.63366,-25.603657],[-57.777217,-25.16234],[-58.807128,-24.771459],[-60.028966,-24.032796],[-60.846565,-23.880713],[-62.685057,-22.249029],[-62.291179,-21.051635],[-62.265961,-20.513735],[-61.786326,-19.633737],[-60.043565,-19.342747],[-59.115042,-19.356906],[-58.183471,-19.868399],[-58.166392,-20.176701]]]}},{"type":"Feature","properties":{"ADMIN":"Yemen","NAME_EN":"Yemen","CONTINENT":"Asia","ADM0_A3":"YEM","ISO_A3":"YEM","NAME":"Yemen","ISO_A2":"YE","NAME_ZH":"也门"},"geometry":{"type":"Polygon","coordinates":[[[52.00001,19.000003],[52.782184,17.349742],[53.108573,16.651051],[52.385206,16.382411],[52.191729,15.938433],[52.168165,15.59742],[51.172515,15.17525],[49.574576,14.708767],[48.679231,14.003202],[48.238947,13.94809],[47.938914,14.007233],[47.354454,13.59222],[46.717076,13.399699],[45.877593,13.347764],[45.62505,13.290946],[45.406459,13.026905],[45.144356,12.953938],[44.989533,12.699587],[44.494576,12.721653],[44.175113,12.58595],[43.482959,12.6368],[43.222871,13.22095],[43.251448,13.767584],[43.087944,14.06263],[42.892245,14.802249],[42.604873,15.213335],[42.805015,15.261963],[42.702438,15.718886],[42.823671,15.911742],[42.779332,16.347891],[43.218375,16.66689],[43.115798,17.08844],[43.380794,17.579987],[43.791519,17.319977],[44.062613,17.410359],[45.216651,17.433329],[45.399999,17.333335],[46.366659,17.233315],[46.749994,17.283338],[47.000005,16.949999],[47.466695,17.116682],[48.183344,18.166669],[49.116672,18.616668],[52.00001,19.000003]]]}},{"type":"Feature","properties":{"ADMIN":"Saudi Arabia","NAME_EN":"Saudi Arabia","CONTINENT":"Asia","ADM0_A3":"SAU","ISO_A3":"SAU","NAME":"Saudi Arabia","ISO_A2":"SA","NAME_ZH":"沙特阿拉伯"},"geometry":{"type":"Polygon","coordinates":[[[34.956037,29.356555],[36.068941,29.197495],[36.501214,29.505254],[36.740528,29.865283],[37.503582,30.003776],[37.66812,30.338665],[37.998849,30.5085],[37.002166,31.508413],[39.004886,32.010217],[39.195468,32.161009],[40.399994,31.889992],[41.889981,31.190009],[44.709499,29.178891],[46.568713,29.099025],[47.459822,29.002519],[47.708851,28.526063],[48.416094,28.552004],[48.807595,27.689628],[49.299554,27.461218],[49.470914,27.109999],[50.152422,26.689663],[50.212935,26.277027],[50.113303,25.943972],[50.239859,25.60805],[50.527387,25.327808],[50.660557,24.999896],[50.810108,24.754743],[51.112415,24.556331],[51.389608,24.627386],[51.579519,24.245497],[51.617708,24.014219],[52.000733,23.001154],[55.006803,22.496948],[55.208341,22.70833],[55.666659,22.000001],[54.999982,19.999994],[52.00001,19.000003],[49.116672,18.616668],[48.183344,18.166669],[47.466695,17.116682],[47.000005,16.949999],[46.749994,17.283338],[46.366659,17.233315],[45.399999,17.333335],[45.216651,17.433329],[44.062613,17.410359],[43.791519,17.319977],[43.380794,17.579987],[43.115798,17.08844],[43.218375,16.66689],[42.779332,16.347891],[42.649573,16.774635],[42.347989,17.075806],[42.270888,17.474722],[41.754382,17.833046],[41.221391,18.6716],[40.939341,19.486485],[40.247652,20.174635],[39.801685,20.338862],[39.139399,21.291905],[39.023696,21.986875],[39.066329,22.579656],[38.492772,23.688451],[38.02386,24.078686],[37.483635,24.285495],[37.154818,24.858483],[37.209491,25.084542],[36.931627,25.602959],[36.639604,25.826228],[36.249137,26.570136],[35.640182,27.37652],[35.130187,28.063352],[34.632336,28.058546],[34.787779,28.607427],[34.83222,28.957483],[34.956037,29.356555]]]}},{"type":"Feature","properties":{"ADMIN":"Antarctica","NAME_EN":"Antarctica","CONTINENT":"Antarctica","ADM0_A3":"ATA","ISO_A3":"ATA","NAME":"Antarctica","ISO_A2":"AQ","NAME_ZH":"南极洲"},"geometry":{"type":"MultiPolygon","coordinates":[[[[-48.660616,-78.047019],[-48.151396,-78.04707],[-46.662857,-77.831476],[-45.154758,-78.04707],[-43.920828,-78.478103],[-43.48995,-79.08556],[-43.372438,-79.516645],[-43.333267,-80.026123],[-44.880537,-80.339644],[-46.506174,-80.594357],[-48.386421,-80.829485],[-50.482107,-81.025442],[-52.851988,-80.966685],[-54.164259,-80.633528],[-53.987991,-80.222028],[-51.853134,-79.94773],[-50.991326,-79.614623],[-50.364595,-79.183487],[-49.914131,-78.811209],[-49.306959,-78.458569],[-48.660616,-78.047018],[-48.660616,-78.047019]]],[[[-66.290031,-80.255773],[-64.037688,-80.294944],[-61.883246,-80.39287],[-61.138976,-79.981371],[-60.610119,-79.628679],[-59.572095,-80.040179],[-59.865849,-80.549657],[-60.159656,-81.000327],[-62.255393,-80.863178],[-64.488125,-80.921934],[-65.741666,-80.588827],[-65.741666,-80.549657],[-66.290031,-80.255773]]],[[[-73.915819,-71.269345],[-73.915819,-71.269344],[-73.230331,-71.15178],[-72.074717,-71.190951],[-71.780962,-70.681473],[-71.72218,-70.309196],[-71.741791,-69.505782],[-71.173815,-69.035475],[-70.253252,-68.87874],[-69.724447,-69.251017],[-69.489422,-69.623346],[-69.058518,-70.074016],[-68.725541,-70.505153],[-68.451346,-70.955823],[-68.333834,-71.406493],[-68.510128,-71.798407],[-68.784297,-72.170736],[-69.959471,-72.307885],[-71.075889,-72.503842],[-72.388134,-72.484257],[-71.8985,-72.092343],[-73.073622,-72.229492],[-74.19004,-72.366693],[-74.953895,-72.072757],[-75.012625,-71.661258],[-73.915819,-71.269345]]],[[[-102.330725,-71.894164],[-102.330725,-71.894164],[-101.703967,-71.717792],[-100.430919,-71.854993],[-98.98155,-71.933334],[-97.884743,-72.070535],[-96.787937,-71.952971],[-96.20035,-72.521205],[-96.983765,-72.442864],[-98.198083,-72.482035],[-99.432013,-72.442864],[-100.783455,-72.50162],[-101.801868,-72.305663],[-102.330725,-71.894164]]],[[[-122.621735,-73.657778],[-122.621735,-73.657777],[-122.406245,-73.324619],[-121.211511,-73.50099],[-119.918851,-73.657725],[-118.724143,-73.481353],[-119.292119,-73.834097],[-120.232217,-74.08881],[-121.62283,-74.010468],[-122.621735,-73.657778]]],[[[-127.28313,-73.461769],[-127.28313,-73.461768],[-126.558472,-73.246226],[-125.559566,-73.481353],[-124.031882,-73.873268],[-124.619469,-73.834097],[-125.912181,-73.736118],[-127.28313,-73.461769]]],[[[-163.712896,-78.595667],[-163.712896,-78.595667],[-163.105801,-78.223338],[-161.245113,-78.380176],[-160.246208,-78.693645],[-159.482405,-79.046338],[-159.208184,-79.497059],[-161.127601,-79.634209],[-162.439847,-79.281465],[-163.027408,-78.928774],[-163.066604,-78.869966],[-163.712896,-78.595667]]],[[[180,-84.71338],[180,-90],[-180,-90],[-180,-84.71338],[-179.942499,-84.721443],[-179.058677,-84.139412],[-177.256772,-84.452933],[-177.140807,-84.417941],[-176.084673,-84.099259],[-175.947235,-84.110449],[-175.829882,-84.117914],[-174.382503,-84.534323],[-173.116559,-84.117914],[-172.889106,-84.061019],[-169.951223,-83.884647],[-168.999989,-84.117914],[-168.530199,-84.23739],[-167.022099,-84.570497],[-164.182144,-84.82521],[-161.929775,-85.138731],[-158.07138,-85.37391],[-155.192253,-85.09956],[-150.942099,-85.295517],[-148.533073,-85.609038],[-145.888918,-85.315102],[-143.107718,-85.040752],[-142.892279,-84.570497],[-146.829068,-84.531274],[-150.060732,-84.296146],[-150.902928,-83.904232],[-153.586201,-83.68869],[-153.409907,-83.23802],[-153.037759,-82.82652],[-152.665637,-82.454192],[-152.861517,-82.042692],[-154.526299,-81.768394],[-155.29018,-81.41565],[-156.83745,-81.102129],[-154.408787,-81.160937],[-152.097662,-81.004151],[-150.648293,-81.337309],[-148.865998,-81.043373],[-147.22075,-80.671045],[-146.417749,-80.337938],[-146.770286,-79.926439],[-148.062947,-79.652089],[-149.531901,-79.358205],[-151.588416,-79.299397],[-153.390322,-79.162248],[-155.329376,-79.064269],[-155.975668,-78.69194],[-157.268302,-78.378419],[-158.051768,-78.025676],[-158.365134,-76.889207],[-157.875474,-76.987238],[-156.974573,-77.300759],[-155.329376,-77.202728],[-153.742832,-77.065579],[-152.920247,-77.496664],[-151.33378,-77.398737],[-150.00195,-77.183143],[-148.748486,-76.908845],[-147.612483,-76.575738],[-146.104409,-76.47776],[-146.143528,-76.105431],[-146.496091,-75.733154],[-146.20231,-75.380411],[-144.909624,-75.204039],[-144.322037,-75.537197],[-142.794353,-75.34124],[-141.638764,-75.086475],[-140.209007,-75.06689],[-138.85759,-74.968911],[-137.5062,-74.733783],[-136.428901,-74.518241],[-135.214583,-74.302699],[-134.431194,-74.361455],[-133.745654,-74.439848],[-132.257168,-74.302699],[-130.925311,-74.479019],[-129.554284,-74.459433],[-128.242038,-74.322284],[-126.890622,-74.420263],[-125.402082,-74.518241],[-124.011496,-74.479019],[-122.562152,-74.498604],[-121.073613,-74.518241],[-119.70256,-74.479019],[-118.684145,-74.185083],[-117.469801,-74.028348],[-116.216312,-74.243891],[-115.021552,-74.067519],[-113.944331,-73.714828],[-113.297988,-74.028348],[-112.945452,-74.38104],[-112.299083,-74.714198],[-111.261059,-74.420263],[-110.066325,-74.79254],[-108.714909,-74.910103],[-107.559346,-75.184454],[-106.149148,-75.125698],[-104.876074,-74.949326],[-103.367949,-74.988497],[-102.016507,-75.125698],[-100.645531,-75.302018],[-100.1167,-74.870933],[-100.763043,-74.537826],[-101.252703,-74.185083],[-102.545337,-74.106742],[-103.113313,-73.734413],[-103.328752,-73.362084],[-103.681289,-72.61753],[-102.917485,-72.754679],[-101.60524,-72.813436],[-100.312528,-72.754679],[-99.13738,-72.911414],[-98.118889,-73.20535],[-97.688037,-73.558041],[-96.336595,-73.616849],[-95.043961,-73.4797],[-93.672907,-73.283743],[-92.439003,-73.166179],[-91.420564,-73.401307],[-90.088733,-73.322914],[-89.226951,-72.558722],[-88.423951,-73.009393],[-87.268337,-73.185764],[-86.014822,-73.087786],[-85.192236,-73.4797],[-83.879991,-73.518871],[-82.665646,-73.636434],[-81.470913,-73.851977],[-80.687447,-73.4797],[-80.295791,-73.126956],[-79.296886,-73.518871],[-77.925858,-73.420892],[-76.907367,-73.636434],[-76.221879,-73.969541],[-74.890049,-73.871614],[-73.852024,-73.65602],[-72.833533,-73.401307],[-71.619215,-73.264157],[-70.209042,-73.146542],[-68.935916,-73.009393],[-67.956622,-72.79385],[-67.369061,-72.480329],[-67.134036,-72.049244],[-67.251548,-71.637745],[-67.56494,-71.245831],[-67.917477,-70.853917],[-68.230843,-70.462055],[-68.485452,-70.109311],[-68.544209,-69.717397],[-68.446282,-69.325535],[-67.976233,-68.953206],[-67.5845,-68.541707],[-67.427843,-68.149844],[-67.62367,-67.718759],[-67.741183,-67.326845],[-67.251548,-66.876175],[-66.703184,-66.58224],[-66.056815,-66.209963],[-65.371327,-65.89639],[-64.568276,-65.602506],[-64.176542,-65.171423],[-63.628152,-64.897073],[-63.001394,-64.642308],[-62.041686,-64.583552],[-61.414928,-64.270031],[-60.709855,-64.074074],[-59.887269,-63.95651],[-59.162585,-63.701745],[-58.594557,-63.388224],[-57.811143,-63.27066],[-57.223582,-63.525425],[-57.59573,-63.858532],[-58.614143,-64.152467],[-59.045073,-64.36801],[-59.789342,-64.211223],[-60.611928,-64.309202],[-61.297416,-64.54433],[-62.0221,-64.799094],[-62.51176,-65.09303],[-62.648858,-65.484942],[-62.590128,-65.857219],[-62.120079,-66.190326],[-62.805567,-66.425505],[-63.74569,-66.503847],[-64.294106,-66.837004],[-64.881693,-67.150474],[-65.508425,-67.58161],[-65.665082,-67.953887],[-65.312545,-68.365335],[-64.783715,-68.678908],[-63.961103,-68.913984],[-63.1973,-69.227556],[-62.785955,-69.619419],[-62.570516,-69.991747],[-62.276736,-70.383661],[-61.806661,-70.716768],[-61.512906,-71.089045],[-61.375809,-72.010074],[-61.081977,-72.382351],[-61.003661,-72.774265],[-60.690269,-73.166179],[-60.827367,-73.695242],[-61.375809,-74.106742],[-61.96337,-74.439848],[-63.295201,-74.576997],[-63.74569,-74.92974],[-64.352836,-75.262847],[-65.860987,-75.635124],[-67.192818,-75.79191],[-68.446282,-76.007452],[-69.797724,-76.222995],[-70.600724,-76.634494],[-72.206776,-76.673665],[-73.969536,-76.634494],[-75.555977,-76.712887],[-77.24037,-76.712887],[-76.926979,-77.104802],[-75.399294,-77.28107],[-74.282876,-77.55542],[-73.656119,-77.908112],[-74.772536,-78.221633],[-76.4961,-78.123654],[-77.925858,-78.378419],[-77.984666,-78.789918],[-78.023785,-79.181833],[-76.848637,-79.514939],[-76.633224,-79.887216],[-75.360097,-80.259545],[-73.244852,-80.416331],[-71.442946,-80.69063],[-70.013163,-81.004151],[-68.191646,-81.317672],[-65.704279,-81.474458],[-63.25603,-81.748757],[-61.552026,-82.042692],[-59.691416,-82.37585],[-58.712121,-82.846106],[-58.222487,-83.218434],[-57.008117,-82.865691],[-55.362894,-82.571755],[-53.619771,-82.258235],[-51.543644,-82.003521],[-49.76135,-81.729171],[-47.273931,-81.709586],[-44.825708,-81.846735],[-42.808363,-82.081915],[-42.16202,-81.65083],[-40.771433,-81.356894],[-38.244818,-81.337309],[-36.26667,-81.121715],[-34.386397,-80.906172],[-32.310296,-80.769023],[-30.097098,-80.592651],[-28.549802,-80.337938],[-29.254901,-79.985195],[-29.685805,-79.632503],[-29.685805,-79.260226],[-31.624808,-79.299397],[-33.681324,-79.456132],[-35.639912,-79.456132],[-35.914107,-79.083855],[-35.77701,-78.339248],[-35.326546,-78.123654],[-33.896763,-77.888526],[-32.212369,-77.65345],[-30.998051,-77.359515],[-29.783732,-77.065579],[-28.882779,-76.673665],[-27.511752,-76.497345],[-26.160336,-76.360144],[-25.474822,-76.281803],[-23.927552,-76.24258],[-22.458598,-76.105431],[-21.224694,-75.909474],[-20.010375,-75.674346],[-18.913543,-75.439218],[-17.522982,-75.125698],[-16.641589,-74.79254],[-15.701491,-74.498604],[-15.40771,-74.106742],[-16.46532,-73.871614],[-16.112784,-73.460114],[-15.446855,-73.146542],[-14.408805,-72.950585],[-13.311973,-72.715457],[-12.293508,-72.401936],[-11.510067,-72.010074],[-11.020433,-71.539767],[-10.295774,-71.265416],[-9.101015,-71.324224],[-8.611381,-71.65733],[-7.416622,-71.696501],[-7.377451,-71.324224],[-6.868232,-70.93231],[-5.790985,-71.030289],[-5.536375,-71.402617],[-4.341667,-71.461373],[-3.048981,-71.285053],[-1.795492,-71.167438],[-0.659489,-71.226246],[-0.228637,-71.637745],[0.868195,-71.304639],[1.886686,-71.128267],[3.022638,-70.991118],[4.139055,-70.853917],[5.157546,-70.618789],[6.273912,-70.462055],[7.13572,-70.246512],[7.742866,-69.893769],[8.48711,-70.148534],[9.525135,-70.011333],[10.249845,-70.48164],[10.817821,-70.834332],[11.953824,-70.638375],[12.404287,-70.246512],[13.422778,-69.972162],[14.734998,-70.030918],[15.126757,-70.403247],[15.949342,-70.030918],[17.026589,-69.913354],[18.201711,-69.874183],[19.259373,-69.893769],[20.375739,-70.011333],[21.452985,-70.07014],[21.923034,-70.403247],[22.569403,-70.697182],[23.666184,-70.520811],[24.841357,-70.48164],[25.977309,-70.48164],[27.093726,-70.462055],[28.09258,-70.324854],[29.150242,-70.20729],[30.031583,-69.93294],[30.971733,-69.75662],[31.990172,-69.658641],[32.754053,-69.384291],[33.302443,-68.835642],[33.870419,-68.502588],[34.908495,-68.659271],[35.300202,-69.012014],[36.16201,-69.247142],[37.200035,-69.168748],[37.905108,-69.52144],[38.649404,-69.776205],[39.667894,-69.541077],[40.020431,-69.109941],[40.921358,-68.933621],[41.959434,-68.600514],[42.938702,-68.463313],[44.113876,-68.267408],[44.897291,-68.051866],[45.719928,-67.816738],[46.503343,-67.601196],[47.44344,-67.718759],[48.344419,-67.366068],[48.990736,-67.091718],[49.930885,-67.111303],[50.753471,-66.876175],[50.949325,-66.523484],[51.791547,-66.249133],[52.614133,-66.053176],[53.613038,-65.89639],[54.53355,-65.818049],[55.414943,-65.876805],[56.355041,-65.974783],[57.158093,-66.249133],[57.255968,-66.680218],[58.137361,-67.013324],[58.744508,-67.287675],[59.939318,-67.405239],[60.605221,-67.679589],[61.427806,-67.953887],[62.387489,-68.012695],[63.19049,-67.816738],[64.052349,-67.405239],[64.992447,-67.620729],[65.971715,-67.738345],[66.911864,-67.855909],[67.891133,-67.934302],[68.890038,-67.934302],[69.712624,-68.972791],[69.673453,-69.227556],[69.555941,-69.678226],[68.596258,-69.93294],[67.81274,-70.305268],[67.949889,-70.697182],[69.066307,-70.677545],[68.929157,-71.069459],[68.419989,-71.441788],[67.949889,-71.853287],[68.71377,-72.166808],[69.869307,-72.264787],[71.024895,-72.088415],[71.573285,-71.696501],[71.906288,-71.324224],[72.454627,-71.010703],[73.08141,-70.716768],[73.33602,-70.364024],[73.864877,-69.874183],[74.491557,-69.776205],[75.62756,-69.737034],[76.626465,-69.619419],[77.644904,-69.462684],[78.134539,-69.07077],[78.428371,-68.698441],[79.113859,-68.326216],[80.093127,-68.071503],[80.93535,-67.875546],[81.483792,-67.542388],[82.051767,-67.366068],[82.776426,-67.209282],[83.775331,-67.30726],[84.676206,-67.209282],[85.655527,-67.091718],[86.752359,-67.150474],[87.477017,-66.876175],[87.986289,-66.209911],[88.358411,-66.484261],[88.828408,-66.954568],[89.67063,-67.150474],[90.630365,-67.228867],[91.5901,-67.111303],[92.608539,-67.189696],[93.548637,-67.209282],[94.17542,-67.111303],[95.017591,-67.170111],[95.781472,-67.385653],[96.682399,-67.248504],[97.759646,-67.248504],[98.68021,-67.111303],[99.718182,-67.248504],[100.384188,-66.915346],[100.893356,-66.58224],[101.578896,-66.30789],[102.832411,-65.563284],[103.478676,-65.700485],[104.242557,-65.974783],[104.90846,-66.327527],[106.181561,-66.934931],[107.160881,-66.954568],[108.081393,-66.954568],[109.15864,-66.837004],[110.235835,-66.699804],[111.058472,-66.425505],[111.74396,-66.13157],[112.860378,-66.092347],[113.604673,-65.876805],[114.388088,-66.072762],[114.897308,-66.386283],[115.602381,-66.699804],[116.699161,-66.660633],[117.384701,-66.915346],[118.57946,-67.170111],[119.832924,-67.268089],[120.871,-67.189696],[121.654415,-66.876175],[122.320369,-66.562654],[123.221296,-66.484261],[124.122274,-66.621462],[125.160247,-66.719389],[126.100396,-66.562654],[127.001427,-66.562654],[127.882768,-66.660633],[128.80328,-66.758611],[129.704259,-66.58224],[130.781454,-66.425505],[131.799945,-66.386283],[132.935896,-66.386283],[133.85646,-66.288304],[134.757387,-66.209963],[135.031582,-65.72007],[135.070753,-65.308571],[135.697485,-65.582869],[135.873805,-66.033591],[136.206705,-66.44509],[136.618049,-66.778197],[137.460271,-66.954568],[138.596223,-66.895761],[139.908442,-66.876175],[140.809421,-66.817367],[142.121692,-66.817367],[143.061842,-66.797782],[144.374061,-66.837004],[145.490427,-66.915346],[146.195552,-67.228867],[145.999699,-67.601196],[146.646067,-67.895131],[147.723263,-68.130259],[148.839629,-68.385024],[150.132314,-68.561292],[151.483705,-68.71813],[152.502247,-68.874813],[153.638199,-68.894502],[154.284567,-68.561292],[155.165857,-68.835642],[155.92979,-69.149215],[156.811132,-69.384291],[158.025528,-69.482269],[159.181013,-69.599833],[159.670699,-69.991747],[160.80665,-70.226875],[161.570479,-70.579618],[162.686897,-70.736353],[163.842434,-70.716768],[164.919681,-70.775524],[166.11444,-70.755938],[167.309095,-70.834332],[168.425616,-70.971481],[169.463589,-71.20666],[170.501665,-71.402617],[171.20679,-71.696501],[171.089227,-72.088415],[170.560422,-72.441159],[170.109958,-72.891829],[169.75737,-73.24452],[169.287321,-73.65602],[167.975101,-73.812806],[167.387489,-74.165498],[166.094803,-74.38104],[165.644391,-74.772954],[164.958851,-75.145283],[164.234193,-75.458804],[163.822797,-75.870303],[163.568239,-76.24258],[163.47026,-76.693302],[163.489897,-77.065579],[164.057873,-77.457442],[164.273363,-77.82977],[164.743464,-78.182514],[166.604126,-78.319611],[166.995781,-78.750748],[165.193876,-78.907483],[163.666217,-79.123025],[161.766385,-79.162248],[160.924162,-79.730482],[160.747894,-80.200737],[160.316964,-80.573066],[159.788211,-80.945395],[161.120016,-81.278501],[161.629287,-81.690001],[162.490992,-82.062278],[163.705336,-82.395435],[165.095949,-82.708956],[166.604126,-83.022477],[168.895665,-83.335998],[169.404782,-83.825891],[172.283934,-84.041433],[172.477049,-84.117914],[173.224083,-84.41371],[175.985672,-84.158997],[178.277212,-84.472518],[180,-84.71338]]]]}},{"type":"Feature","properties":{"ADMIN":"Northern Cyprus","NAME_EN":"Turkish Republic of Northern Cyprus","CONTINENT":"Asia","ADM0_A3":"CYN","ISO_A3":"-99","NAME":"N. Cyprus","ISO_A2":"-99","NAME_ZH":"北塞浦路斯土耳其共和国"},"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.802474,35.145504],[32.946961,35.386703],[33.667227,35.373216],[34.576474,35.671596],[33.900804,35.245756],[33.973617,35.058506],[33.86644,35.093595],[33.675392,35.017863],[33.525685,35.038688],[33.475817,35.000345],[33.455922,35.101424],[33.383833,35.162712],[33.190977,35.173125],[32.919572,35.087833],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"ADMIN":"Cyprus","NAME_EN":"Cyprus","CONTINENT":"Asia","ADM0_A3":"CYP","ISO_A3":"CYP","NAME":"Cyprus","ISO_A2":"CY","NAME_ZH":"塞浦路斯"},"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.919572,35.087833],[33.190977,35.173125],[33.383833,35.162712],[33.455922,35.101424],[33.475817,35.000345],[33.525685,35.038688],[33.675392,35.017863],[33.86644,35.093595],[33.973617,35.058506],[34.004881,34.978098],[32.979827,34.571869],[32.490296,34.701655],[32.256667,35.103232],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"ADMIN":"Morocco","NAME_EN":"Morocco","CONTINENT":"Africa","ADM0_A3":"MAR","ISO_A3":"MAR","NAME":"Morocco","ISO_A2":"MA","NAME_ZH":"摩洛哥"},"geometry":{"type":"Polygon","coordinates":[[[-2.169914,35.168396],[-1.792986,34.527919],[-1.733455,33.919713],[-1.388049,32.864015],[-1.124551,32.651522],[-1.307899,32.262889],[-2.616605,32.094346],[-3.06898,31.724498],[-3.647498,31.637294],[-3.690441,30.896952],[-4.859646,30.501188],[-5.242129,30.000443],[-6.060632,29.7317],[-7.059228,29.579228],[-8.674116,28.841289],[-8.66559,27.656426],[-8.817828,27.656426],[-8.794884,27.120696],[-9.413037,27.088476],[-9.735343,26.860945],[-10.189424,26.860945],[-10.551263,26.990808],[-11.392555,26.883424],[-11.71822,26.104092],[-12.030759,26.030866],[-12.500963,24.770116],[-13.89111,23.691009],[-14.221168,22.310163],[-14.630833,21.86094],[-14.750955,21.5006],[-17.002962,21.420734],[-17.020428,21.42231],[-16.973248,21.885745],[-16.589137,22.158234],[-16.261922,22.67934],[-16.326414,23.017768],[-15.982611,23.723358],[-15.426004,24.359134],[-15.089332,24.520261],[-14.824645,25.103533],[-14.800926,25.636265],[-14.43994,26.254418],[-13.773805,26.618892],[-13.139942,27.640148],[-13.121613,27.654148],[-12.618837,28.038186],[-11.688919,28.148644],[-10.900957,28.832142],[-10.399592,29.098586],[-9.564811,29.933574],[-9.814718,31.177736],[-9.434793,32.038096],[-9.300693,32.564679],[-8.657476,33.240245],[-7.654178,33.697065],[-6.912544,34.110476],[-6.244342,35.145865],[-5.929994,35.759988],[-5.193863,35.755182],[-4.591006,35.330712],[-3.640057,35.399855],[-2.604306,35.179093],[-2.169914,35.168396]]]}},{"type":"Feature","properties":{"ADMIN":"Egypt","NAME_EN":"Egypt","CONTINENT":"Africa","ADM0_A3":"EGY","ISO_A3":"EGY","NAME":"Egypt","ISO_A2":"EG","NAME_ZH":"埃及"},"geometry":{"type":"Polygon","coordinates":[[[36.86623,22],[32.9,22],[29.02,22],[25,22],[25,25.6825],[25,29.238655],[24.70007,30.04419],[24.95762,30.6616],[24.80287,31.08929],[25.16482,31.56915],[26.49533,31.58568],[27.45762,31.32126],[28.45048,31.02577],[28.91353,30.87005],[29.68342,31.18686],[30.09503,31.4734],[30.97693,31.55586],[31.68796,31.4296],[31.96041,30.9336],[32.19247,31.26034],[32.99392,31.02407],[33.7734,30.96746],[34.265435,31.219357],[34.26544,31.21936],[34.823243,29.761081],[34.9226,29.50133],[34.64174,29.09942],[34.42655,28.34399],[34.15451,27.8233],[33.92136,27.6487],[33.58811,27.97136],[33.13676,28.41765],[32.42323,29.85108],[32.32046,29.76043],[32.73482,28.70523],[33.34876,27.69989],[34.10455,26.14227],[34.47387,25.59856],[34.79507,25.03375],[35.69241,23.92671],[35.49372,23.75237],[35.52598,23.10244],[36.69069,22.20485],[36.86623,22]]]}},{"type":"Feature","properties":{"ADMIN":"Libya","NAME_EN":"Libya","CONTINENT":"Africa","ADM0_A3":"LBY","ISO_A3":"LBY","NAME":"Libya","ISO_A2":"LY","NAME_ZH":"利比亚"},"geometry":{"type":"Polygon","coordinates":[[[25,22],[25,20.00304],[23.85,20],[23.83766,19.58047],[19.84926,21.49509],[15.86085,23.40972],[14.8513,22.86295],[14.143871,22.491289],[13.581425,23.040506],[11.999506,23.471668],[11.560669,24.097909],[10.771364,24.562532],[10.303847,24.379313],[9.948261,24.936954],[9.910693,25.365455],[9.319411,26.094325],[9.716286,26.512206],[9.629056,27.140953],[9.756128,27.688259],[9.683885,28.144174],[9.859998,28.95999],[9.805634,29.424638],[9.48214,30.307556],[9.970017,30.539325],[10.056575,30.961831],[9.950225,31.37607],[10.636901,31.761421],[10.94479,32.081815],[11.432253,32.368903],[11.488787,33.136996],[12.66331,32.79278],[13.08326,32.87882],[13.91868,32.71196],[15.24563,32.26508],[15.71394,31.37626],[16.61162,31.18218],[18.02109,30.76357],[19.08641,30.26639],[19.57404,30.52582],[20.05335,30.98576],[19.82033,31.75179],[20.13397,32.2382],[20.85452,32.7068],[21.54298,32.8432],[22.89576,32.63858],[23.2368,32.19149],[23.60913,32.18726],[23.9275,32.01667],[24.92114,31.89936],[25.16482,31.56915],[24.80287,31.08929],[24.95762,30.6616],[24.70007,30.04419],[25,29.238655],[25,25.6825],[25,22]]]}},{"type":"Feature","properties":{"ADMIN":"Ethiopia","NAME_EN":"Ethiopia","CONTINENT":"Africa","ADM0_A3":"ETH","ISO_A3":"ETH","NAME":"Ethiopia","ISO_A2":"ET","NAME_ZH":"埃塞俄比亚"},"geometry":{"type":"Polygon","coordinates":[[[47.78942,8.003],[44.9636,5.00162],[43.66087,4.95755],[42.76967,4.25259],[42.12861,4.23413],[41.855083,3.918912],[41.1718,3.91909],[40.76848,4.25702],[39.85494,3.83879],[39.559384,3.42206],[38.89251,3.50074],[38.67114,3.61607],[38.43697,3.58851],[38.120915,3.598605],[36.855093,4.447864],[36.159079,4.447864],[35.817448,4.776966],[35.817448,5.338232],[35.298007,5.506],[34.70702,6.59422],[34.25032,6.82607],[34.0751,7.22595],[33.56829,7.71334],[32.95418,7.78497],[33.2948,8.35458],[33.8255,8.37916],[33.97498,8.68456],[33.96162,9.58358],[34.25745,10.63009],[34.73115,10.91017],[34.83163,11.31896],[35.26049,12.08286],[35.86363,12.57828],[36.27022,13.56333],[36.42951,14.42211],[37.59377,14.2131],[37.90607,14.95943],[38.51295,14.50547],[39.0994,14.74064],[39.34061,14.53155],[40.02625,14.51959],[40.8966,14.11864],[41.1552,13.77333],[41.59856,13.45209],[42.00975,12.86582],[42.35156,12.54223],[42,12.1],[41.66176,11.6312],[41.73959,11.35511],[41.75557,11.05091],[42.31414,11.0342],[42.55493,11.10511],[42.776852,10.926879],[42.55876,10.57258],[42.92812,10.02194],[43.29699,9.54048],[43.67875,9.18358],[46.94834,7.99688],[47.78942,8.003]]]}},{"type":"Feature","properties":{"ADMIN":"Djibouti","NAME_EN":"Djibouti","CONTINENT":"Africa","ADM0_A3":"DJI","ISO_A3":"DJI","NAME":"Djibouti","ISO_A2":"DJ","NAME_ZH":"吉布提"},"geometry":{"type":"Polygon","coordinates":[[[42.35156,12.54223],[42.779642,12.455416],[43.081226,12.699639],[43.317852,12.390148],[43.286381,11.974928],[42.715874,11.735641],[43.145305,11.46204],[42.776852,10.926879],[42.55493,11.10511],[42.31414,11.0342],[41.75557,11.05091],[41.73959,11.35511],[41.66176,11.6312],[42,12.1],[42.35156,12.54223]]]}},{"type":"Feature","properties":{"ADMIN":"Somaliland","NAME_EN":"Somaliland","CONTINENT":"Africa","ADM0_A3":"SOL","ISO_A3":"-99","NAME":"Somaliland","ISO_A2":"-99","NAME_ZH":"索马里兰"},"geometry":{"type":"Polygon","coordinates":[[[48.948205,11.410617],[48.948205,11.410617],[48.942005,11.394266],[48.938491,10.982327],[48.938233,9.9735],[48.93813,9.451749],[48.486736,8.837626],[47.78942,8.003],[46.94834,7.99688],[43.67875,9.18358],[43.29699,9.54048],[42.92812,10.02194],[42.55876,10.57258],[42.776852,10.926879],[43.145305,11.46204],[43.47066,11.27771],[43.666668,10.864169],[44.117804,10.445538],[44.614259,10.442205],[45.556941,10.698029],[46.645401,10.816549],[47.525658,11.127228],[48.021596,11.193064],[48.378784,11.375482],[48.948206,11.410622],[48.948205,11.410617]]]}},{"type":"Feature","properties":{"ADMIN":"Uganda","NAME_EN":"Uganda","CONTINENT":"Africa","ADM0_A3":"UGA","ISO_A3":"UGA","NAME":"Uganda","ISO_A2":"UG","NAME_ZH":"乌干达"},"geometry":{"type":"Polygon","coordinates":[[[33.903711,-0.95],[31.86617,-1.02736],[30.76986,-1.01455],[30.419105,-1.134659],[29.821519,-1.443322],[29.579466,-1.341313],[29.587838,-0.587406],[29.819503,-0.20531],[29.875779,0.59738],[30.086154,1.062313],[30.468508,1.583805],[30.85267,1.849396],[31.174149,2.204465],[30.773347,2.339883],[30.83386,3.509166],[30.833852,3.509172],[31.24556,3.7819],[31.88145,3.55827],[32.68642,3.79232],[33.39,3.79],[34.005,4.249885],[34.47913,3.5556],[34.59607,3.05374],[35.03599,1.90584],[34.6721,1.17694],[34.18,0.515],[33.893569,0.109814],[33.903711,-0.95]]]}},{"type":"Feature","properties":{"ADMIN":"Rwanda","NAME_EN":"Rwanda","CONTINENT":"Africa","ADM0_A3":"RWA","ISO_A3":"RWA","NAME":"Rwanda","ISO_A2":"RW","NAME_ZH":"卢旺达"},"geometry":{"type":"Polygon","coordinates":[[[30.419105,-1.134659],[30.816135,-1.698914],[30.758309,-2.28725],[30.46967,-2.41383],[30.469674,-2.413855],[29.938359,-2.348487],[29.632176,-2.917858],[29.024926,-2.839258],[29.117479,-2.292211],[29.254835,-2.21511],[29.291887,-1.620056],[29.579466,-1.341313],[29.821519,-1.443322],[30.419105,-1.134659]]]}},{"type":"Feature","properties":{"ADMIN":"Bosnia and Herzegovina","NAME_EN":"Bosnia and Herzegovina","CONTINENT":"Europe","ADM0_A3":"BIH","ISO_A3":"BIH","NAME":"Bosnia and Herz.","ISO_A2":"BA","NAME_ZH":"波斯尼亚和黑塞哥维那"},"geometry":{"type":"Polygon","coordinates":[[[18.56,42.65],[17.674922,43.028563],[17.297373,43.446341],[16.916156,43.667722],[16.456443,44.04124],[16.23966,44.351143],[15.750026,44.818712],[15.959367,45.233777],[16.318157,45.004127],[16.534939,45.211608],[17.002146,45.233777],[17.861783,45.06774],[18.553214,45.08159],[19.005485,44.860234],[19.00548,44.86023],[19.36803,44.863],[19.11761,44.42307],[19.59976,44.03847],[19.454,43.5681],[19.21852,43.52384],[19.03165,43.43253],[18.70648,43.20011],[18.56,42.65]]]}},{"type":"Feature","properties":{"ADMIN":"North Macedonia","NAME_EN":"North Macedonia","CONTINENT":"Europe","ADM0_A3":"MKD","ISO_A3":"MKD","NAME":"North Macedonia","ISO_A2":"MK","NAME_ZH":"北马其顿"},"geometry":{"type":"Polygon","coordinates":[[[22.380526,42.32026],[22.881374,41.999297],[22.952377,41.337994],[22.76177,41.3048],[22.597308,41.130487],[22.055378,41.149866],[21.674161,40.931275],[21.02004,40.842727],[20.605182,41.086226],[20.463175,41.515089],[20.590247,41.855404],[20.590247,41.855409],[20.71731,41.84711],[20.76216,42.05186],[21.3527,42.2068],[21.576636,42.245224],[21.91708,42.30364],[22.380526,42.32026]]]}},{"type":"Feature","properties":{"ADMIN":"Republic of Serbia","NAME_EN":"Serbia","CONTINENT":"Europe","ADM0_A3":"SRB","ISO_A3":"SRB","NAME":"Serbia","ISO_A2":"RS","NAME_ZH":"塞尔维亚"},"geometry":{"type":"MultiPolygon","coordinates":[[[[18.829825,45.908872],[18.829838,45.908878],[19.596045,46.17173],[20.220192,46.127469],[20.762175,45.734573],[20.874313,45.416375],[21.483526,45.18117],[21.562023,44.768947],[22.145088,44.478422],[22.459022,44.702517],[22.705726,44.578003],[22.474008,44.409228],[22.65715,44.234923],[22.410446,44.008063],[22.500157,43.642814],[22.986019,43.211161],[22.604801,42.898519],[22.436595,42.580321],[22.545012,42.461362],[22.380526,42.32026],[21.91708,42.30364],[21.576636,42.245224],[21.54332,42.32025],[21.66292,42.43922],[21.77505,42.6827],[21.63302,42.67717],[21.43866,42.86255],[21.27421,42.90959],[21.143395,43.068685],[20.95651,43.13094],[20.81448,43.27205],[20.63508,43.21671],[20.49679,42.88469],[20.25758,42.81275],[20.3398,42.89852],[19.95857,43.10604],[19.63,43.21378],[19.48389,43.35229],[19.21852,43.52384],[19.454,43.5681],[19.59976,44.03847],[19.11761,44.42307],[19.36803,44.863],[19.00548,44.86023],[19.005485,44.860234],[19.390476,45.236516],[19.072769,45.521511],[18.829825,45.908872]]],[[[20.590247,41.855409],[20.52295,42.21787],[20.283755,42.32026],[20.0707,42.58863],[20.25758,42.81275],[20.49679,42.88469],[20.63508,43.21671],[20.81448,43.27205],[20.95651,43.13094],[21.143395,43.068685],[21.27421,42.90959],[21.43866,42.86255],[21.63302,42.67717],[21.77505,42.6827],[21.66292,42.43922],[21.54332,42.32025],[21.576636,42.245224],[21.3527,42.2068],[20.76216,42.05186],[20.71731,41.84711],[20.590247,41.855409]]]]}},{"type":"Feature","properties":{"ADMIN":"Montenegro","NAME_EN":"Montenegro","CONTINENT":"Europe","ADM0_A3":"MNE","ISO_A3":"MNE","NAME":"Montenegro","ISO_A2":"ME","NAME_ZH":"黑山"},"geometry":{"type":"Polygon","coordinates":[[[20.0707,42.58863],[19.801613,42.500093],[19.738051,42.688247],[19.304486,42.195745],[19.371768,41.877551],[19.16246,41.95502],[18.88214,42.28151],[18.450017,42.479992],[18.56,42.65],[18.70648,43.20011],[19.03165,43.43253],[19.21852,43.52384],[19.48389,43.35229],[19.63,43.21378],[19.95857,43.10604],[20.3398,42.89852],[20.25758,42.81275],[20.0707,42.58863]]]}},{"type":"Feature","properties":{"ADMIN":"Trinidad and Tobago","NAME_EN":"Trinidad and Tobago","CONTINENT":"North America","ADM0_A3":"TTO","ISO_A3":"TTO","NAME":"Trinidad and Tobago","ISO_A2":"TT","NAME_ZH":"特立尼达和多巴哥"},"geometry":{"type":"Polygon","coordinates":[[[-61.68,10.76],[-61.105,10.89],[-60.895,10.855],[-60.935,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.365],[-61.68,10.76]]]}},{"type":"Feature","properties":{"ADMIN":"South Sudan","NAME_EN":"South Sudan","CONTINENT":"Africa","ADM0_A3":"SDS","ISO_A3":"SSD","NAME":"S. Sudan","ISO_A2":"SS","NAME_ZH":"南苏丹"},"geometry":{"type":"Polygon","coordinates":[[[30.833852,3.509172],[29.9535,4.173699],[29.715995,4.600805],[29.159078,4.389267],[28.696678,4.455077],[28.428994,4.287155],[27.979977,4.408413],[27.374226,5.233944],[27.213409,5.550953],[26.465909,5.946717],[26.213418,6.546603],[25.796648,6.979316],[25.124131,7.500085],[25.114932,7.825104],[24.567369,8.229188],[23.88698,8.61973],[24.194068,8.728696],[24.537415,8.917538],[24.794926,9.810241],[25.069604,10.27376],[25.790633,10.411099],[25.962307,10.136421],[26.477328,9.55273],[26.752006,9.466893],[27.112521,9.638567],[27.833551,9.604232],[27.97089,9.398224],[28.966597,9.398224],[29.000932,9.604232],[29.515953,9.793074],[29.618957,10.084919],[29.996639,10.290927],[30.837841,9.707237],[31.352862,9.810241],[31.850716,10.531271],[32.400072,11.080626],[32.314235,11.681484],[32.073892,11.97333],[32.67475,12.024832],[32.743419,12.248008],[33.206938,12.179338],[33.086766,11.441141],[33.206938,10.720112],[33.721959,10.325262],[33.842131,9.981915],[33.824963,9.484061],[33.963393,9.464285],[33.97498,8.68456],[33.8255,8.37916],[33.2948,8.35458],[32.95418,7.78497],[33.56829,7.71334],[34.0751,7.22595],[34.25032,6.82607],[34.70702,6.59422],[35.298007,5.506],[34.620196,4.847123],[34.005,4.249885],[33.39,3.79],[32.68642,3.79232],[31.88145,3.55827],[31.24556,3.7819],[30.833852,3.509172]]]}}]} \ No newline at end of file diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 05c6be81..6faadaf7 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -1420,6 +1420,32 @@ +
+
国界精度
+
+
+
+ 国界精度 + 正在读取高清国界状态... +
+ +
+
+ + +
+ +
+
+
+
系统
diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 7e306f5f..28164a39 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -190,6 +190,7 @@ export const COUNTRY_BOUNDARY_CONFIG = { tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href, tileProvider: "auto", pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href, + legacyFallbackPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href, mvtLayerNames: { boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"], claim: ["claim_line"], diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 1eaad6e4..2e2f7a00 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -34,6 +34,7 @@ import { } from "./terrain.js"; import { reloadData, + reloadCountryBoundaries, clearLockedObject, clearLockedObjectAndInfo, setCablesEnabled, @@ -64,7 +65,12 @@ import { } from "./interactable.js"; import { getShowCables } from "./cables.js"; import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js"; -import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js"; +import { + getHighPrecisionBoundariesEnabled, + getShowCountryBoundaries, + setHighPrecisionBoundariesEnabled, + toggleCountryBoundaries, +} from "./country-boundaries.js"; import { toggleComputeCenters, getShowComputeCenters, @@ -115,6 +121,8 @@ let motionProvider = DEFAULT_MOTION_PROVIDER; let motionDebugSkeletonOnly = false; let activeCamera = null; let settingsApplyPromise = Promise.resolve(); +let boundaryBuildPollTimer = null; +let boundaryBuildAttemptedThisSession = false; let earthObj = null; let listeners = []; @@ -1076,6 +1084,7 @@ function syncMotionProviderControls(nextProvider = motionProvider) { button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); + syncSegmentedControlSliders(); } function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) { @@ -1206,6 +1215,16 @@ function syncCruiseModuleControls() { }); } +function syncSegmentedControlSliders() { + document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => { + if (!(segmented instanceof HTMLElement)) return; + const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill")); + const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active"))); + segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length))); + segmented.style.setProperty("--active-index", String(activeIndex)); + }); +} + function syncSatelliteDisplayStyleControls() { const activeStyle = getSatelliteDisplayStyle(); document.querySelectorAll("[data-satellite-display-style]").forEach((button) => { @@ -1215,6 +1234,7 @@ function syncSatelliteDisplayStyleControls() { button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); + syncSegmentedControlSliders(); } function syncSatelliteIdleBreathingToggle() { @@ -1253,6 +1273,7 @@ function syncSurfaceHoverInfoModeControls() { button.classList.toggle("is-active", active); button.setAttribute("aria-pressed", active ? "true" : "false"); }); + syncSegmentedControlSliders(); } export function getCruiseModules() { @@ -2694,6 +2715,204 @@ export function setDayNightInteractable(enabled) { }); } +function getBoundaryPrecisionEls() { + return { + status: document.getElementById("boundary-precision-status"), + detail: document.getElementById("boundary-precision-detail"), + progressWrap: document.getElementById("boundary-precision-progress-wrap"), + progressBar: document.getElementById("boundary-precision-progress-bar"), + progressValue: document.getElementById("boundary-precision-progress-value"), + buildButton: document.getElementById("boundary-precision-build"), + rebuildButton: document.getElementById("boundary-precision-rebuild"), + disableButton: document.getElementById("boundary-precision-disable"), + }; +} + +async function fetchBoundaryPrecisionJson(path, options = {}) { + const response = await fetch(path, { + cache: "no-store", + ...options, + headers: { + "content-type": "application/json", + ...(options.headers || {}), + }, + }); + const contentType = response.headers.get("content-type") || ""; + if (!response.ok) { + let detail = `HTTP ${response.status}`; + if (contentType.includes("application/json")) { + const payload = await response.json().catch(() => null); + detail = payload?.detail?.message || payload?.detail || detail; + } + throw new Error(detail); + } + if (!contentType.includes("application/json")) { + throw new Error("后端没有返回 JSON 状态"); + } + return response.json(); +} + +function renderBoundaryPrecisionStatus(payload = {}) { + const els = getBoundaryPrecisionEls(); + const job = payload.job || payload.current_job || {}; + const highReady = Boolean(payload.high_precision_ready || job?.result?.high_precision_ready); + const enabled = getHighPrecisionBoundariesEnabled(); + const running = job.status === "queued" || job.status === "running"; + const failed = job.status === "failed" && boundaryBuildAttemptedThisSession; + const progress = Math.max(0, Math.min(100, Number(job.progress || 0))); + const failureMessage = job.code === "source_not_configured" || job.code === "missing_sources" + ? `高清国界更新源未配置完整:${job.message || job.code}` + : `高清国界下载失败:${job.message || job.code || "请检查更新源"}`; + + if (els.status) { + els.status.textContent = "国界精度"; + } + if (els.detail) { + els.detail.textContent = running + ? (job.message || "正在准备高清国界") + : failed + ? failureMessage + : highReady + ? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。") + : "当前使用低精国界;切到高精会下载并构建。"; + } + if (els.progressWrap) { + els.progressWrap.hidden = !running; + } + if (els.progressBar) { + els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`; + } + if (els.progressValue) { + els.progressValue.textContent = job.status === "failed" + ? `失败:${job.message || job.code || "构建失败"}` + : `${running || job.status === "succeeded" ? progress || 100 : progress}%`; + } + if (els.buildButton instanceof HTMLButtonElement) { + els.buildButton.disabled = running; + els.buildButton.textContent = "高精"; + els.buildButton.classList.toggle("is-active", enabled || running); + els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false"); + } + if (els.rebuildButton instanceof HTMLButtonElement) { + const shouldShowRebuild = highReady && enabled && !running; + els.rebuildButton.hidden = !shouldShowRebuild; + els.rebuildButton.disabled = !shouldShowRebuild; + } + if (els.disableButton instanceof HTMLButtonElement) { + els.disableButton.disabled = running; + els.disableButton.classList.toggle("is-active", !enabled && !running); + els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false"); + } + syncSegmentedControlSliders(); +} + +async function refreshBoundaryPrecisionStatus() { + const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/status"); + renderBoundaryPrecisionStatus(payload); + return payload; +} + +function stopBoundaryBuildPolling() { + if (boundaryBuildPollTimer) { + window.clearInterval(boundaryBuildPollTimer); + boundaryBuildPollTimer = null; + } +} + +function startBoundaryBuildPolling() { + stopBoundaryBuildPolling(); + boundaryBuildPollTimer = window.setInterval(async () => { + try { + const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build/status"); + renderBoundaryPrecisionStatus(payload); + const status = payload.job?.status; + if (status === "succeeded" || status === "failed") { + stopBoundaryBuildPolling(); + await refreshBoundaryPrecisionStatus(); + if (status === "succeeded" && boundaryBuildAttemptedThisSession) { + setHighPrecisionBoundariesEnabled(true); + await reloadCountryBoundaries({ suppressStatus: true }); + await refreshBoundaryPrecisionStatus().catch(() => {}); + showStatusMessage("高精国界已下载并应用", "info"); + } + } + } catch (error) { + stopBoundaryBuildPolling(); + showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning"); + } + }, 1000); +} + +async function startBoundaryPrecisionBuild() { + renderBoundaryPrecisionStatus({ + job: { status: "queued", progress: 0, message: "正在启动高精国界构建" }, + }); + boundaryBuildAttemptedThisSession = true; + await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" }); + showStatusMessage("高精国界构建已启动", "info"); + startBoundaryBuildPolling(); +} + +async function setupBoundaryPrecisionControls() { + const els = getBoundaryPrecisionEls(); + if (!els.buildButton && !els.disableButton) return; + try { + const payload = await refreshBoundaryPrecisionStatus(); + const jobStatus = payload.current_job?.status; + if (jobStatus === "queued" || jobStatus === "running") { + startBoundaryBuildPolling(); + } + } catch (error) { + renderBoundaryPrecisionStatus({}); + showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning"); + } + + if (els.buildButton instanceof HTMLButtonElement) { + bindListener(els.buildButton, "click", async () => { + try { + const statusPayload = await refreshBoundaryPrecisionStatus(); + const job = statusPayload.job || statusPayload.current_job || {}; + const highReady = Boolean(statusPayload.high_precision_ready || job?.result?.high_precision_ready); + if (highReady) { + if (getHighPrecisionBoundariesEnabled()) return; + setHighPrecisionBoundariesEnabled(true); + await reloadCountryBoundaries({ suppressStatus: true }); + showStatusMessage("已切换到高精国界", "info"); + await refreshBoundaryPrecisionStatus().catch(() => {}); + return; + } + await startBoundaryPrecisionBuild(); + } catch (error) { + await refreshBoundaryPrecisionStatus().catch(() => {}); + showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning"); + } + }); + } + if (els.rebuildButton instanceof HTMLButtonElement) { + bindListener(els.rebuildButton, "click", async () => { + try { + await startBoundaryPrecisionBuild(); + } catch (error) { + await refreshBoundaryPrecisionStatus().catch(() => {}); + showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning"); + } + }); + } + if (els.disableButton instanceof HTMLButtonElement) { + bindListener(els.disableButton, "click", async () => { + try { + if (!getHighPrecisionBoundariesEnabled()) return; + setHighPrecisionBoundariesEnabled(false); + await reloadCountryBoundaries({ suppressStatus: true }); + showStatusMessage("已切换到低精国界", "info"); + await refreshBoundaryPrecisionStatus().catch(() => {}); + } catch (error) { + showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning"); + } + }); + } +} + function setupSettingsControls() { const settingsTrigger = document.getElementById("settings-trigger"); const settingsClose = document.getElementById("settings-close"); @@ -2907,6 +3126,7 @@ function setupSettingsControls() { syncMotionDebugToggle(motionDebugEnabled); syncMotionProviderControls(motionProvider); syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly); + void setupBoundaryPrecisionControls(); } function setupHudPanelControls() { @@ -4212,6 +4432,7 @@ function syncRotationModeButtons() { button.classList.toggle("is-active", isActive); button.setAttribute("aria-pressed", isActive ? "true" : "false"); }); + syncSegmentedControlSliders(); } function updateRotateUI() { diff --git a/frontend/public/earth/js/country-boundaries.js b/frontend/public/earth/js/country-boundaries.js index 6d35f2e6..1d3e5b58 100644 --- a/frontend/public/earth/js/country-boundaries.js +++ b/frontend/public/earth/js/country-boundaries.js @@ -23,6 +23,7 @@ let _lastHoverInfo = null; let _hoverGeometryCache = new Map(); let _tileManifest = null; let _tileProvider = "pmtiles-mvt"; +let _boundaryProviderState = "unloaded"; let _pmtilesArchive = null; let _tileCache = new Map(); let _tileLru = []; @@ -39,6 +40,32 @@ let _loaded = false; let _loadPromise = null; let _tileAssetVersion = ""; +const HIGH_PRECISION_BOUNDARIES_STORAGE_KEY = "planet.earth.boundaries.highPrecisionEnabled"; + +function canUseLocalStorage() { + try { + return typeof window !== "undefined" && !!window.localStorage; + } catch (_) { + return false; + } +} + +export function getHighPrecisionBoundariesEnabled() { + if (!canUseLocalStorage()) return false; + return window.localStorage.getItem(HIGH_PRECISION_BOUNDARIES_STORAGE_KEY) === "true"; +} + +export function setHighPrecisionBoundariesEnabled(enabled) { + const nextEnabled = Boolean(enabled); + if (canUseLocalStorage()) { + window.localStorage.setItem( + HIGH_PRECISION_BOUNDARIES_STORAGE_KEY, + nextEnabled ? "true" : "false", + ); + } + return nextEnabled; +} + const OCEAN_HEX = 0x010609; // ─── Equirectangular land/ocean fill texture ────────────────────────────────── @@ -346,6 +373,22 @@ function manifestProvider(manifest) { return manifest?.tileProvider || manifest?.format || "pmtiles-mvt"; } +async function fetchJsonAsset(url, { required = false } = {}) { + const resp = await fetch(url, { cache: "no-store" }); + if (!resp.ok) { + if (required) throw new Error(`${url} HTTP ${resp.status}`); + return null; + } + const text = await resp.text(); + try { + return JSON.parse(text); + } catch (err) { + if (required) throw err; + console.warn("[country-boundaries] JSON asset unavailable", url, err); + return null; + } +} + function manifestPmtilesUrl(manifest) { const fromManifest = manifest?.pmtiles?.url || @@ -545,7 +588,7 @@ function tileKeysForBbox(bbox, zoom, { claim = false } = {}) { async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) { if (!_loaded || !_visible || !_tileManifest) return; - if (_tileProvider === "geojson-high-precision") return; + if (_tileProvider !== "pmtiles-mvt") return; const tileZoom = tileZoomForViewZoom(viewZoom); if (!tileZoom) { _lastTileSignature = ""; @@ -787,39 +830,54 @@ export async function loadCountryBoundaries() { _loadPromise = (async () => { let geojson = { type: "FeatureCollection", features: [] }; let baseGeojson = null; - const manifestResp = await fetch(COUNTRY_BOUNDARY_CONFIG.tileManifestPath, { cache: "no-store" }); - if (!manifestResp.ok) { - throw new Error(`高精度国界 PMTiles manifest 未生成 HTTP ${manifestResp.status}`); - } - _tileManifest = await manifestResp.json(); - _tileProvider = manifestProvider(_tileManifest); - if (!["pmtiles-mvt", "geojson-high-precision"].includes(_tileProvider)) { - throw new Error(`高精度国界 provider 不可用: ${_tileProvider}`); - } - _tileAssetVersion = [ - _tileManifest.version, - _tileManifest.builtAt, - _tileManifest.sourceFeatureCount, - _tileManifest.pmtiles?.sha256, - ].filter(Boolean).join("-"); - if (_tileProvider === "pmtiles-mvt") { - const pmtilesUrl = manifestPmtilesUrl(_tileManifest); - const pmtilesResp = await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" }); - if (!pmtilesResp.ok) { - throw new Error(`高精度国界 PMTiles 不存在 HTTP ${pmtilesResp.status}: ${pmtilesUrl}`); + let claimGeojson = null; + const highPrecisionEnabled = getHighPrecisionBoundariesEnabled(); + const manifest = highPrecisionEnabled + ? await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.tileManifestPath) + : null; + if (manifest) { + const provider = manifestProvider(manifest); + const pmtilesUrl = manifestPmtilesUrl(manifest); + const pmtilesResp = provider === "pmtiles-mvt" + ? await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" }) + : null; + const highPrecisionReady = ( + ["pmtiles-mvt", "geojson-high-precision"].includes(provider) && + (provider !== "pmtiles-mvt" || pmtilesResp?.ok) + ); + if (highPrecisionReady) { + _tileManifest = manifest; + _tileProvider = provider; + _boundaryProviderState = provider; + _tileAssetVersion = [ + _tileManifest.version, + _tileManifest.builtAt, + _tileManifest.sourceFeatureCount, + _tileManifest.pmtiles?.sha256, + ].filter(Boolean).join("-"); + const basePath = _tileManifest.base || _tileManifest.baseGeojson; + const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson; + if (basePath) baseGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(basePath)); + if (hoverPath) { + geojson = await fetchJsonAsset(versionedBoundaryAssetUrl(hoverPath)) || geojson; + } + const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path; + if (claimPath) claimGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(claimPath)); } } - const basePath = _tileManifest.base || _tileManifest.baseGeojson; - const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson; - const baseResp = basePath ? await fetch(versionedBoundaryAssetUrl(basePath)) : null; - const hoverResp = hoverPath ? await fetch(versionedBoundaryAssetUrl(hoverPath)) : null; - if (hoverResp?.ok) { - if (baseResp?.ok) baseGeojson = await baseResp.json(); - geojson = await hoverResp.json(); + if (_tileManifest && !(geojson.features || []).length) { + console.warn("[country-boundaries] high precision hover index unavailable; using legacy fallback"); + _tileManifest = null; + _pmtilesArchive = null; + claimGeojson = null; + } + if (!_tileManifest) { + geojson = await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath, { required: true }); + baseGeojson = geojson; + _tileProvider = "legacy-geojson"; + _boundaryProviderState = "legacy-geojson"; + _tileAssetVersion = "legacy"; } - const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path; - const claimResp = claimPath ? await fetch(versionedBoundaryAssetUrl(claimPath)) : null; - const claimGeojson = claimResp?.ok ? await claimResp.json() : null; _features = (geojson.features || []).filter(f => f.geometry); const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry); const boundaryBaseFeatures = baseFeatures.filter( @@ -940,6 +998,10 @@ export function getShowCountryBoundaries() { return _visible; } +export function getCountryBoundaryProviderState() { + return _boundaryProviderState; +} + /** Clear the hover highlight without hiding the full layer. */ export function clearCountryBoundaryHover({ cancelSticky = true } = {}) { if (cancelSticky) cancelPendingHoverClear(); @@ -1048,6 +1110,7 @@ export function clearCountryBoundaryData() { _landTexture = null; _tileManifest = null; _tileProvider = "pmtiles-mvt"; + _boundaryProviderState = "unloaded"; _pmtilesArchive = null; _features = []; disposeHoverGeometryCache(); diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index a576ddf0..1d49027a 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -1,5 +1,9 @@ // info-card.js - Unified info card module import { showStatusMessage } from './ui.js'; +import { + getNewsDisplaySummary, + getNewsDisplayTitle, +} from './news-locale.js'; let currentType = null; let cardMounted = false; @@ -124,7 +128,7 @@ function escapeCssIdentifier(value) { } function getNewsSummaryText(data) { - return (data?.summary || data?.title || '').trim() || '暂无摘要'; + return getNewsDisplaySummary(data); } function getNewsSummaryPreview(data, maxLength = 34) { @@ -180,12 +184,13 @@ function startTypewriterAnimation(target, text, options = {}) { function renderNewsCardContent(content, data) { if (!(content instanceof HTMLElement)) return; const summary = getNewsSummaryText(data); + const title = getNewsDisplayTitle(data); content.innerHTML = `
-
NEWS SIGNAL
-
${data?.title || '新闻事件'}
+
新闻信号
+
${escapeInfoCardHtml(title)}
-
SUMMARY
+
概要
@@ -197,12 +202,13 @@ function renderNewsCardContent(content, data) { function renderMobileNewsCardContent(content, data) { if (!(content instanceof HTMLElement)) return; const summary = getNewsSummaryText(data); + const title = getNewsDisplayTitle(data); content.innerHTML = `
-
NEWS SIGNAL
-
${data?.title || '新闻事件'}
+
新闻信号
+
${escapeInfoCardHtml(title)}
-
SUMMARY
+
概要
@@ -1026,7 +1032,7 @@ function getMobilePopupTitle(type, data) { case 'landing_point': return data.name || '登陆点'; case 'satellite': return data.name || '卫星'; case 'bgp': return data.anomaly_type || 'BGP事件'; - case 'news': return data.title || '新闻事件'; + case 'news': return getNewsDisplayTitle(data); case 'bgp_collector': return data.collector || 'BGP观测站'; case 'compute_center_unresolved': return '待定位算力中心'; case 'supercomputer': return data.name || '超算'; @@ -1709,12 +1715,12 @@ export function showInfoCard(type, data, options = {}) { if (icon) icon.textContent = config.icon; if (title) { title.textContent = type === 'news' - ? (data?.title || '新闻事件') + ? getNewsDisplayTitle(data) : config.title; } if (typeLabel) { typeLabel.textContent = type === 'news' - ? 'news signal' + ? '新闻信号' : type.replaceAll('_', ' '); } @@ -1750,7 +1756,7 @@ export function showInfoCard(type, data, options = {}) { card.className = 'info-card ' + config.className; icon.textContent = config.icon; title.textContent = type === 'news' - ? (data?.title || '新闻事件') + ? getNewsDisplayTitle(data) : config.title; if (type === 'news') { diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index bb2fd67b..739e88f7 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -630,7 +630,6 @@ function clearTransientHoverState() { resetTransientBGPStates(); resetTransientComputeCenterStates(); resetTransientVesselStates(); - clearCountryBoundaryHover(); hoveredBGP = null; hoveredComputeCenter = null; hoveredVessel = null; @@ -4119,6 +4118,26 @@ export async function setCountryBoundariesEnabled( } } +export async function reloadCountryBoundaries({ suppressStatus = false } = {}) { + const wasVisible = getShowCountryBoundaries(); + clearCountryBoundaryHover(); + clearCountryBoundaryData(); + if (wasVisible) { + return await setCountryBoundariesEnabled(true, { suppressStatus }); + } + + const countryCount = await ensureCountryBoundariesReady(); + const textureOn = getEarthTextureVisible(); + toggleCountryBoundaries(false, { + showTint: !textureOn, + showLandFill: true, + suppressLandFill: false, + }); + setLegendItems("countryBoundaries", getCountryBoundaryLegendItems()); + refreshLegend(); + return countryCount; +} + let _dayNightBeforeTextureOff = null; let _terrainBeforeTextureOff = null; @@ -4571,6 +4590,34 @@ function onMouseMove(event) { computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null; const hoveredVesselMarker = vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null; + const earthPoint = screenToEarthCoords( + event.clientX, + event.clientY, + camera, + getEarthSurfacePickTarget() || earth, + document.body, + interactionRaycaster, + interactionMouse, + ); + const surfaceHover = earthPoint + ? { coords: vector3ToLatLon(earthPoint), hoveredCountry: null } + : null; + + if (surfaceHover) { + updateCoordinatesDisplay( + surfaceHover.coords.lat, + surfaceHover.coords.lon, + surfaceHover.coords.alt, + ); + surfaceHover.hoveredCountry = getShowCountryBoundaries() + ? updateCountryBoundaryHover(surfaceHover.coords) + : null; + if (!getShowCountryBoundaries()) { + clearCountryBoundaryHover(); + } + } else { + clearCountryBoundaryHover(); + } if ( hoveredComputeCenter && @@ -4688,18 +4735,8 @@ function onMouseMove(event) { } if (!objectTooltipShown) { - const earthPoint = screenToEarthCoords( - event.clientX, - event.clientY, - camera, - getEarthSurfacePickTarget() || earth, - document.body, - interactionRaycaster, - interactionMouse, - ); - if (earthPoint) { - const coords = vector3ToLatLon(earthPoint); - updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt); + if (surfaceHover) { + const { coords, hoveredCountry } = surfaceHover; const hoverInfoMode = getSurfaceHoverInfoMode(); const shouldShowCountry = hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY || @@ -4707,18 +4744,11 @@ function onMouseMove(event) { const shouldShowPosition = hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION || hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL; - const hoveredCountry = shouldShowCountry && getShowCountryBoundaries() - ? updateCountryBoundaryHover(coords) - : null; const positionHtml = shouldShowPosition ? getSurfacePositionBriefHtml(coords) : ""; - if (!shouldShowCountry) { - clearCountryBoundaryHover(); - } - - if (hoveredCountry && shouldShowPosition) { + if (hoveredCountry && shouldShowCountry && shouldShowPosition) { showTooltip( event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, @@ -4727,7 +4757,7 @@ function onMouseMove(event) { return; } - if (hoveredCountry) { + if (hoveredCountry && shouldShowCountry) { showTooltip( event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, @@ -4736,7 +4766,6 @@ function onMouseMove(event) { return; } - clearCountryBoundaryHover(); if (shouldShowPosition) { showTooltip( event.clientX + TOOLTIP_COORDS_OFFSET, @@ -4750,8 +4779,6 @@ function onMouseMove(event) { clearCountryBoundaryHover(); hideTooltip(); } - } else { - clearCountryBoundaryHover(); } } diff --git a/frontend/public/earth/js/news-cruise-adapter.js b/frontend/public/earth/js/news-cruise-adapter.js index 7179bac7..6f791234 100644 --- a/frontend/public/earth/js/news-cruise-adapter.js +++ b/frontend/public/earth/js/news-cruise-adapter.js @@ -13,6 +13,13 @@ import { selectNewsItem, clearSelectedNewsItem, } from "./news.js"; +import { + getNewsDisplaySummary, + getNewsDisplayTitle, + getNewsFeedLabel, + getNewsLocationSourceLabel, + getNewsRegionLabel, +} from "./news-locale.js"; const CRUISE_PRESENTATION_HIDE_MS = 220; const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; @@ -22,14 +29,6 @@ const MOBILE_CARD_TOP_RATIO = 0.16; const MOBILE_CARD_WIDTH_PX = 220; const scratchNewsWorldPosition = new THREE.Vector3(); -const REGION_LABELS = { - americas: "美洲", - europe: "欧洲", - "middle-east-africa": "中东与非洲", - "asia-pacific": "亚太", - global: "全球", -}; - function getItemTimestamp(item) { const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0; return Number.isFinite(parsed) ? parsed : 0; @@ -94,21 +93,22 @@ function mapNewsItemToCruiseEvent(item) { id: `news:${item.id}`, sourceId: item.id, type: "news", - title: item.title || "新闻事件", - summary: item.summary || "", + title: getNewsDisplayTitle(item), + summary: getNewsDisplaySummary(item), source: item.source || "", - feedName: item.feed_name || "", + feedName: getNewsFeedLabel(item.feed_name), region: item.region || "global", - regionLabel: REGION_LABELS[item.region] || item.region || "全球", + regionLabel: item.display_region || getNewsRegionLabel(item.region), url: item.url || "", publishedAt: item.published_at || null, publishedAtDisplay: formatPublishedAt(item.published_at), latitude, longitude, - locationLabel: item.location_label || REGION_LABELS[item.region] || "全球", - sourceLocationLabel: item.location_label || REGION_LABELS[item.region] || "全球", + locationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region), + sourceLocationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region), targetLocationConfidence: item.location_meta?.target?.confidence ?? null, targetLocationSource: item.location_source || "", + targetLocationSourceLabel: getNewsLocationSourceLabel(item.location_source), verified: item.verified === true, locationMeta: item.location_meta || null, sortTimestamp: getItemTimestamp(item), diff --git a/frontend/public/earth/js/news-locale.js b/frontend/public/earth/js/news-locale.js new file mode 100644 index 00000000..dd7b6a1f --- /dev/null +++ b/frontend/public/earth/js/news-locale.js @@ -0,0 +1,107 @@ +const DEFAULT_LOCALE = "zh-CN"; + +const REGION_LABELS = { + americas: "美洲", + europe: "欧洲", + "middle-east-africa": "中东与非洲", + "asia-pacific": "亚太", + global: "全球", +}; + +const FEED_LABELS = { + "Global Monitor / World": "全球监测", + "Global Monitor / Americas": "美洲监测", + "Global Monitor / Europe": "欧洲监测", + "Global Monitor / MEA": "中东与非洲监测", + "Global Monitor / APAC": "亚太监测", +}; + +const LOCATION_SOURCE_LABELS = { + region_anchor: "区域锚点", + ai_inferred_target: "AI 推断位置", + headline_location_hint: "标题位置线索", + headline_country_hint: "标题国家线索", +}; + +const ENRICHMENT_STATUS_LABELS = { + pending: "待增强", + queued: "增强排队中", + attempted: "增强中", + success: "已汉化", + content_only: "已汉化", + location_only: "位置已增强", + unavailable: "AI 未配置", + provider_error: "增强失败", + parse_error: "增强解析失败", + no_result: "暂无增强结果", +}; + +const TITLE_PLACEHOLDERS = { + queued: "新闻汉化排队中", + attempted: "新闻汉化中", + provider_error: "新闻汉化失败,正在重试", + parse_error: "新闻解析失败,正在重试", + unavailable: "等待 AI 配置", + no_result: "新闻汉化待重试", + location_only: "新闻汉化待重试", +}; + +const SUMMARY_PLACEHOLDERS = { + queued: "中文概要正在生成,请稍后刷新。", + attempted: "中文概要正在生成,请稍后刷新。", + provider_error: "中文概要生成失败,系统会重新提交增强任务。", + parse_error: "中文概要解析失败,系统会重新提交增强任务。", + unavailable: "AI 服务配置完成后将生成中文概要。", + no_result: "中文概要暂未生成,系统会继续重试。", + location_only: "已完成位置增强,中文概要将继续重试。", +}; + +function normalizeText(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function getLocalization(item, locale = DEFAULT_LOCALE) { + const localizations = item?.localizations; + const localized = localizations && typeof localizations === "object" + ? localizations[locale] + : null; + return localized && typeof localized === "object" ? localized : {}; +} + +export function getNewsDisplayTitle(item, locale = DEFAULT_LOCALE) { + const localized = getLocalization(item, locale).title; + if (localized || item?.display_title) { + return normalizeText(item?.display_title || localized); + } + return normalizeText( + TITLE_PLACEHOLDERS[item?.enrichment_status] + || "新闻汉化中", + ); +} + +export function getNewsDisplaySummary(item, locale = DEFAULT_LOCALE) { + const localized = getLocalization(item, locale).summary; + if (localized || item?.display_summary) { + return normalizeText(item?.display_summary || localized); + } + return normalizeText( + SUMMARY_PLACEHOLDERS[item?.enrichment_status] + || "中文概要生成中,请稍后刷新。", + ); +} + +export function getNewsRegionLabel(region, fallback = "") { + return REGION_LABELS[region] || fallback || region || REGION_LABELS.global; +} + +export function getNewsFeedLabel(feedName) { + return FEED_LABELS[feedName] || feedName || "聚合源"; +} + +export function getNewsLocationSourceLabel(source) { + return LOCATION_SOURCE_LABELS[source] || source || "位置来源"; +} + +export function getNewsEnrichmentStatusLabel(status) { + return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态"; +} diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js index bef7ff79..94f0263c 100644 --- a/frontend/public/earth/js/news.js +++ b/frontend/public/earth/js/news.js @@ -1,4 +1,11 @@ import { showStatusMessage } from "./ui.js"; +import { + getNewsDisplaySummary, + getNewsDisplayTitle, + getNewsEnrichmentStatusLabel, + getNewsFeedLabel, + getNewsRegionLabel, +} from "./news-locale.js"; // Desktop news has two surfaces: // - a persistent top ticker @@ -285,6 +292,14 @@ function escapeTickerText(value) { .replaceAll('"', """); } +function escapeNewsHtml(value) { + return escapeTickerText(value); +} + +function hasLocalizedNewsContent(item) { + return Boolean(String(item?.display_title || "").trim() && String(item?.display_summary || "").trim()); +} + function renderTicker(nextPayload) { const { ticker, tickerRegion, tickerTrack } = getElements(); if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return; @@ -292,7 +307,7 @@ function renderTicker(nextPayload) { const focus = nextPayload?.focus || {}; if (tickerRegion instanceof HTMLElement) { - tickerRegion.textContent = (focus.region || "global").toUpperCase(); + tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region); tickerRegion.style.color = focus.accent || ""; } @@ -302,13 +317,18 @@ function renderTicker(nextPayload) { return; } - const visibleItems = items.slice(0, 6); + const visibleItems = items.filter(hasLocalizedNewsContent).slice(0, 6); + if (visibleItems.length === 0) { + tickerTrack.textContent = "正在等待中文新闻..."; + tickerTrack.style.removeProperty("--news-ticker-duration"); + return; + } const tickerItems = [...visibleItems, ...visibleItems]; tickerTrack.innerHTML = tickerItems .map((item) => ` ${escapeTickerText(item.source || item.feed_name || "NEWS")} - ${escapeTickerText(item.title || "未命名新闻")} + ${escapeTickerText(getNewsDisplayTitle(item))} `) .join(""); @@ -360,7 +380,7 @@ function renderPayload(nextPayload) { } if (regionChip) { - regionChip.textContent = focus.region || "global"; + regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region); regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff"); } @@ -412,20 +432,26 @@ function renderPayload(nextPayload) { const cardClass = item.is_focus_match ? "news-story-card news-story-card--focus" : "news-story-card"; - const summary = item.summary - ? `
${item.summary}
` + const title = getNewsDisplayTitle(item); + const summaryText = getNewsDisplaySummary(item); + const regionLabel = item.display_region || getNewsRegionLabel(item.region); + const feedLabel = getNewsFeedLabel(item.feed_name); + const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status); + const summary = summaryText + ? `
${escapeNewsHtml(summaryText)}
` : ""; return ` -
${item.title}
+
${escapeNewsHtml(title)}
${summary}
`; diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 320d34ce..f7a0f245 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -714,6 +714,7 @@ function createSatelliteTwinkleState(index) { function createSatellitePositionState(index = 0) { return { current: new THREE.Vector3(), + currentTime: null, trail: [], trailIndex: 0, trailCount: 0, @@ -1309,6 +1310,7 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = } satellitePositions[i].current.copy(pos); + satellitePositions[i].currentTime = adjustedTime; if (shouldUpdateTrails) { const satPos = satellitePositions[i]; @@ -2647,11 +2649,11 @@ function calculatePredictedOrbit( ) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); - const now = new Date(); - const fixedSiderealTime = gstime(now); + const startTime = getSelectedSatellitePositionState(satellite)?.currentTime || new Date(); + const fixedSiderealTime = gstime(startTime); for (let i = 0; i <= samples; i++) { - const time = new Date(now.getTime() + i * sampleInterval * 1000); + const time = new Date(startTime.getTime() + i * sampleInterval * 1000); const pos = computeSatelliteInertialOrbitPosition( satellite, time, @@ -2710,6 +2712,21 @@ function calculateFallbackPredictedOrbit(satellite, samples) { return points; } +function getSelectedSatellitePositionState(satellite) { + if (selectedSatellite === null || !satellitePositions?.[selectedSatellite]) { + return null; + } + + const selectedData = satelliteData?.[selectedSatellite]; + const selectedNoradId = selectedData?.properties?.norad_cat_id; + const targetNoradId = satellite?.properties?.norad_cat_id; + if (selectedData !== satellite && selectedNoradId !== targetNoradId) { + return null; + } + + return satellitePositions[selectedSatellite]; +} + export function showPredictedOrbit(satellite) { hidePredictedOrbit(); if (!earthObjRef) return; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 12239f44..7cf0c07c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 4aea8162..dd13ce79 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -16,6 +16,7 @@ import { ToolOutlined, InboxOutlined, FileTextOutlined, + VideoCameraOutlined, } from '@ant-design/icons' import { useLocation, useNavigate } from 'react-router-dom' import type { ItemType, MenuItemType } from 'antd/es/menu/interface' @@ -85,9 +86,11 @@ function AppLayout({ children }: AppLayoutProps) { label: '运维与配置', children: [ { key: '/ai', icon: , label: 'AI' }, + { key: '/earth-content', icon: , label: 'Earth 内容' }, + { key: '/collection-management', icon: , label: '采集管理' }, ...(isSuperAdmin ? [{ key: '/logs', icon: , label: '系统日志' }] : []), { key: '/users', icon: , label: '用户管理' }, - { key: '/settings', icon: , label: '系统配置' }, + { key: '/settings', icon: , label: '系统设置' }, ], }, ] diff --git a/frontend/src/pages/AISettings/AISettings.tsx b/frontend/src/pages/AISettings/AISettings.tsx index 3feb2857..83be0884 100644 --- a/frontend/src/pages/AISettings/AISettings.tsx +++ b/frontend/src/pages/AISettings/AISettings.tsx @@ -3,8 +3,10 @@ import { ApiOutlined, EyeInvisibleOutlined, EyeOutlined, + SaveOutlined, SyncOutlined, ToolOutlined, + UndoOutlined, } from '@ant-design/icons' import { Alert, @@ -167,6 +169,19 @@ interface WebSearchPreset { scrape_formats?: string[] } +interface AIPromptConfig { + key: string + label: string + group: string + version: string + default_system_prompt: string + default_prompt: string + system_prompt: string + prompt: string + is_custom: boolean + updated_at?: string | null +} + function AISettingsPanel({ loading, children }: { loading: boolean; children: ReactNode }) { return (
@@ -180,11 +195,16 @@ function AISettingsPanel({ loading, children }: { loading: boolean; children: Re export default function AISettings() { const [searchParams, setSearchParams] = useSearchParams() const [form] = Form.useForm() + const [promptForm] = Form.useForm() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [savingPrompt, setSavingPrompt] = useState(false) + const [resettingPrompt, setResettingPrompt] = useState(false) const [integrations, setIntegrations] = useState(null) const [aiProviderPresets, setAiProviderPresets] = useState([]) const [webSearchPresets, setWebSearchPresets] = useState([]) + const [aiPrompts, setAiPrompts] = useState([]) + const [selectedPromptKey, setSelectedPromptKey] = useState('') const [refreshingAiPreset, setRefreshingAiPreset] = useState(false) const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false) const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false) @@ -210,20 +230,28 @@ export default function AISettings() { : integrations?.web_search.api_key const activeTab = useMemo(() => { const tab = searchParams.get('tab') || 'providers' - return new Set(['providers', 'tools', 'playground']).has(tab) ? tab : 'providers' + return new Set(['providers', 'tools', 'prompts', 'playground']).has(tab) ? tab : 'providers' }, [searchParams]) + const selectedPrompt = useMemo( + () => aiPrompts.find((item) => item.key === selectedPromptKey) || aiPrompts[0], + [aiPrompts, selectedPromptKey], + ) const fetchAISettings = async () => { try { setLoading(true) - const [settingsResponse, aiPresetsResponse, webPresetsResponse] = await Promise.all([ + const [settingsResponse, aiPresetsResponse, webPresetsResponse, promptsResponse] = await Promise.all([ axios.get('/api/v1/settings'), axios.get('/api/v1/settings/integrations/ai-provider/presets'), axios.get('/api/v1/settings/integrations/web-search/presets'), + axios.get('/api/v1/settings/ai-prompts'), ]) setIntegrations(settingsResponse.data.integrations || null) setAiProviderPresets(aiPresetsResponse.data.data || []) setWebSearchPresets(webPresetsResponse.data.data || []) + const prompts = promptsResponse.data.data || [] + setAiPrompts(prompts) + setSelectedPromptKey((current) => current || prompts[0]?.key || '') } catch { message.error('获取 AI 配置失败') } finally { @@ -235,6 +263,14 @@ export default function AISettings() { void fetchAISettings() }, []) + useEffect(() => { + if (!selectedPrompt) return + promptForm.setFieldsValue({ + system_prompt: selectedPrompt.system_prompt, + prompt: selectedPrompt.prompt, + }) + }, [promptForm, selectedPrompt]) + useEffect(() => { if (loading || !integrations) return form.setFieldsValue({ @@ -391,6 +427,52 @@ export default function AISettings() { } } + const saveSelectedPrompt = async (values: { system_prompt?: string; prompt?: string }) => { + if (!selectedPrompt) return + try { + setSavingPrompt(true) + const response = await axios.put(`/api/v1/settings/ai-prompts/${selectedPrompt.key}`, { + system_prompt: values.system_prompt || '', + prompt: values.prompt || '', + }) + const nextPrompt = response.data.data as AIPromptConfig + setAiPrompts((items) => items.map((item) => (item.key === nextPrompt.key ? nextPrompt : item))) + setFeedback({ type: 'success', message: '提示词已保存' }) + message.success('提示词已保存') + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string; message?: string } } } + const errorMessage = err.response?.data?.message || err.response?.data?.detail || '提示词保存失败' + setFeedback({ type: 'error', message: errorMessage }) + message.error(errorMessage) + } finally { + setSavingPrompt(false) + } + } + + const resetSelectedPrompt = async () => { + if (!selectedPrompt) return + if (!window.confirm(`重置「${selectedPrompt.label}」为缺省提示词?`)) return + try { + setResettingPrompt(true) + const response = await axios.post(`/api/v1/settings/ai-prompts/${selectedPrompt.key}/reset`) + const nextPrompt = response.data.data as AIPromptConfig + setAiPrompts((items) => items.map((item) => (item.key === nextPrompt.key ? nextPrompt : item))) + promptForm.setFieldsValue({ + system_prompt: nextPrompt.system_prompt, + prompt: nextPrompt.prompt, + }) + setFeedback({ type: 'success', message: '提示词已重置为缺省' }) + message.success('提示词已重置为缺省') + } catch (error: unknown) { + const err = error as { response?: { data?: { detail?: string; message?: string } } } + const errorMessage = err.response?.data?.message || err.response?.data?.detail || '提示词重置失败' + setFeedback({ type: 'error', message: errorMessage }) + message.error(errorMessage) + } finally { + setResettingPrompt(false) + } + } + const revealAiProviderSecrets = async (provider: string) => { const cached = revealedAiProviderSecrets[provider] if (cached) return cached @@ -988,6 +1070,95 @@ export default function AISettings() { ), }, + { + key: 'prompts', + label: '提示词', + forceRender: true, + children: ( + +
setFeedback(null)}> + 功能提示词}> + + {!selectedCollector?.is_custom ? ( @@ -2505,24 +2686,6 @@ function Settings() { ) : null} - {isEarthBoundarySourceCollector ? ( - <> -
- - - -
- - - - - - - - ) : null} {(fields, { add, remove }) => ( @@ -2656,15 +2819,57 @@ function Settings() {
), }, + { + key: 'collection_history', + label: '采集历史 / 快照', + children: renderPlaceholderTab('采集历史 / 快照', '后续会接入 collection task、snapshot 和 collected data 浏览能力。'), + }, + { + key: 'basemap', + label: '地球底图', + children: renderPlaceholderTab('地球底图', '地球底图配置待接入。'), + }, + { + key: 'layer_resources', + label: '图层资源', + children: renderPlaceholderTab('图层资源', '图层资源配置待接入。'), + }, + { + key: 'models_3d', + label: '三维素材', + children: renderPlaceholderTab('三维素材', '三维素材管理待接入。'), + }, + { + key: 'news_anchor_strategy', + label: '新闻锚点策略', + children: renderPlaceholderTab('新闻锚点策略', '新闻区域锚点与精修策略配置待接入。'), + }, ] + const pageCopy = { + settings: { + title: '系统设置', + subtitle: '管理全局平台配置、安全策略、通知和基础集成。', + }, + earth: { + title: 'Earth 内容', + subtitle: '管理 Earth 前端体验依赖的内容源和可视化资源。', + }, + collection: { + title: '采集管理', + subtitle: '管理采集器配置、调度和采集历史;数据源状态入口保留在采集与数据下。', + }, + }[pageMode] + + const visibleTabItems = tabItems.filter((item) => settingsTabKeysByMode[pageMode].has(String(item.key))) + return (
- 系统配置中心 - 这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。 + {pageCopy.title} + {pageCopy.subtitle}
@@ -2673,7 +2878,7 @@ function Settings() { className="settings-tabs" activeKey={activeSettingsTab} onChange={updateSettingsTab} - items={tabItems.filter((item) => item.key !== 'ai')} + items={visibleTabItems} />
diff --git a/planet.sh b/planet.sh index 2eb3e128..15871bbb 100755 --- a/planet.sh +++ b/planet.sh @@ -78,6 +78,7 @@ WAIT_SPINNER_TICKS_PER_SECOND=8 WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" WAIT_SESSION_ACTIVE=0 +PORT_CHECK_STATUS_ACTIVE=0 VERBOSE=0 WAIT_VERBOSE_LOG_FILE="" WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}" @@ -897,9 +898,17 @@ request_windows_port_cleanup() { mkdir -p "$PLANET_STATE_DIR" write_windows_port_cleanup_script "$script_path" "$@" - log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" + else + log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}" + fi run_windows_admin_powershell_script "$script_path" "清理 Windows 侧端口 ${ports_label}" || { - log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" + else + log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定" + fi return 1 } } @@ -1655,13 +1664,21 @@ retry_wait_for_port_release() { pids="$(collect_port_pids "$port" || true)" elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$PORT_PRESTART_RETRY_INTERVAL")" if [ -n "$pids" ]; then - log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" + else + log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理" + fi for pid in $pids; do terminate_process_tree KILL "$pid" done else - log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" - print_port_listener_details "$port" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" + else + log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放" + print_port_listener_details "$port" + fi fi sleep "$PORT_PRESTART_RETRY_INTERVAL" @@ -1721,20 +1738,36 @@ force_cleanup_external_port_listener() { is_wsl_environment || return 1 command -v powershell.exe >/dev/null 2>&1 || return 1 - log_warn "检查 Windows 侧端口占用: ${port}" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "检查 Windows 侧端口 ${port}" + else + log_warn "检查 Windows 侧端口占用: ${port}" + fi if ! print_windows_port_listener_details "$port"; then - log_note "Windows 侧未发现端口 ${port} 监听,继续启动" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "Windows 侧未发现端口 ${port} 监听,继续检查" + else + log_note "Windows 侧未发现端口 ${port} 监听,继续启动" + fi return 0 fi - log_warn "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理" + else + log_warn "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理" + fi request_windows_port_cleanup "$port" || return 1 if wait_for_port_release "$port" 10 0.3; then return 0 fi - log_warn "Windows 侧端口 ${port} 清理后仍不可绑定" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "Windows 侧端口 ${port} 清理后仍不可绑定" + else + log_warn "Windows 侧端口 ${port} 清理后仍不可绑定" + fi print_windows_cleanup_recovery_steps "$port" "$service_name" return 1 } @@ -2134,61 +2167,92 @@ wait_for_port_release() { return 1 } +format_port_check_detail() { + local service_name="$1" + local port="$2" + + printf "检查 %s 端口 %s" "$service_name" "$port" | sed -E 's/检查 (前端|后端) /检查\1/' +} + kill_port_if_requested() { local port="$1" local service_name="$2" local pids="" local pid="" - log_warn "检测 ${service_name} 端口 ${port} 占用" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "$(format_port_check_detail "$service_name" "$port")" + else + log_warn "检测 ${service_name} 端口 ${port} 占用" + fi pids="$(collect_port_pids "$port" || true)" if [ -z "$pids" ] && can_bind_port "$port"; then - log_success "端口 ${port} 未被占用" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 未被占用" + fi return 0 fi if [ -z "$pids" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then - log_success "端口 ${port} 已释放" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 已释放" + fi return 0 fi fail_unreleased_port "$port" fi - log_step "发现端口 ${port} 占用,正在终止" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "${service_name}端口 ${port} 被占用,正在释放" + else + log_step "发现端口 ${port} 占用,正在终止" + fi for pid in $pids; do terminate_process_tree TERM "$pid" done if retry_wait_for_port_release "$port" "$service_name"; then - log_success "端口 ${port} 已释放" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 已释放" + fi return 0 fi if [ -z "$(collect_port_pids "$port" || true)" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then - log_success "端口 ${port} 已释放" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 已释放" + fi return 0 fi fail_unreleased_port "$port" fi - log_warn "端口 ${port} 仍被占用,正在强制终止" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "端口 ${port} 仍被占用,正在强制释放" + else + log_warn "端口 ${port} 仍被占用,正在强制终止" + fi pids="$(collect_port_pids "$port" || true)" for pid in $pids; do terminate_process_tree KILL "$pid" done if retry_wait_for_port_release "$port" "$service_name"; then - log_success "端口 ${port} 已释放" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 已释放" + fi return 0 fi if [ -z "$(collect_port_pids "$port" || true)" ]; then if force_cleanup_external_port_listener "$port" "$service_name"; then - log_success "端口 ${port} 已释放" + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then + log_success "端口 ${port} 已释放" + fi return 0 fi fail_unreleased_port "$port" @@ -2197,6 +2261,27 @@ kill_port_if_requested() { fail_unreleased_port "$port" } +release_requested_ports() { + local service_name="" + local port="" + + [ "$#" -gt 0 ] || return 0 + + start_wait_session "检测端口占用情况" + PORT_CHECK_STATUS_ACTIVE=1 + + while [ "$#" -gt 0 ]; do + service_name="$1" + port="$2" + kill_port_if_requested "$port" "$service_name" + shift 2 + done + + PORT_CHECK_STATUS_ACTIVE=0 + stop_wait_session + log_success "端口已全部释放" +} + frontend_log_indicates_port_conflict() { local log_file="$1" @@ -2232,6 +2317,10 @@ foreach (\$connection in \$connections) { )" [ -n "$output" ] || return 1 + if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then + set_wait_detail "$(printf "%s\n" "$output" | head -n 1)" + return 0 + fi while IFS= read -r line; do [ -n "$line" ] || continue printf "${DIM} %s${NC}\n" "$line" @@ -2860,10 +2949,10 @@ PY prepare_allow_lan_public_ports() { [ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0 - log_step "检查局域网端口可绑定" - kill_port_if_requested "$FRONTEND_PORT" "前端" - kill_port_if_requested "$BACKEND_PORT" "后端" - kill_port_if_requested "$AI_PROVIDER_PORT" "AI Provider" + release_requested_ports \ + "后端" "$BACKEND_PORT" \ + "前端" "$FRONTEND_PORT" \ + "AI Provider" "$AI_PROVIDER_PORT" request_windows_portproxy_cleanup_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true request_windows_firewall_rules_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true } diff --git a/pyproject.toml b/pyproject.toml index 2e7bff77..d2692131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.58.0" +version = "0.59.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index e3c5de03..763f774a 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.58.0" +version = "0.59.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, -- 2.49.1 From 81970a1d059cca31db33d7f6f477f40ed24203e9 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Sun, 17 May 2026 02:50:42 +0800 Subject: [PATCH 11/19] release: bump version to 0.60.0 --- README.md | 203 ++--- TODO.md | 7 + VERSION | 2 +- backend/app/ai_tasks/default_prompts.json | 8 +- backend/app/api/v1/collected_data.py | 12 +- backend/app/api/v1/datasources.py | 3 +- backend/app/api/v1/earth.py | 133 ++- backend/app/main.py | 10 + backend/app/services/bgp_ai_brief.py | 1 + backend/app/services/earth_news.py | 7 +- .../services/situational_alert_ai_brief.py | 18 +- backend/tests/test_api.py | 144 +++ .../tests/test_situational_alert_ai_brief.py | 71 ++ docs/CHANGELOG.md | 17 + .../agents-earth-command-runtime-plan.md | 810 +++++++++++++++++ docs/technical/en/agents-aiprovider.md | 6 + docs/technical/en/earth-frontend-context.md | 6 + docs/technical/en/faq.md | 6 + docs/technical/en/manual.md | 5 +- docs/technical/zh/agents-aiprovider.md | 6 + docs/technical/zh/earth-frontend-context.md | 6 + docs/technical/zh/faq.md | 6 + docs/technical/zh/manual.md | 5 +- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 243 +++++ frontend/public/earth/css/info-panel.css | 10 + frontend/public/earth/index.html | 367 +++++--- frontend/public/earth/js/brand.js | 89 +- frontend/public/earth/js/controls.js | 852 ++++++++++++++++-- frontend/public/earth/js/main.js | 9 +- frontend/public/earth/js/news.js | 9 +- .../src/components/AppLayout/AppLayout.tsx | 38 +- frontend/src/index.css | 164 ++++ frontend/src/pages/AISettings/AISettings.tsx | 66 +- frontend/src/pages/Alerts/BGPAlerts.tsx | 3 +- .../src/pages/Alerts/SituationalAlerts.tsx | 17 +- frontend/src/pages/DataList/DataList.tsx | 49 +- .../src/pages/DataSources/DataSources.tsx | 38 +- frontend/src/pages/Settings/Settings.tsx | 379 +++++++- frontend/src/pages/Settings/SmtpPanel.tsx | 7 +- pyproject.toml | 2 +- uv.lock | 2 +- 43 files changed, 3378 insertions(+), 463 deletions(-) create mode 100644 backend/tests/test_situational_alert_ai_brief.py create mode 100644 docs/plans/agents-earth-command-runtime-plan.md diff --git a/README.md b/README.md index c069cb99..001c18e1 100644 --- a/README.md +++ b/README.md @@ -8,68 +8,54 @@ ## 系统架构 +当前仓库的核心形态是“Web Earth 可视化 + React 运维台 + FastAPI 数据与 AI 编排后端 + 独立模型适配层”。物理大屏与 UE 客户端仍是长期方向,但不再作为本地开发和当前发布的必需运行单元。 + ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 物理大屏展示层 │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 偏振片3D大屏 (2m×3m, 4K, 120Hz, 眼镜式) │ │ -│ │ ┌─────────────────────────────────────────────────────────┐ │ │ -│ │ │ 虚幻引擎 UE5 客户端 │ │ │ -│ │ │ ├── 3D地球渲染 (Cesium for UE) │ │ │ -│ │ │ ├── 算力点可视化 (GPU集群、智算中心) │ │ │ -│ │ │ ├── 连接弧线 (光缆、路由、数据流向) │ │ │ -│ │ │ ├── 粒子效果 (数据流动、告警提示) │ │ │ -│ │ │ └── 自动巡航相机 + 交互控制 │ │ │ -│ │ └─────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ WebSocket (实时推送) - │ 120Hz 心跳 / 数据帧同步 - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 数据中台服务层 (FastAPI) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ API Gateway (Redis 限流) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌───────────────────┬──────────────────────────┬──────────────────┐ │ -│ │ 数据采集服务 │ 核心业务服务 │ 运维管理服务 │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 调度中心 │ │ │ WebSocket 服务 │ │ │ 用户管理 │ │ │ -│ │ │ (Celery) │ │ │ (FastAPI) │ │ │ (JWT Auth) │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 采集器池 │ │ │ 数据查询 API │ │ │ 数据源配置 │ │ │ -│ │ │ (10+源) │ │ │ (REST) │ │ │ 监控告警 │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 消息队列 │ │ │ 态势分析引擎 │ │ │ 系统配置 │ │ │ -│ │ │ (Kafka) │ │ │ (计算/聚合) │ │ │ 日志审计 │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ └───────────────────┴──────────────────────────┴──────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ 内部 API 调用 - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ Web管理端 (React Admin) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 登录页 │ 仪表盘 │ 用户管理 │ 数据源配置 │ 任务监控 │ 系统配置 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ PostgreSQL / Redis - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 数据存储层 │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ PostgreSQL │ │ TimescaleDB │ │ Redis │ │ MinIO │ │ -│ │ (用户/配置) │ │ (时序数据) │ │ (缓存/会话) │ │ (文件存储) │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ 浏览器展示与运维层 │ +│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Web Earth │ │ React 运维台 │ │ +│ │ frontend/public/earth │ │ frontend/src │ │ +│ │ Three.js 地球 / HUD / 新闻 │ │ 数据源 / 告警 / AI 设置 │ │ +│ │ 国界精度 / 品牌内容配置 │ │ 提示词配置 / 用户与系统配置 │ │ +│ └──────────────────────────────┘ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ REST / WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ FastAPI 业务与编排后端 │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ +│ │ 数据 API 与认证 │ │ Earth 新闻增强 │ │ 告警与态势简报 │ │ +│ │ JWT / 权限 / 审计 │ │ 位置推断 / 本地化 │ │ BGP / 告警研判 │ │ +│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ +│ │ 系统运行配置 │ │ 默认提示词注册表 │ │ 未来 Agent Runtime│ │ +│ │ system_settings │ │ 代码发布 + DB 覆盖 │ │ 工具/证据/工作流 │ │ +│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ SQLAlchemy / Redis Stream │ 纯净 LLM 调用 + ▼ ▼ +┌──────────────────────────────┐ ┌──────────────────────────────┐ +│ PostgreSQL / Redis │ │ aiprovider │ +│ 用户、配置、采集结果、新闻 │ │ provider + protocol adapter │ +│ Stream、缓存、运行状态 │ │ OpenAI / MiniMax / Ollama 等 │ +└──────────────────────────────┘ └──────────────────────────────┘ + ▲ + │ 采集器 / 外部数据源 + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ RSS 新闻、BGP 观测、公开数据源、后续 WebSearch/OCR/语音识别等工具 │ +└─────────────────────────────────────────────────────────────────────┘ ``` +架构边界: + +- `backend` 负责业务语义、证据收集、提示词选择、AI 任务编排、权限和数据落库。 +- `aiprovider` 只负责把纯净模型请求适配到不同供应商或协议,不内置具体业务提示词。 +- 默认提示词随代码发布并保存在 `backend/app/ai_tasks/default_prompts.json`,运维台可在数据库中保存覆盖值,重置时回到当前代码版本的默认提示词。 +- Earth 新闻保留英文原文,中文展示结果存入 `localizations`,前端默认展示 `zh-CN` 的 `display_title`、`display_summary` 和中文地域/状态文案。 +- Earth LLM 指令、语音识别、多角色态势研判属于后续 Agent Runtime 方向,计划见 [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)。 + ## 四大核心要素 | 层级 | 要素 | 描述 | @@ -87,11 +73,10 @@ |------|------|------| | FastAPI | 0.109+ | Web 框架 | | SQLAlchemy | 2.0+ | ORM | -| Alembic | - | 数据库迁移 | -| Celery | 5.3+ | 任务队列 | -| Redis | 7.0+ | 缓存/消息 | -| Kafka | 3.0+ | 事件流 | +| uv | - | Python 依赖与命令运行 | +| Redis | 7.0+ | 缓存、Stream 与运行协调 | | PyJWT | - | 认证 | +| APScheduler / 后台任务 | - | 采集、增强与运行时任务 | ### 前端 (React Admin) @@ -102,6 +87,7 @@ | Axios | HTTP 客户端 | | Socket.io-client | WebSocket 客户端 | | ECharts | 统计图表 | +| Three.js | Earth 3D 地球渲染 | | Bun | 前端包管理与脚本运行 | 前端工程统一使用 Bun: @@ -110,22 +96,16 @@ - 运行脚本使用 `bun run ` + return html.includes('') ? html.replace('', `${client}`) : `${html}${client}` +} + +function serveFile(request, response) { + if (request.url === '/__preview_reload') { + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + response.write('\n') + clients.add(response) + request.on('close', () => clients.delete(response)) + return + } + + let file = safePath(request.url || '/') + if (!existsSync(file) || statSync(file).isDirectory()) file = join(file, 'index.html') + if (!existsSync(file)) file = join(dist, 'index.html') + if (!existsSync(file)) { + response.writeHead(404) + response.end('Run bun run build first.') + return + } + + const ext = extname(file) + response.setHeader('content-type', mime[ext] || 'application/octet-stream') + response.setHeader('cache-control', 'no-store') + if (ext === '.html') { + let html = '' + createReadStream(file, 'utf8') + .on('data', (chunk) => { html += chunk }) + .on('end', () => response.end(injectReloadClient(html))) + .on('error', () => { + response.writeHead(500) + response.end('Failed to read preview file.') + }) + return + } + createReadStream(file).pipe(response) +} + +await mkdir(dist, { recursive: true }) +await runBuild() + +watchPath(join(root, 'src')) +watchPath(join(root, 'public')) +for (const file of ['index.html', 'vite.config.mts', 'tailwind.config.ts', 'postcss.config.cjs', 'package.json']) { + watchPath(join(root, file)) +} + +if (watchOnly) { + console.log('[preview:auto] watching source changes; press Ctrl+C to stop') +} else { + createServer(serveFile).listen(port, () => { + console.log(`[preview:auto] serving http://localhost:${port}`) + console.log('[preview:auto] build success will reload connected pages') + }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cf0c07c..7ac080ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ const AISettings = lazy(() => import('./pages/AISettings/AISettings')) const BGP = lazy(() => import('./pages/BGP/BGP')) const Logs = lazy(() => import('./pages/Logs/Logs')) const Docs = lazy(() => import('./pages/Docs/Docs')) +const AdminNextRoutes = lazy(() => import('./admin-next/AdminNextRoutes')) const ROOT_ROUTE = '/' const EARTH_ROUTE = '/earth' @@ -35,6 +36,12 @@ function isPublicPath(pathname: string) { return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX) } +function AdminNextCompatRedirect() { + const { pathname, search, hash } = useLocation() + const nextPath = pathname.replace(/^\/admin-next/, '') || '' + return +} + function App() { const { token } = useAuthStore() const { pathname } = useLocation() @@ -57,26 +64,29 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> ) diff --git a/frontend/src/admin-next/AdminNextRoutes.tsx b/frontend/src/admin-next/AdminNextRoutes.tsx new file mode 100644 index 00000000..6132d7d7 --- /dev/null +++ b/frontend/src/admin-next/AdminNextRoutes.tsx @@ -0,0 +1,48 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { AdminThemeProvider } from './design/theme' +import DashboardNext from './pages/DashboardNext' +import DataListNext from './pages/DataListNext' +import { + AINext, + BGPAlertsNext, + BGPNext, + CollectionManagementNext, + DataSourcesNext, + EarthContentNext, + SettingsNext, + SituationalAlertsNext, + SystemAlertsNext, +} from './pages/PlainResourcePages' +import UsersNext from './pages/UsersNext' +import LogsNext from './pages/LogsNext' +import { ToastProvider } from './components/ui/toast' +import { AdminSearchProvider } from './search/AdminSearchContext' +import './styles.css' + +export default function AdminNextRoutes() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/admin-next/components/data-table/DataTable.tsx b/frontend/src/admin-next/components/data-table/DataTable.tsx new file mode 100644 index 00000000..b0564152 --- /dev/null +++ b/frontend/src/admin-next/components/data-table/DataTable.tsx @@ -0,0 +1,150 @@ +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react' +import { useMemo, useState } from 'react' +import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion' +import { Button } from '../ui/button' + +interface DataTableProps { + columns: Array> + data: TData[] + getRowId?: (row: TData, index: number) => string + getRowClassName?: (row: TData) => string | undefined + loading?: boolean + emptyText?: string + className?: string + footer?: React.ReactNode + onRowClick?: (row: TData) => void +} + +export function DataTable({ + columns, + data, + getRowId, + getRowClassName, + loading = false, + emptyText = '暂无数据', + className = '', + footer, + onRowClick, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + const memoizedColumns = useMemo(() => columns, [columns]) + + const table = useReactTable({ + data, + columns: memoizedColumns, + state: { sorting }, + onSortingChange: setSorting, + getRowId, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( +
+ +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted() + const stickyEnd = header.column.id === 'actions' || header.column.id === 'action' + return ( + + ) + })} + + ))} + + + {loading ? ( + + + + ) : table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original) : undefined} + data-clickable={onRowClick ? 'true' : undefined} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {header.isPlaceholder ? null : ( + + )} +
+
+ + 加载中 +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
{emptyText}
+
+
+ + {footer ?
{footer}
: null} +
+ ) +} + +export function DataTablePager({ + page, + pageSize, + total, + onPageChange, +}: { + page: number + pageSize: number + total: number + onPageChange: (page: number) => void +}) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + return ( +
+ + 第 {page} / {totalPages} 页,共 {total.toLocaleString()} 条 + +
+ + +
+
+ ) +} diff --git a/frontend/src/admin-next/components/layout/AdminNextLayout.tsx b/frontend/src/admin-next/components/layout/AdminNextLayout.tsx new file mode 100644 index 00000000..610aa698 --- /dev/null +++ b/frontend/src/admin-next/components/layout/AdminNextLayout.tsx @@ -0,0 +1,309 @@ +import { + ChevronDown, + LogOut, + Menu, + Moon, + Monitor, + Search, + Sun, + X, +} from 'lucide-react' +import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import packageJson from '../../../../package.json' +import Scrollbar from '../../../components/Scrollbar/Scrollbar' +import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl' +import { useAuthStore } from '../../../stores/auth' +import { useAdminTheme, type AdminThemeMode } from '../../design/theme' +import { cn } from '../../lib/utils' +import { adminRouteGroups, getVisibleAdminRoutes } from '../../routes/manifest' +import { useAdminSearch } from '../../search/AdminSearchContext' +import { Button } from '../ui/button' + +const DEFAULT_OPEN_MENU_KEY = 'collection' +let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY] +let cachedMenuScrollTop = 0 + +export function AdminNextLayout({ children }: { children: ReactNode }) { + const location = useLocation() + const navigate = useNavigate() + const adminSearch = useAdminSearch() + const { user, logout } = useAuthStore() + const { mode, setMode } = useAdminTheme() + const [collapsed, setCollapsed] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [openKeys, setOpenKeys] = useState(cachedOpenKeys) + const [searchQuery, setSearchQuery] = useState('') + const [searchOpen, setSearchOpen] = useState(false) + const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0) + const menuViewportRef = useRef(null) + const searchInputRef = useRef(null) + const isSuperAdmin = user?.role === 'super_admin' + const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin]) + const navGroups = useMemo(() => { + return adminRouteGroups.map((group) => ({ + ...group, + children: visibleRoutes.filter((route) => route.group === group.key), + })).filter((group) => group.children.length > 0) + }, [visibleRoutes]) + const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '') + const activeRoute = visibleRoutes.find((route) => route.path === selectedKey) + const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery]) + const themeOptions = useMemo(() => [ + { value: 'light' as const, label: '浅色', title: '浅色', icon: }, + { value: 'system' as const, label: '系统', title: '跟随系统', icon: }, + { value: 'dark' as const, label: '深色', title: '深色', icon: }, + ], []) + + const updateOpenKeys = (nextKeys: string[]) => { + cachedOpenKeys = nextKeys + setOpenKeys(nextKeys) + } + + const selectSearchResult = (index = highlightedSearchIndex) => { + const target = searchResults[index] || searchResults[0] + if (!target) return + setSearchOpen(false) + setSearchQuery('') + setHighlightedSearchIndex(0) + searchInputRef.current?.blur() + adminSearch.openTarget(target) + } + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSearchOpen(false) + setHighlightedSearchIndex(0) + searchInputRef.current?.blur() + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + setSearchOpen(true) + setHighlightedSearchIndex((index) => Math.min(index + 1, Math.max(searchResults.length - 1, 0))) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setSearchOpen(true) + setHighlightedSearchIndex((index) => Math.max(index - 1, 0)) + return + } + if (event.key === 'Enter') { + event.preventDefault() + selectSearchResult() + } + } + + const handleSearchBlur = (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return + setSearchOpen(false) + setHighlightedSearchIndex(0) + } + + useEffect(() => { + void adminSearch.ensureDynamicIndex(searchQuery) + }, [adminSearch, searchQuery]) + + const nav = ( + <> +
setCollapsed(false) : undefined}> + + {!collapsed ? ( +
+ Planet + Admin Next +
+ ) : null} +
+ + + + + + {!collapsed ? ( +
+
+
+ Hi, {user?.username || '-'} +
+ +
+
+ 版本号 + v{packageJson.version} +
+ + ariaLabel="Admin Next 主题" + className="admin-next__theme-control admin-next__theme-control--sider" + options={themeOptions} + scale={0.72} + value={mode} + onChange={setMode} + /> +
+ ) : null} + + ) + + useLayoutEffect(() => { + const viewport = menuViewportRef.current + if (!viewport) return + const restoreScroll = () => { + viewport.scrollTop = cachedMenuScrollTop + } + restoreScroll() + const frameId = window.requestAnimationFrame(restoreScroll) + return () => window.cancelAnimationFrame(frameId) + }, [collapsed, location.pathname, openKeys]) + + useLayoutEffect(() => { + const viewport = menuViewportRef.current + if (!viewport) return + const cacheScroll = () => { + cachedMenuScrollTop = viewport.scrollTop + } + viewport.addEventListener('scroll', cacheScroll, { passive: true }) + return () => viewport.removeEventListener('scroll', cacheScroll) + }, []) + + return ( +
+ + {mobileNavOpen ? ( +
+
{nav}
+
+ ) : null} +
+
+ +
+ + { + setSearchQuery(event.target.value) + setSearchOpen(true) + setHighlightedSearchIndex(0) + }} + onFocus={() => setSearchOpen(true)} + onKeyDown={handleSearchKeyDown} + /> + {searchOpen ? ( +
+ {searchResults.length > 0 ? searchResults.map((target, index) => { + const ResultIcon = target.icon || Search + return ( + + ) + }) : ( +
{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}
+ )} +
+ ) : null} +
+
+
{children}
+
+
+ ) +} diff --git a/frontend/src/admin-next/components/ui/badge.tsx b/frontend/src/admin-next/components/ui/badge.tsx new file mode 100644 index 00000000..4c1b93be --- /dev/null +++ b/frontend/src/admin-next/components/ui/badge.tsx @@ -0,0 +1,12 @@ +import { type HTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +type BadgeTone = 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'slate' + +interface BadgeProps extends HTMLAttributes { + tone?: BadgeTone +} + +export function Badge({ className, tone = 'default', ...props }: BadgeProps) { + return +} diff --git a/frontend/src/admin-next/components/ui/button.tsx b/frontend/src/admin-next/components/ui/button.tsx new file mode 100644 index 00000000..4d975c71 --- /dev/null +++ b/frontend/src/admin-next/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' + +import { + TactileButton, + type TactileButtonProps, + type TactileButtonSize, + type TactileButtonVariant, + type TactileControlOptions, + type TactileIconValue, +} from '../../../components/tactile-ui' + +type AdminButtonVariant = 'default' | 'primary' | 'ghost' | 'subtle' | 'danger' | 'link' +type AdminButtonSize = 'sm' | 'md' | 'icon' + +function mapVariant(variant?: AdminButtonVariant): TactileButtonVariant { + if (variant === 'primary') return 'primary' + if (variant === 'danger') return 'danger' + if (variant === 'ghost' || variant === 'link') return 'ghost' + if (variant === 'subtle') return 'subtle' + return 'neutral' +} + +function mapSize(size?: AdminButtonSize): TactileButtonSize { + if (size === 'sm') return 'sm' + if (size === 'icon') return 'icon' + return 'md' +} + +export interface ButtonProps + extends Omit, 'size'> { + asChild?: boolean + loading?: boolean + icon?: TactileIconValue + iconSize?: number + size?: AdminButtonSize + tactile?: boolean | TactileControlOptions + variant?: AdminButtonVariant +} + +export const Button = forwardRef(function Button( + { asChild = false, loading = false, size, title, variant, ...props }, + ref, +) { + const tooltip = typeof title === 'string' ? title : undefined + const searchText = [tooltip, typeof props['aria-label'] === 'string' ? props['aria-label'] : undefined] + .filter(Boolean) + .join(' ') + const buttonProps = { + ...props, + asChild, + disabled: props.disabled || loading, + loading, + size: mapSize(size), + title: tooltip, + tooltip, + variant: mapVariant(variant), + 'data-admin-search-text': searchText || undefined, + } satisfies TactileButtonProps & { 'data-admin-search-text'?: string } + if (variant === 'link') buttonProps.tactile = false + return +}) diff --git a/frontend/src/admin-next/components/ui/card.tsx b/frontend/src/admin-next/components/ui/card.tsx new file mode 100644 index 00000000..4e1a6712 --- /dev/null +++ b/frontend/src/admin-next/components/ui/card.tsx @@ -0,0 +1,22 @@ +import { type HTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +export function Card({ className, ...props }: HTMLAttributes) { + return
+} + +export function CardHeader({ className, ...props }: HTMLAttributes) { + return
+} + +export function CardTitle({ className, ...props }: HTMLAttributes) { + return

+} + +export function CardDescription({ className, ...props }: HTMLAttributes) { + return

+} + +export function CardContent({ className, ...props }: HTMLAttributes) { + return

+} diff --git a/frontend/src/admin-next/components/ui/dialog.tsx b/frontend/src/admin-next/components/ui/dialog.tsx new file mode 100644 index 00000000..7780a5ad --- /dev/null +++ b/frontend/src/admin-next/components/ui/dialog.tsx @@ -0,0 +1,90 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { type ReactNode } from 'react' +import Scrollbar from '../../../components/Scrollbar/Scrollbar' +import { Button } from './button' + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + children: ReactNode + footer?: ReactNode + width?: number +} + +export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) { + return ( + + + + +
+
+ {title} + {description ? ( + + {description} + + ) : null} +
+ + + +
+ {children} + {footer ?
{footer}
: null} +
+
+
+ ) +} + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean + loading?: boolean + onConfirm: () => void +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = '确认', + cancelLabel = '取消', + danger = false, + loading = false, + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + + )} + > + {description || '请确认本次操作。'} + + ) +} diff --git a/frontend/src/admin-next/components/ui/input.tsx b/frontend/src/admin-next/components/ui/input.tsx new file mode 100644 index 00000000..6b10bc70 --- /dev/null +++ b/frontend/src/admin-next/components/ui/input.tsx @@ -0,0 +1,14 @@ +import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +export const Input = forwardRef>( + ({ className, ...props }, ref) => , +) + +Input.displayName = 'Input' + +export const Textarea = forwardRef>( + ({ className, ...props }, ref) =>

6rPxL#}N(4ZVWaC?hG^r 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 literal 0 HcmV?d00001 diff --git a/frontend/public/earth/css/fonts.css b/frontend/public/earth/css/fonts.css new file mode 100644 index 00000000..bdae8b2e --- /dev/null +++ b/frontend/public/earth/css/fonts.css @@ -0,0 +1,24 @@ +@font-face { + font-family: "Material Symbols Rounded"; + font-style: normal; + font-weight: 500; + font-display: block; + src: url("../assets/fonts/material-symbols-rounded-500.ttf") format("truetype"); +} + +.material-symbols-rounded { + direction: ltr; + display: inline-block; + font-family: "Material Symbols Rounded"; + font-feature-settings: "liga"; + font-size: 24px; + font-style: normal; + font-weight: 500; + letter-spacing: normal; + line-height: 1; + text-transform: none; + white-space: nowrap; + word-wrap: normal; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; +} diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 94091e88..f3513dde 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -136,7 +136,7 @@ - +