diff --git a/VERSION b/VERSION index f57373a0..d3568f30 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.40.4 +0.40.5 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 363a05a9..10827114 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,16 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.40.5] — 2026-04-26 + +### 🔧 Improvements +- 卫星拖尾改用 Instanced screen-space ribbon,单 draw call 渲染所有轨迹段,支持像素级宽度控制 +- Iridium 地面覆盖重写为球面投影径向网格,修复填充光晕不可见问题;新增外圈 LineLoop +- 搜索面板打开时改用双 rAF 延迟聚焦输入框,确保 CSS 过渡完成后焦点可靠触发 +- 代码清理:提取 `IRIDIUM_OVERLAY_COLOR`、`IRIDIUM_REFERENCE_ALTITUDE_KM` 常量,消除重复三角函数调用 + +--- + ## [0.39.0] — 2026-04-24 ## [0.40.4] — 2026-04-26 diff --git a/docs/version-history.md b/docs/version-history.md index 3f0f3149..2b484bbc 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.40.4` +- `dev` 当前开发分支历史推导到:`0.40.5` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.40.5` | improvement | `dev` | `pending` | 卫星 ribbon 拖尾、Iridium 覆盖球面投影填充+外圈、搜索自动聚焦修复 | | `0.40.4` | bugfix | `dev` | `pending` | 修复页面后台恢复后卫星轨迹跳变与位置错位,统一轨迹重置路径 | | `0.40.3` | improvement | `dev` | `pending` | 卫星点云升级 ShaderMaterial,修复锁定环 depthTest 与位置漂移,新增悬停态缩放 | | `0.40.2` | improvement | `dev` | `pending` | 卫星点大小随镜头缩放动态调整,调小默认基础尺寸 | diff --git a/frontend/package.json b/frontend/package.json index ee6427a2..cde9bc2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.40.4", + "version": "0.40.5", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index efe2a9a8..629057c1 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -277,9 +277,10 @@ export const CABLE_STATE = { export const SATELLITE_CONFIG = { maxCount: -1, - initialLoadCount: 2400, - hydrateFullAfterInitialLoad: true, + initialLoadCount: null, + hydrateFullAfterInitialLoad: false, trailLength: 10, + trailLineWidth: 3, displayAltitudeOffset: 8, frontFacingDotThreshold: 0.015, overlayRenderOrder: 12, diff --git a/frontend/public/earth/js/iridium-footprint-adapter.js b/frontend/public/earth/js/iridium-footprint-adapter.js index 531546fb..6039cdcc 100644 --- a/frontend/public/earth/js/iridium-footprint-adapter.js +++ b/frontend/public/earth/js/iridium-footprint-adapter.js @@ -5,7 +5,11 @@ const SURFACE_SCALE = 1.003; const SURFACE_OFFSET = 0.72; const CLUSTER_DIAMETER_KM_APPROX = 4500; const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2; -const SURFACE_AXIS = new THREE.Vector3(0, 0, 1); +const IRIDIUM_OVERLAY_COLOR = 0x5faeff; +const IRIDIUM_REFERENCE_ALTITUDE_KM = 780; +const FILL_RINGS = 12; +const FILL_SEGMENTS = 48; +const RING_SEGMENTS = 72; function disposeMaterial(material) { if (!material) return; @@ -19,34 +23,27 @@ function disposeMaterial(material) { function disposeObjectTree(object) { if (!object) return; object.traverse((child) => { - if (child.geometry) { - child.geometry.dispose(); - } - if (child.material) { - disposeMaterial(child.material); - } + if (child.geometry) child.geometry.dispose(); + if (child.material) disposeMaterial(child.material); }); } -function createIridiumClusterMaterial() { +function createIridiumFillMaterial() { return new THREE.ShaderMaterial({ transparent: true, side: THREE.DoubleSide, depthTest: true, depthWrite: false, - polygonOffset: true, - polygonOffsetFactor: -3, - polygonOffsetUnits: -3, blending: THREE.AdditiveBlending, uniforms: { - uColor: { value: new THREE.Color(0x5faeff) }, - uOpacity: { value: 0.24 }, + uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) }, + uOpacity: { value: 0.55 }, }, vertexShader: ` + attribute vec2 aUv; varying vec2 vUv; - void main() { - vUv = uv; + vUv = aUv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, @@ -54,12 +51,10 @@ function createIridiumClusterMaterial() { uniform vec3 uColor; uniform float uOpacity; varying vec2 vUv; - void main() { - vec2 p = vUv * 2.0 - 1.0; - float ellipseMetric = p.x * p.x * 0.82 + p.y * p.y * 1.06; - float alpha = exp(-ellipseMetric * 1.05) * (1.0 - smoothstep(0.86, 1.24, ellipseMetric)); - alpha *= uOpacity; + float r2 = dot(vUv, vUv); + float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2)); + float alpha = glow * uOpacity; if (alpha <= 0.001) discard; gl_FragColor = vec4(uColor, alpha); } @@ -67,6 +62,17 @@ function createIridiumClusterMaterial() { }); } +function createIridiumRingMaterial() { + return new THREE.LineBasicMaterial({ + color: new THREE.Color(IRIDIUM_OVERLAY_COLOR), + transparent: true, + opacity: 0.75, + blending: THREE.AdditiveBlending, + depthTest: true, + depthWrite: false, + }); +} + function projectOffsetToSurface( centerNormal, alongTrack, @@ -88,13 +94,55 @@ function projectOffsetToSurface( function computeClusterRadiusKm(altitudeKm) { const altitudeScale = THREE.MathUtils.clamp( - (Number(altitudeKm) || 780) / 780, + (Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM, 0.88, 1.18, ); return CLUSTER_RADIUS_KM_BASE * altitudeScale; } +function buildFillGeometry() { + // Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each. + // Positions are updated in world space each frame; indices are static. + const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS; + const positions = new Float32Array(vertexCount * 3); + const uvs = new Float32Array(vertexCount * 2); + + // Center vertex: uv = (0,0) + // Edge vertices: uv on unit circle, r = ring/FILL_RINGS + + const indices = []; + // Center to first ring: triangle fan + for (let s = 0; s < FILL_SEGMENTS; s++) { + const a = 1 + s; + const b = 1 + (s + 1) % FILL_SEGMENTS; + indices.push(0, a, b); + } + // Ring to ring + for (let r = 0; r < FILL_RINGS - 1; r++) { + const ringBase = 1 + r * FILL_SEGMENTS; + const nextBase = ringBase + FILL_SEGMENTS; + for (let s = 0; s < FILL_SEGMENTS; s++) { + const s1 = (s + 1) % FILL_SEGMENTS; + indices.push(ringBase + s, nextBase + s, ringBase + s1); + indices.push(nextBase + s, nextBase + s1, ringBase + s1); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + return geometry; +} + +function buildRingGeometry() { + const positions = new Float32Array(RING_SEGMENTS * 3); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return geometry; +} + export function createIridiumFootprintAdapter({ earthObj, earthRadiusWorld, @@ -105,19 +153,21 @@ export function createIridiumFootprintAdapter({ const group = new THREE.Group(); group.name = "iridium-footprint-overlay"; group.renderOrder = renderOrder; - group.userData = { - earthRadiusWorld, - clusterGlow: null, - }; + group.userData = { earthRadiusWorld, fill: null, outerRing: null }; - const clusterGlow = new THREE.Mesh( - new THREE.CircleGeometry(1, 72), - createIridiumClusterMaterial(), - ); - clusterGlow.name = "iridium-cluster-glow"; - clusterGlow.renderOrder = renderOrder - 1; - group.add(clusterGlow); - group.userData.clusterGlow = clusterGlow; + const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial()); + fill.name = "iridium-cluster-fill"; + fill.renderOrder = renderOrder; + fill.frustumCulled = false; + group.add(fill); + group.userData.fill = fill; + + const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial()); + outerRing.name = "iridium-outer-ring"; + outerRing.renderOrder = renderOrder; + outerRing.frustumCulled = false; + group.add(outerRing); + group.userData.outerRing = outerRing; earthObj.add(group); return group; @@ -129,30 +179,65 @@ export function updateIridiumFootprintAdapter( ) { if (!group || !position || !alongTrack || !crossTrack) return; - const earthRadiusWorld = - group.userData?.earthRadiusWorld || EARTH_RADIUS_KM; + const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM; const centerNormal = position.clone().normalize(); const clusterRadiusKm = computeClusterRadiusKm(altitudeKm); - const clusterGlow = group.userData?.clusterGlow || null; - const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM; + const alongRadiusKm = clusterRadiusKm * 1.18; + const crossRadiusKm = clusterRadiusKm * 0.96; - if (clusterGlow) { - const clusterCenter = projectOffsetToSurface( - centerNormal, - alongTrack, - crossTrack, - 0, - 0, - earthRadiusWorld, - ); - const clusterNormal = clusterCenter.clone().normalize(); - clusterGlow.position.copy(clusterCenter); - clusterGlow.quaternion.setFromUnitVectors(SURFACE_AXIS, clusterNormal); - clusterGlow.scale.set( - clusterRadiusKm * worldUnitsPerKm * 1.18, - clusterRadiusKm * worldUnitsPerKm * 0.96, - 1, + const fill = group.userData?.fill; + if (fill) { + const posAttr = fill.geometry.attributes.position; + const uvAttr = fill.geometry.attributes.aUv; + + // Center vertex + const center = projectOffsetToSurface( + centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld, ); + posAttr.setXYZ(0, center.x, center.y, center.z); + uvAttr.setXY(0, 0, 0); + + // Ring vertices + for (let r = 1; r <= FILL_RINGS; r++) { + const t = r / FILL_RINGS; + const aKm = alongRadiusKm * t; + const cKm = crossRadiusKm * t; + for (let s = 0; s < FILL_SEGMENTS; s++) { + const angle = (s / FILL_SEGMENTS) * Math.PI * 2; + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const pt = projectOffsetToSurface( + centerNormal, alongTrack, crossTrack, + aKm * cosA, + cKm * sinA, + earthRadiusWorld, + ); + const vi = 1 + (r - 1) * FILL_SEGMENTS + s; + posAttr.setXYZ(vi, pt.x, pt.y, pt.z); + uvAttr.setXY(vi, t * cosA, t * sinA); + } + } + + posAttr.needsUpdate = true; + uvAttr.needsUpdate = true; + fill.geometry.computeBoundingSphere(); + } + + const outerRing = group.userData?.outerRing; + if (outerRing) { + const posAttr = outerRing.geometry.attributes.position; + for (let k = 0; k < RING_SEGMENTS; k++) { + const angle = (k / RING_SEGMENTS) * Math.PI * 2; + const pt = projectOffsetToSurface( + centerNormal, alongTrack, crossTrack, + alongRadiusKm * Math.cos(angle), + crossRadiusKm * Math.sin(angle), + earthRadiusWorld, + ); + posAttr.setXYZ(k, pt.x, pt.y, pt.z); + } + posAttr.needsUpdate = true; + outerRing.geometry.computeBoundingSphere(); } } diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 45be0577..e403eaef 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -66,6 +66,61 @@ const SATELLITE_CONSTELLATION_LABELS = Object.freeze({ }); const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; + +const TRAIL_RIBBON_VERTEX_SHADER = /* glsl */ ` + attribute vec3 instanceStart; + attribute vec3 instanceEnd; + attribute vec3 instanceColorStart; + attribute vec3 instanceColorEnd; + uniform vec2 resolution; + uniform float lineWidth; + varying vec3 vColor; + void main() { + float t = position.x; + float side = position.y; + + vec4 clipStart = projectionMatrix * modelViewMatrix * vec4(instanceStart, 1.0); + vec4 clipEnd = projectionMatrix * modelViewMatrix * vec4(instanceEnd, 1.0); + + vec2 screenStart = (clipStart.xy / clipStart.w * 0.5 + 0.5) * resolution; + vec2 screenEnd = (clipEnd.xy / clipEnd.w * 0.5 + 0.5) * resolution; + vec2 dir = screenEnd - screenStart; + float segLen = length(dir); + + vec4 clipPos = mix(clipStart, clipEnd, t); + + if (segLen > 0.001) { + dir /= segLen; + vec2 normal = vec2(-dir.y, dir.x); + clipPos.xy += normal * side * lineWidth * 0.5 / resolution * 2.0 * clipPos.w; + vColor = mix(instanceColorStart, instanceColorEnd, t); + } else { + vColor = vec3(0.0); + } + + gl_Position = clipPos; + } +`; + +const TRAIL_RIBBON_FRAGMENT_SHADER = /* glsl */ ` + varying vec3 vColor; + void main() { + gl_FragColor = vec4(vColor, 1.0); + } +`; + +const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [ + "instanceStart", + "instanceEnd", + "instanceColorStart", + "instanceColorEnd", +]; +const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000; +const FALLBACK_MIN_MEAN_MOTION = 12; +const FALLBACK_MEAN_MOTION_SPREAD = 4; +const FALLBACK_TRAIL_TIP_LENGTH = 0.004; +const FALLBACK_TRAIL_ALPHA_START = 0.2; +const FALLBACK_TRAIL_ALPHA_END = 0.8; const DOT_TEXTURE_SIZE = 32; const POSITION_UPDATE_INTERVAL_MS = 250; const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000; @@ -520,16 +575,32 @@ export function createSatellites(scene, earthObj) { earthObj.add(satelliteBackdropPoints); earthObj.add(satellitePoints); - const trailGeometry = new THREE.BufferGeometry(); + // Instanced screen-space ribbon: one quad instance per trail segment. + // Single mesh / single draw call for all satellite trails. + const ribbonGeometry = new THREE.InstancedBufferGeometry(); + // Base quad: position.x = t (0=seg-start, 1=seg-end), position.y = side (-1/+1) + ribbonGeometry.setAttribute( + "position", + new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3), + ); + ribbonGeometry.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1)); - const trailMaterial = new THREE.LineBasicMaterial({ - vertexColors: true, + const trailResolution = new THREE.Vector2(window.innerWidth, window.innerHeight); + const trailMaterial = new THREE.ShaderMaterial({ + uniforms: { + lineWidth: { value: SATELLITE_CONFIG.trailLineWidth }, + resolution: { value: trailResolution }, + }, + vertexShader: TRAIL_RIBBON_VERTEX_SHADER, + fragmentShader: TRAIL_RIBBON_FRAGMENT_SHADER, transparent: true, - opacity: 0.3, blending: THREE.AdditiveBlending, + depthWrite: false, }); - satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); + satelliteTrails = new THREE.Mesh(ribbonGeometry, trailMaterial); + satelliteTrails.onBeforeRender = (renderer) => renderer.getSize(trailResolution); + satelliteTrails.frustumCulled = false; satelliteTrails.visible = false; satelliteTrails.userData = { type: "satelliteTrails" }; earthObj.add(satelliteTrails); @@ -568,15 +639,12 @@ function resetSatelliteTrailState() { function clearSatelliteTrailGeometry() { if (!satelliteTrails) return; - 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; + for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) { + const attr = satelliteTrails.geometry.attributes[name]; + if (attr?.array) { + attr.array.fill(0); + attr.needsUpdate = true; + } } } @@ -599,10 +667,14 @@ function ensureSatelliteCapacity(count) { const previousPointAlphas = satellitePoints.geometry.attributes.alpha?.array || null; const previousBackdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha?.array || null; - const previousTrailPositions = - satelliteTrails.geometry.attributes.position?.array || null; - const previousTrailColors = - satelliteTrails.geometry.attributes.color?.array || null; + const previousInstanceStarts = + satelliteTrails.geometry.attributes.instanceStart?.array || null; + const previousInstanceEnds = + satelliteTrails.geometry.attributes.instanceEnd?.array || null; + const previousInstanceColorStarts = + satelliteTrails.geometry.attributes.instanceColorStart?.array || null; + const previousInstanceColorEnds = + satelliteTrails.geometry.attributes.instanceColorEnd?.array || null; const previousSatellitePositions = satellitePositions; const previousCapacity = satelliteCapacity; @@ -668,32 +740,48 @@ function ensureSatelliteCapacity(count) { ); satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity)); - const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); - const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); - if (previousTrailPositions) { - trailPositions.set( - previousTrailPositions.subarray( - 0, - Math.min(previousTrailPositions.length, trailPositions.length), - ), + const segCount = nextCapacity * (TRAIL_LENGTH - 1); + const instanceStarts = new Float32Array(segCount * 3); + const instanceEnds = new Float32Array(segCount * 3); + const instanceColorStarts = new Float32Array(segCount * 3); + const instanceColorEnds = new Float32Array(segCount * 3); + if (previousInstanceStarts) { + instanceStarts.set( + previousInstanceStarts.subarray(0, Math.min(previousInstanceStarts.length, instanceStarts.length)), ); } - if (previousTrailColors) { - trailColors.set( - previousTrailColors.subarray( - 0, - Math.min(previousTrailColors.length, trailColors.length), - ), + if (previousInstanceEnds) { + instanceEnds.set( + previousInstanceEnds.subarray(0, Math.min(previousInstanceEnds.length, instanceEnds.length)), + ); + } + if (previousInstanceColorStarts) { + instanceColorStarts.set( + previousInstanceColorStarts.subarray(0, Math.min(previousInstanceColorStarts.length, instanceColorStarts.length)), + ); + } + if (previousInstanceColorEnds) { + instanceColorEnds.set( + previousInstanceColorEnds.subarray(0, Math.min(previousInstanceColorEnds.length, instanceColorEnds.length)), ); } satelliteTrails.geometry.setAttribute( - "position", - new THREE.BufferAttribute(trailPositions, 3), + "instanceStart", + new THREE.InstancedBufferAttribute(instanceStarts, 3), ); satelliteTrails.geometry.setAttribute( - "color", - new THREE.BufferAttribute(trailColors, 3), + "instanceEnd", + new THREE.InstancedBufferAttribute(instanceEnds, 3), ); + satelliteTrails.geometry.setAttribute( + "instanceColorStart", + new THREE.InstancedBufferAttribute(instanceColorStarts, 3), + ); + satelliteTrails.geometry.setAttribute( + "instanceColorEnd", + new THREE.InstancedBufferAttribute(instanceColorEnds, 3), + ); + satelliteTrails.geometry.instanceCount = segCount; satellitePositions = Array.from({ length: nextCapacity }, (_, index) => { const previousState = previousSatellitePositions[index]; @@ -903,7 +991,7 @@ function buildTleLinesFromElements(props, fallbackTime) { }; } -function generateFallbackPosition(satellite, index, total) { +function generateFallbackPosition(satellite, index, total, time = new Date()) { const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; const noradId = satellite.properties?.norad_cat_id || index; @@ -915,9 +1003,21 @@ function generateFallbackPosition(satellite, index, total) { .split("") .reduce((a, b) => a + b.charCodeAt(0), 0); const randomOffset = (hash % 1000) / 1000; + const rawMeanMotion = Number(satellite.properties?.mean_motion); + const meanMotion = + Number.isFinite(rawMeanMotion) && rawMeanMotion > 0 + ? rawMeanMotion + : FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD; const normalizedIndex = index / total; - const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180; + const elapsedDays = Number.isFinite(time?.getTime?.()) + ? time.getTime() / FALLBACK_ORBIT_DAY_MS + : Date.now() / FALLBACK_ORBIT_DAY_MS; + const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2; + const theta = + normalizedIndex * Math.PI * 2 * 10 + + (raan * Math.PI) / 180 + + fallbackPhase; const phi = (inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1; @@ -946,6 +1046,7 @@ export async function loadSatellites(options = {}) { const data = await response.json(); satelliteData = data.features || []; satelliteSatrecCache = new Map(); + resetSatelliteTrailState(); ensureSatelliteCapacity(satelliteData.length); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return { @@ -958,7 +1059,9 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return; const shouldUpdateTrails = - showSatellites || showTrails || lockedSatelliteIndex !== null; + showSatellites || + showTrails || + lockedSatelliteIndex !== null; const shouldResetTrails = options.resetTrails || (!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS); @@ -988,10 +1091,13 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = const colors = satellitePoints.geometry.attributes.color.array; const pointAlphas = satellitePoints.geometry.attributes.alpha.array; const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array; - const trailPositions = satelliteTrails.geometry.attributes.position.array; - const trailColors = satelliteTrails.geometry.attributes.color.array; + const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array; + const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array; + const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array; + const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array; const baseTime = new Date(Date.now() + elapsedMs); const count = Math.min(satelliteData.length, satelliteCapacity); + let trailSegmentCount = 0; for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; @@ -1003,16 +1109,30 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = let pos = computeSatellitePosition(satellite, adjustedTime); if (!pos) { - pos = generateFallbackPosition(satellite, i, count); + pos = generateFallbackPosition(satellite, i, count, adjustedTime); } satellitePositions[i].current.copy(pos); - if (shouldUpdateTrails && i !== lockedSatelliteIndex) { + if (shouldUpdateTrails) { const satPos = satellitePositions[i]; - satPos.trail[satPos.trailIndex] = pos.clone(); - satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; - if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; + if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) { + for (let k = 0; k < TRAIL_LENGTH; k++) { + const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS; + const pastTime = new Date(adjustedTime.getTime() - offsetMs); + let pastPos = computeSatellitePosition(satellite, pastTime); + if (!pastPos) { + pastPos = generateFallbackPosition(satellite, i, count, pastTime); + } + satPos.trail[satPos.trailIndex] = pastPos; + satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; + } + satPos.trailCount = TRAIL_LENGTH; + } else { + satPos.trail[satPos.trailIndex] = pos.clone(); + satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; + if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; + } } positions[i * 3] = pos.x; @@ -1040,32 +1160,59 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = backdropAlphas[i] = pointAlpha; const satPosition = satellitePositions[i]; - for (let j = 0; j < TRAIL_LENGTH; j++) { - const trailIdx = (i * TRAIL_LENGTH + j) * 3; - - if (j < satPosition.trailCount) { - const idx = - (satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) % - TRAIL_LENGTH; - const trailPoint = satPosition.trail[idx]; - if (trailPoint) { - trailPositions[trailIdx] = trailPoint.x; - trailPositions[trailIdx + 1] = trailPoint.y; - trailPositions[trailIdx + 2] = trailPoint.z; - const alpha = (j + 1) / satPosition.trailCount; - trailColors[trailIdx] = r * alpha * trailBrightness; - trailColors[trailIdx + 1] = g * alpha * trailBrightness; - trailColors[trailIdx + 2] = b * alpha * trailBrightness; - continue; + const tc = satPosition.trailCount; + let hasVisibleTrail = false; + for (let j = 0; j < TRAIL_LENGTH - 1; j++) { + if (j + 1 < tc) { + const idxA = + (satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; + const idxB = + (satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH; + const ptA = satPosition.trail[idxA]; + const ptB = satPosition.trail[idxB]; + if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) { + const base = trailSegmentCount * 3; + instanceStarts[base] = ptA.x; + instanceStarts[base + 1] = ptA.y; + instanceStarts[base + 2] = ptA.z; + instanceEnds[base] = ptB.x; + instanceEnds[base + 1] = ptB.y; + instanceEnds[base + 2] = ptB.z; + const alphaA = (j + 1) / tc; + const alphaB = (j + 2) / tc; + instanceColorStarts[base] = r * alphaA * trailBrightness; + instanceColorStarts[base + 1] = g * alphaA * trailBrightness; + instanceColorStarts[base + 2] = b * alphaA * trailBrightness; + instanceColorEnds[base] = r * alphaB * trailBrightness; + instanceColorEnds[base + 1] = g * alphaB * trailBrightness; + instanceColorEnds[base + 2] = b * alphaB * trailBrightness; + hasVisibleTrail = true; + trailSegmentCount++; } } - - trailPositions[trailIdx] = pos.x; - trailPositions[trailIdx + 1] = pos.y; - trailPositions[trailIdx + 2] = pos.z; - trailColors[trailIdx] = 0; - trailColors[trailIdx + 1] = 0; - trailColors[trailIdx + 2] = 0; + } + if (!hasVisibleTrail) { + const base = trailSegmentCount * 3; + const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1; + const nx = pos.x / dist; + const ny = pos.y / dist; + const nz = pos.z / dist; + const tip = FALLBACK_TRAIL_TIP_LENGTH; + instanceStarts[base] = pos.x + nx * tip; + instanceStarts[base + 1] = pos.y + ny * tip; + instanceStarts[base + 2] = pos.z + nz * tip; + instanceEnds[base] = pos.x; + instanceEnds[base + 1] = pos.y; + instanceEnds[base + 2] = pos.z; + const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness; + const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness; + instanceColorStarts[base] = r * fallbackAlphaStart; + instanceColorStarts[base + 1] = g * fallbackAlphaStart; + instanceColorStarts[base + 2] = b * fallbackAlphaStart; + instanceColorEnds[base] = r * fallbackAlphaEnd; + instanceColorEnds[base + 1] = g * fallbackAlphaEnd; + instanceColorEnds[base + 2] = b * fallbackAlphaEnd; + trailSegmentCount++; } } @@ -1079,12 +1226,23 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = pointAlphas[i] = 0; backdropAlphas[i] = 0; - for (let j = 0; j < TRAIL_LENGTH; j++) { - const trailIdx = (i * TRAIL_LENGTH + j) * 3; - trailPositions[trailIdx] = 0; - trailPositions[trailIdx + 1] = 0; - trailPositions[trailIdx + 2] = 0; - } + } + + const trailArrayLength = instanceStarts.length / 3; + for (let i = trailSegmentCount; i < trailArrayLength; i++) { + const base = i * 3; + instanceStarts[base] = 0; + instanceStarts[base + 1] = 0; + instanceStarts[base + 2] = 0; + instanceEnds[base] = 0; + instanceEnds[base + 1] = 0; + instanceEnds[base + 2] = 0; + instanceColorStarts[base] = 0; + instanceColorStarts[base + 1] = 0; + instanceColorStarts[base + 2] = 0; + instanceColorEnds[base] = 0; + instanceColorEnds[base + 1] = 0; + instanceColorEnds[base + 2] = 0; } satellitePoints.geometry.attributes.position.needsUpdate = true; @@ -1095,8 +1253,10 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options = satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true; satelliteBackdropPoints.geometry.setDrawRange(0, count); - satelliteTrails.geometry.attributes.position.needsUpdate = true; - satelliteTrails.geometry.attributes.color.needsUpdate = true; + for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) { + satelliteTrails.geometry.attributes[name].needsUpdate = true; + } + satelliteTrails.geometry.instanceCount = trailSegmentCount; // Keep the hover ring synced with the propagated satellite position even // when the pointer stays still and no new hover event is emitted. @@ -2044,24 +2204,8 @@ function updateLockedMarkerVisual(isHovered) { } } -function createRelatedSatelliteSprite(position, color = "#7dd3fc") { - if (!earthObjRef) return null; - - const ringTexture = createRingTexture(7, 11, color); - const spriteMaterial = new THREE.SpriteMaterial({ - map: ringTexture, - transparent: true, - opacity: 0.55, - depthTest: false, - sizeAttenuation: false, - }); - - const sprite = new THREE.Sprite(spriteMaterial); - sprite.position.copy(position); - sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); - sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder; - earthObjRef.add(sprite); - return sprite; +function createRelatedSatelliteSprite(position) { + return createRingSprite(position, false); } export function showHoverRing(position, isLocked = false) { @@ -2259,7 +2403,7 @@ export function highlightRelatedSatellites(indices, color = "#7dd3fc") { indices.forEach((index) => { const pos = satellitePositions?.[index]?.current; if (!pos) return; - const sprite = createRelatedSatelliteSprite(pos, color); + const sprite = createRelatedSatelliteSprite(pos); if (!sprite) return; relatedSatelliteSprites.push({ index, sprite, color }); }); diff --git a/frontend/public/earth/js/search.js b/frontend/public/earth/js/search.js index 24b7d0fb..52446529 100644 --- a/frontend/public/earth/js/search.js +++ b/frontend/public/earth/js/search.js @@ -242,13 +242,15 @@ export function openSearchPanel() { window.dispatchEvent( new CustomEvent("earth:search-open-change", { detail: { open: true } }), ); - window.setTimeout(() => { - input?.focus(); - input?.select(); - runSearch().catch((error) => { - console.warn("Running search failed:", error); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + input?.focus(); + input?.select(); + runSearch().catch((error) => { + console.warn("Running search failed:", error); + }); }); - }, 16); + }); } export function focusSearchInput({ select = false } = {}) { diff --git a/pyproject.toml b/pyproject.toml index b791d0c3..ea278239 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.40.4" +version = "0.40.5" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 4ab1ce6f..d01df61c 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.40.4" +version = "0.40.5" source = { virtual = "." } dependencies = [ { name = "aiofiles" },