날씨 진행 중 세이브

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

@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe
clouds = 30;
}
// 격자좌표 → 위도경도 변환
const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny);
return {
city: gridCoord.name,
country: 'KR',
lat,
lng,
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.round(humidity),
@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe
return grids[city] || null;
}
/**
* (nx, ny)
*
*/
function gridToLatLng(nx: number, ny: number): { lat: number; lng: number } {
const RE = 6371.00877; // 지구 반경(km)
const GRID = 5.0; // 격자 간격(km)
const SLAT1 = 30.0; // 표준위도1(degree)
const SLAT2 = 60.0; // 표준위도2(degree)
const OLON = 126.0; // 기준점 경도(degree)
const OLAT = 38.0; // 기준점 위도(degree)
const XO = 43; // 기준점 X좌표
const YO = 136; // 기준점 Y좌표
const DEGRAD = Math.PI / 180.0;
const re = RE / GRID;
const slat1 = SLAT1 * DEGRAD;
const slat2 = SLAT2 * DEGRAD;
const olon = OLON * DEGRAD;
const olat = OLAT * DEGRAD;
const sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
const sn_log = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
const sf_pow = Math.pow(sf, sn_log);
const sf_result = (Math.cos(slat1) * sf_pow) / sn_log;
const ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
const ro_pow = Math.pow(ro, sn_log);
const ro_result = (re * sf_result) / ro_pow;
const xn = nx - XO;
const yn = ro_result - (ny - YO);
const ra = Math.sqrt(xn * xn + yn * yn);
let alat: number;
if (sn_log > 0) {
alat = 2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) - Math.PI * 0.5;
} else {
alat = -2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) + Math.PI * 0.5;
}
let theta: number;
if (Math.abs(xn) <= 0.0) {
theta = 0.0;
} else {
if (Math.abs(yn) <= 0.0) {
theta = 0.0;
} else {
theta = Math.atan2(xn, yn);
}
}
const alon = theta / sn_log + olon;
return {
lat: parseFloat((alat / DEGRAD).toFixed(6)),
lng: parseFloat((alon / DEGRAD).toFixed(6)),
};
}
/**
*
* @param apiResponse - API
@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string;
weatherDescription = '추움';
}
// 격자좌표 → 위도경도 변환
const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny);
return {
city: gridInfo.name,
lat,
lng,
temperature: Math.round(temperature * 10) / 10,
humidity: Math.round(humidity),
windSpeed: Math.round(windSpeed * 10) / 10,

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>
);
}

View File

@ -15,6 +15,8 @@ import { apiClient } from './client';
export interface WeatherData {
city: string;
country: string;
lat?: number;
lng?: number;
temperature: number;
feelsLike: number;
humidity: number;
@ -79,6 +81,15 @@ export async function getWeather(
return response.data.data;
}
/**
*
* @param cities
*/
export async function getMultipleWeather(cities: string[]): Promise<WeatherData[]> {
const promises = cities.map(city => getWeather(city));
return Promise.all(promises);
}
/**
*
* @param base (기본값: KRW)