"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 turfUnion from "@turf/union"; import { polygon } from "@turf/helpers"; import { getApiUrl } from "@/lib/utils/apiUrl"; 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 MapTestWidgetProps { element: DashboardElement; } interface MarkerData { id?: string; lat: number; lng: number; latitude?: number; longitude?: number; name: string; status?: string; description?: 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.48, lng: 127.289 }, { name: "제주", lat: 33.4996, lng: 126.5312 }, ]; // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준 - 깔끔한 사각형) const MARITIME_ZONES: Record> = { // 제주도 해역 제주도남부앞바다: [ [33.25, 126.0], [33.25, 126.85], [33.0, 126.85], [33.0, 126.0], ], 제주도남쪽바깥먼바다: [ [33.15, 125.7], [33.15, 127.3], [32.5, 127.3], [32.5, 125.7], ], 제주도동부앞바다: [ [33.4, 126.7], [33.4, 127.25], [33.05, 127.25], [33.05, 126.7], ], 제주도남동쪽안쪽먼바다: [ [33.3, 126.85], [33.3, 127.95], [32.65, 127.95], [32.65, 126.85], ], 제주도남서쪽안쪽먼바다: [ [33.3, 125.35], [33.3, 126.45], [32.7, 126.45], [32.7, 125.35], ], // 남해 해역 남해동부앞바다: [ [34.65, 128.3], [34.65, 129.65], [33.95, 129.65], [33.95, 128.3], ], 남해동부안쪽먼바다: [ [34.25, 127.95], [34.25, 129.75], [33.45, 129.75], [33.45, 127.95], ], 남해동부바깥먼바다: [ [33.65, 127.95], [33.65, 130.35], [32.45, 130.35], [32.45, 127.95], ], // 동해 해역 경북북부앞바다: [ [36.65, 129.2], [36.65, 130.1], [35.95, 130.1], [35.95, 129.2], ], 경북남부앞바다: [ [36.15, 129.1], [36.15, 129.95], [35.45, 129.95], [35.45, 129.1], ], 동해남부남쪽안쪽먼바다: [ [35.65, 129.35], [35.65, 130.65], [34.95, 130.65], [34.95, 129.35], ], 동해남부남쪽바깥먼바다: [ [35.25, 129.45], [35.25, 131.15], [34.15, 131.15], [34.15, 129.45], ], 동해남부북쪽안쪽먼바다: [ [36.6, 129.65], [36.6, 130.95], [35.85, 130.95], [35.85, 129.65], ], 동해남부북쪽바깥먼바다: [ [36.65, 130.35], [36.65, 132.15], [35.85, 132.15], [35.85, 130.35], ], // 강원 해역 강원북부앞바다: [ [38.15, 128.4], [38.15, 129.55], [37.45, 129.55], [37.45, 128.4], ], 강원중부앞바다: [ [37.65, 128.7], [37.65, 129.6], [36.95, 129.6], [36.95, 128.7], ], 강원남부앞바다: [ [37.15, 128.9], [37.15, 129.85], [36.45, 129.85], [36.45, 128.9], ], 동해중부안쪽먼바다: [ [38.55, 129.35], [38.55, 131.15], [37.25, 131.15], [37.25, 129.35], ], 동해중부바깥먼바다: [ [38.6, 130.35], [38.6, 132.55], [37.65, 132.55], [37.65, 130.35], ], // 울릉도·독도 "울릉도.독도": [ [37.7, 130.7], [37.7, 132.0], [37.4, 132.0], [37.4, 130.7], ], }; // 두 좌표 간 거리 계산 (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 ; } }; // 특보 심각도별 색상 반환 (CSS 변수 사용) const getAlertColor = (severity: string): string => { // CSS 변수 값을 가져오기 const getCSSVariable = (varName: string): string => { if (typeof window !== "undefined") { const value = getComputedStyle(document.documentElement) .getPropertyValue(varName) .trim(); return value ? `hsl(${value})` : "#6b7280"; } return "#6b7280"; }; switch (severity) { case "high": return getCSSVariable("--destructive"); // 경보 (빨강) case "medium": return getCSSVariable("--warning"); // 주의보 (주황) case "low": return getCSSVariable("--warning"); // 약한 주의보 (노랑) default: return getCSSVariable("--muted-foreground"); // 회색 } }; // 지역명 정규화 (특보 API 지역명 → GeoJSON 지역명) const normalizeRegionName = (location: string): string => { // 기상청 특보는 "강릉시", "속초시", "인제군" 등으로 옴 // GeoJSON도 같은 형식이므로 그대로 반환 return location; }; /** * 범용 지도 위젯 (커스텀 지도 카드) * - 위도/경도가 있는 모든 데이터를 지도에 표시 * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원 * - Leaflet + 브이월드 지도 사용 */ function MapTestWidget({ element }: MapTestWidgetProps) { console.log("🧪 MapTestWidget 렌더링!", element); 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); // 기상특보 지역 코드 → 폴리곤 경계 좌표 매핑 (직사각형) const REGION_POLYGONS: Record = { // 서울경기 해역 (동해 중부) "S1132210": [ { lat: 37.5, lng: 129.5 }, { lat: 37.5, lng: 130.3 }, { lat: 36.8, lng: 130.3 }, { lat: 36.8, lng: 129.5 }, ], "S1132220": [ { lat: 36.8, lng: 129.4 }, { lat: 36.8, lng: 130.1 }, { lat: 36.2, lng: 130.1 }, { lat: 36.2, lng: 129.4 }, ], "S1132110": [ { lat: 37.4, lng: 129.2 }, { lat: 37.4, lng: 129.7 }, { lat: 36.9, lng: 129.7 }, { lat: 36.9, lng: 129.2 }, ], "S1132120": [ { lat: 37.0, lng: 129.2 }, { lat: 37.0, lng: 129.7 }, { lat: 36.5, lng: 129.7 }, { lat: 36.5, lng: 129.2 }, ], // 강원 해역 "S1152010": [ { lat: 38.9, lng: 129.4 }, { lat: 38.9, lng: 130.2 }, { lat: 38.1, lng: 130.2 }, { lat: 38.1, lng: 129.4 }, ], "S1152020": [ { lat: 37.9, lng: 129.8 }, { lat: 37.9, lng: 130.5 }, { lat: 37.1, lng: 130.5 }, { lat: 37.1, lng: 129.8 }, ], // 충청 해역 (서해) "S1312020": [ { lat: 37.2, lng: 125.8 }, { lat: 37.2, lng: 126.5 }, { lat: 36.4, lng: 126.5 }, { lat: 36.4, lng: 125.8 }, ], "S1323400": [ { lat: 36.9, lng: 127.2 }, { lat: 36.9, lng: 127.8 }, { lat: 36.1, lng: 127.8 }, { lat: 36.1, lng: 127.2 }, ], "S1324020": [ { lat: 36.6, lng: 126.5 }, { lat: 36.6, lng: 127.1 }, { lat: 35.8, lng: 127.1 }, { lat: 35.8, lng: 126.5 }, ], // L로 시작하는 동해 해역 "L1070200": [ { lat: 39.0, lng: 128.6 }, { lat: 39.0, lng: 129.3 }, { lat: 38.2, lng: 129.3 }, { lat: 38.2, lng: 128.6 }, ], "L1070400": [ { lat: 39.5, lng: 128.2 }, { lat: 39.5, lng: 128.9 }, { lat: 38.7, lng: 128.9 }, { lat: 38.7, lng: 128.2 }, ], "L1071000": [ { lat: 39.3, lng: 129.2 }, { lat: 39.3, lng: 129.9 }, { lat: 38.5, lng: 129.9 }, { lat: 38.5, lng: 129.2 }, ], "L1071400": [ { lat: 38.0, lng: 130.2 }, { lat: 38.0, lng: 131.0 }, { lat: 37.2, lng: 131.0 }, { lat: 37.2, lng: 130.2 }, ], "L1071500": [ { lat: 38.5, lng: 129.7 }, { lat: 38.5, lng: 130.4 }, { lat: 37.7, lng: 130.4 }, { lat: 37.7, lng: 129.7 }, ], "L1071600": [ { lat: 37.5, lng: 130.2 }, { lat: 37.5, lng: 130.9 }, { lat: 36.7, lng: 130.9 }, { lat: 36.7, lng: 130.2 }, ], "L1071700": [ { lat: 37.0, lng: 129.7 }, { lat: 37.0, lng: 130.4 }, { lat: 36.2, lng: 130.4 }, { lat: 36.2, lng: 129.7 }, ], "L1071800": [ { lat: 36.5, lng: 129.5 }, { lat: 36.5, lng: 130.2 }, { lat: 35.7, lng: 130.2 }, { lat: 35.7, lng: 129.5 }, ], "L1071910": [ { lat: 36.0, lng: 129.7 }, { lat: 36.0, lng: 130.4 }, { lat: 35.2, lng: 130.4 }, { lat: 35.2, lng: 129.7 }, ], "L1072100": [ { lat: 35.5, lng: 130.2 }, { lat: 35.5, lng: 130.9 }, { lat: 34.7, lng: 130.9 }, { lat: 34.7, lng: 130.2 }, ], "L1072400": [ { lat: 35.0, lng: 129.2 }, { lat: 35.0, lng: 129.9 }, { lat: 34.2, lng: 129.9 }, { lat: 34.2, lng: 129.2 }, ], }; // 기상특보 지역 코드 → 위도/경도 매핑 (주요 지역 - 마커용) const REGION_COORDINATES: Record = { // 육상 지역 "11B10101": { lat: 37.5665, lng: 126.9780, name: "서울" }, "11B20201": { lat: 37.4563, lng: 126.7052, name: "인천" }, "11B20601": { lat: 37.2636, lng: 127.0286, name: "수원" }, "11C20401": { lat: 36.3504, lng: 127.3845, name: "대전" }, "11C20101": { lat: 36.6424, lng: 127.4890, name: "청주" }, "11F20501": { lat: 35.1796, lng: 129.0756, name: "부산" }, "11H20201": { lat: 35.8714, lng: 128.6014, name: "대구" }, "11H10701": { lat: 35.5384, lng: 129.3114, name: "울산" }, "21F20801": { lat: 33.4996, lng: 126.5312, name: "제주" }, "11D10301": { lat: 37.8813, lng: 127.7300, name: "춘천" }, "11D20501": { lat: 37.7519, lng: 128.8761, name: "강릉" }, "11G00201": { lat: 35.8242, lng: 127.1480, name: "전주" }, "11F10201": { lat: 35.1601, lng: 126.8514, name: "광주" }, // 해상 지역 (앞바다 - 육지에서 가까운 해역) "S1131100": { lat: 35.4, lng: 129.4, name: "울산앞바다" }, "S1131200": { lat: 35.8, lng: 129.5, name: "경북남부앞바다" }, "S1131300": { lat: 36.5, lng: 129.5, name: "경북북부앞바다" }, "S1132110": { lat: 37.2, lng: 129.6, name: "경기북부앞바다" }, "S1132120": { lat: 36.8, lng: 129.5, name: "경기중부앞바다" }, "S1132210": { lat: 37.0, lng: 130.0, name: "서울경기북부먼바다" }, "S1132220": { lat: 36.5, lng: 129.8, name: "서울경기남부먼바다" }, "S1151100": { lat: 38.2, lng: 128.6, name: "강원북부앞바다" }, "S1151200": { lat: 37.8, lng: 129.0, name: "강원중부앞바다" }, "S1151300": { lat: 37.4, lng: 129.2, name: "강원남부앞바다" }, "S1152010": { lat: 38.5, lng: 130.0, name: "강원북부먼바다" }, "S1231100": { lat: 35.8, lng: 126.0, name: "전북북부앞바다" }, "S1231200": { lat: 35.4, lng: 126.2, name: "전북남부앞바다" }, "S1251100": { lat: 34.8, lng: 126.0, name: "전남북부앞바다" }, "S1251200": { lat: 34.4, lng: 126.4, name: "전남남부앞바다" }, "S1271100": { lat: 33.5, lng: 126.3, name: "제주북부앞바다" }, "S1271200": { lat: 33.2, lng: 126.5, name: "제주남부앞바다" }, "S1312020": { lat: 36.8, lng: 126.2, name: "충남서부먼바다" }, "S1323400": { lat: 36.5, lng: 127.5, name: "충북내륙" }, "S1324020": { lat: 36.2, lng: 126.8, name: "충북서부먼바다" }, "S1152020": { lat: 37.5, lng: 130.2, name: "강원남부먼바다" }, // 먼바다 (육지에서 먼 해역) "S1132000": { lat: 36.0, lng: 130.0, name: "동해남부먼바다" }, "S1152000": { lat: 38.0, lng: 130.5, name: "동해중부먼바다" }, "S1232000": { lat: 35.0, lng: 125.0, name: "서해중부먼바다" }, "S1252000": { lat: 34.0, lng: 124.5, name: "서해남부먼바다" }, "S1272000": { lat: 32.5, lng: 126.0, name: "제주남쪽먼바다" }, // L로 시작하는 해역 (특정 해역 구역) "L1070200": { lat: 38.5, lng: 129.0, name: "청진앞바다" }, "L1070400": { lat: 39.0, lng: 128.5, name: "함흥앞바다" }, "L1071000": { lat: 38.8, lng: 129.5, name: "동해북부해역" }, "L1071400": { lat: 37.5, lng: 130.5, name: "동해중부해역" }, "L1071500": { lat: 38.0, lng: 130.0, name: "동해중북부해역" }, "L1071600": { lat: 37.0, lng: 130.5, name: "동해중남부해역" }, "L1071700": { lat: 36.5, lng: 130.0, name: "동해남부해역" }, "L1071800": { lat: 36.0, lng: 129.8, name: "동해남동해역" }, "L1071910": { lat: 35.5, lng: 130.0, name: "동해남서해역" }, "L1072100": { lat: 35.0, lng: 130.5, name: "대한해협" }, "L1072400": { lat: 34.5, lng: 129.5, name: "남해동부해역" }, }; // 초기 로드 (마운트 시 1회만) useEffect(() => { console.log("🗺️ MapTestWidget 초기화 (마운트)"); console.log("📋 element.dataSource:", element?.dataSource); loadGeoJsonData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // dataSource 변경 감지 (데이터 소스가 설정되면 자동 로드) useEffect(() => { // REST API 데이터 소스가 있으면 데이터 로드 if (element?.dataSource?.type === "api" && element?.dataSource?.endpoint) { console.log("🌐 REST API 데이터 소스 감지 - 데이터 로드 시작"); console.log("🔗 API Endpoint:", element.dataSource.endpoint); loadMapData(); } else { console.log("⚠️ REST API 데이터 소스 없음 또는 endpoint 없음"); console.log(" - type:", element?.dataSource?.type); console.log(" - endpoint:", element?.dataSource?.endpoint); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [element?.dataSource?.type, element?.dataSource?.endpoint]); // 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) { return; } const dataSource = element.dataSource; // 쿼리에서 테이블 이름 추출 (데이터베이스용) 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); let rows: any[] = []; // ✅ 데이터 소스 타입별 분기 처리 if (dataSource.type === "database") { // === 기존 데이터베이스 로직 === if (!dataSource.query) { setError("데이터베이스 쿼리가 설정되지 않았습니다."); setLoading(false); return; } const extractedTableName = extractTableName(dataSource.query); setTableName(extractedTableName); const token = localStorage.getItem("authToken"); const response = await fetch(getApiUrl("/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) { rows = result.data.rows; } } else if (dataSource.type === "api") { // === ✅ REST API 로직 (백엔드 프록시 사용) === // API URL 확인 const apiUrl = dataSource.endpoint || ""; if (!apiUrl) { setError("API 엔드포인트가 설정되지 않았습니다."); setLoading(false); return; } // 쿼리 파라미터 구성 const params: Record = {}; if (dataSource.queryParams && Array.isArray(dataSource.queryParams)) { dataSource.queryParams.forEach((param: any) => { if (param.key && param.value) { params[param.key] = param.value; } }); } // 헤더 구성 const headers: Record = {}; if (dataSource.headers && Array.isArray(dataSource.headers)) { dataSource.headers.forEach((header: any) => { if (header.key && header.value) { headers[header.key] = header.value; } }); } // 백엔드 프록시를 통한 외부 API 호출 (CORS 우회) console.log("🌐 REST API 호출 (프록시):", apiUrl); const token = localStorage.getItem("authToken"); const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ url: apiUrl, method: "GET", headers: headers, queryParams: params, }), }); if (!response.ok) throw new Error("API 호출 실패"); const result = await response.json(); if (!result.success) throw new Error(result.message || "API 호출 실패"); console.log("📦 API 응답:", result); // 텍스트 응답인 경우 파싱 (기상청 API) let apiData = result.data; if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") { const textData = apiData.text; // CSV 형식 파싱 (기상청 API) if (textData.includes("#START7777") || textData.includes(",")) { const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#")); const parsedRows = lines.map((line) => { const values = line.split(",").map((v) => v.trim()); return { reg_up: values[0] || "", reg_up_ko: values[1] || "", reg_id: values[2] || "", reg_ko: values[3] || "", tm_fc: values[4] || "", tm_ef: values[5] || "", wrn: values[6] || "", lvl: values[7] || "", cmd: values[8] || "", ed_tm: values[9] || "", }; }); apiData = parsedRows; console.log("🚨 기상특보 데이터 파싱 완료:", parsedRows.length, "건"); } else { apiData = [{ text: textData }]; } } // jsonPath로 데이터 추출 // 이미 배열인 경우 jsonPath 무시 (기상청 API 등) if (Array.isArray(apiData)) { rows = apiData; console.log("✅ 배열 데이터 자동 감지 (jsonPath 무시)"); } else if (dataSource.jsonPath) { const pathParts = dataSource.jsonPath.split("."); let data = apiData; for (const part of pathParts) { data = data?.[part]; } rows = Array.isArray(data) ? data : []; } else { rows = Array.isArray(apiData) ? apiData : [apiData]; } console.log("✅ 추출된 데이터:", rows.length, "개"); // 기상특보 데이터인 경우 weatherAlerts로 설정 if (rows.length > 0 && rows[0].reg_ko && rows[0].wrn) { console.log("🚨 기상특보 데이터 감지! 영역 표시 준비 중..."); // WeatherAlert 타입에 맞게 변환 + 폴리곤 좌표 추가 const alerts: WeatherAlert[] = rows .map((row: any, idx: number) => { const regionCode = row.reg_id; const polygon = REGION_POLYGONS[regionCode]; const coords = REGION_COORDINATES[regionCode]; if (!polygon && !coords) { console.warn(`⚠️ 지역 코드 ${regionCode} (${row.reg_ko})의 좌표를 찾을 수 없습니다.`); return null; } console.log(`✅ 기상특보 영역 생성: ${row.wrn} ${row.lvl} - ${row.reg_ko}`); return { id: `alert-${idx}`, type: row.wrn || "기타", severity: (row.lvl === "경보" ? "high" : row.lvl === "주의보" ? "medium" : "low") as "high" | "medium" | "low", title: `${row.wrn} ${row.lvl}`, location: row.reg_ko, description: `${row.reg_up_ko} ${row.reg_ko} - ${row.cmd === "발표" ? "발표" : "해제"}`, timestamp: row.tm_fc || new Date().toISOString(), polygon: polygon, // 폴리곤 좌표 추가 center: coords || (polygon ? { lat: polygon[0].lat, lng: polygon[0].lng } : undefined), // 중심점 }; }) .filter((alert): alert is WeatherAlert => alert !== null); setWeatherAlerts(alerts); console.log("🚨 기상특보 영역 설정 완료:", alerts.length, "건 (폴리곤 표시)"); // 기상특보 표시 옵션 자동 활성화 if (!element.chartConfig?.showWeatherAlerts) { console.log("🚨 기상특보 표시 옵션 자동 활성화"); } // 기상특보는 폴리곤으로 표시하므로 마커는 생성하지 않음 setMarkers([]); setLoading(false); return; } } // === 공통: 마커 데이터 생성 === if (rows.length > 0) { // 위도/경도 컬럼 찾기 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, })); console.log("📍 생성된 마커:", markerData.length, "개"); setMarkers(markerData); // 날씨 정보 로드 (showWeather가 활성화된 경우만) if (element.chartConfig?.showWeather && markerData.length > 0) { loadWeatherForMarkers(markerData); } } else { setMarkers([]); console.log("⚠️ 데이터가 없습니다"); } setError(null); } catch (err) { console.error("❌ 데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터 로딩 실패"); } finally { setLoading(false); } }; // customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성 const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"); // 데이터 소스 타입 확인 const dataSource = element?.dataSource; return (
{/* 헤더 */}

{displayTitle}

{dataSource ? (

{dataSource.type === "api" ? "🌐 REST API" : "💾 Database"} · 총 {markers.length.toLocaleString()}개 마커

) : (

데이터를 연결하세요

)}
{/* 에러 메시지 (지도 위에 오버레이) */} {error && (
⚠️ {error}
)} {/* 지도 또는 빈 상태 */}
{!element?.chartConfig?.tileMapUrl && !element?.dataSource ? ( // 타일맵 URL도 없고 데이터 소스도 없을 때: 빈 상태 표시

🗺️ 지도를 설정하세요

차트 설정에서 타일맵 URL을 입력하거나
데이터 소스에서 마커 데이터를 연결하세요

) : ( // 데이터 소스가 있을 때: 지도 표시 {/* 타일맵 레이어 (chartConfig.tileMapUrl 또는 기본 VWorld) */} {/* 기상특보 영역 표시 (육지 - GeoJSON 레이어) */} {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 레이어) - 개별 표시 */} {weatherAlerts && weatherAlerts.length > 0 && weatherAlerts .filter((alert) => alert.polygon || MARITIME_ZONES[alert.location]) .map((alert, idx) => { // alert.polygon 우선 사용, 없으면 MARITIME_ZONES 사용 const coordinates = alert.polygon ? alert.polygon.map(coord => [coord.lat, coord.lng] as [number, number]) : MARITIME_ZONES[alert.location]; const alertColor = getAlertColor(alert.severity); return ( { const layer = e.target; layer.setStyle({ fillOpacity: 0.3, weight: 3, }); }, mouseout: (e) => { const layer = e.target; layer.setStyle({ fillOpacity: 0.15, weight: 2, }); }, }} >
⚠️ {alert.location}
{alert.title}
{alert.description}
{new Date(alert.timestamp).toLocaleString("ko-KR")}
); })} {/* 마커 표시 */} {markers.map((marker, idx) => { // 기상특보 마커인지 확인 const isWeatherAlert = marker.id?.startsWith("alert-marker-"); const isWarning = marker.status === "경보"; // 마커 아이콘 설정 const markerIcon = isWeatherAlert ? L.divIcon({ html: `
⚠️
`, className: "", iconSize: [32, 32], iconAnchor: [16, 16], }) : undefined; return (
{/* 마커 정보 */}
{marker.name}
{marker.description && (
{marker.description}
)} {marker.info && 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
)}
); })}
)} {/* 범례 (특보가 있을 때만 표시) */} {weatherAlerts && weatherAlerts.length > 0 && (
기상특보
경보
주의보
약한 주의보
총 {weatherAlerts.length}건 발효 중
)}
); } // React.memo로 감싸서 불필요한 리렌더링 방지 export default React.memo(MapTestWidget);