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

1194 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 } from "react";
import dynamic from "next/dynamic";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { getWeather, WeatherData, getWeatherAlerts, WeatherAlert } from "@/lib/api/openApi";
import { Cloud, CloudRain, CloudSnow, Sun, Wind, AlertTriangle } from "lucide-react";
import turfUnion from "@turf/union";
import { polygon } from "@turf/helpers";
import { getApiUrl } from "@/lib/utils/apiUrl";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
if (typeof window !== "undefined") {
const L = require("leaflet");
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
}
// Leaflet 동적 import (SSR 방지)
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
// 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
interface MapTestWidgetProps {
element: DashboardElement;
}
interface MarkerData {
id?: string;
lat: number;
lng: number;
latitude?: number;
longitude?: number;
name: string;
status?: string;
description?: string;
info?: any;
weather?: WeatherData | null;
}
// 테이블명 한글 번역
const translateTableName = (name: string): string => {
const tableTranslations: { [key: string]: string } = {
vehicle_locations: "차량",
vehicles: "차량",
warehouses: "창고",
warehouse: "창고",
customers: "고객",
customer: "고객",
deliveries: "배송",
delivery: "배송",
drivers: "기사",
driver: "기사",
stores: "매장",
store: "매장",
};
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
};
// 주요 도시 좌표 (날씨 API 지원 도시)
const CITY_COORDINATES = [
{ name: "서울", lat: 37.5665, lng: 126.978 },
{ name: "부산", lat: 35.1796, lng: 129.0756 },
{ name: "인천", lat: 37.4563, lng: 126.7052 },
{ name: "대구", lat: 35.8714, lng: 128.6014 },
{ name: "광주", lat: 35.1595, lng: 126.8526 },
{ name: "대전", lat: 36.3504, lng: 127.3845 },
{ name: "울산", lat: 35.5384, lng: 129.3114 },
{ name: "세종", lat: 36.48, lng: 127.289 },
{ name: "제주", lat: 33.4996, lng: 126.5312 },
];
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준 - 깔끔한 사각형)
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],
],
};
// 두 좌표 간 거리 계산 (Haversine formula)
const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const R = 6371; // 지구 반경 (km)
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
// 가장 가까운 도시 찾기
const findNearestCity = (lat: number, lng: number): string => {
let nearestCity = "서울";
let minDistance = Infinity;
for (const city of CITY_COORDINATES) {
const distance = getDistance(lat, lng, city.lat, city.lng);
if (distance < minDistance) {
minDistance = distance;
nearestCity = city.name;
}
}
return nearestCity;
};
// 날씨 아이콘 반환
const getWeatherIcon = (weatherMain: string) => {
switch (weatherMain.toLowerCase()) {
case "clear":
return <Sun className="h-4 w-4 text-warning" />;
case "rain":
return <CloudRain className="h-4 w-4 text-primary" />;
case "snow":
return <CloudSnow className="h-4 w-4 text-primary/70" />;
case "clouds":
return <Cloud className="h-4 w-4 text-muted-foreground" />;
default:
return <Wind className="h-4 w-4 text-muted-foreground" />;
}
};
// 특보 심각도별 색상 반환
const getAlertColor = (severity: string): string => {
switch (severity) {
case "high":
return "#ef4444"; // 빨강 (경보)
case "medium":
return "#f59e0b"; // 주황 (주의보)
case "low":
return "#eab308"; // 노랑 (약한 주의보)
default:
return "#6b7280"; // 회색
}
};
// 지역명 정규화 (특보 API 지역명 → GeoJSON 지역명)
const normalizeRegionName = (location: string): string => {
// 기상청 특보는 "강릉시", "속초시", "인제군" 등으로 옴
// GeoJSON도 같은 형식이므로 그대로 반환
return location;
};
/**
* 범용 지도 위젯 (커스텀 지도 카드)
* - 위도/경도가 있는 모든 데이터를 지도에 표시
* - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원
* - Leaflet + 브이월드 지도 사용
*/
function MapTestWidget({ element }: MapTestWidgetProps) {
console.log("🧪 MapTestWidget 렌더링!", element);
const [markers, setMarkers] = useState<MarkerData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tableName, setTableName] = useState<string | null>(null);
const [weatherCache, setWeatherCache] = useState<Map<string, WeatherData>>(new Map());
const [weatherAlerts, setWeatherAlerts] = useState<WeatherAlert[]>([]);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
// 기상특보 지역 코드 → 폴리곤 경계 좌표 매핑 (직사각형)
const REGION_POLYGONS: Record<string, { lat: number; lng: number }[]> = {
// 서울경기 해역 (동해 중부)
"S1132210": [
{ lat: 37.5, lng: 129.5 },
{ lat: 37.5, lng: 130.3 },
{ lat: 36.8, lng: 130.3 },
{ lat: 36.8, lng: 129.5 },
],
"S1132220": [
{ lat: 36.8, lng: 129.4 },
{ lat: 36.8, lng: 130.1 },
{ lat: 36.2, lng: 130.1 },
{ lat: 36.2, lng: 129.4 },
],
"S1132110": [
{ lat: 37.4, lng: 129.2 },
{ lat: 37.4, lng: 129.7 },
{ lat: 36.9, lng: 129.7 },
{ lat: 36.9, lng: 129.2 },
],
"S1132120": [
{ lat: 37.0, lng: 129.2 },
{ lat: 37.0, lng: 129.7 },
{ lat: 36.5, lng: 129.7 },
{ lat: 36.5, lng: 129.2 },
],
// 강원 해역
"S1152010": [
{ lat: 38.9, lng: 129.4 },
{ lat: 38.9, lng: 130.2 },
{ lat: 38.1, lng: 130.2 },
{ lat: 38.1, lng: 129.4 },
],
"S1152020": [
{ lat: 37.9, lng: 129.8 },
{ lat: 37.9, lng: 130.5 },
{ lat: 37.1, lng: 130.5 },
{ lat: 37.1, lng: 129.8 },
],
// 충청 해역 (서해)
"S1312020": [
{ lat: 37.2, lng: 125.8 },
{ lat: 37.2, lng: 126.5 },
{ lat: 36.4, lng: 126.5 },
{ lat: 36.4, lng: 125.8 },
],
"S1323400": [
{ lat: 36.9, lng: 127.2 },
{ lat: 36.9, lng: 127.8 },
{ lat: 36.1, lng: 127.8 },
{ lat: 36.1, lng: 127.2 },
],
"S1324020": [
{ lat: 36.6, lng: 126.5 },
{ lat: 36.6, lng: 127.1 },
{ lat: 35.8, lng: 127.1 },
{ lat: 35.8, lng: 126.5 },
],
// L로 시작하는 동해 해역
"L1070200": [
{ lat: 39.0, lng: 128.6 },
{ lat: 39.0, lng: 129.3 },
{ lat: 38.2, lng: 129.3 },
{ lat: 38.2, lng: 128.6 },
],
"L1070400": [
{ lat: 39.5, lng: 128.2 },
{ lat: 39.5, lng: 128.9 },
{ lat: 38.7, lng: 128.9 },
{ lat: 38.7, lng: 128.2 },
],
"L1071000": [
{ lat: 39.3, lng: 129.2 },
{ lat: 39.3, lng: 129.9 },
{ lat: 38.5, lng: 129.9 },
{ lat: 38.5, lng: 129.2 },
],
"L1071400": [
{ lat: 38.0, lng: 130.2 },
{ lat: 38.0, lng: 131.0 },
{ lat: 37.2, lng: 131.0 },
{ lat: 37.2, lng: 130.2 },
],
"L1071500": [
{ lat: 38.5, lng: 129.7 },
{ lat: 38.5, lng: 130.4 },
{ lat: 37.7, lng: 130.4 },
{ lat: 37.7, lng: 129.7 },
],
"L1071600": [
{ lat: 37.5, lng: 130.2 },
{ lat: 37.5, lng: 130.9 },
{ lat: 36.7, lng: 130.9 },
{ lat: 36.7, lng: 130.2 },
],
"L1071700": [
{ lat: 37.0, lng: 129.7 },
{ lat: 37.0, lng: 130.4 },
{ lat: 36.2, lng: 130.4 },
{ lat: 36.2, lng: 129.7 },
],
"L1071800": [
{ lat: 36.5, lng: 129.5 },
{ lat: 36.5, lng: 130.2 },
{ lat: 35.7, lng: 130.2 },
{ lat: 35.7, lng: 129.5 },
],
"L1071910": [
{ lat: 36.0, lng: 129.7 },
{ lat: 36.0, lng: 130.4 },
{ lat: 35.2, lng: 130.4 },
{ lat: 35.2, lng: 129.7 },
],
"L1072100": [
{ lat: 35.5, lng: 130.2 },
{ lat: 35.5, lng: 130.9 },
{ lat: 34.7, lng: 130.9 },
{ lat: 34.7, lng: 130.2 },
],
"L1072400": [
{ lat: 35.0, lng: 129.2 },
{ lat: 35.0, lng: 129.9 },
{ lat: 34.2, lng: 129.9 },
{ lat: 34.2, lng: 129.2 },
],
};
// 기상특보 지역 코드 → 위도/경도 매핑 (주요 지역 - 마커용)
const REGION_COORDINATES: Record<string, { lat: number; lng: number; name: string }> = {
// 육상 지역
"11B10101": { lat: 37.5665, lng: 126.9780, name: "서울" },
"11B20201": { lat: 37.4563, lng: 126.7052, name: "인천" },
"11B20601": { lat: 37.2636, lng: 127.0286, name: "수원" },
"11C20401": { lat: 36.3504, lng: 127.3845, name: "대전" },
"11C20101": { lat: 36.6424, lng: 127.4890, name: "청주" },
"11F20501": { lat: 35.1796, lng: 129.0756, name: "부산" },
"11H20201": { lat: 35.8714, lng: 128.6014, name: "대구" },
"11H10701": { lat: 35.5384, lng: 129.3114, name: "울산" },
"21F20801": { lat: 33.4996, lng: 126.5312, name: "제주" },
"11D10301": { lat: 37.8813, lng: 127.7300, name: "춘천" },
"11D20501": { lat: 37.7519, lng: 128.8761, name: "강릉" },
"11G00201": { lat: 35.8242, lng: 127.1480, name: "전주" },
"11F10201": { lat: 35.1601, lng: 126.8514, name: "광주" },
// 해상 지역 (앞바다 - 육지에서 가까운 해역)
"S1131100": { lat: 35.4, lng: 129.4, name: "울산앞바다" },
"S1131200": { lat: 35.8, lng: 129.5, name: "경북남부앞바다" },
"S1131300": { lat: 36.5, lng: 129.5, name: "경북북부앞바다" },
"S1132110": { lat: 37.2, lng: 129.6, name: "경기북부앞바다" },
"S1132120": { lat: 36.8, lng: 129.5, name: "경기중부앞바다" },
"S1132210": { lat: 37.0, lng: 130.0, name: "서울경기북부먼바다" },
"S1132220": { lat: 36.5, lng: 129.8, name: "서울경기남부먼바다" },
"S1151100": { lat: 38.2, lng: 128.6, name: "강원북부앞바다" },
"S1151200": { lat: 37.8, lng: 129.0, name: "강원중부앞바다" },
"S1151300": { lat: 37.4, lng: 129.2, name: "강원남부앞바다" },
"S1152010": { lat: 38.5, lng: 130.0, name: "강원북부먼바다" },
"S1231100": { lat: 35.8, lng: 126.0, name: "전북북부앞바다" },
"S1231200": { lat: 35.4, lng: 126.2, name: "전북남부앞바다" },
"S1251100": { lat: 34.8, lng: 126.0, name: "전남북부앞바다" },
"S1251200": { lat: 34.4, lng: 126.4, name: "전남남부앞바다" },
"S1271100": { lat: 33.5, lng: 126.3, name: "제주북부앞바다" },
"S1271200": { lat: 33.2, lng: 126.5, name: "제주남부앞바다" },
"S1312020": { lat: 36.8, lng: 126.2, name: "충남서부먼바다" },
"S1323400": { lat: 36.5, lng: 127.5, name: "충북내륙" },
"S1324020": { lat: 36.2, lng: 126.8, name: "충북서부먼바다" },
"S1152020": { lat: 37.5, lng: 130.2, name: "강원남부먼바다" },
// 먼바다 (육지에서 먼 해역)
"S1132000": { lat: 36.0, lng: 130.0, name: "동해남부먼바다" },
"S1152000": { lat: 38.0, lng: 130.5, name: "동해중부먼바다" },
"S1232000": { lat: 35.0, lng: 125.0, name: "서해중부먼바다" },
"S1252000": { lat: 34.0, lng: 124.5, name: "서해남부먼바다" },
"S1272000": { lat: 32.5, lng: 126.0, name: "제주남쪽먼바다" },
// L로 시작하는 해역 (특정 해역 구역)
"L1070200": { lat: 38.5, lng: 129.0, name: "청진앞바다" },
"L1070400": { lat: 39.0, lng: 128.5, name: "함흥앞바다" },
"L1071000": { lat: 38.8, lng: 129.5, name: "동해북부해역" },
"L1071400": { lat: 37.5, lng: 130.5, name: "동해중부해역" },
"L1071500": { lat: 38.0, lng: 130.0, name: "동해중북부해역" },
"L1071600": { lat: 37.0, lng: 130.5, name: "동해중남부해역" },
"L1071700": { lat: 36.5, lng: 130.0, name: "동해남부해역" },
"L1071800": { lat: 36.0, lng: 129.8, name: "동해남동해역" },
"L1071910": { lat: 35.5, lng: 130.0, name: "동해남서해역" },
"L1072100": { lat: 35.0, lng: 130.5, name: "대한해협" },
"L1072400": { lat: 34.5, lng: 129.5, name: "남해동부해역" },
};
// 초기 로드 (마운트 시 1회만)
useEffect(() => {
console.log("🗺️ MapTestWidget 초기화 (마운트)");
console.log("📋 element.dataSource:", element?.dataSource);
loadGeoJsonData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// dataSource 변경 감지 (데이터 소스가 설정되면 자동 로드)
useEffect(() => {
// REST API 데이터 소스가 있으면 데이터 로드
if (element?.dataSource?.type === "api" && element?.dataSource?.endpoint) {
console.log("🌐 REST API 데이터 소스 감지 - 데이터 로드 시작");
console.log("🔗 API Endpoint:", element.dataSource.endpoint);
loadMapData();
} else {
console.log("⚠️ REST API 데이터 소스 없음 또는 endpoint 없음");
console.log(" - type:", element?.dataSource?.type);
console.log(" - endpoint:", element?.dataSource?.endpoint);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element?.dataSource?.type, element?.dataSource?.endpoint]);
// GeoJSON 데이터 로드 (시/군/구 단위)
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);
}
};
// 기상특보 로드
const loadWeatherAlerts = async () => {
try {
const alerts = await getWeatherAlerts();
console.log("🚨 기상특보 로드 완료:", alerts.length, "건");
console.log("🚨 특보 목록:", alerts);
setWeatherAlerts(alerts);
} catch (err) {
console.error("❌ 기상특보 로드 실패:", err);
}
};
// 마커들의 날씨 정보 로드 (배치 처리 + 딜레이)
const loadWeatherForMarkers = async (markerData: MarkerData[]) => {
try {
// 각 마커의 가장 가까운 도시 찾기
const citySet = new Set<string>();
markerData.forEach((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
citySet.add(nearestCity);
});
// 캐시에 없는 도시만 날씨 조회
const citiesToFetch = Array.from(citySet).filter((city) => !weatherCache.has(city));
console.log(`🌤️ 날씨 로드: 총 ${citySet.size}개 도시, 캐시 미스 ${citiesToFetch.length}`);
if (citiesToFetch.length > 0) {
// 배치 처리: 5개씩 나눠서 호출
const BATCH_SIZE = 5;
const newCache = new Map(weatherCache);
for (let i = 0; i < citiesToFetch.length; i += BATCH_SIZE) {
const batch = citiesToFetch.slice(i, i + BATCH_SIZE);
console.log(`📦 배치 ${Math.floor(i / BATCH_SIZE) + 1}: ${batch.join(", ")}`);
// 배치 내에서는 병렬 호출
const batchPromises = batch.map(async (city) => {
try {
const weather = await getWeather(city);
return { city, weather };
} catch (err) {
console.error(`${city} 날씨 로드 실패:`, err);
return { city, weather: null };
}
});
const batchResults = await Promise.all(batchPromises);
// 캐시 업데이트
batchResults.forEach(({ city, weather }) => {
if (weather) {
newCache.set(city, weather);
}
});
// 다음 배치 전 1초 대기 (서버 부하 방지)
if (i + BATCH_SIZE < citiesToFetch.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
setWeatherCache(newCache);
// 마커에 날씨 정보 추가
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: newCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
console.log("✅ 날씨 로드 완료!");
} else {
// 캐시에서 날씨 정보 가져오기
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: weatherCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
console.log("✅ 캐시에서 날씨 로드 완료!");
}
} catch (err) {
console.error("❌ 날씨 정보 로드 실패:", err);
// 날씨 로드 실패해도 마커는 표시
setMarkers(markerData);
}
};
const loadMapData = async () => {
// ✅ 데이터 소스 존재 여부 체크
if (!element?.dataSource) {
return;
}
const dataSource = element.dataSource;
// 쿼리에서 테이블 이름 추출 (데이터베이스용)
const extractTableName = (query: string): string | null => {
const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i);
if (fromMatch) {
return fromMatch[1];
}
return null;
};
try {
setLoading(true);
let rows: any[] = [];
// ✅ 데이터 소스 타입별 분기 처리
if (dataSource.type === "database") {
// === 기존 데이터베이스 로직 ===
if (!dataSource.query) {
setError("데이터베이스 쿼리가 설정되지 않았습니다.");
setLoading(false);
return;
}
const extractedTableName = extractTableName(dataSource.query);
setTableName(extractedTableName);
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: element.dataSource.connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
rows = result.data.rows;
}
} else if (dataSource.type === "api") {
// === ✅ REST API 로직 (백엔드 프록시 사용) ===
// API URL 확인
const apiUrl = dataSource.endpoint || "";
if (!apiUrl) {
setError("API 엔드포인트가 설정되지 않았습니다.");
setLoading(false);
return;
}
// 쿼리 파라미터 구성
const params: Record<string, string> = {};
if (dataSource.queryParams && Array.isArray(dataSource.queryParams)) {
dataSource.queryParams.forEach((param: any) => {
if (param.key && param.value) {
params[param.key] = param.value;
}
});
}
// 헤더 구성
const headers: Record<string, string> = {};
if (dataSource.headers && Array.isArray(dataSource.headers)) {
dataSource.headers.forEach((header: any) => {
if (header.key && header.value) {
headers[header.key] = header.value;
}
});
}
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
console.log("🌐 REST API 호출 (프록시):", apiUrl);
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
url: apiUrl,
method: "GET",
headers: headers,
queryParams: params,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (!result.success) throw new Error(result.message || "API 호출 실패");
console.log("📦 API 응답:", result);
// 텍스트 응답인 경우 파싱 (기상청 API)
let apiData = result.data;
if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") {
const textData = apiData.text;
// CSV 형식 파싱 (기상청 API)
if (textData.includes("#START7777") || textData.includes(",")) {
const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#"));
const parsedRows = lines.map((line) => {
const values = line.split(",").map((v) => v.trim());
return {
reg_up: values[0] || "",
reg_up_ko: values[1] || "",
reg_id: values[2] || "",
reg_ko: values[3] || "",
tm_fc: values[4] || "",
tm_ef: values[5] || "",
wrn: values[6] || "",
lvl: values[7] || "",
cmd: values[8] || "",
ed_tm: values[9] || "",
};
});
apiData = parsedRows;
console.log("🚨 기상특보 데이터 파싱 완료:", parsedRows.length, "건");
} else {
apiData = [{ text: textData }];
}
}
// jsonPath로 데이터 추출
// 이미 배열인 경우 jsonPath 무시 (기상청 API 등)
if (Array.isArray(apiData)) {
rows = apiData;
console.log("✅ 배열 데이터 자동 감지 (jsonPath 무시)");
} else if (dataSource.jsonPath) {
const pathParts = dataSource.jsonPath.split(".");
let data = apiData;
for (const part of pathParts) {
data = data?.[part];
}
rows = Array.isArray(data) ? data : [];
} else {
rows = Array.isArray(apiData) ? apiData : [apiData];
}
console.log("✅ 추출된 데이터:", rows.length, "개");
// 기상특보 데이터인 경우 weatherAlerts로 설정
if (rows.length > 0 && rows[0].reg_ko && rows[0].wrn) {
console.log("🚨 기상특보 데이터 감지! 영역 표시 준비 중...");
// WeatherAlert 타입에 맞게 변환 + 폴리곤 좌표 추가
const alerts: WeatherAlert[] = rows
.map((row: any, idx: number) => {
const regionCode = row.reg_id;
const polygon = REGION_POLYGONS[regionCode];
const coords = REGION_COORDINATES[regionCode];
if (!polygon && !coords) {
console.warn(`⚠️ 지역 코드 ${regionCode} (${row.reg_ko})의 좌표를 찾을 수 없습니다.`);
return null;
}
console.log(`✅ 기상특보 영역 생성: ${row.wrn} ${row.lvl} - ${row.reg_ko}`);
return {
id: `alert-${idx}`,
type: row.wrn || "기타",
severity: (row.lvl === "경보" ? "high" : row.lvl === "주의보" ? "medium" : "low") as "high" | "medium" | "low",
title: `${row.wrn} ${row.lvl}`,
location: row.reg_ko,
description: `${row.reg_up_ko} ${row.reg_ko} - ${row.cmd === "발표" ? "발표" : "해제"}`,
timestamp: row.tm_fc || new Date().toISOString(),
polygon: polygon, // 폴리곤 좌표 추가
center: coords || (polygon ? { lat: polygon[0].lat, lng: polygon[0].lng } : undefined), // 중심점
};
})
.filter((alert): alert is WeatherAlert => alert !== null);
setWeatherAlerts(alerts);
console.log("🚨 기상특보 영역 설정 완료:", alerts.length, "건 (폴리곤 표시)");
// 기상특보 표시 옵션 자동 활성화
if (!element.chartConfig?.showWeatherAlerts) {
console.log("🚨 기상특보 표시 옵션 자동 활성화");
}
// 기상특보는 폴리곤으로 표시하므로 마커는 생성하지 않음
setMarkers([]);
setLoading(false);
return;
}
}
// === 공통: 마커 데이터 생성 ===
if (rows.length > 0) {
// 위도/경도 컬럼 찾기
const latCol = element.chartConfig?.latitudeColumn || "latitude";
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
// 유효한 좌표 필터링 및 마커 데이터 생성
const markerData = rows
.filter((row: any) => row[latCol] && row[lngCol])
.map((row: any) => ({
lat: parseFloat(row[latCol]),
lng: parseFloat(row[lngCol]),
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
info: row,
weather: null,
}));
console.log("📍 생성된 마커:", markerData.length, "개");
setMarkers(markerData);
// 날씨 정보 로드 (showWeather가 활성화된 경우만)
if (element.chartConfig?.showWeather && markerData.length > 0) {
loadWeatherForMarkers(markerData);
}
} else {
setMarkers([]);
console.log("⚠️ 데이터가 없습니다");
}
setError(null);
} catch (err) {
console.error("❌ 데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setLoading(false);
}
};
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도");
// 데이터 소스 타입 확인
const dataSource = element?.dataSource;
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-foreground">{displayTitle}</h3>
{dataSource ? (
<p className="text-xs text-muted-foreground">
{dataSource.type === "api" ? "🌐 REST API" : "💾 Database"} · {markers.length.toLocaleString()}
</p>
) : (
<p className="text-xs text-warning"> </p>
)}
</div>
<button
onClick={loadMapData}
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs disabled:opacity-50"
disabled={loading || !element?.dataSource}
>
{loading ? "⏳" : "🔄"}
</button>
</div>
{/* 에러 메시지 (지도 위에 오버레이) */}
{error && (
<div className="mb-2 rounded border border-destructive bg-destructive/10 p-2 text-center text-xs text-destructive">
{error}
</div>
)}
{/* 지도 또는 빈 상태 */}
<div className="relative z-0 flex-1 overflow-hidden rounded border border-border bg-background">
{!element?.chartConfig?.tileMapUrl && !element?.dataSource ? (
// 타일맵 URL도 없고 데이터 소스도 없을 때: 빈 상태 표시
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm font-medium text-foreground">🗺 </p>
<p className="mt-2 text-xs text-muted-foreground">
URL을
<br />
</p>
</div>
</div>
) : (
// 데이터 소스가 있을 때: 지도 표시
<MapContainer
key={`map-${element.id}`}
center={[36.5, 127.5]}
zoom={7}
style={{ height: "100%", width: "100%", zIndex: 0 }}
zoomControl={true}
preferCanvas={true}
className="z-0"
>
{/* 타일맵 레이어 (chartConfig.tileMapUrl 또는 기본 VWorld) */}
<TileLayer
url={
element.chartConfig?.tileMapUrl ||
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`
}
attribution='&copy; <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
maxZoom={19}
minZoom={7}
updateWhenIdle={true}
updateWhenZooming={false}
keepBuffer={2}
/>
{/* 기상특보 영역 표시 (육지 - GeoJSON 레이어) */}
{geoJsonData && weatherAlerts && weatherAlerts.length > 0 && (
<GeoJSON
key={`alerts-${weatherAlerts.length}`}
data={geoJsonData}
style={(feature) => {
// 해당 지역에 특보가 있는지 확인
const regionName = feature?.properties?.name;
const alert = weatherAlerts.find((a) => normalizeRegionName(a.location) === regionName);
if (alert) {
return {
fillColor: getAlertColor(alert.severity),
fillOpacity: 0.3,
color: getAlertColor(alert.severity),
weight: 2,
};
}
// 특보가 없는 지역은 투명하게
return {
fillOpacity: 0,
color: "transparent",
weight: 0,
};
}}
onEachFeature={(feature, layer) => {
const regionName = feature?.properties?.name;
const regionAlerts = weatherAlerts.filter((a) => normalizeRegionName(a.location) === regionName);
if (regionAlerts.length > 0) {
const popupContent = `
<div style="min-width: 200px;">
<div style="font-weight: bold; font-size: 14px; margin-bottom: 8px; display: flex; align-items: center; gap: 4px;">
<span style="color: ${getAlertColor(regionAlerts[0].severity)};">⚠️</span>
${regionName}
</div>
${regionAlerts
.map(
(alert) => `
<div style="margin-bottom: 8px; padding: 8px; background: #f9fafb; border-radius: 4px; border-left: 3px solid ${getAlertColor(alert.severity)};">
<div style="font-weight: 600; font-size: 12px; color: ${getAlertColor(alert.severity)};">
${alert.title}
</div>
<div style="font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 4px;">
${alert.description}
</div>
<div style="font-size: 10px; color: hsl(var(--muted-foreground) / 0.7); margin-top: 4px;">
${new Date(alert.timestamp).toLocaleString("ko-KR")}
</div>
</div>
`,
)
.join("")}
</div>
`;
layer.bindPopup(popupContent);
}
}}
/>
)}
{/* 기상특보 영역 표시 (Polygon 레이어) - 개별 표시 */}
{weatherAlerts &&
weatherAlerts.length > 0 &&
weatherAlerts
.filter((alert) => alert.polygon || MARITIME_ZONES[alert.location])
.map((alert, idx) => {
// alert.polygon 우선 사용, 없으면 MARITIME_ZONES 사용
const coordinates = alert.polygon
? alert.polygon.map(coord => [coord.lat, coord.lng] as [number, number])
: MARITIME_ZONES[alert.location];
const alertColor = getAlertColor(alert.severity);
return (
<Polygon
key={`alert-polygon-${idx}`}
positions={coordinates}
pathOptions={{
fillColor: alertColor,
fillOpacity: 0.15,
color: alertColor,
weight: 2,
opacity: 0.9,
dashArray: "5, 5",
lineCap: "round",
lineJoin: "round",
}}
eventHandlers={{
mouseover: (e) => {
const layer = e.target;
layer.setStyle({
fillOpacity: 0.3,
weight: 3,
});
},
mouseout: (e) => {
const layer = e.target;
layer.setStyle({
fillOpacity: 0.15,
weight: 2,
});
},
}}
>
<Popup>
<div style={{ minWidth: "180px" }}>
<div
style={{
fontWeight: "bold",
fontSize: "13px",
marginBottom: "6px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<span style={{ color: alertColor }}></span>
{alert.location}
</div>
<div
style={{
padding: "6px",
background: "#f9fafb",
borderRadius: "4px",
borderLeft: `3px solid ${alertColor}`,
}}
>
<div style={{ fontWeight: "600", fontSize: "11px", color: alertColor }}>{alert.title}</div>
<div className="text-[10px] text-muted-foreground mt-[3px]">
{alert.description}
</div>
<div className="text-[9px] text-muted-foreground/70 mt-[3px]">
{new Date(alert.timestamp).toLocaleString("ko-KR")}
</div>
</div>
</div>
</Popup>
</Polygon>
);
})}
{/* 마커 표시 */}
{markers.map((marker, idx) => {
// 기상특보 마커인지 확인
const isWeatherAlert = marker.id?.startsWith("alert-marker-");
const isWarning = marker.status === "경보";
// 마커 아이콘 설정
const markerIcon = isWeatherAlert
? L.divIcon({
html: `<div style="
background: ${isWarning ? "#ef4444" : "#f59e0b"};
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
">⚠️</div>`,
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
})
: undefined;
return (
<Marker key={idx} position={[marker.lat, marker.lng]} icon={markerIcon}>
<Popup>
<div className="min-w-[200px] text-xs">
{/* 마커 정보 */}
<div className="mb-2 border-b pb-2">
<div className="mb-1 text-sm font-bold">{marker.name}</div>
{marker.description && (
<div className="text-xs text-foreground whitespace-pre-line">{marker.description}</div>
)}
{marker.info && Object.entries(marker.info)
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
.map(([key, value]) => (
<div key={key} className="text-xs">
<strong>{key}:</strong> {String(value)}
</div>
))}
</div>
{/* 날씨 정보 */}
{marker.weather && (
<div className="space-y-1">
<div className="mb-1 flex items-center gap-2">
{getWeatherIcon(marker.weather.weatherMain)}
<span className="text-xs font-semibold"> </span>
</div>
<div className="text-xs text-foreground">{marker.weather.weatherDescription}</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{marker.weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{marker.weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{marker.weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
</div>
</div>
</div>
)}
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
)}
{/* 범례 (특보가 있을 때만 표시) */}
{weatherAlerts && weatherAlerts.length > 0 && (
<div className="absolute right-4 bottom-4 z-10 rounded-lg border bg-background p-3 shadow-lg">
<div className="mb-2 flex items-center gap-1 text-xs font-semibold">
<AlertTriangle className="h-3 w-3" />
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("high") }}></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("medium") }}></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("low") }}></div>
<span> </span>
</div>
</div>
<div className="mt-2 border-t pt-2 text-[10px] text-muted-foreground"> {weatherAlerts.length} </div>
</div>
)}
</div>
</div>
);
}
// React.memo로 감싸서 불필요한 리렌더링 방지
export default React.memo(MapTestWidget);