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

1091 lines
44 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 "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; // 어느 데이터 소스에서 왔는지
}
interface PolygonData {
id?: string;
name: string;
coordinates: [number, number][] | [number, number][][]; // 단일 폴리곤 또는 멀티 폴리곤
status?: string;
description?: string;
source?: string;
color?: string;
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = 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);
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<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("/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}개 행`);
return convertToMapData(parsedData, source.name || source.id || "API", source.mapDisplayType);
}
}
// 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];
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
return convertToMapData(rows, source.name || source.id || "API", source.mapDisplayType);
};
// 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;
}
// 마커와 폴리곤으로 변환 (mapDisplayType 전달)
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
};
// 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"): { markers: MarkerData[]; polygons: PolygonData[] } => {
console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
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);
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: 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: 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: 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,
});
} 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: 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.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<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.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 (
<div className="flex h-full w-full flex-col bg-background">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-lg font-semibold">
{element?.customTitle || "지도 테스트 V2 (다중 데이터 소스)"}
</h3>
<p className="text-xs text-muted-foreground">
{element?.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-sm text-destructive">{error}</p>
</div>
) : !element?.dataSources || element.dataSources.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
</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="mb-1 text-xs text-muted-foreground">
: {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) => (
<Marker
key={marker.id}
position={[marker.lat, marker.lng]}
>
<Popup maxWidth={350}>
<div className="min-w-[250px] max-w-[350px]">
{/* 제목 */}
<div className="mb-2 border-b pb-2">
<div className="text-base font-bold">{marker.name}</div>
{marker.source && (
<div className="mt-1 text-xs text-muted-foreground">
📡 {marker.source}
</div>
)}
</div>
{/* 상세 정보 */}
<div className="space-y-2">
{marker.description && (
<div className="rounded bg-muted p-2">
<div className="mb-1 text-xs font-semibold text-foreground"> </div>
<div className="text-xs text-muted-foreground whitespace-pre-wrap">
{(() => {
try {
const parsed = JSON.parse(marker.description);
return (
<div className="space-y-1">
{parsed.incidenteTypeCd === "1" && (
<div className="font-semibold text-destructive">🚨 </div>
)}
{parsed.incidenteTypeCd === "2" && (
<div className="font-semibold text-warning">🚧 </div>
)}
{parsed.addressJibun && (
<div>📍 {parsed.addressJibun}</div>
)}
{parsed.addressNew && parsed.addressNew !== parsed.addressJibun && (
<div>📍 {parsed.addressNew}</div>
)}
{parsed.roadName && (
<div>🛣 {parsed.roadName}</div>
)}
{parsed.linkName && (
<div>🔗 {parsed.linkName}</div>
)}
{parsed.incidentMsg && (
<div className="mt-2 border-t pt-2">💬 {parsed.incidentMsg}</div>
)}
{parsed.eventContent && (
<div className="mt-2 border-t pt-2">📝 {parsed.eventContent}</div>
)}
{parsed.startDate && (
<div className="text-[10px]">🕐 {parsed.startDate}</div>
)}
{parsed.endDate && (
<div className="text-[10px]">🕐 : {parsed.endDate}</div>
)}
</div>
);
} catch {
return marker.description;
}
})()}
</div>
</div>
)}
{marker.status && (
<div className="text-xs">
<span className="font-semibold">:</span> {marker.status}
</div>
)}
{/* 좌표 */}
<div className="border-t pt-2 text-[10px] text-muted-foreground">
📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div>
</div>
</div>
</Popup>
</Marker>
))}
</MapContainer>
)}
</div>
{/* 하단 정보 */}
{(markers.length > 0 || polygons.length > 0) && (
<div className="border-t p-2 text-xs text-muted-foreground">
{markers.length > 0 && `마커 ${markers.length}`}
{markers.length > 0 && polygons.length > 0 && " · "}
{polygons.length > 0 && `영역 ${polygons.length}`}
</div>
)}
</div>
);
}