935 lines
38 KiB
TypeScript
935 lines
38 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
|
import { Loader2 } 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);
|
|
|
|
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);
|
|
} catch (err: any) {
|
|
console.error("❌ 데이터 로딩 중 오류:", err);
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [dataSources]);
|
|
|
|
// 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);
|
|
};
|
|
|
|
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
|
const parseTextData = (text: string): any[] => {
|
|
try {
|
|
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
|
|
|
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;
|
|
let lng = row.lng || row.longitude || row.x;
|
|
|
|
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
|
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]);
|
|
|
|
// 타일맵 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}개 데이터 소스 연결됨
|
|
</p>
|
|
</div>
|
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</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='© VWorld'
|
|
maxZoom={19}
|
|
/>
|
|
|
|
{/* 폴리곤 렌더링 */}
|
|
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
|
{geoJsonData && polygons.length > 0 && (
|
|
<GeoJSON
|
|
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>
|
|
`);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 폴리곤 렌더링 (해상 구역만) */}
|
|
{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>
|
|
<div className="min-w-[200px]">
|
|
<div className="mb-2 font-semibold">{marker.name}</div>
|
|
{marker.source && (
|
|
<div className="mb-1 text-xs text-muted-foreground">
|
|
출처: {marker.source}
|
|
</div>
|
|
)}
|
|
{marker.status && (
|
|
<div className="mb-1 text-xs">
|
|
상태: {marker.status}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground">
|
|
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
|
</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>
|
|
);
|
|
}
|
|
|