2025-10-27 18:33:15 +09:00
|
|
|
|
"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":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Sun className="h-4 w-4 text-warning" />;
|
2025-10-27 18:33:15 +09:00
|
|
|
|
case "rain":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <CloudRain className="h-4 w-4 text-primary" />;
|
2025-10-27 18:33:15 +09:00
|
|
|
|
case "snow":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <CloudSnow className="h-4 w-4 text-primary/70" />;
|
2025-10-27 18:33:15 +09:00
|
|
|
|
case "clouds":
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Cloud className="h-4 w-4 text-muted-foreground" />;
|
2025-10-27 18:33:15 +09:00
|
|
|
|
default:
|
2025-10-29 17:53:03 +09:00
|
|
|
|
return <Wind className="h-4 w-4 text-muted-foreground" />;
|
2025-10-27 18:33:15 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 15:39:39 +09:00
|
|
|
|
// 특보 심각도별 색상 반환 (CSS 변수 사용)
|
2025-10-27 18:33:15 +09:00
|
|
|
|
const getAlertColor = (severity: string): string => {
|
2025-10-30 15:39:39 +09:00
|
|
|
|
// CSS 변수 값을 가져오기
|
|
|
|
|
|
const getCSSVariable = (varName: string): string => {
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
const value = getComputedStyle(document.documentElement)
|
|
|
|
|
|
.getPropertyValue(varName)
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
return value ? `hsl(${value})` : "#6b7280";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "#6b7280";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-27 18:33:15 +09:00
|
|
|
|
switch (severity) {
|
|
|
|
|
|
case "high":
|
2025-10-30 15:39:39 +09:00
|
|
|
|
return getCSSVariable("--destructive"); // 경보 (빨강)
|
2025-10-27 18:33:15 +09:00
|
|
|
|
case "medium":
|
2025-10-30 15:39:39 +09:00
|
|
|
|
return getCSSVariable("--warning"); // 주의보 (주황)
|
2025-10-27 18:33:15 +09:00
|
|
|
|
case "low":
|
2025-10-30 15:39:39 +09:00
|
|
|
|
return getCSSVariable("--warning"); // 약한 주의보 (노랑)
|
2025-10-27 18:33:15 +09:00
|
|
|
|
default:
|
2025-10-30 15:39:39 +09:00
|
|
|
|
return getCSSVariable("--muted-foreground"); // 회색
|
2025-10-27 18:33:15 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 지역명 정규화 (특보 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 (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
|
|
|
|
|
<div className="flex-1">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<h3 className="text-sm font-bold text-foreground">{displayTitle}</h3>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{dataSource ? (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{dataSource.type === "api" ? "🌐 REST API" : "💾 Database"} · 총 {markers.length.toLocaleString()}개 마커
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="text-xs text-warning">데이터를 연결하세요</p>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={loadMapData}
|
2025-10-29 17:53:03 +09:00
|
|
|
|
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"
|
2025-10-27 18:33:15 +09:00
|
|
|
|
disabled={loading || !element?.dataSource}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? "⏳" : "🔄"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 에러 메시지 (지도 위에 오버레이) */}
|
|
|
|
|
|
{error && (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="mb-2 rounded border border-destructive bg-destructive/10 p-2 text-center text-xs text-destructive">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
⚠️ {error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 지도 또는 빈 상태 */}
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="relative z-0 flex-1 overflow-hidden rounded border border-border bg-background">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{!element?.chartConfig?.tileMapUrl && !element?.dataSource ? (
|
|
|
|
|
|
// 타일맵 URL도 없고 데이터 소스도 없을 때: 빈 상태 표시
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<p className="text-sm font-medium text-foreground">🗺️ 지도를 설정하세요</p>
|
|
|
|
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
차트 설정에서 타일맵 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='© <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) => `
|
2025-10-30 15:39:39 +09:00
|
|
|
|
<div style="margin-bottom: 8px; padding: 8px; background: hsl(var(--muted)); border-radius: 4px; border-left: 3px solid ${getAlertColor(alert.severity)};">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<div style="font-weight: 600; font-size: 12px; color: ${getAlertColor(alert.severity)};">
|
|
|
|
|
|
${alert.title}
|
|
|
|
|
|
</div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div style="font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 4px;">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
${alert.description}
|
|
|
|
|
|
</div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div style="font-size: 10px; color: hsl(var(--muted-foreground) / 0.7); margin-top: 4px;">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
${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",
|
2025-10-30 15:39:39 +09:00
|
|
|
|
background: "hsl(var(--muted))",
|
2025-10-27 18:33:15 +09:00
|
|
|
|
borderRadius: "4px",
|
|
|
|
|
|
borderLeft: `3px solid ${alertColor}`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ fontWeight: "600", fontSize: "11px", color: alertColor }}>{alert.title}</div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-[10px] text-muted-foreground mt-[3px]">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{alert.description}
|
|
|
|
|
|
</div>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-[9px] text-muted-foreground/70 mt-[3px]">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
{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({
|
2025-10-30 15:39:39 +09:00
|
|
|
|
html: `<div style="
|
|
|
|
|
|
background: ${isWarning ? "hsl(var(--destructive))" : "hsl(var(--warning))"};
|
|
|
|
|
|
color: hsl(var(--destructive-foreground));
|
2025-10-27 18:33:15 +09:00
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 18px;
|
2025-10-30 15:39:39 +09:00
|
|
|
|
border: 3px solid hsl(var(--background));
|
2025-10-27 18:33:15 +09:00
|
|
|
|
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 && (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-xs text-foreground whitespace-pre-line">{marker.description}</div>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
)}
|
|
|
|
|
|
{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>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="text-xs text-foreground">{marker.weather.weatherDescription}</div>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<div className="mt-2 space-y-1 text-xs">
|
|
|
|
|
|
<div className="flex justify-between">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<span className="text-muted-foreground">온도</span>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<span className="font-medium">{marker.weather.temperature}°C</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<span className="text-muted-foreground">체감온도</span>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<span className="text-muted-foreground">습도</span>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<span className="font-medium">{marker.weather.humidity}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between">
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<span className="text-muted-foreground">풍속</span>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Popup>
|
|
|
|
|
|
</Marker>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</MapContainer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 범례 (특보가 있을 때만 표시) */}
|
|
|
|
|
|
{weatherAlerts && weatherAlerts.length > 0 && (
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="absolute right-4 bottom-4 z-10 rounded-lg border bg-background p-3 shadow-lg">
|
2025-10-27 18:33:15 +09:00
|
|
|
|
<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>
|
2025-10-29 17:53:03 +09:00
|
|
|
|
<div className="mt-2 border-t pt-2 text-[10px] text-muted-foreground">총 {weatherAlerts.length}건 발효 중</div>
|
2025-10-27 18:33:15 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// React.memo로 감싸서 불필요한 리렌더링 방지
|
|
|
|
|
|
export default React.memo(MapTestWidget);
|