"use client"; import React, { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { DashboardElement } from "@/components/admin/dashboard/types"; import { getWeather, WeatherData, getWeatherAlerts, WeatherAlert } from "@/lib/api/openApi"; import { Cloud, CloudRain, CloudSnow, Sun, Wind, AlertTriangle } 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 }); const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false }); const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false }); // 브이월드 API 키 const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; interface MapSummaryWidgetProps { element: DashboardElement; } interface MarkerData { lat: number; lng: number; name: string; info: any; weather?: WeatherData | null; } // 테이블명 한글 번역 const translateTableName = (name: string): string => { const tableTranslations: { [key: string]: string } = { vehicle_locations: "차량", vehicles: "차량", warehouses: "창고", warehouse: "창고", customers: "고객", customer: "고객", deliveries: "배송", delivery: "배송", drivers: "기사", driver: "기사", stores: "매장", store: "매장", }; 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 }, ]; // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) const MARITIME_ZONES: Record> = { // 제주도 해역 "제주도남부앞바다": [ [33.2, 126.2], [33.2, 126.8], [33.0, 126.8], [33.0, 126.2] ], "제주도남쪽바깥먼바다": [ [32.5, 125.8], [32.5, 127.2], [33.0, 127.2], [33.0, 125.8] ], "제주도동부앞바다": [ [33.3, 126.8], [33.3, 127.2], [33.1, 127.2], [33.1, 126.8] ], "제주도남동쪽안쪽먼바다": [ [32.8, 127.0], [32.8, 127.8], [33.2, 127.8], [33.2, 127.0] ], "제주도남서쪽안쪽먼바다": [ [32.8, 125.5], [32.8, 126.3], [33.2, 126.3], [33.2, 125.5] ], // 남해 해역 "남해동부앞바다": [ [34.5, 128.5], [34.5, 129.5], [34.0, 129.5], [34.0, 128.5] ], "남해동부안쪽먼바다": [ [33.5, 128.0], [33.5, 129.5], [34.0, 129.5], [34.0, 128.0] ], "남해동부바깥먼바다": [ [32.5, 128.0], [32.5, 130.0], [33.5, 130.0], [33.5, 128.0] ], // 동해 해역 "경북북부앞바다": [ [36.5, 129.3], [36.5, 130.0], [36.0, 130.0], [36.0, 129.3] ], "경북남부앞바다": [ [36.0, 129.2], [36.0, 129.8], [35.5, 129.8], [35.5, 129.2] ], "동해남부남쪽안쪽먼바다": [ [35.0, 129.5], [35.0, 130.5], [35.5, 130.5], [35.5, 129.5] ], "동해남부남쪽바깥먼바다": [ [34.0, 129.5], [34.0, 131.0], [35.0, 131.0], [35.0, 129.5] ], "동해남부북쪽안쪽먼바다": [ [35.5, 129.8], [35.5, 130.8], [36.5, 130.8], [36.5, 129.8] ], "동해남부북쪽바깥먼바다": [ [35.5, 130.5], [35.5, 132.0], [36.5, 132.0], [36.5, 130.5] ], // 강원 해역 "강원북부앞바다": [ [38.0, 128.5], [38.0, 129.5], [37.5, 129.5], [37.5, 128.5] ], "강원중부앞바다": [ [37.5, 128.8], [37.5, 129.5], [37.0, 129.5], [37.0, 128.8] ], "강원남부앞바다": [ [37.0, 129.0], [37.0, 129.8], [36.5, 129.8], [36.5, 129.0] ], "동해중부안쪽먼바다": [ [37.0, 129.5], [37.0, 131.0], [38.5, 131.0], [38.5, 129.5] ], "동해중부바깥먼바다": [ [37.0, 130.5], [37.0, 132.5], [38.5, 132.5], [38.5, 130.5] ], // 울릉도·독도 "울릉도.독도": [ [37.4, 130.8], [37.4, 131.9], [37.6, 131.9], [37.6, 130.8] ], }; // 두 좌표 간 거리 계산 (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 ; } }; // 특보 심각도별 색상 반환 const getAlertColor = (severity: string): string => { switch (severity) { case "high": return "#ef4444"; // 빨강 (경보) case "medium": return "#f59e0b"; // 주황 (주의보) case "low": return "#eab308"; // 노랑 (약한 주의보) default: return "#6b7280"; // 회색 } }; // 지역명 정규화 (특보 API 지역명 → GeoJSON 지역명) const normalizeRegionName = (location: string): string => { // 기상청 특보는 "강릉시", "속초시", "인제군" 등으로 옴 // GeoJSON도 같은 형식이므로 그대로 반환 return location; }; /** * 범용 지도 위젯 (커스텀 지도 카드) * - 위도/경도가 있는 모든 데이터를 지도에 표시 * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원 * - Leaflet + 브이월드 지도 사용 */ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { const [markers, setMarkers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [tableName, setTableName] = useState(null); const [weatherCache, setWeatherCache] = useState>(new Map()); const [weatherAlerts, setWeatherAlerts] = useState([]); const [geoJsonData, setGeoJsonData] = useState(null); useEffect(() => { console.log("🗺️ MapSummaryWidget 초기화"); console.log("🗺️ showWeatherAlerts:", element.chartConfig?.showWeatherAlerts); // GeoJSON 데이터 로드 loadGeoJsonData(); // 기상특보 로드 (showWeatherAlerts가 활성화된 경우) if (element.chartConfig?.showWeatherAlerts) { console.log("🚨 기상특보 로드 시작..."); loadWeatherAlerts(); } else { console.log("⚠️ 기상특보 표시 옵션이 꺼져있습니다"); } if (element?.dataSource?.query) { loadMapData(); } // 자동 새로고침 (30초마다) const interval = setInterval(() => { if (element?.dataSource?.query) { loadMapData(); } if (element.chartConfig?.showWeatherAlerts) { loadWeatherAlerts(); } }, 30000); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [element.id, element.dataSource?.query, element.chartConfig?.showWeather, element.chartConfig?.showWeatherAlerts]); // GeoJSON 데이터 로드 (시/군/구 단위) const loadGeoJsonData = async () => { try { const response = await fetch("/geojson/korea-municipalities.json"); const data = await response.json(); console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구"); setGeoJsonData(data); } catch (err) { console.error("❌ GeoJSON 로드 실패:", err); } }; // 기상특보 로드 const loadWeatherAlerts = async () => { try { const alerts = await getWeatherAlerts(); console.log("🚨 기상특보 로드 완료:", alerts.length, "건"); console.log("🚨 특보 목록:", alerts); setWeatherAlerts(alerts); } catch (err) { console.error("❌ 기상특보 로드 실패:", err); } }; // 마커들의 날씨 정보 로드 (배치 처리 + 딜레이) 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)); console.log(`🌤️ 날씨 로드: 총 ${citySet.size}개 도시, 캐시 미스 ${citiesToFetch.length}개`); if (citiesToFetch.length > 0) { // 배치 처리: 5개씩 나눠서 호출 const BATCH_SIZE = 5; const newCache = new Map(weatherCache); for (let i = 0; i < citiesToFetch.length; i += BATCH_SIZE) { const batch = citiesToFetch.slice(i, i + BATCH_SIZE); console.log(`📦 배치 ${Math.floor(i / BATCH_SIZE) + 1}: ${batch.join(", ")}`); // 배치 내에서는 병렬 호출 const batchPromises = batch.map(async (city) => { try { const weather = await getWeather(city); return { city, weather }; } catch (err) { console.error(`❌ ${city} 날씨 로드 실패:`, err); return { city, weather: null }; } }); const batchResults = await Promise.all(batchPromises); // 캐시 업데이트 batchResults.forEach(({ city, weather }) => { if (weather) { newCache.set(city, weather); } }); // 다음 배치 전 1초 대기 (서버 부하 방지) if (i + BATCH_SIZE < citiesToFetch.length) { await new Promise((resolve) => setTimeout(resolve, 1000)); } } setWeatherCache(newCache); // 마커에 날씨 정보 추가 const updatedMarkers = markerData.map((marker) => { const nearestCity = findNearestCity(marker.lat, marker.lng); return { ...marker, weather: newCache.get(nearestCity) || null, }; }); setMarkers(updatedMarkers); console.log("✅ 날씨 로드 완료!"); } else { // 캐시에서 날씨 정보 가져오기 const updatedMarkers = markerData.map((marker) => { const nearestCity = findNearestCity(marker.lat, marker.lng); return { ...marker, weather: weatherCache.get(nearestCity) || null, }; }); setMarkers(updatedMarkers); console.log("✅ 캐시에서 날씨 로드 완료!"); } } catch (err) { console.error("❌ 날씨 정보 로드 실패:", err); // 날씨 로드 실패해도 마커는 표시 setMarkers(markerData); } }; const loadMapData = async () => { if (!element?.dataSource?.query) { return; } // 쿼리에서 테이블 이름 추출 const extractTableName = (query: string): string | null => { const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); if (fromMatch) { return fromMatch[1]; } return null; }; try { setLoading(true); const extractedTableName = extractTableName(element.dataSource.query); setTableName(extractedTableName); const token = localStorage.getItem("authToken"); const response = await fetch("/api/dashboards/execute-query", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query: element.dataSource.query, connectionType: element.dataSource.connectionType || "current", connectionId: element.dataSource.connectionId, }), }); if (!response.ok) throw new Error("데이터 로딩 실패"); const result = await response.json(); if (result.success && result.data?.rows) { const rows = result.data.rows; // 위도/경도 컬럼 찾기 const latCol = element.chartConfig?.latitudeColumn || "latitude"; const lngCol = element.chartConfig?.longitudeColumn || "longitude"; // 유효한 좌표 필터링 및 마커 데이터 생성 const markerData = rows .filter((row: any) => row[latCol] && row[lngCol]) .map((row: any) => ({ lat: parseFloat(row[latCol]), lng: parseFloat(row[lngCol]), name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음", info: row, weather: null, })); setMarkers(markerData); // 날씨 정보 로드 (showWeather가 활성화된 경우만) if (element.chartConfig?.showWeather) { loadWeatherForMarkers(markerData); } } setError(null); } catch (err) { setError(err instanceof Error ? err.message : "데이터 로딩 실패"); } finally { setLoading(false); } }; // customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성 const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"); return (
{/* 헤더 */}

{displayTitle}

{element?.dataSource?.query ? (

총 {markers.length.toLocaleString()}개 마커

) : (

데이터를 연결하세요

)}
{/* 에러 메시지 (지도 위에 오버레이) */} {error && (
⚠️ {error}
)} {/* 지도 (항상 표시) */}
{/* 브이월드 타일맵 */} {/* 기상특보 영역 표시 (육지 - GeoJSON 레이어) */} {element.chartConfig?.showWeatherAlerts && geoJsonData && weatherAlerts && weatherAlerts.length > 0 && ( { // 해당 지역에 특보가 있는지 확인 const regionName = feature?.properties?.name; const alert = weatherAlerts.find((a) => normalizeRegionName(a.location) === regionName); if (alert) { return { fillColor: getAlertColor(alert.severity), fillOpacity: 0.3, color: getAlertColor(alert.severity), weight: 2, }; } // 특보가 없는 지역은 투명하게 return { fillOpacity: 0, color: "transparent", weight: 0, }; }} onEachFeature={(feature, layer) => { const regionName = feature?.properties?.name; const regionAlerts = weatherAlerts.filter((a) => normalizeRegionName(a.location) === regionName); if (regionAlerts.length > 0) { const popupContent = `
⚠️ ${regionName}
${regionAlerts .map( (alert) => `
${alert.title}
${alert.description}
${new Date(alert.timestamp).toLocaleString("ko-KR")}
`, ) .join("")}
`; layer.bindPopup(popupContent); } }} /> )} {/* 기상특보 영역 표시 (해상 - Polygon 레이어) */} {element.chartConfig?.showWeatherAlerts && weatherAlerts && weatherAlerts.length > 0 && weatherAlerts .filter((alert) => MARITIME_ZONES[alert.location]) // 해상 구역만 필터링 .map((alert, idx) => { const coordinates = MARITIME_ZONES[alert.location]; return (
⚠️ {alert.location}
{alert.title}
{alert.description}
{new Date(alert.timestamp).toLocaleString("ko-KR")}
); }) } {/* 마커 표시 */} {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.weather && (
{getWeatherIcon(marker.weather.weatherMain)} 현재 날씨
{marker.weather.weatherDescription}
온도 {marker.weather.temperature}°C
체감온도 {marker.weather.feelsLike}°C
습도 {marker.weather.humidity}%
풍속 {marker.weather.windSpeed} m/s
)}
))}
{/* 범례 (특보가 있을 때만 표시) */} {element.chartConfig?.showWeatherAlerts && weatherAlerts && weatherAlerts.length > 0 && (
기상특보
경보
주의보
약한 주의보
총 {weatherAlerts.length}건 발효 중
)}
); }