"use client"; import { CSSProperties, useEffect, useRef, useState } from "react"; import * as THREE from "three"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; import { getWeatherAuraProfile } from "@/lib/weather-aura"; import styles from "./Dashboard.module.css"; function hexToRgba(hex: string, alpha: number) { const sanitized = hex.replace("#", ""); const normalized = sanitized.length === 3 ? sanitized .split("") .map((char) => `${char}${char}`) .join("") : sanitized.padEnd(6, "0"); const numeric = Number.parseInt(normalized, 16); const r = (numeric >> 16) & 255; const g = (numeric >> 8) & 255; const b = numeric & 255; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } export function WeatherAuraLayer() { const store = useDashboardStore(); const prefersReducedMotion = usePrefersReducedMotion(); const [isDesktop, setIsDesktop] = useState(false); const containerRef = useRef(null); const aura = getWeatherAuraProfile(store.selectedDetail, store.cities); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) { return; } const mediaQuery = window.matchMedia("(min-width: 1024px)"); const apply = () => { setIsDesktop(mediaQuery.matches); }; apply(); mediaQuery.addEventListener("change", apply); return () => { mediaQuery.removeEventListener("change", apply); }; }, []); useEffect(() => { const host = containerRef.current; if (!host || !isDesktop || prefersReducedMotion) { return; } const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: "low-power", }); renderer.setClearColor(0x000000, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); 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, pointSize: number, opacity: number, depthShift: number, ) { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); const baseY = new Float32Array(count); const drift = new Float32Array(count); const phase = new Float32Array(count); const primaryColor = new THREE.Color(aura.primary); const secondaryColor = new THREE.Color(aura.secondary); const tertiaryColor = new THREE.Color(aura.tertiary); for (let index = 0; index < count; index += 1) { const offset = index * 3; const x = Math.random() * 2.6 - 1.3; const y = Math.random() * 1.8 - 0.9; const z = (Math.random() * 0.8 - 0.4) + depthShift; positions[offset] = x; positions[offset + 1] = y; positions[offset + 2] = z; baseY[index] = y; drift[index] = (0.00045 + Math.random() * 0.0012) * aura.drift; phase[index] = Math.random() * Math.PI * 2; const mixedColor = primaryColor .clone() .lerp(secondaryColor, Math.random() * 0.65) .lerp(tertiaryColor, Math.random() * 0.4); colors[offset] = mixedColor.r; colors[offset + 1] = mixedColor.g; colors[offset + 2] = mixedColor.b; } geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); const material = new THREE.PointsMaterial({ size: pointSize, transparent: true, opacity, vertexColors: true, depthWrite: false, blending: THREE.AdditiveBlending, sizeAttenuation: true, }); const points = new THREE.Points(geometry, material); scene.add(points); 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; renderer.setSize(width, height, false); }; resize(); host.appendChild(renderer.domElement); let frameId = 0; let lastFrameAt = 0; const renderFrame = (timestamp: number) => { frameId = window.requestAnimationFrame(renderFrame); if (timestamp - lastFrameAt < 40) { return; } lastFrameAt = timestamp; const elapsed = clock.getElapsedTime(); for (const field of particleGroups) { for (let index = 0; index < field.baseY.length; index += 1) { const offset = index * 3; 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.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); }; frameId = window.requestAnimationFrame(renderFrame); window.addEventListener("resize", resize); return () => { window.cancelAnimationFrame(frameId); window.removeEventListener("resize", resize); for (const child of [...scene.children]) { scene.remove(child); } for (const field of particleGroups) { field.geometry.dispose(); } cleanupMaterials.forEach((material) => material.dispose()); renderer.dispose(); if (renderer.domElement.parentNode === host) { host.removeChild(renderer.domElement); } }; }, [ aura.drift, aura.intensity, aura.particleOpacity, aura.primary, aura.secondary, aura.tertiary, aura.effect, aura.effectIntensity, isDesktop, prefersReducedMotion, ]); if (!isDesktop) { return null; } const overlayStyle = { backgroundImage: [ `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("#6FB7FF", 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; return (