diff --git a/VERSION b/VERSION index 5a03fb73..88541566 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.0 +0.21.0 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 215183f5..2d10ac7b 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -5,7 +5,7 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. """ from datetime import datetime -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from typing import List, Dict, Any, Optional @@ -400,7 +400,11 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)): @router.get("/geo/satellites") async def get_satellites_geojson( - limit: int = 10000, + limit: Optional[int] = Query( + None, + ge=1, + description="Maximum number of satellites to return. Omit for no limit.", + ), db: AsyncSession = Depends(get_db), ): """获取卫星 TLE GeoJSON 数据""" @@ -409,8 +413,9 @@ async def get_satellites_geojson( .where(CollectedData.source == "celestrak_tle") .where(CollectedData.name != "Unknown") .order_by(CollectedData.id.desc()) - .limit(limit) ) + if limit is not None: + stmt = stmt.limit(limit) result = await db.execute(stmt) records = result.scalars().all() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 28f36ada..342818ff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -47,6 +47,32 @@ Released: 2026-03-26 - Older satellite records can still fall back to backend-generated TLE lines when raw lines are unavailable. - This release is primarily focused on Earth module stability rather than visible admin UI changes. +## 0.21.0 + +Released: 2026-03-26 + +### Highlights + +- Added legacy-inspired inertial drag behavior to the Earth big-screen module. +- Removed the hard 10,000-satellite ceiling when Earth satellite loading is configured as unlimited. +- Tightened Earth toolbar and hover-state synchronization for a more consistent runtime feel. + +### Added + +- Added inertial drag state and smoothing to the Earth runtime so drag release now decays naturally. + +### Improved + +- Improved drag handling so moving the pointer outside the canvas no longer prematurely stops rotation. +- Improved satellite loading to support dynamic frontend buffer sizing when no explicit limit is set. +- Improved Earth interaction fidelity by keeping the hover ring synchronized with moving satellites. + +### Fixed + +- Fixed the trails toolbar button so its default visual state matches the actual default runtime state. +- Fixed the satellite GeoJSON endpoint so omitting `limit` no longer silently falls back to `10000`. +- Fixed hover ring lag where the ring could stay behind the satellite until the next mouse move. + ## 0.19.0 Released: 2026-03-25 diff --git a/docs/version-history.md b/docs/version-history.md index c4ba134d..f0cc9597 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.20.0` +- `dev` 当前开发分支历史推导到:`0.21.0` ## Timeline @@ -62,6 +62,7 @@ | `0.18.1` | bugfix | `dev` | `cc5f16f8` | fix settings layout and frontend startup checks | | `0.19.0` | feature | `dev` | `020c1d50` | refine data management and collection workflows | | `0.20.0` | feature | `dev` | `ce5feba3` | stabilize Earth module and fix satellite TLE handling | +| `0.21.0` | feature | `dev` | `pending` | add Earth inertial drag, sync hover/trail state, and support unlimited satellite loading | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/package.json b/frontend/package.json index 9c15708c..64412908 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.20.0", + "version": "0.21.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index c46ab8bb..cd38bf76 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -50,7 +50,7 @@ - + diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index b1c81d9b..5cee455d 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -54,7 +54,7 @@ export const CABLE_STATE = { }; export const SATELLITE_CONFIG = { - maxCount: 10000, + maxCount: -1, trailLength: 10, dotSize: 4, ringSize: 0.07, diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index f15aa2c1..d4c4f8eb 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -293,6 +293,12 @@ function setupTerrainControls() { const toolbarToggle = document.getElementById("toolbar-toggle"); const toolbar = document.getElementById("control-toolbar"); + if (trailsBtn) { + trailsBtn.classList.add("active"); + const tooltip = trailsBtn.querySelector(".tooltip"); + if (tooltip) tooltip.textContent = "隐藏轨迹"; + } + bindListener(terrainBtn, "click", function () { showTerrain = !showTerrain; toggleTerrain(showTerrain); diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 03cf3d5e..4298e268 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -81,6 +81,8 @@ export let renderer; let simplex; let isDragging = false; let previousMousePosition = { x: 0, y: 0 }; +let targetRotation = { x: 0, y: 0 }; +let inertialVelocity = { x: 0, y: 0 }; let hoveredCable = null; let hoveredSatellite = null; let hoveredSatelliteIndex = null; @@ -108,6 +110,10 @@ const scratchCableCenter = new THREE.Vector3(); const scratchCableDirection = new THREE.Vector3(); const cleanupFns = []; +const DRAG_ROTATION_FACTOR = 0.005; +const DRAG_SMOOTHING_FACTOR = 0.18; +const INERTIA_DAMPING = 0.92; +const INERTIA_MIN_VELOCITY = 0.00008; function bindListener(target, eventName, handler, options) { if (!target) return; @@ -327,6 +333,11 @@ export function init() { addLights(); initInfoCard(); const earthObj = createEarth(scene); + targetRotation = { + x: earthObj.rotation.x, + y: earthObj.rotation.y, + }; + inertialVelocity = { x: 0, y: 0 }; createClouds(scene, earthObj); createTerrain(scene, earthObj, simplex); createStars(scene); @@ -461,16 +472,17 @@ function setupEventListeners() { const handleMouseMove = (event) => onMouseMove(event); const handleMouseDown = (event) => onMouseDown(event); const handleMouseUp = () => onMouseUp(); + const handleMouseLeave = () => onMouseLeave(); const handleClick = (event) => onClick(event); const handlePageHide = () => destroy(); bindListener(window, "resize", handleResize); bindListener(window, "pagehide", handlePageHide); bindListener(window, "beforeunload", handlePageHide); - bindListener(renderer.domElement, "mousemove", handleMouseMove); + bindListener(window, "mousemove", handleMouseMove); bindListener(renderer.domElement, "mousedown", handleMouseDown); - bindListener(renderer.domElement, "mouseup", handleMouseUp); - bindListener(renderer.domElement, "mouseleave", handleMouseUp); + bindListener(window, "mouseup", handleMouseUp); + bindListener(renderer.domElement, "mouseleave", handleMouseLeave); bindListener(renderer.domElement, "click", handleClick); } @@ -512,8 +524,13 @@ function onMouseMove(event) { const deltaX = event.clientX - previousMousePosition.x; const deltaY = event.clientY - previousMousePosition.y; - earth.rotation.y += deltaX * 0.005; - earth.rotation.x += deltaY * 0.005; + const rotationDeltaY = deltaX * DRAG_ROTATION_FACTOR; + const rotationDeltaX = deltaY * DRAG_ROTATION_FACTOR; + + targetRotation.y += rotationDeltaY; + targetRotation.x += rotationDeltaX; + inertialVelocity.y = rotationDeltaY; + inertialVelocity.x = rotationDeltaX; previousMousePosition = { x: event.clientX, y: event.clientY }; hideTooltip(); return; @@ -624,10 +641,18 @@ function onMouseMove(event) { } function onMouseDown(event) { + const earth = getEarth(); isDragging = true; dragStartTime = Date.now(); isLongDrag = false; previousMousePosition = { x: event.clientX, y: event.clientY }; + inertialVelocity = { x: 0, y: 0 }; + if (earth) { + targetRotation = { + x: earth.rotation.x, + y: earth.rotation.y, + }; + } document.getElementById("container")?.classList.add("dragging"); hideTooltip(); } @@ -637,6 +662,10 @@ function onMouseUp() { document.getElementById("container")?.classList.remove("dragging"); } +function onMouseLeave() { + hideTooltip(); +} + function onClick(event) { const earth = getEarth(); if (!earth) return; @@ -735,6 +764,36 @@ function animate() { if (getAutoRotate() && earth) { earth.rotation.y += CONFIG.rotationSpeed * (deltaTime / 16); + targetRotation.y = earth.rotation.y; + targetRotation.x = earth.rotation.x; + } + + if (earth) { + if (isDragging) { + // Smoothly follow the drag target to match the legacy interaction feel. + earth.rotation.x += + (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR; + earth.rotation.y += + (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR; + } else if ( + Math.abs(inertialVelocity.x) > INERTIA_MIN_VELOCITY || + Math.abs(inertialVelocity.y) > INERTIA_MIN_VELOCITY + ) { + // Continue rotating after release and gradually decay the motion. + targetRotation.x += inertialVelocity.x * (deltaTime / 16); + targetRotation.y += inertialVelocity.y * (deltaTime / 16); + earth.rotation.x += + (targetRotation.x - earth.rotation.x) * DRAG_SMOOTHING_FACTOR; + earth.rotation.y += + (targetRotation.y - earth.rotation.y) * DRAG_SMOOTHING_FACTOR; + inertialVelocity.x *= Math.pow(INERTIA_DAMPING, deltaTime / 16); + inertialVelocity.y *= Math.pow(INERTIA_DAMPING, deltaTime / 16); + } else { + inertialVelocity.x = 0; + inertialVelocity.y = 0; + targetRotation.x = earth.rotation.x; + targetRotation.y = earth.rotation.y; + } } applyCableVisualState(); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 09e26800..a28f21eb 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -19,9 +19,10 @@ let earthObjRef = null; let sceneRef = null; let cameraRef = null; let lockedSatelliteIndex = null; +let hoveredSatelliteIndex = null; let positionUpdateAccumulator = 0; +let satelliteCapacity = 0; -const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; const POSITION_UPDATE_INTERVAL_MS = 250; @@ -114,17 +115,9 @@ function createRingTexture(innerRadius, outerRadius, color = "#ffffff") { export function createSatellites(scene, earthObj) { initSatelliteScene(scene, earthObj); - - const positions = new Float32Array(MAX_SATELLITES * 3); - const colors = new Float32Array(MAX_SATELLITES * 3); const dotTexture = createDotTexture(); const pointsGeometry = new THREE.BufferGeometry(); - pointsGeometry.setAttribute( - "position", - new THREE.BufferAttribute(positions, 3), - ); - pointsGeometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); const pointsMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize, @@ -159,18 +152,7 @@ export function createSatellites(scene, earthObj) { earthObj.add(satellitePoints); - const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); - const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); - const trailGeometry = new THREE.BufferGeometry(); - trailGeometry.setAttribute( - "position", - new THREE.BufferAttribute(trailPositions, 3), - ); - trailGeometry.setAttribute( - "color", - new THREE.BufferAttribute(trailColors, 3), - ); const trailMaterial = new THREE.LineBasicMaterial({ vertexColors: true, @@ -183,16 +165,59 @@ export function createSatellites(scene, earthObj) { satelliteTrails.visible = false; satelliteTrails.userData = { type: "satelliteTrails" }; earthObj.add(satelliteTrails); + ensureSatelliteCapacity(0); - satellitePositions = Array.from({ length: MAX_SATELLITES }, () => ({ + positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; + return satellitePoints; +} + +function getRequestedSatelliteLimit() { + return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount; +} + +function createSatellitePositionState() { + return { current: new THREE.Vector3(), trail: [], trailIndex: 0, trailCount: 0, - })); + }; +} - positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; - return satellitePoints; +function ensureSatelliteCapacity(count) { + if (!satellitePoints || !satelliteTrails) return; + + const nextCapacity = Math.max(count, 0); + if (nextCapacity === satelliteCapacity) return; + + const positions = new Float32Array(nextCapacity * 3); + const colors = new Float32Array(nextCapacity * 3); + satellitePoints.geometry.setAttribute( + "position", + new THREE.BufferAttribute(positions, 3), + ); + satellitePoints.geometry.setAttribute( + "color", + new THREE.BufferAttribute(colors, 3), + ); + satellitePoints.geometry.setDrawRange(0, 0); + + const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); + const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); + satelliteTrails.geometry.setAttribute( + "position", + new THREE.BufferAttribute(trailPositions, 3), + ); + satelliteTrails.geometry.setAttribute( + "color", + new THREE.BufferAttribute(trailColors, 3), + ); + + satellitePositions = Array.from( + { length: nextCapacity }, + createSatellitePositionState, + ); + satelliteCapacity = nextCapacity; } function computeSatellitePosition(satellite, time) { @@ -357,15 +382,20 @@ function generateFallbackPosition(satellite, index, total) { } export async function loadSatellites() { - const response = await fetch( - `${SATELLITE_CONFIG.apiPath}?limit=${SATELLITE_CONFIG.maxCount}`, - ); + const limit = getRequestedSatelliteLimit(); + const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); + if (limit !== null) { + url.searchParams.set("limit", String(limit)); + } + + const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`卫星接口返回 HTTP ${response.status}`); } const data = await response.json(); satelliteData = data.features || []; + ensureSatelliteCapacity(satelliteData.length); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return satelliteData.length; } @@ -392,7 +422,7 @@ export function updateSatellitePositions(deltaTime = 0, force = false) { const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailColors = satelliteTrails.geometry.attributes.color.array; const baseTime = new Date(Date.now() + elapsedMs); - const count = Math.min(satelliteData.length, MAX_SATELLITES); + const count = Math.min(satelliteData.length, satelliteCapacity); for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; @@ -485,7 +515,7 @@ export function updateSatellitePositions(deltaTime = 0, force = false) { } } - for (let i = count; i < MAX_SATELLITES; i++) { + for (let i = count; i < satelliteCapacity; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; @@ -504,6 +534,17 @@ export function updateSatellitePositions(deltaTime = 0, force = false) { satelliteTrails.geometry.attributes.position.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true; + + // Keep the hover ring synced with the propagated satellite position even + // when the pointer stays still and no new hover event is emitted. + if ( + hoveredSatelliteIndex !== null && + hoveredSatelliteIndex >= 0 && + hoveredSatelliteIndex < count && + hoveredSatelliteIndex !== lockedSatelliteIndex + ) { + updateHoverRingPosition(satellitePositions[hoveredSatelliteIndex].current); + } } export function toggleSatellites(visible) { @@ -563,6 +604,10 @@ export function setLockedSatelliteIndex(index) { lockedSatelliteIndex = index; } +export function setHoveredSatelliteIndex(index) { + hoveredSatelliteIndex = index; +} + export function isSatelliteFrontFacing(index, camera = cameraRef) { if (!earthObjRef || !camera) return true; if (!satellitePositions || !satellitePositions[index]) return true; @@ -718,14 +763,17 @@ export function updateHoverRingPosition(position) { export function setSatelliteRingState(index, state, position) { switch (state) { case "hover": + hoveredSatelliteIndex = index; hideHoverRings(); showHoverRing(position, false); break; case "locked": + hoveredSatelliteIndex = null; hideHoverRings(); showHoverRing(position, true); break; case "none": + hoveredSatelliteIndex = null; hideHoverRings(); hideLockedRing(); break; @@ -826,6 +874,7 @@ export function clearSatelliteData() { satelliteData = []; selectedSatellite = null; lockedSatelliteIndex = null; + hoveredSatelliteIndex = null; positionUpdateAccumulator = 0; satellitePositions.forEach((position) => { @@ -836,22 +885,30 @@ export function clearSatelliteData() { }); if (satellitePoints) { - const positions = satellitePoints.geometry.attributes.position.array; - const colors = satellitePoints.geometry.attributes.color.array; - positions.fill(0); - colors.fill(0); - satellitePoints.geometry.attributes.position.needsUpdate = true; - satellitePoints.geometry.attributes.color.needsUpdate = true; + const positionAttr = satellitePoints.geometry.attributes.position; + const colorAttr = satellitePoints.geometry.attributes.color; + if (positionAttr?.array) { + positionAttr.array.fill(0); + positionAttr.needsUpdate = true; + } + if (colorAttr?.array) { + colorAttr.array.fill(0); + colorAttr.needsUpdate = true; + } satellitePoints.geometry.setDrawRange(0, 0); } if (satelliteTrails) { - const trailPositions = satelliteTrails.geometry.attributes.position.array; - const trailColors = satelliteTrails.geometry.attributes.color.array; - trailPositions.fill(0); - trailColors.fill(0); - satelliteTrails.geometry.attributes.position.needsUpdate = true; - satelliteTrails.geometry.attributes.color.needsUpdate = true; + const trailPositionAttr = satelliteTrails.geometry.attributes.position; + const trailColorAttr = satelliteTrails.geometry.attributes.color; + if (trailPositionAttr?.array) { + trailPositionAttr.array.fill(0); + trailPositionAttr.needsUpdate = true; + } + if (trailColorAttr?.array) { + trailColorAttr.array.fill(0); + trailColorAttr.needsUpdate = true; + } } hideHoverRings(); @@ -873,6 +930,7 @@ export function resetSatelliteState() { } satellitePositions = []; + satelliteCapacity = 0; showSatellites = false; showTrails = true; } diff --git a/pyproject.toml b/pyproject.toml index 7c18b658..a7382c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.20.0" +version = "0.21.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index ed6ae818..31124edb 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.20.0" +version = "0.21.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },