From 8ec54b4e7da18a1bf386cc8160257392f351c4af Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 23 Oct 2025 12:31:14 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=20=EC=84=B8=EC=9D=B4=EB=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/openApiProxyController.ts | 69 ++++++ .../admin/dashboard/DashboardSidebar.tsx | 6 + .../admin/dashboard/DashboardTopMenu.tsx | 1 + frontend/components/admin/dashboard/types.ts | 1 + .../components/dashboard/DashboardViewer.tsx | 3 + .../dashboard/widgets/MapSummaryWidget.tsx | 167 +++++++++++++- .../dashboard/widgets/WeatherMapWidget.tsx | 211 ++++++++++++++++++ frontend/lib/api/openApi.ts | 11 + 8 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 frontend/components/dashboard/widgets/WeatherMapWidget.tsx diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index b84dc218..d7cf570e 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -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, diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 62c50fdc..e9f597eb 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -126,6 +126,12 @@ export function DashboardSidebar() { subtype="weather" onDragStart={handleDragStart} /> + 일반 위젯 날씨 + 날씨 지도 환율 계산기 달력 diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 7ae9b4d8..d72b306f 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -15,6 +15,7 @@ export type ElementSubtype = | "combo" // 차트 타입 | "exchange" | "weather" + | "weather-map" // 날씨 지도 위젯 | "clock" | "calendar" | "calculator" diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 5438b0c0..48549a73 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -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 ; case "weather": return ; + case "weather-map": + return ; case "calculator": return ; case "clock": diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index ade1bda0..eea52942 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -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 ; + case "rain": + return ; + case "snow": + return ; + case "clouds": + return ; + default: + return ; + } +}; + /** * 범용 지도 위젯 (커스텀 지도 카드) * - 위도/경도가 있는 모든 데이터를 지도에 표시 @@ -67,6 +130,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [tableName, setTableName] = useState(null); + const [weatherCache, setWeatherCache] = useState>(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(); + 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.name}
- {Object.entries(marker.info) - .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) - .map(([key, value]) => ( -
- {key}: {String(value)} +
+ {/* 마커 정보 */} +
+
{marker.name}
+ {Object.entries(marker.info) + .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) + .map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+ + {/* 날씨 정보 */} + {marker.weather && ( +
+
+ {getWeatherIcon(marker.weather.weatherMain)} + 현재 날씨
- ))} +
{marker.weather.weatherDescription}
+
+
+ 온도 + {marker.weather.temperature}°C +
+
+ 체감온도 + {marker.weather.feelsLike}°C +
+
+ 습도 + {marker.weather.humidity}% +
+
+ 풍속 + {marker.weather.windSpeed} m/s +
+
+
+ )}
diff --git a/frontend/components/dashboard/widgets/WeatherMapWidget.tsx b/frontend/components/dashboard/widgets/WeatherMapWidget.tsx new file mode 100644 index 00000000..035e7052 --- /dev/null +++ b/frontend/components/dashboard/widgets/WeatherMapWidget.tsx @@ -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 ; + case "rain": + return ; + case "snow": + return ; + case "clouds": + return ; + default: + return ; + } +}; + +/** + * 날씨 지도 위젯 + * - 여러 도시의 날씨를 지도에 표시 + * - 실시간 날씨 정보 (온도, 습도, 풍속 등) + * - Leaflet + 브이월드 지도 사용 + */ +export default function WeatherMapWidget({ element, cities }: WeatherMapWidgetProps) { + const [weatherData, setWeatherData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+

날씨 정보 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (weatherData.length === 0) { + return ( +
+

날씨 데이터가 없습니다.

+
+ ); + } + + // 지도 중심 (대한민국 중심) + const center: [number, number] = [36.5, 127.5]; + + return ( +
+ + {/* 브이월드 Base Map */} + + + {/* 날씨 마커 */} + {weatherData.map((weather, index) => { + if (!weather.lat || !weather.lng) return null; + + return ( + + +
+ {/* 도시명 */} +
+

{weather.city}

+ {getWeatherIcon(weather.weatherMain)} +
+ + {/* 날씨 설명 */} +

{weather.weatherDescription}

+ + {/* 날씨 정보 */} +
+
+ 온도 + {weather.temperature}°C +
+
+ 체감온도 + {weather.feelsLike}°C +
+
+ 습도 + {weather.humidity}% +
+
+ 풍속 + {weather.windSpeed} m/s +
+
+ + {/* 타임스탬프 */} +
+ {new Date(weather.timestamp).toLocaleString("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +
+
+
+
+ ); + })} +
+
+ ); +} + diff --git a/frontend/lib/api/openApi.ts b/frontend/lib/api/openApi.ts index 89cbdd49..5cb90a5b 100644 --- a/frontend/lib/api/openApi.ts +++ b/frontend/lib/api/openApi.ts @@ -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 { + const promises = cities.map(city => getWeather(city)); + return Promise.all(promises); +} + /** * 환율 정보 조회 * @param base 기준 통화 (기본값: KRW)