"use client"; import React, { useEffect, useState, useCallback, useMemo } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; 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 Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false }); const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false }); // 브이월드 API 키 const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; interface MapTestWidgetV2Props { element: DashboardElement; } interface MarkerData { id?: string; lat: number; lng: number; latitude?: number; longitude?: number; name: string; status?: string; description?: string; source?: string; // 어느 데이터 소스에서 왔는지 color?: string; // 마커 색상 } interface PolygonData { id?: string; name: string; coordinates: [number, number][] | [number, number][][]; // 단일 폴리곤 또는 멀티 폴리곤 status?: string; description?: string; source?: string; color?: string; opacity?: number; // 투명도 (0.0 ~ 1.0) } export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); const [polygons, setPolygons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); // // console.log("🧪 MapTestWidgetV2 렌더링!", element); // // console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length); // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; }, [element?.dataSources, element?.chartConfig?.dataSources]); // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { const dataSourcesList = dataSources; if (!dataSources || dataSources.length === 0) { // // console.log("⚠️ 데이터 소스가 없습니다."); return; } // // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); setLoading(true); setError(null); try { // 모든 데이터 소스를 병렬로 로딩 const results = await Promise.allSettled( dataSources.map(async (source) => { try { // // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); if (source.type === "api") { return await loadRestApiData(source); } else if (source.type === "database") { return await loadDatabaseData(source); } return { markers: [], polygons: [] }; } catch (err: any) { console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); return { markers: [], polygons: [] }; } }) ); // 성공한 데이터만 병합 const allMarkers: MarkerData[] = []; const allPolygons: PolygonData[] = []; results.forEach((result, index) => { // // console.log(`🔍 결과 ${index}:`, result); if (result.status === "fulfilled" && result.value) { const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] }; // // console.log(`✅ 데이터 소스 ${index} 성공:`, value); // 마커 병합 if (value.markers && Array.isArray(value.markers)) { // // console.log(` → 마커 ${value.markers.length}개 추가`); allMarkers.push(...value.markers); } // 폴리곤 병합 if (value.polygons && Array.isArray(value.polygons)) { // // console.log(` → 폴리곤 ${value.polygons.length}개 추가`); allPolygons.push(...value.polygons); } } else if (result.status === "rejected") { console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason); } }); // // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`); // // console.log("📍 최종 마커 데이터:", allMarkers); // // console.log("🔷 최종 폴리곤 데이터:", allPolygons); setMarkers(allMarkers); setPolygons(allPolygons); setLastRefreshTime(new Date()); } catch (err: any) { console.error("❌ 데이터 로딩 중 오류:", err); setError(err.message); } finally { setLoading(false); } }, [dataSources]); // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { // // console.log("🔄 수동 새로고침 버튼 클릭"); loadMultipleDataSources(); }, [loadMultipleDataSources]); // REST API 데이터 로딩 const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { // // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } // 쿼리 파라미터 구성 const queryParams: Record = {}; if (source.queryParams) { source.queryParams.forEach((param) => { if (param.key && param.value) { queryParams[param.key] = param.value; } }); } // 헤더 구성 const headers: Record = {}; if (source.headers) { source.headers.forEach((header) => { if (header.key && header.value) { headers[header.key] = header.value; } }); } // 백엔드 프록시를 통해 API 호출 const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ url: source.endpoint, method: source.method || "GET", headers, queryParams, }), }); if (!response.ok) { throw new Error(`API 호출 실패: ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.message || "API 호출 실패"); } // 데이터 추출 및 파싱 let data = result.data; // 텍스트 형식 데이터 체크 (기상청 API 등) if (data && typeof data === 'object' && data.text && typeof data.text === 'string') { // // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(data.text); if (parsedData.length > 0) { // // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); // 컬럼 매핑 적용 const mappedData = applyColumnMapping(parsedData, source.columnMapping); return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); } } // JSON Path로 데이터 추출 if (source.jsonPath) { const pathParts = source.jsonPath.split("."); for (const part of pathParts) { data = data?.[part]; } } const rows = Array.isArray(data) ? data : [data]; // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); }; // Database 데이터 로딩 const loadDatabaseData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { // // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); if (!source.query) { throw new Error("SQL 쿼리가 없습니다."); } let rows: any[] = []; if (source.connectionType === "external" && source.externalConnectionId) { // 외부 DB const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const externalResult = await ExternalDbConnectionAPI.executeQuery( parseInt(source.externalConnectionId), source.query ); if (!externalResult.success || !externalResult.data) { throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); } const resultData = externalResult.data as unknown as { rows: Record[]; }; rows = resultData.rows; } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(source.query); rows = result.rows; } // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) return convertToMapData(mappedRows, source.name || source.id || "Database", source.mapDisplayType, source); }; // XML 데이터 파싱 (UTIC API 등) const parseXmlData = (xmlText: string): any[] => { try { // // console.log(" 📄 XML 파싱 시작"); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, "text/xml"); const records = xmlDoc.getElementsByTagName("record"); const results: any[] = []; for (let i = 0; i < records.length; i++) { const record = records[i]; const obj: any = {}; for (let j = 0; j < record.children.length; j++) { const child = record.children[j]; obj[child.tagName] = child.textContent || ""; } results.push(obj); } // // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`); return results; } catch (error) { console.error(" ❌ XML 파싱 실패:", error); return []; } }; // 텍스트 데이터 파싱 (CSV, 기상청 형식 등) const parseTextData = (text: string): any[] => { try { // // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500)); // XML 형식 감지 if (text.trim().startsWith("")) { // // console.log(" 📄 XML 형식 데이터 감지"); return parseXmlData(text); } const lines = text.split('\n').filter(line => { const trimmed = line.trim(); return trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('=') && !trimmed.startsWith('---'); }); // // console.log(` 📝 유효한 라인: ${lines.length}개`); if (lines.length === 0) return []; // CSV 형식으로 파싱 const result: any[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const values = line.split(',').map(v => v.trim().replace(/,=$/g, '')); // // console.log(` 라인 ${i}:`, values); // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 if (values.length >= 4) { const obj: any = { code: values[0] || '', // 지역 코드 (예: L1070000) region: values[1] || '', // 지역명 (예: 경상북도) subCode: values[2] || '', // 하위 코드 (예: L1071600) subRegion: values[3] || '', // 하위 지역명 (예: 영주시) tmFc: values[4] || '', // 발표시각 type: values[5] || '', // 특보종류 (강풍, 호우 등) level: values[6] || '', // 등급 (주의, 경보) status: values[7] || '', // 발표상태 description: values.slice(8).join(', ').trim() || '', }; // 지역 이름 설정 (하위 지역명 우선, 없으면 상위 지역명) obj.name = obj.subRegion || obj.region || obj.code; result.push(obj); // console.log(` ✅ 파싱 성공:`, obj); } } // // console.log(" 📊 최종 파싱 결과:", result.length, "개"); return result; } catch (error) { console.error(" ❌ 텍스트 파싱 오류:", error); return []; } }; // 데이터를 마커와 폴리곤으로 변환 const convertToMapData = ( rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon", dataSource?: ChartDataSource ): { markers: MarkerData[]; polygons: PolygonData[] } => { // // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행"); // // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`); // // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor); if (rows.length === 0) return { markers: [], polygons: [] }; const markers: MarkerData[] = []; const polygons: PolygonData[] = []; rows.forEach((row, index) => { // // console.log(` 행 ${index}:`, row); // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === 'object' && row.text && typeof row.text === 'string') { // // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(row.text); // // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`); // 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달) const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource); markers.push(...result.markers); polygons.push(...result.polygons); return; // 이 행은 처리 완료 } // 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드) if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) { // // console.log(` → coordinates 발견:`, row.coordinates.length, "개"); // coordinates가 [lat, lng] 배열의 배열인지 확인 const firstCoord = row.coordinates[0]; if (Array.isArray(firstCoord) && firstCoord.length === 2) { // console.log(` → 폴리곤으로 처리:`, row.name); polygons.push({ id: row.id || row.code || `polygon-${index}`, name: row.name || row.title || `영역 ${index + 1}`, coordinates: row.coordinates as [number, number][], status: row.status || row.level, description: row.description || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } } // 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만) const regionName = row.name || row.area || row.region || row.location || row.subRegion; if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") { // // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } // 마커 데이터 처리 (위도/경도가 있는 경우) let lat = row.lat || row.latitude || row.y || row.locationDataY; let lng = row.lng || row.longitude || row.x || row.locationDataX; // 위도/경도가 없으면 지역 코드/지역명으로 변환 시도 if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) { const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId; // // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionCode(regionCode); if (coords) { lat = coords.lat; lng = coords.lng; // console.log(` → 변환 성공: (${lat}, ${lng})`); } } // 지역명으로도 시도 if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) { const regionName = row.name || row.area || row.region || row.location; // // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionName(regionName); if (coords) { lat = coords.lat; lng = coords.lng; // console.log(` → 변환 성공: (${lat}, ${lng})`); } } // mapDisplayType이 "polygon"이면 무조건 폴리곤으로 처리 if (mapDisplayType === "polygon") { const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { // console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, description: row.description || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); } else { // console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`); } return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } // 위도/경도가 있고 marker 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && mapDisplayType !== "polygon") { // // console.log(` → 마커로 처리: (${lat}, ${lng})`); markers.push({ id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 lat: Number(lat), lng: Number(lng), latitude: Number(lat), longitude: Number(lng), name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`, status: row.status || row.level, description: row.description || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 }); } else { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { // console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, description: row.description || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); } else { // console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`); // console.log(` 데이터:`, row); } } }); // // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`); return { markers, polygons }; }; // 상태에 따른 색상 반환 const getColorByStatus = (status?: string): string => { if (!status) return "#3b82f6"; // 기본 파란색 const statusLower = status.toLowerCase(); if (statusLower.includes("경보") || statusLower.includes("위험")) return "#ef4444"; // 빨강 if (statusLower.includes("주의")) return "#f59e0b"; // 주황 if (statusLower.includes("정상")) return "#10b981"; // 초록 return "#3b82f6"; // 기본 파란색 }; // 지역 코드를 위도/경도로 변환 const getCoordinatesByRegionCode = (code: string): { lat: number; lng: number } | null => { // 기상청 지역 코드 매핑 (예시) const regionCodeMap: Record = { // 서울/경기 "11": { lat: 37.5665, lng: 126.9780 }, // 서울 "41": { lat: 37.4138, lng: 127.5183 }, // 경기 // 강원 "42": { lat: 37.8228, lng: 128.1555 }, // 강원 // 충청 "43": { lat: 36.6357, lng: 127.4913 }, // 충북 "44": { lat: 36.5184, lng: 126.8000 }, // 충남 // 전라 "45": { lat: 35.7175, lng: 127.1530 }, // 전북 "46": { lat: 34.8679, lng: 126.9910 }, // 전남 // 경상 "47": { lat: 36.4919, lng: 128.8889 }, // 경북 "48": { lat: 35.4606, lng: 128.2132 }, // 경남 // 제주 "50": { lat: 33.4996, lng: 126.5312 }, // 제주 // 광역시 "26": { lat: 35.1796, lng: 129.0756 }, // 부산 "27": { lat: 35.8714, lng: 128.6014 }, // 대구 "28": { lat: 35.1595, lng: 126.8526 }, // 광주 "29": { lat: 36.3504, lng: 127.3845 }, // 대전 "30": { lat: 35.5384, lng: 129.3114 }, // 울산 "31": { lat: 36.8000, lng: 127.7000 }, // 세종 }; return regionCodeMap[code] || null; }; // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) 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]], }; // 지역명을 위도/경도로 변환 const getCoordinatesByRegionName = (name: string): { lat: number; lng: number } | null => { // 먼저 해상 구역인지 확인 if (MARITIME_ZONES[name]) { // 폴리곤의 중심점 계산 const coords = MARITIME_ZONES[name]; const centerLat = coords.reduce((sum, c) => sum + c[0], 0) / coords.length; const centerLng = coords.reduce((sum, c) => sum + c[1], 0) / coords.length; return { lat: centerLat, lng: centerLng }; } const regionNameMap: Record = { // 서울/경기 "서울": { lat: 37.5665, lng: 126.9780 }, "서울특별시": { lat: 37.5665, lng: 126.9780 }, "경기": { lat: 37.4138, lng: 127.5183 }, "경기도": { lat: 37.4138, lng: 127.5183 }, "인천": { lat: 37.4563, lng: 126.7052 }, "인천광역시": { lat: 37.4563, lng: 126.7052 }, // 강원 "강원": { lat: 37.8228, lng: 128.1555 }, "강원도": { lat: 37.8228, lng: 128.1555 }, "강원특별자치도": { lat: 37.8228, lng: 128.1555 }, // 충청 "충북": { lat: 36.6357, lng: 127.4913 }, "충청북도": { lat: 36.6357, lng: 127.4913 }, "충남": { lat: 36.5184, lng: 126.8000 }, "충청남도": { lat: 36.5184, lng: 126.8000 }, "대전": { lat: 36.3504, lng: 127.3845 }, "대전광역시": { lat: 36.3504, lng: 127.3845 }, "세종": { lat: 36.8000, lng: 127.7000 }, "세종특별자치시": { lat: 36.8000, lng: 127.7000 }, // 전라 "전북": { lat: 35.7175, lng: 127.1530 }, "전북특별자치도": { lat: 35.7175, lng: 127.1530 }, "전라북도": { lat: 35.7175, lng: 127.1530 }, "전남": { lat: 34.8679, lng: 126.9910 }, "전라남도": { lat: 34.8679, lng: 126.9910 }, "광주": { lat: 35.1595, lng: 126.8526 }, "광주광역시": { lat: 35.1595, lng: 126.8526 }, // 경상 "경북": { lat: 36.4919, lng: 128.8889 }, "경상북도": { lat: 36.4919, lng: 128.8889 }, "포항": { lat: 36.0190, lng: 129.3435 }, "포항시": { lat: 36.0190, lng: 129.3435 }, "경주": { lat: 35.8562, lng: 129.2247 }, "경주시": { lat: 35.8562, lng: 129.2247 }, "안동": { lat: 36.5684, lng: 128.7294 }, "안동시": { lat: 36.5684, lng: 128.7294 }, "영주": { lat: 36.8056, lng: 128.6239 }, "영주시": { lat: 36.8056, lng: 128.6239 }, "경남": { lat: 35.4606, lng: 128.2132 }, "경상남도": { lat: 35.4606, lng: 128.2132 }, "창원": { lat: 35.2280, lng: 128.6811 }, "창원시": { lat: 35.2280, lng: 128.6811 }, "진주": { lat: 35.1800, lng: 128.1076 }, "진주시": { lat: 35.1800, lng: 128.1076 }, "부산": { lat: 35.1796, lng: 129.0756 }, "부산광역시": { lat: 35.1796, lng: 129.0756 }, "대구": { lat: 35.8714, lng: 128.6014 }, "대구광역시": { lat: 35.8714, lng: 128.6014 }, "울산": { lat: 35.5384, lng: 129.3114 }, "울산광역시": { lat: 35.5384, lng: 129.3114 }, // 제주 "제주": { lat: 33.4996, lng: 126.5312 }, "제주도": { lat: 33.4996, lng: 126.5312 }, "제주특별자치도": { lat: 33.4996, lng: 126.5312 }, // 울릉도/독도 "울릉도": { lat: 37.4845, lng: 130.9057 }, "울릉도.독도": { lat: 37.4845, lng: 130.9057 }, "독도": { lat: 37.2433, lng: 131.8642 }, }; // 정확한 매칭 if (regionNameMap[name]) { return regionNameMap[name]; } // 부분 매칭 (예: "서울시 강남구" → "서울") for (const [key, value] of Object.entries(regionNameMap)) { if (name.includes(key)) { return value; } } return null; }; // 데이터를 마커로 변환 (하위 호환성) const convertToMarkers = (rows: any[]): MarkerData[] => { if (rows.length === 0) return []; // 위도/경도 컬럼 찾기 const firstRow = rows[0]; const columns = Object.keys(firstRow); const latColumn = columns.find((col) => /^(lat|latitude|위도|y)$/i.test(col) ); const lngColumn = columns.find((col) => /^(lng|lon|longitude|경도|x)$/i.test(col) ); const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col) ); if (!latColumn || !lngColumn) { console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다."); return []; } return rows .map((row, index) => { const lat = parseFloat(row[latColumn]); const lng = parseFloat(row[lngColumn]); if (isNaN(lat) || isNaN(lng)) { return null; } return { id: row.id || `marker-${index}`, lat, lng, latitude: lat, longitude: lng, name: row[nameColumn || "name"] || `위치 ${index + 1}`, status: row.status, description: JSON.stringify(row, null, 2), }; }) .filter((marker): marker is MarkerData => marker !== null); }; // GeoJSON 데이터 로드 useEffect(() => { 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); } }; loadGeoJsonData(); }, []); // 초기 로드 useEffect(() => { const dataSources = element?.dataSources || element?.chartConfig?.dataSources; // // console.log("🔄 useEffect 트리거! dataSources:", dataSources); if (dataSources && dataSources.length > 0) { loadMultipleDataSources(); } else { // // console.log("⚠️ dataSources가 없거나 비어있음"); setMarkers([]); setPolygons([]); } }, [dataSources, loadMultipleDataSources]); // 자동 새로고침 useEffect(() => { if (!dataSources || dataSources.length === 0) return; // 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기 const intervals = dataSources .map((ds) => ds.refreshInterval) .filter((interval): interval is number => typeof interval === "number" && interval > 0); if (intervals.length === 0) return; const minInterval = Math.min(...intervals); // // console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`); const intervalId = setInterval(() => { // // console.log("🔄 자동 새로고침 실행"); loadMultipleDataSources(); }, minInterval * 1000); return () => { // // console.log("⏹️ 자동 새로고침 정리"); clearInterval(intervalId); }; }, [dataSources, loadMultipleDataSources]); // 타일맵 URL (chartConfig에서 가져오기) const tileMapUrl = element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; // 지도 중심점 계산 const center: [number, number] = markers.length > 0 ? [ markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, ] : [37.5665, 126.978]; // 기본: 서울 return (
{/* 헤더 */}

{element?.customTitle || "지도"}

{element?.dataSources?.length || 0}개 데이터 소스 연결됨 {lastRefreshTime && ( • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")} )}

{loading && }
{/* 지도 */}
{error ? (

{error}

) : !element?.dataSources || element.dataSources.length === 0 ? (

데이터 소스를 연결해주세요

) : ( {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} {(() => { // console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, { // geoJsonData: !!geoJsonData, // polygonsLength: polygons.length, // polygonNames: polygons.map(p => p.name), // }); return null; })()} {geoJsonData && polygons.length > 0 ? ( p.id))} // 폴리곤 변경 시 재렌더링 data={geoJsonData} style={(feature: any) => { const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도) const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군) // 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명) const matchingPolygon = polygons.find(p => { if (!p.name) return false; // 정확한 매칭 if (p.name === sigName) { // console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`); return true; } if (p.name === ctpName) { // console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`); return true; } // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) if (sigName && sigName.includes(p.name)) { // console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`); return true; } if (ctpName && ctpName.includes(p.name)) { // console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`); return true; } // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) if (sigName && p.name.includes(sigName)) { // console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`); return true; } if (ctpName && p.name.includes(ctpName)) { // console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`); return true; } return false; }); if (matchingPolygon) { return { fillColor: matchingPolygon.color || "#3b82f6", fillOpacity: 0.3, color: matchingPolygon.color || "#3b82f6", weight: 2, }; } return { fillOpacity: 0, opacity: 0, }; }} onEachFeature={(feature: any, layer: any) => { const ctpName = feature?.properties?.CTP_KOR_NM; const sigName = feature?.properties?.SIG_KOR_NM; const matchingPolygon = polygons.find(p => { if (!p.name) return false; if (p.name === sigName || p.name === ctpName) return true; if (sigName && sigName.includes(p.name)) return true; if (ctpName && ctpName.includes(p.name)) return true; if (sigName && p.name.includes(sigName)) return true; if (ctpName && p.name.includes(ctpName)) return true; return false; }); if (matchingPolygon) { layer.bindPopup(`
${matchingPolygon.name}
${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ''} ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ''} ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ''}
`); } }} /> ) : ( <> {/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */} )} {/* 폴리곤 렌더링 (해상 구역만) */} {polygons.filter(p => MARITIME_ZONES[p.name]).map((polygon) => (
{polygon.name}
{polygon.source && (
출처: {polygon.source}
)} {polygon.status && (
상태: {polygon.status}
)} {polygon.description && (
{polygon.description}
)}
))} {/* 마커 렌더링 */} {markers.map((marker) => { // 커스텀 색상 아이콘 생성 let customIcon; if (typeof window !== "undefined") { const L = require("leaflet"); customIcon = L.divIcon({ className: "custom-marker", html: `
`, iconSize: [30, 30], iconAnchor: [15, 15], }); } return (
{/* 제목 */}
{marker.name}
{marker.source && (
📡 {marker.source}
)}
{/* 상세 정보 */}
{marker.description && (
상세 정보
{(() => { try { const parsed = JSON.parse(marker.description); return (
{parsed.incidenteTypeCd === "1" && (
🚨 교통사고
)} {parsed.incidenteTypeCd === "2" && (
🚧 도로공사
)} {parsed.addressJibun && (
📍 {parsed.addressJibun}
)} {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && (
📍 {parsed.addressNew}
)} {parsed.roadName && (
🛣️ {parsed.roadName}
)} {parsed.linkName && (
🔗 {parsed.linkName}
)} {parsed.incidentMsg && (
💬 {parsed.incidentMsg}
)} {parsed.eventContent && (
📝 {parsed.eventContent}
)} {parsed.startDate && (
🕐 {parsed.startDate}
)} {parsed.endDate && (
🕐 종료: {parsed.endDate}
)}
); } catch { return marker.description; } })()}
)} {marker.status && (
상태: {marker.status}
)} {/* 좌표 */}
📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
); })}
)}
{/* 하단 정보 */} {(markers.length > 0 || polygons.length > 0) && (
{markers.length > 0 && `마커 ${markers.length}개`} {markers.length > 0 && polygons.length > 0 && " · "} {polygons.length > 0 && `영역 ${polygons.length}개`}
)}
); }