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" },