212 lines
7.1 KiB
TypeScript
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='© <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>
|
|
);
|
|
}
|
|
|