From cbb704577a14da95469e496e374f974fc98f4eea Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 27 Mar 2026 23:45:40 +0800 Subject: [PATCH] Add weather-driven city effects and keep upper-air cards visible --- .../components/dashboard/Dashboard.module.css | 14 + .../dashboard/FutureForecastModal.tsx | 43 ++- .../components/dashboard/WeatherAuraLayer.tsx | 293 +++++++++++++++++- frontend/lib/weather-aura.ts | 69 ++++- 4 files changed, 395 insertions(+), 24 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index ec565686..09e48d14 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -3022,6 +3022,20 @@ line-height: 1.45; } +.root :global(.future-trend-summary-muted) { + color: var(--text-muted); + border: 1px dashed rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.018); +} + +.root :global(.future-trend-card-empty) { + background: linear-gradient( + 180deg, + rgba(148, 163, 184, 0.05) 0%, + rgba(255, 255, 255, 0.02) 100% + ); +} + .root :global(.future-subsection-title) { color: var(--text-primary); font-size: 0.95rem; diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 86b29523..8eb770f8 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -1600,17 +1600,22 @@ export function FutureForecastModal() { ))} - {displayedUpperAirSummary || - displayedUpperAirMetrics.length > 0 ? ( - <> -
- {locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"} + <> +
+ {locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"} +
+ {displayedUpperAirSummary ? ( +
+ {displayedUpperAirSummary}
- {displayedUpperAirSummary ? ( -
- {displayedUpperAirSummary} -
- ) : null} + ) : ( +
+ {locale === "en-US" + ? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing." + : "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"} +
+ )} + {displayedUpperAirMetrics.length > 0 ? (
{displayedUpperAirMetrics.map((metric) => (
@@ -1653,8 +1658,22 @@ export function FutureForecastModal() {
))}
- - ) : null} + ) : ( +
+
+ {locale === "en-US" ? "Upper-air source" : "高空数据源"} +
+
+ {locale === "en-US" ? "Not available" : "暂不可用"} +
+
+ {locale === "en-US" + ? "No upper-air diagnostic feed is attached to this city right now." + : "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"} +
+
+ )} +
diff --git a/frontend/components/dashboard/WeatherAuraLayer.tsx b/frontend/components/dashboard/WeatherAuraLayer.tsx index cd81c913..27e8dec1 100644 --- a/frontend/components/dashboard/WeatherAuraLayer.tsx +++ b/frontend/components/dashboard/WeatherAuraLayer.tsx @@ -67,13 +67,30 @@ export function WeatherAuraLayer() { camera.position.z = 2; const clock = new THREE.Clock(); + const cleanupMaterials = new Set(); const particleGroups: Array<{ + kind: "flow" | "rain" | "snow" | "fog" | "cloud"; geometry: THREE.BufferGeometry; positions: Float32Array; baseY: Float32Array; drift: Float32Array; phase: Float32Array; + material: THREE.Material; + mesh: THREE.Object3D; }> = []; + const effectLights: THREE.Light[] = []; + const flashOverlay = new THREE.Mesh( + new THREE.PlaneGeometry(2.2, 2.2), + new THREE.MeshBasicMaterial({ + color: new THREE.Color("#e0f2fe"), + transparent: true, + opacity: 0, + blending: THREE.AdditiveBlending, + }), + ); + flashOverlay.position.z = -0.3; + scene.add(flashOverlay); + cleanupMaterials.add(flashOverlay.material as THREE.Material); function createParticleField( count: number, @@ -127,12 +144,203 @@ export function WeatherAuraLayer() { const points = new THREE.Points(geometry, material); scene.add(points); - particleGroups.push({ geometry, positions, baseY, drift, phase }); + cleanupMaterials.add(material); + particleGroups.push({ + geometry, + positions, + baseY, + drift, + phase, + kind: "flow", + material, + mesh: points, + }); + } + + function createRainField(count: number) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const baseY = new Float32Array(count); + const drift = new Float32Array(count); + const phase = new Float32Array(count); + + for (let index = 0; index < count; index += 1) { + const offset = index * 3; + positions[offset] = Math.random() * 2.8 - 1.4; + positions[offset + 1] = Math.random() * 2.2 - 1.1; + positions[offset + 2] = Math.random() * 0.4 - 0.2; + baseY[index] = positions[offset + 1]; + drift[index] = (0.018 + Math.random() * 0.016) * aura.effectIntensity; + phase[index] = 0.004 + Math.random() * 0.004; + } + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + size: 0.018, + color: new THREE.Color("#7dd3fc"), + transparent: true, + opacity: 0.72, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const points = new THREE.Points(geometry, material); + scene.add(points); + cleanupMaterials.add(material); + particleGroups.push({ + geometry, + positions, + baseY, + drift, + phase, + kind: "rain", + material, + mesh: points, + }); + } + + function createSnowField(count: number) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const baseY = new Float32Array(count); + const drift = new Float32Array(count); + const phase = new Float32Array(count); + + for (let index = 0; index < count; index += 1) { + const offset = index * 3; + positions[offset] = Math.random() * 2.8 - 1.4; + positions[offset + 1] = Math.random() * 2.1 - 1.05; + positions[offset + 2] = Math.random() * 0.35 - 0.18; + baseY[index] = positions[offset + 1]; + drift[index] = (0.0045 + Math.random() * 0.0045) * aura.effectIntensity; + phase[index] = Math.random() * Math.PI * 2; + } + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + size: 0.024, + color: new THREE.Color("#f8fafc"), + transparent: true, + opacity: 0.85, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const points = new THREE.Points(geometry, material); + scene.add(points); + cleanupMaterials.add(material); + particleGroups.push({ + geometry, + positions, + baseY, + drift, + phase, + kind: "snow", + material, + mesh: points, + }); + } + + function createFogField(count: number) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const baseY = new Float32Array(count); + const drift = new Float32Array(count); + const phase = new Float32Array(count); + + for (let index = 0; index < count; index += 1) { + const offset = index * 3; + positions[offset] = Math.random() * 2.6 - 1.3; + positions[offset + 1] = Math.random() * 0.9 - 0.45; + positions[offset + 2] = Math.random() * 0.45 - 0.2; + baseY[index] = positions[offset + 1]; + drift[index] = (0.0014 + Math.random() * 0.001) * aura.effectIntensity; + phase[index] = Math.random() * Math.PI * 2; + } + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + size: 0.12, + color: new THREE.Color("#cbd5e1"), + transparent: true, + opacity: 0.18, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const points = new THREE.Points(geometry, material); + scene.add(points); + cleanupMaterials.add(material); + particleGroups.push({ + geometry, + positions, + baseY, + drift, + phase, + kind: "fog", + material, + mesh: points, + }); + } + + function createCloudField(count: number) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(count * 3); + const baseY = new Float32Array(count); + const drift = new Float32Array(count); + const phase = new Float32Array(count); + + for (let index = 0; index < count; index += 1) { + const offset = index * 3; + positions[offset] = Math.random() * 2.7 - 1.35; + positions[offset + 1] = Math.random() * 0.75 + 0.1; + positions[offset + 2] = Math.random() * 0.3 - 0.15; + baseY[index] = positions[offset + 1]; + drift[index] = (0.0012 + Math.random() * 0.0009) * aura.effectIntensity; + phase[index] = Math.random() * Math.PI * 2; + } + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + size: 0.1, + color: new THREE.Color("#dbeafe"), + transparent: true, + opacity: 0.13, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const points = new THREE.Points(geometry, material); + scene.add(points); + cleanupMaterials.add(material); + particleGroups.push({ + geometry, + positions, + baseY, + drift, + phase, + kind: "cloud", + material, + mesh: points, + }); } createParticleField(90, 0.018, aura.particleOpacity * 0.9, -0.1); createParticleField(60, 0.026, aura.particleOpacity * 0.65, 0.08); + if (aura.effect === "rain" || aura.effect === "storm") { + createRainField(aura.effect === "storm" ? 240 : 170); + } else if (aura.effect === "snow") { + createSnowField(150); + } else if (aura.effect === "fog") { + createFogField(90); + } else if (aura.effect === "cloud" || aura.effect === "wind") { + createCloudField(aura.effect === "wind" ? 80 : 54); + } + + if (aura.effect === "storm") { + const flashLight = new THREE.PointLight(0xdbeafe, 0, 6, 2); + flashLight.position.set(0.2, 0.9, 1.1); + scene.add(flashLight); + effectLights.push(flashLight); + } + const resize = () => { const width = host.clientWidth || window.innerWidth; const height = host.clientHeight || window.innerHeight; @@ -156,22 +364,73 @@ export function WeatherAuraLayer() { for (const field of particleGroups) { for (let index = 0; index < field.baseY.length; index += 1) { const offset = index * 3; - let nextX = field.positions[offset] + field.drift[index]; - if (nextX > 1.35) { - nextX = -1.35; - field.baseY[index] = Math.random() * 1.8 - 0.9; + if (field.kind === "flow") { + let nextX = field.positions[offset] + field.drift[index]; + if (nextX > 1.35) { + nextX = -1.35; + field.baseY[index] = Math.random() * 1.8 - 0.9; + } + field.positions[offset] = nextX; + field.positions[offset + 1] = + field.baseY[index] + + Math.sin(elapsed * 0.45 + field.phase[index] + nextX * 2.4) * + 0.06 * + aura.intensity; + } else if (field.kind === "rain") { + let nextY = field.positions[offset + 1] - field.drift[index]; + let nextX = field.positions[offset] + field.phase[index] * aura.drift; + if (nextY < -1.12 || nextX > 1.45) { + nextY = 1.15 + Math.random() * 0.25; + nextX = Math.random() * 2.9 - 1.45; + } + field.positions[offset] = nextX; + field.positions[offset + 1] = nextY; + } else if (field.kind === "snow") { + let nextY = field.positions[offset + 1] - field.drift[index]; + let nextX = + field.positions[offset] + + Math.sin(elapsed * 0.9 + field.phase[index]) * 0.0024 * aura.effectIntensity; + if (nextY < -1.1) { + nextY = 1.12 + Math.random() * 0.2; + nextX = Math.random() * 2.8 - 1.4; + } + field.positions[offset] = nextX; + field.positions[offset + 1] = nextY; + } else if (field.kind === "fog" || field.kind === "cloud") { + let nextX = field.positions[offset] + field.drift[index]; + if (nextX > 1.38) { + nextX = -1.38; + field.baseY[index] = + field.kind === "cloud" + ? Math.random() * 0.75 + 0.1 + : Math.random() * 0.9 - 0.45; + } + field.positions[offset] = nextX; + field.positions[offset + 1] = + field.baseY[index] + + Math.sin(elapsed * 0.3 + field.phase[index]) * + (field.kind === "cloud" ? 0.03 : 0.05); } - field.positions[offset] = nextX; - field.positions[offset + 1] = - field.baseY[index] + - Math.sin(elapsed * 0.45 + field.phase[index] + nextX * 2.4) * - 0.06 * - aura.intensity; } field.geometry.attributes.position.needsUpdate = true; } + if (effectLights.length > 0) { + const flashPulse = Math.max(0, Math.sin(elapsed * 2.1) - 0.78) * 20; + for (const light of effectLights) { + if (light instanceof THREE.PointLight) { + light.intensity = flashPulse; + } + } + (flashOverlay.material as THREE.MeshBasicMaterial).opacity = Math.min( + 0.18, + flashPulse / 120, + ); + } else { + (flashOverlay.material as THREE.MeshBasicMaterial).opacity = 0; + } + renderer.render(scene, camera); }; @@ -187,6 +446,7 @@ export function WeatherAuraLayer() { for (const field of particleGroups) { field.geometry.dispose(); } + cleanupMaterials.forEach((material) => material.dispose()); renderer.dispose(); if (renderer.domElement.parentNode === host) { host.removeChild(renderer.domElement); @@ -199,6 +459,8 @@ export function WeatherAuraLayer() { aura.primary, aura.secondary, aura.tertiary, + aura.effect, + aura.effectIntensity, isDesktop, prefersReducedMotion, ]); @@ -212,6 +474,15 @@ export function WeatherAuraLayer() { `radial-gradient(circle at 18% 22%, ${hexToRgba(aura.primary, 0.18 * aura.intensity)}, transparent 32%)`, `radial-gradient(circle at 78% 20%, ${hexToRgba(aura.secondary, 0.14 * aura.intensity)}, transparent 34%)`, `radial-gradient(circle at 52% 78%, ${hexToRgba(aura.tertiary, 0.12 * aura.intensity)}, transparent 38%)`, + aura.effect === "rain" || aura.effect === "storm" + ? `linear-gradient(180deg, ${hexToRgba("#67e8f9", 0.06 * aura.effectIntensity)}, transparent 45%)` + : aura.effect === "snow" + ? `linear-gradient(180deg, ${hexToRgba("#e2e8f0", 0.06 * aura.effectIntensity)}, transparent 45%)` + : aura.effect === "fog" + ? `radial-gradient(circle at 50% 56%, ${hexToRgba("#cbd5e1", 0.08 * aura.effectIntensity)}, transparent 60%)` + : aura.effect === "cloud" + ? `linear-gradient(180deg, ${hexToRgba("#dbeafe", 0.04 * aura.effectIntensity)}, transparent 40%)` + : "none", ].join(", "), } as CSSProperties; diff --git a/frontend/lib/weather-aura.ts b/frontend/lib/weather-aura.ts index 867c46d4..12cddca6 100644 --- a/frontend/lib/weather-aura.ts +++ b/frontend/lib/weather-aura.ts @@ -7,9 +7,23 @@ export interface WeatherAuraProfile { intensity: number; drift: number; particleOpacity: number; + effect: WeatherAuraEffect; + effectIntensity: number; } -const RISK_AURA: Record> = +export type WeatherAuraEffect = + | "rain" + | "snow" + | "fog" + | "storm" + | "wind" + | "cloud" + | "clear"; + +const RISK_AURA: Record< + string, + Omit +> = { high: { primary: "#ff7c2a", @@ -56,11 +70,64 @@ export function getWeatherAuraProfile( const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45); const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35); const particleOpacity = clamp(base.particleOpacity + (intensity - 1) * 0.08, 0.22, 0.48); + const wxText = `${String(detail?.current?.wx_desc || "")} ${String( + detail?.current?.cloud_desc || "", + )}`.toUpperCase(); + const windSpeed = Number(detail?.current?.wind_speed_kt); + const humidity = Number(detail?.current?.humidity); + + let effect: WeatherAuraEffect = "clear"; + if ( + /(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText) + ) { + effect = "storm"; + } else if (/(SN|SG|GS|ICE|SLEET|雪|霰)/.test(wxText)) { + effect = "snow"; + } else if (/(RA|DZ|SHRA|SHOWER|RAIN|DRIZZLE|雨)/.test(wxText)) { + effect = "rain"; + } else if (/(FG|BR|HZ|FU|MIST|FOG|雾|霾)/.test(wxText)) { + effect = "fog"; + } else if ( + /(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText) + ) { + effect = "cloud"; + } else if (Number.isFinite(windSpeed) && windSpeed >= 18) { + effect = "wind"; + } + + const effectIntensity = + effect === "storm" + ? clamp( + 0.9 + + (Number.isFinite(windSpeed) ? windSpeed / 35 : 0) + + (Number.isFinite(humidity) ? humidity / 220 : 0), + 0.9, + 1.9, + ) + : effect === "rain" + ? clamp( + 0.75 + + (Number.isFinite(humidity) ? humidity / 180 : 0) + + (Number.isFinite(windSpeed) ? windSpeed / 45 : 0), + 0.72, + 1.7, + ) + : effect === "snow" + ? clamp(0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0), 0.78, 1.4) + : effect === "fog" + ? clamp(0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0), 0.75, 1.3) + : effect === "wind" + ? clamp(0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0), 0.72, 1.5) + : effect === "cloud" + ? 0.82 + : 0.72; return { ...base, intensity, drift, particleOpacity, + effect, + effectIntensity, }; }