날씨 진행 중 세이브

This commit is contained in:
leeheejin
2025-10-23 12:31:14 +09:00
parent 8ab36f32a0
commit 8ec54b4e7d
8 changed files with 461 additions and 8 deletions

View File

@@ -126,6 +126,12 @@ export function DashboardSidebar() {
subtype="weather"
onDragStart={handleDragStart}
/>
<DraggableItem
title="날씨 지도 위젯"
type="widget"
subtype="weather-map"
onDragStart={handleDragStart}
/>
<DraggableItem
title="계산기 위젯"
type="widget"

View File

@@ -191,6 +191,7 @@ export function DashboardTopMenu({
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="weather"></SelectItem>
<SelectItem value="weather-map"> </SelectItem>
<SelectItem value="exchange"></SelectItem>
<SelectItem value="calculator"></SelectItem>
<SelectItem value="calendar"></SelectItem>

View File

@@ -15,6 +15,7 @@ export type ElementSubtype =
| "combo" // 차트 타입
| "exchange"
| "weather"
| "weather-map" // 날씨 지도 위젯
| "clock"
| "calendar"
| "calculator"

View File

@@ -12,6 +12,7 @@ const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { s
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false });
const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false });
const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false });
const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false });
@@ -64,6 +65,8 @@ function renderWidget(element: DashboardElement) {
return <ExchangeWidget element={element} />;
case "weather":
return <WeatherWidget element={element} />;
case "weather-map":
return <WeatherMapWidget element={element} />;
case "calculator":
return <CalculatorWidget element={element} />;
case "clock":

View File

@@ -3,6 +3,8 @@
import React, { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getWeather, WeatherData } from "@/lib/api/openApi";
import { Cloud, CloudRain, CloudSnow, Sun, Wind } from "lucide-react";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
@@ -34,6 +36,7 @@ interface MarkerData {
lng: number;
name: string;
info: any;
weather?: WeatherData | null;
}
// 테이블명 한글 번역
@@ -56,6 +59,66 @@ const translateTableName = (name: string): string => {
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
};
// 주요 도시 좌표 (날씨 API 지원 도시)
const CITY_COORDINATES = [
{ name: "서울", lat: 37.5665, lng: 126.978 },
{ name: "부산", lat: 35.1796, lng: 129.0756 },
{ name: "인천", lat: 37.4563, lng: 126.7052 },
{ name: "대구", lat: 35.8714, lng: 128.6014 },
{ name: "광주", lat: 35.1595, lng: 126.8526 },
{ name: "대전", lat: 36.3504, lng: 127.3845 },
{ name: "울산", lat: 35.5384, lng: 129.3114 },
{ name: "세종", lat: 36.4800, lng: 127.2890 },
{ name: "제주", lat: 33.4996, lng: 126.5312 },
];
// 두 좌표 간 거리 계산 (Haversine formula)
const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const R = 6371; // 지구 반경 (km)
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
// 가장 가까운 도시 찾기
const findNearestCity = (lat: number, lng: number): string => {
let nearestCity = "서울";
let minDistance = Infinity;
for (const city of CITY_COORDINATES) {
const distance = getDistance(lat, lng, city.lat, city.lng);
if (distance < minDistance) {
minDistance = distance;
nearestCity = city.name;
}
}
return nearestCity;
};
// 날씨 아이콘 반환
const getWeatherIcon = (weatherMain: string) => {
switch (weatherMain.toLowerCase()) {
case "clear":
return <Sun className="h-4 w-4 text-yellow-500" />;
case "rain":
return <CloudRain className="h-4 w-4 text-blue-500" />;
case "snow":
return <CloudSnow className="h-4 w-4 text-blue-300" />;
case "clouds":
return <Cloud className="h-4 w-4 text-gray-400" />;
default:
return <Wind className="h-4 w-4 text-gray-500" />;
}
};
/**
* 범용 지도 위젯 (커스텀 지도 카드)
* - 위도/경도가 있는 모든 데이터를 지도에 표시
@@ -67,6 +130,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tableName, setTableName] = useState<string | null>(null);
const [weatherCache, setWeatherCache] = useState<Map<string, WeatherData>>(new Map());
useEffect(() => {
if (element?.dataSource?.query) {
@@ -83,6 +147,57 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
return () => clearInterval(interval);
}, [element]);
// 마커들의 날씨 정보 로드
const loadWeatherForMarkers = async (markerData: MarkerData[]) => {
try {
// 각 마커의 가장 가까운 도시 찾기
const citySet = new Set<string>();
markerData.forEach((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
citySet.add(nearestCity);
});
// 캐시에 없는 도시만 날씨 조회
const citiesToFetch = Array.from(citySet).filter((city) => !weatherCache.has(city));
if (citiesToFetch.length > 0) {
// 날씨 정보 병렬 로드
const weatherPromises = citiesToFetch.map((city) => getWeather(city));
const weatherResults = await Promise.all(weatherPromises);
// 캐시 업데이트
const newCache = new Map(weatherCache);
citiesToFetch.forEach((city, index) => {
newCache.set(city, weatherResults[index]);
});
setWeatherCache(newCache);
// 마커에 날씨 정보 추가
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: newCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
} else {
// 캐시에서 날씨 정보 가져오기
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: weatherCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
}
} catch (err) {
console.error("날씨 정보 로드 실패:", err);
// 날씨 로드 실패해도 마커는 표시
}
};
const loadMapData = async () => {
if (!element?.dataSource?.query) {
return;
@@ -135,9 +250,13 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
lng: parseFloat(row[lngCol]),
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
info: row,
weather: null,
}));
setMarkers(markerData);
// 날씨 정보 로드 (비동기)
loadWeatherForMarkers(markerData);
}
setError(null);
@@ -205,15 +324,47 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{markers.map((marker, idx) => (
<Marker key={idx} position={[marker.lat, marker.lng]}>
<Popup>
<div className="text-xs">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key}>
<strong>{key}:</strong> {String(value)}
<div className="min-w-[200px] text-xs">
{/* 마커 정보 */}
<div className="mb-2 border-b pb-2">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key} className="text-xs">
<strong>{key}:</strong> {String(value)}
</div>
))}
</div>
{/* 날씨 정보 */}
{marker.weather && (
<div className="space-y-1">
<div className="mb-1 flex items-center gap-2">
{getWeatherIcon(marker.weather.weatherMain)}
<span className="text-xs font-semibold"> </span>
</div>
))}
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
</div>
</div>
</div>
)}
</div>
</Popup>
</Marker>

View File

@@ -0,0 +1,211 @@
"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>
);
}