From 3fcbae55dc1b7be9f67ea5ede5dff6b2ebe4fcb3 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 20 Mar 2026 15:45:02 +0800 Subject: [PATCH] feat(earth): add cable-landing point relation via city_id Backend: - Fix arcgis_landing collector to extract city_id - Fix arcgis_relation collector to extract city_id - Fix convert_landing_point_to_geojson to use city_id mapping Frontend: - Update landing point cableNames to use array - Add applyLandingPointVisualState for cable lock highlight - Dim all landing points when satellite is locked --- backend/app/api/v1/visualization.py | 107 ++++++++++++++---- .../app/services/collectors/arcgis_landing.py | 1 + .../services/collectors/arcgis_relation.py | 1 + frontend/public/earth/js/cables.js | 46 +++++++- frontend/public/earth/js/main.js | 10 +- 5 files changed, 139 insertions(+), 26 deletions(-) diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index bd3786c1..39da00c7 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -96,37 +96,48 @@ def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: return {"type": "FeatureCollection", "features": features} -def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: - """Convert landing point records to GeoJSON FeatureCollection""" +def convert_landing_point_to_geojson(records: List[CollectedData], city_to_cable_ids_map: Dict[int, List[int]] = None, cable_id_to_name_map: Dict[int, str] = None) -> Dict[str, Any]: features = [] - + for record in records: try: lat = float(record.latitude) if record.latitude else None lon = float(record.longitude) if record.longitude else None except (ValueError, TypeError): continue - + if lat is None or lon is None: continue - + metadata = record.extra_data or {} - + city_id = metadata.get("city_id") + + props = { + "id": record.id, + "source_id": record.source_id, + "name": record.name, + "country": record.country, + "city": record.city, + "is_tbd": metadata.get("is_tbd", False), + } + + cable_names = [] + if city_to_cable_ids_map and city_id in city_to_cable_ids_map: + for cable_id in city_to_cable_ids_map[city_id]: + if cable_id_to_name_map and cable_id in cable_id_to_name_map: + cable_names.append(cable_id_to_name_map[cable_id]) + + if cable_names: + props["cable_names"] = cable_names + features.append( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, - "properties": { - "id": record.id, - "source_id": record.source_id, - "name": record.name, - "country": record.country, - "city": record.city, - "is_tbd": metadata.get("is_tbd", False), - }, + "properties": props, } ) - + return {"type": "FeatureCollection", "features": features} @@ -264,19 +275,45 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/landing-points") async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): - """获取登陆点 GeoJSON 数据 (Point)""" try: - stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") - result = await db.execute(stmt) - records = result.scalars().all() - + landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") + landing_result = await db.execute(landing_stmt) + records = landing_result.scalars().all() + + relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") + relation_result = await db.execute(relation_stmt) + relation_records = relation_result.scalars().all() + + cable_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") + cable_result = await db.execute(cable_stmt) + cable_records = cable_result.scalars().all() + + city_to_cable_ids_map = {} + for rel in relation_records: + if rel.extra_data: + city_id = rel.extra_data.get("city_id") + cable_id = rel.extra_data.get("cable_id") + if city_id is not None and cable_id is not None: + if city_id not in city_to_cable_ids_map: + city_to_cable_ids_map[city_id] = [] + if cable_id not in city_to_cable_ids_map[city_id]: + city_to_cable_ids_map[city_id].append(cable_id) + + cable_id_to_name_map = {} + for cable in cable_records: + if cable.extra_data: + cable_id = cable.extra_data.get("cable_id") + cable_name = cable.name + if cable_id and cable_name: + cable_id_to_name_map[cable_id] = cable_name + if not records: raise HTTPException( status_code=404, detail="No landing point data found. Please run the arcgis_landing_points collector first.", ) - - return convert_landing_point_to_geojson(records) + + return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map) except HTTPException: raise except Exception as e: @@ -285,7 +322,6 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/all") async def get_all_geojson(db: AsyncSession = Depends(get_db)): - """获取所有可视化数据 (电缆 + 登陆点)""" cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables") cables_result = await db.execute(cables_stmt) cables_records = cables_result.scalars().all() @@ -293,6 +329,29 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)): points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points") points_result = await db.execute(points_stmt) points_records = points_result.scalars().all() + + relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation") + relation_result = await db.execute(relation_stmt) + relation_records = relation_result.scalars().all() + + city_to_cable_ids_map = {} + for rel in relation_records: + if rel.extra_data: + city_id = rel.extra_data.get("city_id") + cable_id = rel.extra_data.get("cable_id") + if city_id is not None and cable_id is not None: + if city_id not in city_to_cable_ids_map: + city_to_cable_ids_map[city_id] = [] + if cable_id not in city_to_cable_ids_map[city_id]: + city_to_cable_ids_map[city_id].append(cable_id) + + cable_id_to_name_map = {} + for cable in cables_records: + if cable.extra_data: + cable_id = cable.extra_data.get("cable_id") + cable_name = cable.name + if cable_id and cable_name: + cable_id_to_name_map[cable_id] = cable_name cables = ( convert_cable_to_geojson(cables_records) @@ -300,7 +359,7 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)): else {"type": "FeatureCollection", "features": []} ) points = ( - convert_landing_point_to_geojson(points_records) + convert_landing_point_to_geojson(points_records, city_to_cable_ids_map, cable_id_to_name_map) if points_records else {"type": "FeatureCollection", "features": []} ) diff --git a/backend/app/services/collectors/arcgis_landing.py b/backend/app/services/collectors/arcgis_landing.py index 07307dc5..93976198 100644 --- a/backend/app/services/collectors/arcgis_landing.py +++ b/backend/app/services/collectors/arcgis_landing.py @@ -59,6 +59,7 @@ class ArcGISLandingPointCollector(BaseCollector): "unit": "", "metadata": { "objectid": props.get("OBJECTID"), + "city_id": props.get("city_id"), "cable_id": props.get("cable_id"), "cable_name": props.get("cable_name"), "facility": props.get("facility"), diff --git a/backend/app/services/collectors/arcgis_relation.py b/backend/app/services/collectors/arcgis_relation.py index c98a0797..8b98536f 100644 --- a/backend/app/services/collectors/arcgis_relation.py +++ b/backend/app/services/collectors/arcgis_relation.py @@ -50,6 +50,7 @@ class ArcGISCableLandingRelationCollector(BaseCollector): "unit": "", "metadata": { "objectid": props.get("OBJECTID"), + "city_id": props.get("city_id"), "cable_id": props.get("cable_id"), "cable_name": props.get("cable_name"), "landing_point_id": props.get("landing_point_id"), diff --git a/frontend/public/earth/js/cables.js b/frontend/public/earth/js/cables.js index 30c550d6..be42a82c 100644 --- a/frontend/public/earth/js/cables.js +++ b/frontend/public/earth/js/cables.js @@ -286,7 +286,7 @@ export async function loadLandingPoints(scene, earthObj) { sphere.userData = { type: 'landingPoint', name: properties.name || '未知登陆站', - cableName: properties.cable_system || '未知系统', + cableNames: properties.cable_names || [], country: properties.country || '未知国家', status: properties.status || 'Unknown' }; @@ -362,3 +362,47 @@ export function getCableStateInfo() { }); return states; } + +export function getLandingPointsByCableName(cableName) { + return landingPoints.filter(lp => lp.userData.cableNames?.includes(cableName)); +} + +export function getAllLandingPoints() { + return landingPoints; +} + +export function applyLandingPointVisualState(lockedCableName, dimAll = false) { + const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5; + const brightness = 0.3; + + landingPoints.forEach(lp => { + const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName); + + if (isRelated) { + lp.material.color.setHex(0xffaa00); + lp.material.emissive.setHex(0x442200); + lp.material.emissiveIntensity = 0.5 + pulse * 0.5; + lp.material.opacity = 0.8 + pulse * 0.2; + lp.scale.setScalar(1.2 + pulse * 0.3); + } else { + const r = 255 * brightness; + const g = 170 * brightness; + const b = 0 * brightness; + lp.material.color.setRGB(r / 255, g / 255, b / 255); + lp.material.emissive.setHex(0x000000); + lp.material.emissiveIntensity = 0; + lp.material.opacity = 0.3; + lp.scale.setScalar(1.0); + } + }); +} + +export function resetLandingPointVisualState() { + landingPoints.forEach(lp => { + lp.material.color.setHex(0xffaa00); + lp.material.emissive.setHex(0x442200); + lp.material.emissiveIntensity = 0.5; + lp.material.opacity = 1.0; + lp.scale.setScalar(1.0); + }); +} diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 0d9e2de3..5baa1672 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -13,7 +13,7 @@ import { hideTooltip } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; -import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates } from './cables.js'; +import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints } from './cables.js'; import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; @@ -475,6 +475,14 @@ function animate() { applyCableVisualState(); + if (lockedObjectType === 'cable' && lockedObject) { + applyLandingPointVisualState(lockedObject.userData.name, false); + } else if (lockedObjectType === 'satellite' && lockedSatellite) { + applyLandingPointVisualState(null, true); + } else { + resetLandingPointVisualState(); + } + updateSatellitePositions(16); const satPositions = getSatellitePositions();