Add weather-driven city effects and keep upper-air cards visible
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1600,17 +1600,22 @@ export function FutureForecastModal() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{displayedUpperAirSummary ||
|
||||
displayedUpperAirMetrics.length > 0 ? (
|
||||
<>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||
<>
|
||||
<div className="future-subsection-title">
|
||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||
</div>
|
||||
{displayedUpperAirSummary ? (
|
||||
<div className="future-trend-summary">
|
||||
{displayedUpperAirSummary}
|
||||
</div>
|
||||
{displayedUpperAirSummary ? (
|
||||
<div className="future-trend-summary">
|
||||
{displayedUpperAirSummary}
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing."
|
||||
: "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
|
||||
</div>
|
||||
)}
|
||||
{displayedUpperAirMetrics.length > 0 ? (
|
||||
<div className="future-trend-grid">
|
||||
{displayedUpperAirMetrics.map((metric) => (
|
||||
<div key={metric.label} className="future-trend-card">
|
||||
@@ -1653,8 +1658,22 @@ export function FutureForecastModal() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="future-trend-card future-trend-card-empty">
|
||||
<div className="future-trend-label">
|
||||
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
|
||||
</div>
|
||||
<div className="future-trend-value">
|
||||
{locale === "en-US" ? "Not available" : "暂不可用"}
|
||||
</div>
|
||||
<div className="future-trend-note">
|
||||
{locale === "en-US"
|
||||
? "No upper-air diagnostic feed is attached to this city right now."
|
||||
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -67,13 +67,30 @@ export function WeatherAuraLayer() {
|
||||
camera.position.z = 2;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const cleanupMaterials = new Set<THREE.Material>();
|
||||
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;
|
||||
|
||||
|
||||
@@ -7,9 +7,23 @@ export interface WeatherAuraProfile {
|
||||
intensity: number;
|
||||
drift: number;
|
||||
particleOpacity: number;
|
||||
effect: WeatherAuraEffect;
|
||||
effectIntensity: number;
|
||||
}
|
||||
|
||||
const RISK_AURA: Record<string, Omit<WeatherAuraProfile, "intensity" | "drift">> =
|
||||
export type WeatherAuraEffect =
|
||||
| "rain"
|
||||
| "snow"
|
||||
| "fog"
|
||||
| "storm"
|
||||
| "wind"
|
||||
| "cloud"
|
||||
| "clear";
|
||||
|
||||
const RISK_AURA: Record<
|
||||
string,
|
||||
Omit<WeatherAuraProfile, "intensity" | "drift" | "effect" | "effectIntensity">
|
||||
> =
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user