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

703 lines
26 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { DashboardElement } from "@/components/admin/dashboard/types";
2025-10-23 13:17:21 +09:00
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 "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 });
2025-10-23 13:17:21 +09:00
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 MapSummaryWidgetProps {
element: DashboardElement;
}
interface MarkerData {
lat: number;
lng: number;
name: string;
info: any;
2025-10-23 12:31:14 +09:00
weather?: WeatherData | null;
}
// 테이블명 한글 번역
const translateTableName = (name: string): string => {
const tableTranslations: { [key: string]: string } = {
2025-10-22 13:40:15 +09:00
vehicle_locations: "차량",
vehicles: "차량",
warehouses: "창고",
warehouse: "창고",
customers: "고객",
customer: "고객",
deliveries: "배송",
delivery: "배송",
drivers: "기사",
driver: "기사",
stores: "매장",
store: "매장",
};
2025-10-22 13:40:15 +09:00
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
};
2025-10-23 12:31:14 +09:00
// 주요 도시 좌표 (날씨 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.4800, lng: 127.2890 },
{ name: "제주", lat: 33.4996, lng: 126.5312 },
];
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준 - 깔끔한 사각형)
2025-10-23 13:17:21 +09:00
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
// 제주도 해역
"제주도남부앞바다": [
[33.25, 126.0], [33.25, 126.85], [33.0, 126.85], [33.0, 126.0]
2025-10-23 13:17:21 +09:00
],
"제주도남쪽바깥먼바다": [
[33.15, 125.7], [33.15, 127.3], [32.5, 127.3], [32.5, 125.7]
2025-10-23 13:17:21 +09:00
],
"제주도동부앞바다": [
[33.4, 126.7], [33.4, 127.25], [33.05, 127.25], [33.05, 126.7]
2025-10-23 13:17:21 +09:00
],
"제주도남동쪽안쪽먼바다": [
[33.3, 126.85], [33.3, 127.95], [32.65, 127.95], [32.65, 126.85]
2025-10-23 13:17:21 +09:00
],
"제주도남서쪽안쪽먼바다": [
[33.3, 125.35], [33.3, 126.45], [32.7, 126.45], [32.7, 125.35]
2025-10-23 13:17:21 +09:00
],
// 남해 해역
"남해동부앞바다": [
[34.65, 128.3], [34.65, 129.65], [33.95, 129.65], [33.95, 128.3]
2025-10-23 13:17:21 +09:00
],
"남해동부안쪽먼바다": [
[34.25, 127.95], [34.25, 129.75], [33.45, 129.75], [33.45, 127.95]
2025-10-23 13:17:21 +09:00
],
"남해동부바깥먼바다": [
[33.65, 127.95], [33.65, 130.35], [32.45, 130.35], [32.45, 127.95]
2025-10-23 13:17:21 +09:00
],
// 동해 해역
"경북북부앞바다": [
[36.65, 129.2], [36.65, 130.1], [35.95, 130.1], [35.95, 129.2]
2025-10-23 13:17:21 +09:00
],
"경북남부앞바다": [
[36.15, 129.1], [36.15, 129.95], [35.45, 129.95], [35.45, 129.1]
2025-10-23 13:17:21 +09:00
],
"동해남부남쪽안쪽먼바다": [
[35.65, 129.35], [35.65, 130.65], [34.95, 130.65], [34.95, 129.35]
2025-10-23 13:17:21 +09:00
],
"동해남부남쪽바깥먼바다": [
[35.25, 129.45], [35.25, 131.15], [34.15, 131.15], [34.15, 129.45]
2025-10-23 13:17:21 +09:00
],
"동해남부북쪽안쪽먼바다": [
[36.6, 129.65], [36.6, 130.95], [35.85, 130.95], [35.85, 129.65]
2025-10-23 13:17:21 +09:00
],
"동해남부북쪽바깥먼바다": [
[36.65, 130.35], [36.65, 132.15], [35.85, 132.15], [35.85, 130.35]
2025-10-23 13:17:21 +09:00
],
// 강원 해역
"강원북부앞바다": [
[38.15, 128.4], [38.15, 129.55], [37.45, 129.55], [37.45, 128.4]
2025-10-23 13:17:21 +09:00
],
"강원중부앞바다": [
[37.65, 128.7], [37.65, 129.6], [36.95, 129.6], [36.95, 128.7]
2025-10-23 13:17:21 +09:00
],
"강원남부앞바다": [
[37.15, 128.9], [37.15, 129.85], [36.45, 129.85], [36.45, 128.9]
2025-10-23 13:17:21 +09:00
],
"동해중부안쪽먼바다": [
[38.55, 129.35], [38.55, 131.15], [37.25, 131.15], [37.25, 129.35]
2025-10-23 13:17:21 +09:00
],
"동해중부바깥먼바다": [
[38.6, 130.35], [38.6, 132.55], [37.65, 132.55], [37.65, 130.35]
2025-10-23 13:17:21 +09:00
],
// 울릉도·독도
"울릉도.독도": [
[37.7, 130.7], [37.7, 132.0], [37.4, 132.0], [37.4, 130.7]
2025-10-23 13:17:21 +09:00
],
};
2025-10-23 12:31:14 +09:00
// 두 좌표 간 거리 계산 (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-yellow-500" />;
case "rain":
return <CloudRain className="h-4 w-4 text-blue-500" />;
case "snow":
return <CloudSnow className="h-4 w-4 text-blue-300" />;
case "clouds":
return <Cloud className="h-4 w-4 text-gray-400" />;
default:
return <Wind className="h-4 w-4 text-gray-500" />;
}
};
2025-10-23 13:17:21 +09:00
// 특보 심각도별 색상 반환
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 +
*/
export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
const [markers, setMarkers] = useState<MarkerData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tableName, setTableName] = useState<string | null>(null);
2025-10-23 12:31:14 +09:00
const [weatherCache, setWeatherCache] = useState<Map<string, WeatherData>>(new Map());
2025-10-23 13:17:21 +09:00
const [weatherAlerts, setWeatherAlerts] = useState<WeatherAlert[]>([]);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
useEffect(() => {
2025-10-23 13:17:21 +09:00
console.log("🗺️ MapSummaryWidget 초기화");
console.log("🗺️ showWeatherAlerts:", element.chartConfig?.showWeatherAlerts);
// GeoJSON 데이터 로드
loadGeoJsonData();
// 기상특보 로드 (showWeatherAlerts가 활성화된 경우)
if (element.chartConfig?.showWeatherAlerts) {
console.log("🚨 기상특보 로드 시작...");
loadWeatherAlerts();
} else {
console.log("⚠️ 기상특보 표시 옵션이 꺼져있습니다");
}
if (element?.dataSource?.query) {
loadMapData();
}
2025-10-22 13:40:15 +09:00
// 자동 새로고침 (30초마다)
const interval = setInterval(() => {
if (element?.dataSource?.query) {
loadMapData();
}
2025-10-23 13:17:21 +09:00
if (element.chartConfig?.showWeatherAlerts) {
loadWeatherAlerts();
}
}, 30000);
2025-10-22 13:40:15 +09:00
return () => clearInterval(interval);
2025-10-23 13:17:21 +09:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element.id, element.dataSource?.query, element.chartConfig?.showWeather, element.chartConfig?.showWeatherAlerts]);
// 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);
}
};
2025-10-23 13:17:21 +09:00
// 마커들의 날씨 정보 로드 (배치 처리 + 딜레이)
2025-10-23 12:31:14 +09:00
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));
2025-10-23 13:17:21 +09:00
console.log(`🌤️ 날씨 로드: 총 ${citySet.size}개 도시, 캐시 미스 ${citiesToFetch.length}`);
2025-10-23 12:31:14 +09:00
2025-10-23 13:17:21 +09:00
if (citiesToFetch.length > 0) {
// 배치 처리: 5개씩 나눠서 호출
const BATCH_SIZE = 5;
2025-10-23 12:31:14 +09:00
const newCache = new Map(weatherCache);
2025-10-23 13:17:21 +09:00
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));
}
}
2025-10-23 12:31:14 +09:00
setWeatherCache(newCache);
// 마커에 날씨 정보 추가
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: newCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
2025-10-23 13:17:21 +09:00
console.log("✅ 날씨 로드 완료!");
2025-10-23 12:31:14 +09:00
} else {
// 캐시에서 날씨 정보 가져오기
const updatedMarkers = markerData.map((marker) => {
const nearestCity = findNearestCity(marker.lat, marker.lng);
return {
...marker,
weather: weatherCache.get(nearestCity) || null,
};
});
setMarkers(updatedMarkers);
2025-10-23 13:17:21 +09:00
console.log("✅ 캐시에서 날씨 로드 완료!");
2025-10-23 12:31:14 +09:00
}
} catch (err) {
2025-10-23 13:17:21 +09:00
console.error("❌ 날씨 정보 로드 실패:", err);
2025-10-23 12:31:14 +09:00
// 날씨 로드 실패해도 마커는 표시
2025-10-23 13:17:21 +09:00
setMarkers(markerData);
2025-10-23 12:31:14 +09:00
}
};
const loadMapData = async () => {
if (!element?.dataSource?.query) {
return;
}
// 쿼리에서 테이블 이름 추출
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);
const extractedTableName = extractTableName(element.dataSource.query);
setTableName(extractedTableName);
const token = localStorage.getItem("authToken");
const response = await fetch("/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) {
const rows = result.data.rows;
2025-10-22 13:40:15 +09:00
// 위도/경도 컬럼 찾기
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,
2025-10-23 12:31:14 +09:00
weather: null,
}));
setMarkers(markerData);
2025-10-23 12:31:14 +09:00
2025-10-23 13:17:21 +09:00
// 날씨 정보 로드 (showWeather가 활성화된 경우만)
if (element.chartConfig?.showWeather) {
loadWeatherForMarkers(markerData);
}
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
} finally {
setLoading(false);
}
};
// customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도");
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 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-gray-900">{displayTitle}</h3>
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (
2025-10-22 13:40:15 +09:00
<p className="text-xs text-orange-500"> </p>
)}
</div>
<button
onClick={loadMapData}
2025-10-22 13:40:15 +09:00
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
disabled={loading || !element?.dataSource?.query}
>
{loading ? "⏳" : "🔄"}
</button>
</div>
{/* 에러 메시지 (지도 위에 오버레이) */}
{error && (
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
{error}
</div>
)}
{/* 지도 (항상 표시) */}
2025-10-22 13:40:15 +09:00
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
<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"
>
{/* 브이월드 타일맵 */}
<TileLayer
url={`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}
/>
2025-10-23 13:17:21 +09:00
{/* 기상특보 영역 표시 (육지 - GeoJSON 레이어) */}
{element.chartConfig?.showWeatherAlerts && 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: #6b7280; margin-top: 4px;">
${alert.description}
</div>
<div style="font-size: 10px; color: #9ca3af; margin-top: 4px;">
${new Date(alert.timestamp).toLocaleString("ko-KR")}
</div>
</div>
`,
)
.join("")}
</div>
`;
layer.bindPopup(popupContent);
}
}}
/>
)}
{/* 기상특보 영역 표시 (해상 - Polygon 레이어) - 개별 표시 */}
2025-10-23 13:17:21 +09:00
{element.chartConfig?.showWeatherAlerts && weatherAlerts && weatherAlerts.length > 0 &&
weatherAlerts
.filter((alert) => MARITIME_ZONES[alert.location])
2025-10-23 13:17:21 +09:00
.map((alert, idx) => {
const coordinates = MARITIME_ZONES[alert.location];
const alertColor = getAlertColor(alert.severity);
2025-10-23 13:17:21 +09:00
return (
<Polygon
key={`maritime-${idx}`}
positions={coordinates}
pathOptions={{
fillColor: alertColor,
fillOpacity: 0.15,
color: alertColor,
2025-10-23 13:17:21 +09:00
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,
});
},
2025-10-23 13:17:21 +09:00
}}
>
<Popup>
<div style={{ minWidth: "180px" }}>
<div style={{ fontWeight: "bold", fontSize: "13px", marginBottom: "6px", display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ color: alertColor }}></span>
2025-10-23 13:17:21 +09:00
{alert.location}
</div>
<div style={{ padding: "6px", background: "#f9fafb", borderRadius: "4px", borderLeft: `3px solid ${alertColor}` }}>
<div style={{ fontWeight: "600", fontSize: "11px", color: alertColor }}>
2025-10-23 13:17:21 +09:00
{alert.title}
</div>
<div style={{ fontSize: "10px", color: "#6b7280", marginTop: "3px" }}>
2025-10-23 13:17:21 +09:00
{alert.description}
</div>
<div style={{ fontSize: "9px", color: "#9ca3af", marginTop: "3px" }}>
2025-10-23 13:17:21 +09:00
{new Date(alert.timestamp).toLocaleString("ko-KR")}
</div>
</div>
</div>
</Popup>
</Polygon>
);
})
}
{/* 마커 표시 */}
{markers.map((marker, idx) => (
<Marker key={idx} position={[marker.lat, marker.lng]}>
<Popup>
2025-10-23 12:31:14 +09:00
<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>
{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-gray-600">{marker.weather.weatherDescription}</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.temperature}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.feelsLike}°C</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.humidity}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
</div>
</div>
2025-10-23 12:31:14 +09:00
</div>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
2025-10-23 13:17:21 +09:00
{/* 범례 (특보가 있을 때만 표시) */}
{element.chartConfig?.showWeatherAlerts && weatherAlerts && weatherAlerts.length > 0 && (
<div className="absolute bottom-4 right-4 z-10 rounded-lg border bg-white 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-gray-500">
{weatherAlerts.length}
</div>
</div>
)}
</div>
</div>
);
}