ERP-node/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx

1352 lines
53 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-require-imports */
"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") {
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 });
// 브이월드 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)
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = useState<MarkerData[]>([]);
const [prevMarkers, setPrevMarkers] = useState<MarkerData[]>([]); // 이전 마커 위치 저장
const [polygons, setPolygons] = useState<PolygonData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
// 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 loadMultipleDataSources = useCallback(async () => {
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);
// 이전 마커 위치와 비교하여 진행 방향 계산
const markersWithHeading = allMarkers.map((marker) => {
const prevMarker = prevMarkers.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,
};
});
setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장
setMarkers(markersWithHeading);
setPolygons(allPolygons);
setLastRefreshTime(new Date());
} catch (err: any) {
console.error("❌ 데이터 로딩 중 오류:", err);
setError(err.message);
} finally {
setLoading(false);
}
}, [dataSources, prevMarkers, calculateHeading]);
// 수동 새로고침 핸들러
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<string, string> = {};
if (source.queryParams) {
source.queryParams.forEach((param) => {
if (param.key && param.value) {
queryParams[param.key] = param.value;
}
});
}
// 헤더 구성
const headers: Record<string, string> = {};
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<string, unknown>[];
};
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("<?xml") || text.trim().startsWith("<result>")) {
// // 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; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
}
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "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<string, { lat: number; lng: number }> = {
// 서울/경기
"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<string, Array<[number, number]>> = {
// 제주도 해역
: [
[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<string, { lat: number; lng: number }> = {
// 서울/경기
: { 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) {
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;
}
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();
// // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
setGeoJsonData(data);
} catch (err) {
console.error("❌ GeoJSON 로드 실패:", err);
}
};
loadGeoJsonData();
}, []);
// 초기 로드 및 자동 새로고침 (마커 데이터만 polling)
useEffect(() => {
if (!dataSources || dataSources.length === 0) {
setMarkers([]);
setPolygons([]);
return;
}
// 즉시 첫 로드 (마커 데이터)
loadMultipleDataSources();
// 첫 번째 데이터 소스의 새로고침 간격 사용 (초)
const firstDataSource = dataSources[0];
const refreshInterval = firstDataSource?.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]);
// 타일맵 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 (
<div className="bg-background flex h-full w-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">{element?.customTitle || "지도"}</h3>
<p className="text-muted-foreground text-xs">
{dataSources?.length || 0}
{lastRefreshTime && (
<span className="ml-2"> : {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleManualRefresh}
disabled={loading}
className="h-8 gap-2 text-xs"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
</div>
{/* 지도 */}
<div className="relative flex-1">
{error ? (
<div className="flex h-full items-center justify-center">
<p className="text-destructive text-sm">{error}</p>
</div>
) : (
<MapContainer center={center} zoom={13} style={{ width: "100%", height: "100%" }} className="z-0">
<TileLayer url={tileMapUrl} attribution="&copy; VWorld" maxZoom={19} />
{/* 폴리곤 렌더링 */}
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
{(() => {
// console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
// geoJsonData: !!geoJsonData,
// polygonsLength: polygons.length,
// polygonNames: polygons.map(p => p.name),
// });
return null;
})()}
{geoJsonData && polygons.length > 0 ? (
<GeoJSON
key={JSON.stringify(polygons.map((p) => 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(`
<div class="min-w-[200px]">
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
</div>
`);
}
}}
/>
) : (
<>
{/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */}
</>
)}
{/* 폴리곤 렌더링 (해상 구역만) */}
{polygons
.filter((p) => MARITIME_ZONES[p.name])
.map((polygon) => (
<Polygon
key={polygon.id}
positions={polygon.coordinates}
pathOptions={{
color: polygon.color || "#3b82f6",
fillColor: polygon.color || "#3b82f6",
fillOpacity: 0.3,
weight: 2,
}}
>
<Popup>
<div className="min-w-[200px]">
<div className="mb-2 font-semibold">{polygon.name}</div>
{polygon.source && (
<div className="text-muted-foreground mb-1 text-xs">: {polygon.source}</div>
)}
{polygon.status && <div className="mb-1 text-xs">: {polygon.status}</div>}
{polygon.description && (
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
</div>
)}
</div>
</Popup>
</Polygon>
))}
{/* 마커 렌더링 */}
{markers.map((marker) => {
// 첫 번째 데이터 소스의 마커 종류 가져오기
const firstDataSource = dataSources?.[0];
const markerType = firstDataSource?.markerType || "circle";
let markerIcon: any;
if (typeof window !== "undefined") {
const L = require("leaflet");
const heading = marker.heading || 0;
if (markerType === "arrow") {
// 화살표 마커
markerIcon = L.divIcon({
className: "custom-arrow-marker",
html: `
<div style="
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%) rotate(${heading}deg);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<!-- 화살표 몸통 -->
<polygon
points="20,5 25,15 23,15 23,25 17,25 17,15 15,15"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
/>
<!-- 화살촉 -->
<polygon
points="20,2 28,12 12,12"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
/>
<!-- 중심점 -->
<circle
cx="20"
cy="30"
r="3"
fill="white"
stroke="${marker.color || "#3b82f6"}"
stroke-width="1.5"
/>
</svg>
</div>
`,
iconSize: [40, 40],
iconAnchor: [20, 20],
});
} else {
// 동그라미 마커 (기본)
markerIcon = L.divIcon({
className: "custom-circle-marker",
html: `
<div style="
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- 외부 원 -->
<circle
cx="16"
cy="16"
r="14"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="2"
/>
<!-- 내부 점 -->
<circle
cx="16"
cy="16"
r="6"
fill="white"
/>
</svg>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
}
}
return (
<Marker key={marker.id} position={[marker.lat, marker.lng]} icon={markerIcon}>
<Popup maxWidth={350}>
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
{/* 데이터 소스명만 표시 */}
{marker.source && (
<div className="mb-2 border-b pb-2">
<div className="text-muted-foreground text-xs">📡 {marker.source}</div>
</div>
)}
{/* 상세 정보 */}
<div className="space-y-2">
{marker.description &&
(() => {
const firstDataSource = dataSources?.[0];
const popupFields = firstDataSource?.popupFields;
// popupFields가 설정되어 있으면 설정된 필드만 표시
if (popupFields && popupFields.length > 0) {
try {
const parsed = JSON.parse(marker.description);
return (
<div className="bg-muted rounded p-2">
<div className="text-foreground mb-1 text-xs font-semibold"> </div>
<div className="space-y-2">
{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 (
<div key={idx} className="text-xs">
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
</a>
</div>
);
}
return (
<div key={idx} className="text-xs">
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
<span className="text-foreground">{String(formattedValue)}</span>
</div>
);
})}
</div>
</div>
);
} catch (error) {
return (
<div className="bg-muted rounded p-2">
<div className="text-foreground mb-1 text-xs font-semibold"> </div>
<div className="text-muted-foreground text-xs">{marker.description}</div>
</div>
);
}
}
// popupFields가 없으면 전체 데이터 표시 (기본 동작)
try {
const parsed = JSON.parse(marker.description);
return (
<div className="bg-muted rounded p-2">
<div className="text-foreground mb-1 text-xs font-semibold"> </div>
<div className="space-y-2">
{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 (
<div key={idx} className="text-xs">
<span className="text-muted-foreground font-medium">{key}:</span>{" "}
<span className="text-foreground">{String(value)}</span>
</div>
);
})}
</div>
</div>
);
} catch (error) {
return (
<div className="bg-muted rounded p-2">
<div className="text-foreground mb-1 text-xs font-semibold"> </div>
<div className="text-muted-foreground text-xs">{marker.description}</div>
</div>
);
}
})()}
{/* 좌표 */}
<div className="text-muted-foreground border-t pt-2 text-[10px]">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div>
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
)}
</div>
{/* 하단 정보 */}
{(markers.length > 0 || polygons.length > 0) && (
<div className="text-muted-foreground border-t p-2 text-xs">
{markers.length > 0 && `마커 ${markers.length}`}
{markers.length > 0 && polygons.length > 0 && " · "}
{polygons.length > 0 && `영역 ${polygons.length}`}
</div>
)}
</div>
);
}