/* eslint-disable @typescript-eslint/no-require-imports */ "use client"; import React, { useEffect, useState, useCallback, useMemo, useRef } 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"; // Popup 말풍선 꼬리 제거 스타일 if (typeof document !== "undefined") { const style = document.createElement("style"); style.textContent = ` .leaflet-popup-tip-container { display: none !important; } .leaflet-popup-content-wrapper { border-radius: 8px !important; } `; document.head.appendChild(style); } // Leaflet 아이콘 경로 설정 (엑박 방지) if (typeof window !== "undefined") { import("leaflet").then((L) => { 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 }); const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { 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; // 마커 색상 heading?: number; // 진행 방향 (0-360도, 0=북쪽) prevLat?: number; // 이전 위도 (방향 계산용) prevLng?: number; // 이전 경도 (방향 계산용) } 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) } // 이동경로 타입 interface RoutePoint { lat: number; lng: number; recordedAt: string; } export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); const prevMarkersRef = useRef([]); // 이전 마커 위치 저장 (useRef 사용) const [polygons, setPolygons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); // 이동경로 상태 const [routePoints, setRoutePoints] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [routeLoading, setRouteLoading] = useState(false); const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; }, [element?.dataSources, element?.chartConfig?.dataSources]); // 두 좌표 사이의 방향 계산 (0-360도, 0=북쪽) const calculateHeading = useCallback((lat1: number, lng1: number, lat2: number, lng2: number): number => { const dLng = (lng2 - lng1) * (Math.PI / 180); const lat1Rad = lat1 * (Math.PI / 180); const lat2Rad = lat2 * (Math.PI / 180); const y = Math.sin(dLng) * Math.cos(lat2Rad); const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng); let heading = Math.atan2(y, x) * (180 / Math.PI); heading = (heading + 360) % 360; // 0-360 범위로 정규화 return heading; }, []); // 이동경로 로드 함수 const loadRoute = useCallback( async (userId: string, date?: string) => { if (!userId) { return; } setRouteLoading(true); setSelectedUserId(userId); try { // 선택한 날짜 기준으로 이동경로 조회 const targetDate = date || routeDate; const startOfDay = `${targetDate}T00:00:00.000Z`; const endOfDay = `${targetDate}T23:59:59.999Z`; const query = `SELECT latitude, longitude, recorded_at FROM vehicle_location_history WHERE user_id = '${userId}' AND recorded_at >= '${startOfDay}' AND recorded_at <= '${endOfDay}' ORDER BY recorded_at ASC`; const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, }, body: JSON.stringify({ query }), }); if (response.ok) { const result = await response.json(); if (result.success && result.data.rows.length > 0) { const points: RoutePoint[] = result.data.rows.map((row: any) => ({ lat: parseFloat(row.latitude), lng: parseFloat(row.longitude), recordedAt: row.recorded_at, })); setRoutePoints(points); } else { setRoutePoints([]); } } } catch { setRoutePoints([]); } setRouteLoading(false); }, [routeDate], ); // 이동경로 숨기기 const clearRoute = useCallback(() => { setSelectedUserId(null); setRoutePoints([]); }, []); // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { return; } setLoading(true); setError(null); try { // 모든 데이터 소스를 병렬로 로딩 const results = await Promise.allSettled( dataSources.map(async (source) => { try { if (source.type === "api") { return await loadRestApiData(source); } else if (source.type === "database") { return await loadDatabaseData(source); } return { markers: [], polygons: [] }; } catch (err: any) { return { markers: [], polygons: [] }; } }), ); // 성공한 데이터만 병합 const allMarkers: MarkerData[] = []; const allPolygons: PolygonData[] = []; results.forEach((result, index) => { if (result.status === "fulfilled" && result.value) { const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] }; // 마커 병합 if (value.markers && Array.isArray(value.markers)) { allMarkers.push(...value.markers); } // 폴리곤 병합 if (value.polygons && Array.isArray(value.polygons)) { allPolygons.push(...value.polygons); } } }); // 이전 마커 위치와 비교하여 진행 방향 계산 const markersWithHeading = allMarkers.map((marker) => { const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id); if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) { // 이동했으면 방향 계산 const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng); return { ...marker, heading, prevLat: prevMarker.lat, prevLng: prevMarker.lng, }; } // 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0) return { ...marker, heading: marker.heading || prevMarker?.heading || 0, }; }); prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용) setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); } catch (err: any) { setError(err.message); } finally { setLoading(false); } }, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로) // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { loadMultipleDataSources(); }, [loadMultipleDataSources]); // REST API 데이터 로딩 const loadRestApiData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { 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; } }); } // Request Body 파싱 let requestBody: any = undefined; if (source.body) { try { requestBody = JSON.parse(source.body); } catch { // JSON 파싱 실패시 문자열 그대로 사용 requestBody = source.body; } } // 백엔드 프록시를 통해 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, body: requestBody, externalConnectionId: source.externalConnectionId, }), }); 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") { const parsedData = parseTextData(data.text); if (parsedData.length > 0) { // 컬럼 매핑 적용 const mappedData = applyColumnMapping(parsedData, source.columnMapping); const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); return result; } } // JSON Path로 데이터 추출 if (source.jsonPath) { const pathParts = source.jsonPath.split("."); for (const part of pathParts) { data = data?.[part]; } } // 데이터가 null/undefined면 빈 결과 반환 if (data === null || data === undefined) { return { markers: [], polygons: [] }; } const rows = Array.isArray(data) ? data : [data]; // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); // ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용) // - 모든 REST API 차량 위치 데이터는 자동으로 저장됨 if (mapData.markers.length > 0) { try { const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""; // 마커 데이터를 vehicle_location_history에 저장 for (const marker of mapData.markers) { // user_id 추출 (마커 description에서 파싱) let userId = ""; let vehicleId: number | undefined = undefined; let vehicleName = ""; if (marker.description) { try { const parsed = JSON.parse(marker.description); // 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분) userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || parsed.id || parsed.code || ""; vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId; vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName || parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || ""; } catch { // 파싱 실패 시 무시 } } // user_id가 없으면 마커 이름이나 ID를 사용 if (!userId) { userId = marker.name || marker.id || `marker_${Date.now()}`; } // vehicle_location_history에 저장 await fetch(getApiUrl("/api/dynamic-form/location-history"), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${authToken}`, }, credentials: "include", body: JSON.stringify({ latitude: marker.lat, longitude: marker.lng, userId: userId, vehicleId: vehicleId, tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시 departureName: source.name || "REST API", destinationName: vehicleName || marker.name, }), }); console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng }); } } catch (saveError) { console.error("❌ [saveToHistory] 저장 실패:", saveError); // 저장 실패해도 마커 표시는 계속 } } return mapData; }; // Database 데이터 로딩 const loadDatabaseData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { 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 { 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); } return results; } catch (error) { return []; } }; // 텍스트 데이터 파싱 (CSV, 기상청 형식 등) const parseTextData = (text: string): any[] => { try { // XML 형식 감지 if (text.trim().startsWith("")) { return parseXmlData(text); } const lines = text.split("\n").filter((line) => { const trimmed = line.trim(); return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---"); }); 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, "")); // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 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); } } return result; } catch (error) { return []; } }; // 데이터를 마커와 폴리곤으로 변환 const convertToMapData = ( rows: any[], sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon", dataSource?: ChartDataSource, ): { markers: MarkerData[]; polygons: PolygonData[] } => { if (rows.length === 0) return { markers: [], polygons: [] }; const markers: MarkerData[] = []; const polygons: PolygonData[] = []; rows.forEach((row, index) => { // null/undefined 체크 if (!row) { return; } // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === "object" && row.text && typeof row.text === "string") { const parsedData = parseTextData(row.text); // 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달) 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) { // coordinates가 [lat, lng] 배열의 배열인지 확인 const firstCoord = row.coordinates[0]; if (Array.isArray(firstCoord) && firstCoord.length === 2) { polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: row.name || row.title || `영역 ${index + 1}`, coordinates: row.coordinates as [number, number][], status: row.status || row.level, description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 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") { 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: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 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; const coords = getCoordinatesByRegionCode(regionCode); if (coords) { lat = coords.lat; lng = coords.lng; } } // 지역명으로도 시도 if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) { const regionName = row.name || row.area || row.region || row.location; const coords = getCoordinatesByRegionName(regionName); if (coords) { lat = coords.lat; lng = coords.lng; } } // mapDisplayType이 "polygon"이면 무조건 폴리곤으로 처리 if (mapDisplayType === "polygon") { const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); } return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { markers.push({ // 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용 // 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성 id: `${sourceName}-${row.id || row.code || "marker"}-${index}`, 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: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑 }); } else { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); } } }); 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.978 }, // 서울 "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.8 }, // 충남 // 전라 "45": { lat: 35.7175, lng: 127.153 }, // 전북 "46": { lat: 34.8679, lng: 126.991 }, // 전남 // 경상 "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.8, lng: 127.7 }, // 세종 }; return regionCodeMap[code] || null; }; // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) const MARITIME_ZONES: Record> = { // 서해 해역 "인천·경기북부앞바다": [ [37.8, 125.8], [37.8, 126.5], [37.3, 126.5], [37.3, 125.8], ], "인천·경기남부앞바다": [ [37.3, 125.7], [37.3, 126.4], [36.8, 126.4], [36.8, 125.7], ], 충남북부앞바다: [ [36.8, 125.6], [36.8, 126.3], [36.3, 126.3], [36.3, 125.6], ], 충남남부앞바다: [ [36.3, 125.5], [36.3, 126.2], [35.8, 126.2], [35.8, 125.5], ], 전북북부앞바다: [ [35.8, 125.4], [35.8, 126.1], [35.3, 126.1], [35.3, 125.4], ], 전북남부앞바다: [ [35.3, 125.3], [35.3, 126.0], [34.8, 126.0], [34.8, 125.3], ], 전남북부서해앞바다: [ [35.5, 125.2], [35.5, 125.9], [35.0, 125.9], [35.0, 125.2], ], 전남중부서해앞바다: [ [35.0, 125.1], [35.0, 125.8], [34.5, 125.8], [34.5, 125.1], ], 전남남부서해앞바다: [ [34.5, 125.0], [34.5, 125.7], [34.0, 125.7], [34.0, 125.0], ], 서해중부안쪽먼바다: [ [37.5, 124.5], [37.5, 126.0], [36.0, 126.0], [36.0, 124.5], ], 서해중부바깥먼바다: [ [37.5, 123.5], [37.5, 125.0], [36.0, 125.0], [36.0, 123.5], ], 서해남부북쪽안쪽먼바다: [ [36.0, 124.5], [36.0, 126.0], [35.0, 126.0], [35.0, 124.5], ], 서해남부북쪽바깥먼바다: [ [36.0, 123.5], [36.0, 125.0], [35.0, 125.0], [35.0, 123.5], ], 서해남부남쪽안쪽먼바다: [ [35.0, 124.0], [35.0, 125.5], [34.0, 125.5], [34.0, 124.0], ], 서해남부남쪽바깥먼바다: [ [35.0, 123.0], [35.0, 124.5], [33.5, 124.5], [33.5, 123.0], ], // 제주도 해역 제주도남부앞바다: [ [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.978 }, 서울특별시: { lat: 37.5665, lng: 126.978 }, 경기: { 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.8 }, 충청남도: { lat: 36.5184, lng: 126.8 }, 대전: { lat: 36.3504, lng: 127.3845 }, 대전광역시: { lat: 36.3504, lng: 127.3845 }, 세종: { lat: 36.8, lng: 127.7 }, 세종특별자치시: { lat: 36.8, lng: 127.7 }, // 전라 전북: { lat: 35.7175, lng: 127.153 }, 전북특별자치도: { lat: 35.7175, lng: 127.153 }, 전라북도: { lat: 35.7175, lng: 127.153 }, 전남: { lat: 34.8679, lng: 126.991 }, 전라남도: { lat: 34.8679, lng: 126.991 }, 광주: { 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.019, lng: 129.3435 }, 포항시: { lat: 36.019, 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.228, lng: 128.6811 }, 창원시: { lat: 35.228, lng: 128.6811 }, 진주: { lat: 35.18, lng: 128.1076 }, 진주시: { lat: 35.18, 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; }; // 데이터를 마커로 변환 (하위 호환성 - 현재 미사용) // eslint-disable-next-line @typescript-eslint/no-unused-vars 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) { return []; } return rows .map((row, index) => { const lat = parseFloat(row[latColumn]); const lng = parseFloat(row[lngColumn]); if (isNaN(lat) || isNaN(lng)) { return null; } const marker: MarkerData = { 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), }; return marker; }) .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(); setGeoJsonData(data); } catch (err) { // GeoJSON 로드 실패 처리 } }; loadGeoJsonData(); }, []); // 초기 로드 및 자동 새로고침 (마커 데이터만 polling) useEffect(() => { if (!dataSources || dataSources.length === 0) { setMarkers([]); setPolygons([]); return; } // 즉시 첫 로드 (마커 데이터) loadMultipleDataSources(); // 위젯 레벨의 새로고침 간격 사용 (초) const refreshInterval = element?.chartConfig?.refreshInterval ?? 5; // 0이면 자동 새로고침 비활성화 if (refreshInterval === 0) { return; } const intervalId = setInterval(() => { loadMultipleDataSources(); }, refreshInterval * 1000); return () => { clearInterval(intervalId); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataSources, element?.chartConfig?.refreshInterval]); // 타일맵 URL (VWorld 한국 지도) const 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, ] : [36.5, 127.5]; // 한국 중심 return (
{/* 헤더 */}

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

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

{/* 이동경로 날짜 선택 */} {selectedUserId && (
🛣️ { setRouteDate(e.target.value); if (selectedUserId) { loadRoute(selectedUserId, e.target.value); } }} className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none" /> ({routePoints.length}개)
)} {loading && }
{/* 지도 */}
{error ? (

{error}

) : ( {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} {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) { return true; } if (p.name === ctpName) { return true; } // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) if (sigName && sigName.includes(p.name)) { return true; } if (ctpName && ctpName.includes(p.name)) { return true; } // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) if (sigName && p.name.includes(sigName)) { return true; } if (ctpName && 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) { // 폴리곤의 데이터 소스 찾기 const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source); const popupFields = polygonDataSource?.popupFields; let popupContent = ""; // popupFields가 설정되어 있으면 설정된 필드만 표시 if (popupFields && popupFields.length > 0 && matchingPolygon.description) { try { const parsed = JSON.parse(matchingPolygon.description); popupContent = `
${matchingPolygon.source ? `
📡 ${matchingPolygon.source}
` : ""}
상세 정보
${popupFields .map((field) => { const value = parsed[field.fieldName]; if (value === undefined || value === null) return ""; return `
${field.label}: ${value}
`; }) .join("")}
`; } catch (error) { // JSON 파싱 실패 시 기본 표시 popupContent = `
${matchingPolygon.name}
${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""}
`; } } else { // popupFields가 없으면 전체 데이터 표시 popupContent = `
${matchingPolygon.name}
${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""}
`; } layer.bindPopup(popupContent); } }} /> ) : null} {/* 폴리곤 렌더링 (해상 구역만) */} {polygons .filter((p) => MARITIME_ZONES[p.name]) .map((polygon) => { // 폴리곤의 데이터 소스 찾기 const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source); const popupFields = polygonDataSource?.popupFields; return (
{/* popupFields가 설정되어 있으면 설정된 필드만 표시 */} {popupFields && popupFields.length > 0 && polygon.description ? ( (() => { try { const parsed = JSON.parse(polygon.description); return ( <> {polygon.source && (
📡 {polygon.source}
)}
상세 정보
{popupFields.map((field, idx) => { const value = parsed[field.fieldName]; if (value === undefined || value === null) return null; return (
{field.label}:{" "} {String(value)}
); })}
); } catch (error) { // JSON 파싱 실패 시 기본 표시 return ( <>
{polygon.name}
{polygon.source && (
출처: {polygon.source}
)} {polygon.status &&
상태: {polygon.status}
} {polygon.description && (
{polygon.description}
)} ); } })() ) : ( // popupFields가 없으면 전체 데이터 표시 <>
{polygon.name}
{polygon.source && (
출처: {polygon.source}
)} {polygon.status &&
상태: {polygon.status}
} {polygon.description && (
{polygon.description}
)} )}
); })} {/* 마커 렌더링 */} {markers.map((marker) => { // 마커의 소스에 해당하는 데이터 소스 찾기 const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0]; const markerType = sourceDataSource?.markerType || "circle"; let markerIcon: any; if (typeof window !== "undefined") { const L = require("leaflet"); // heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함 const heading = marker.heading || 90; if (markerType === "arrow") { // 화살표 마커 markerIcon = L.divIcon({ className: "custom-arrow-marker", html: `
`, iconSize: [40, 40], iconAnchor: [20, 20], }); } else if (markerType === "truck") { // 트럭 마커 // 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요 const rotation = heading - 90; // 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로) // 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함 const normalizedRotation = ((rotation % 360) + 360) % 360; const isFlipped = normalizedRotation > 90 && normalizedRotation < 270; const transformStyle = isFlipped ? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)` : `translate(-50%, -50%) rotate(${rotation}deg)`; markerIcon = L.divIcon({ className: "custom-truck-marker", html: `
`, iconSize: [48, 48], iconAnchor: [24, 24], }); } else { // 동그라미 마커 (기본) markerIcon = L.divIcon({ className: "custom-circle-marker", html: `
`, iconSize: [32, 32], iconAnchor: [16, 16], }); } } return (
{/* 데이터 소스명만 표시 */} {marker.source && (
📡 {marker.source}
)} {/* 상세 정보 */}
{marker.description && (() => { // 마커의 소스에 해당하는 데이터 소스 찾기 const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source); const popupFields = sourceDataSource?.popupFields; // popupFields가 설정되어 있으면 설정된 필드만 표시 if (popupFields && popupFields.length > 0) { try { const parsed = JSON.parse(marker.description); return (
상세 정보
{popupFields.map((field, idx) => { const value = parsed[field.fieldName]; if (value === undefined || value === null) return null; // 포맷팅 적용 let formattedValue = value; if (field.format === "date" && value) { formattedValue = new Date(value).toLocaleDateString("ko-KR"); } else if (field.format === "datetime" && value) { formattedValue = new Date(value).toLocaleString("ko-KR"); } else if (field.format === "number" && typeof value === "number") { formattedValue = value.toLocaleString(); } else if ( field.format === "url" && typeof value === "string" && value.startsWith("http") ) { return (
{field.label}:{" "} 링크 열기
); } return (
{field.label}:{" "} {String(formattedValue)}
); })}
); } catch (error) { return (
상세 정보
{marker.description}
); } } // popupFields가 없으면 전체 데이터 표시 (기본 동작) try { const parsed = JSON.parse(marker.description); return (
상세 정보
{Object.entries(parsed).map(([key, value], idx) => { if (value === undefined || value === null) return null; // 좌표 필드는 제외 (하단에 별도 표시) if (["lat", "lng", "latitude", "longitude", "x", "y"].includes(key)) return null; return (
{key}:{" "} {String(value)}
); })}
); } catch (error) { return (
상세 정보
{marker.description}
); } })()} {/* 좌표 */}
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
{/* 이동경로 버튼 */} {(() => { try { const parsed = JSON.parse(marker.description || "{}"); // 다양한 필드명 지원 (plate_no 우선) const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || parsed.id || parsed.code || marker.name; if (visibleUserId) { return (
); } return null; } catch { return null; } })()}
); })} {/* 이동경로 Polyline */} {routePoints.length > 1 && ( [p.lat, p.lng] as [number, number])} pathOptions={{ color: "#3b82f6", weight: 4, opacity: 0.8, dashArray: "10, 5", }} /> )}
)}
{/* 하단 정보 */} {(markers.length > 0 || polygons.length > 0) && (
{markers.length > 0 && `마커 ${markers.length}개`} {markers.length > 0 && polygons.length > 0 && " · "} {polygons.length > 0 && `영역 ${polygons.length}개`}
)}
); }