ERP-node/frontend/components/dashboard/widgets/WeatherMapWidget.tsx

212 lines
7.1 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getMultipleWeather, WeatherData } from "@/lib/api/openApi";
import { Cloud, CloudRain, CloudSnow, Sun, Wind } from "lucide-react";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
if (typeof window !== "undefined") {
const L = require("leaflet");
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
}
// Leaflet 동적 import (SSR 방지)
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
// 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
interface WeatherMapWidgetProps {
element: DashboardElement;
cities?: string[];
}
/**
* 날씨 아이콘 반환
*/
const getWeatherIcon = (weatherMain: string) => {
switch (weatherMain.toLowerCase()) {
case "clear":
return <Sun className="h-6 w-6 text-yellow-500" />;
case "rain":
return <CloudRain className="h-6 w-6 text-blue-500" />;
case "snow":
return <CloudSnow className="h-6 w-6 text-blue-300" />;
case "clouds":
return <Cloud className="h-6 w-6 text-gray-400" />;
default:
return <Wind className="h-6 w-6 text-gray-500" />;
}
};
/**
* 날씨 지도 위젯
* - 여러 도시의 날씨를 지도에 표시
* - 실시간 날씨 정보 (온도, 습도, 풍속 등)
* - Leaflet + 브이월드 지도 사용
*/
export default function WeatherMapWidget({ element, cities }: WeatherMapWidgetProps) {
const [weatherData, setWeatherData] = useState<WeatherData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 기본 도시 목록 (사용자가 지정하지 않은 경우)
const defaultCities = [
"서울",
"부산",
"인천",
"대구",
"광주",
"대전",
"울산",
"세종",
"제주",
];
const targetCities = cities || defaultCities;
useEffect(() => {
loadWeatherData();
// 자동 새로고침 (5분마다)
const interval = setInterval(() => {
loadWeatherData();
}, 300000);
return () => clearInterval(interval);
}, []);
const loadWeatherData = async () => {
try {
setLoading(true);
setError(null);
const data = await getMultipleWeather(targetCities);
// 위도경도가 있는 데이터만 필터링
const validData = data.filter((item) => item.lat && item.lng);
setWeatherData(validData);
} catch (err: any) {
console.error("날씨 데이터 로드 실패:", err);
setError(err.message || "날씨 데이터를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading && weatherData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-destructive">
<p className="text-sm">{error}</p>
</div>
</div>
);
}
if (weatherData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
);
}
// 지도 중심 (대한민국 중심)
const center: [number, number] = [36.5, 127.5];
return (
<div className="h-full w-full">
<MapContainer
center={center}
zoom={7}
scrollWheelZoom={true}
style={{ height: "100%", width: "100%" }}
className="rounded-lg"
>
{/* 브이월드 Base Map */}
<TileLayer
attribution='&copy; <a href="http://www.vworld.kr">VWorld</a>'
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
/>
{/* 날씨 마커 */}
{weatherData.map((weather, index) => {
if (!weather.lat || !weather.lng) return null;
return (
<Marker key={index} position={[weather.lat, weather.lng]}>
<Popup>
<div className="min-w-[200px] p-2">
{/* 도시명 */}
<div className="mb-2 flex items-center justify-between">
<h3 className="text-base font-semibold">{weather.city}</h3>
{getWeatherIcon(weather.weatherMain)}
</div>
{/* 날씨 설명 */}
<p className="mb-3 text-sm text-muted-foreground">{weather.weatherDescription}</p>
{/* 날씨 정보 */}
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{weather.windSpeed} m/s</span>
</div>
</div>
{/* 타임스탬프 */}
<div className="mt-3 border-t pt-2 text-[10px] text-muted-foreground">
{new Date(weather.timestamp).toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
);
}