2022 lines
82 KiB
TypeScript
2022 lines
82 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-require-imports */
|
||
"use client";
|
||
|
||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Loader2, RefreshCw } from "lucide-react";
|
||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||
import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import "leaflet/dist/leaflet.css";
|
||
|
||
// Popup 말풍선 꼬리 제거 스타일
|
||
if (typeof document !== "undefined") {
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
.leaflet-popup-tip-container {
|
||
display: none !important;
|
||
}
|
||
.leaflet-popup-content-wrapper {
|
||
border-radius: 8px !important;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||
if (typeof window !== "undefined") {
|
||
import("leaflet").then((L) => {
|
||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||
});
|
||
});
|
||
}
|
||
|
||
// Leaflet 동적 import (SSR 방지)
|
||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
|
||
|
||
// 브이월드 API 키
|
||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||
|
||
interface MapTestWidgetV2Props {
|
||
element: DashboardElement;
|
||
}
|
||
|
||
interface MarkerData {
|
||
id?: string;
|
||
lat: number;
|
||
lng: number;
|
||
latitude?: number;
|
||
longitude?: number;
|
||
name: string;
|
||
status?: string;
|
||
description?: string;
|
||
source?: string; // 어느 데이터 소스에서 왔는지
|
||
color?: string; // 마커 색상
|
||
heading?: number; // 진행 방향 (0-360도, 0=북쪽)
|
||
prevLat?: number; // 이전 위도 (방향 계산용)
|
||
prevLng?: number; // 이전 경도 (방향 계산용)
|
||
}
|
||
|
||
interface PolygonData {
|
||
id?: string;
|
||
name: string;
|
||
coordinates: [number, number][] | [number, number][][]; // 단일 폴리곤 또는 멀티 폴리곤
|
||
status?: string;
|
||
description?: string;
|
||
source?: string;
|
||
color?: string;
|
||
opacity?: number; // 투명도 (0.0 ~ 1.0)
|
||
}
|
||
|
||
// 이동경로 타입
|
||
interface RoutePoint {
|
||
lat: number;
|
||
lng: number;
|
||
recordedAt: string;
|
||
}
|
||
|
||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||
const [polygons, setPolygons] = useState<PolygonData[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||
|
||
// 이동경로 상태
|
||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||
const [routeLoading, setRouteLoading] = useState(false);
|
||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
||
|
||
// 공차/운행 정보 상태
|
||
const [tripInfo, setTripInfo] = useState<Record<string, any>>({});
|
||
const [tripInfoLoading, setTripInfoLoading] = useState<string | null>(null);
|
||
|
||
// Popup 열림 상태 (자동 새로고침 일시 중지용)
|
||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||
|
||
// 지역 필터 상태
|
||
const [selectedRegion, setSelectedRegion] = useState<string>("all");
|
||
|
||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||
const dataSources = useMemo(() => {
|
||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||
|
||
// 두 좌표 사이의 방향 계산 (0-360도, 0=북쪽)
|
||
const calculateHeading = useCallback((lat1: number, lng1: number, lat2: number, lng2: number): number => {
|
||
const dLng = (lng2 - lng1) * (Math.PI / 180);
|
||
const lat1Rad = lat1 * (Math.PI / 180);
|
||
const lat2Rad = lat2 * (Math.PI / 180);
|
||
|
||
const y = Math.sin(dLng) * Math.cos(lat2Rad);
|
||
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
|
||
|
||
let heading = Math.atan2(y, x) * (180 / Math.PI);
|
||
heading = (heading + 360) % 360; // 0-360 범위로 정규화
|
||
|
||
return heading;
|
||
}, []);
|
||
|
||
// 이동경로 로드 함수
|
||
const loadRoute = useCallback(
|
||
async (userId: string, date?: string) => {
|
||
if (!userId) {
|
||
return;
|
||
}
|
||
|
||
setRouteLoading(true);
|
||
setSelectedUserId(userId);
|
||
|
||
try {
|
||
// 선택한 날짜 기준으로 이동경로 조회
|
||
const targetDate = date || routeDate;
|
||
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
||
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
||
|
||
const query = `SELECT latitude, longitude, recorded_at
|
||
FROM vehicle_location_history
|
||
WHERE user_id = '${userId}'
|
||
AND recorded_at >= '${startOfDay}'
|
||
AND recorded_at <= '${endOfDay}'
|
||
ORDER BY recorded_at ASC`;
|
||
|
||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||
},
|
||
body: JSON.stringify({ query }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.success && result.data.rows.length > 0) {
|
||
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||
lat: parseFloat(row.latitude),
|
||
lng: parseFloat(row.longitude),
|
||
recordedAt: row.recorded_at,
|
||
}));
|
||
|
||
setRoutePoints(points);
|
||
} else {
|
||
setRoutePoints([]);
|
||
}
|
||
}
|
||
} catch {
|
||
setRoutePoints([]);
|
||
}
|
||
|
||
setRouteLoading(false);
|
||
},
|
||
[routeDate],
|
||
);
|
||
|
||
// 이동경로 숨기기
|
||
const clearRoute = useCallback(() => {
|
||
setSelectedUserId(null);
|
||
setRoutePoints([]);
|
||
}, []);
|
||
|
||
// 공차/운행 정보 로드 함수
|
||
const loadTripInfo = useCallback(async (identifier: string) => {
|
||
if (!identifier || tripInfo[identifier]) {
|
||
return; // 이미 로드됨
|
||
}
|
||
|
||
setTripInfoLoading(identifier);
|
||
|
||
try {
|
||
// user_id 또는 vehicle_number로 조회
|
||
const query = `SELECT
|
||
id, vehicle_number, user_id,
|
||
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
|
||
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
|
||
departure, arrival, status
|
||
FROM vehicles
|
||
WHERE user_id = '${identifier}'
|
||
OR vehicle_number = '${identifier}'
|
||
LIMIT 1`;
|
||
|
||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||
},
|
||
body: JSON.stringify({ query }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.success && result.data.rows.length > 0) {
|
||
setTripInfo((prev) => ({
|
||
...prev,
|
||
[identifier]: result.data.rows[0],
|
||
}));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("공차/운행 정보 로드 실패:", err);
|
||
}
|
||
|
||
setTripInfoLoading(null);
|
||
}, [tripInfo]);
|
||
|
||
// 다중 데이터 소스 로딩
|
||
const loadMultipleDataSources = useCallback(async () => {
|
||
if (!dataSources || dataSources.length === 0) {
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 모든 데이터 소스를 병렬로 로딩
|
||
const results = await Promise.allSettled(
|
||
dataSources.map(async (source) => {
|
||
try {
|
||
if (source.type === "api") {
|
||
return await loadRestApiData(source);
|
||
} else if (source.type === "database") {
|
||
return await loadDatabaseData(source);
|
||
}
|
||
|
||
return { markers: [], polygons: [] };
|
||
} catch (err: any) {
|
||
return { markers: [], polygons: [] };
|
||
}
|
||
}),
|
||
);
|
||
|
||
// 성공한 데이터만 병합
|
||
const allMarkers: MarkerData[] = [];
|
||
const allPolygons: PolygonData[] = [];
|
||
|
||
results.forEach((result, index) => {
|
||
if (result.status === "fulfilled" && result.value) {
|
||
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
||
|
||
// 마커 병합
|
||
if (value.markers && Array.isArray(value.markers)) {
|
||
allMarkers.push(...value.markers);
|
||
}
|
||
|
||
// 폴리곤 병합
|
||
if (value.polygons && Array.isArray(value.polygons)) {
|
||
allPolygons.push(...value.polygons);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 이전 마커 위치와 비교하여 진행 방향 계산
|
||
const markersWithHeading = allMarkers.map((marker) => {
|
||
const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id);
|
||
|
||
if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) {
|
||
// 이동했으면 방향 계산
|
||
const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng);
|
||
return {
|
||
...marker,
|
||
heading,
|
||
prevLat: prevMarker.lat,
|
||
prevLng: prevMarker.lng,
|
||
};
|
||
}
|
||
|
||
// 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0)
|
||
return {
|
||
...marker,
|
||
heading: marker.heading || prevMarker?.heading || 0,
|
||
};
|
||
});
|
||
|
||
prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용)
|
||
setMarkers(markersWithHeading);
|
||
setPolygons(allPolygons);
|
||
setLastRefreshTime(new Date());
|
||
} catch (err: any) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로)
|
||
|
||
// 수동 새로고침 핸들러
|
||
const handleManualRefresh = useCallback(() => {
|
||
loadMultipleDataSources();
|
||
}, [loadMultipleDataSources]);
|
||
|
||
// REST API 데이터 로딩
|
||
const loadRestApiData = async (
|
||
source: ChartDataSource,
|
||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||
if (!source.endpoint) {
|
||
throw new Error("API endpoint가 없습니다.");
|
||
}
|
||
|
||
// 쿼리 파라미터 구성
|
||
const queryParams: Record<string, string> = {};
|
||
if (source.queryParams) {
|
||
source.queryParams.forEach((param) => {
|
||
if (param.key && param.value) {
|
||
queryParams[param.key] = param.value;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 헤더 구성
|
||
const headers: Record<string, string> = {};
|
||
if (source.headers) {
|
||
source.headers.forEach((header) => {
|
||
if (header.key && header.value) {
|
||
headers[header.key] = header.value;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Request Body 파싱
|
||
let requestBody: any = undefined;
|
||
if (source.body) {
|
||
try {
|
||
requestBody = JSON.parse(source.body);
|
||
} catch {
|
||
// JSON 파싱 실패시 문자열 그대로 사용
|
||
requestBody = source.body;
|
||
}
|
||
}
|
||
|
||
// 백엔드 프록시를 통해 API 호출
|
||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
url: source.endpoint,
|
||
method: source.method || "GET",
|
||
headers,
|
||
queryParams,
|
||
body: requestBody,
|
||
externalConnectionId: source.externalConnectionId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API 호출 실패: ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (!result.success) {
|
||
throw new Error(result.message || "API 호출 실패");
|
||
}
|
||
|
||
// 데이터 추출 및 파싱
|
||
let data = result.data;
|
||
|
||
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
||
if (data && typeof data === "object" && data.text && typeof data.text === "string") {
|
||
const parsedData = parseTextData(data.text);
|
||
if (parsedData.length > 0) {
|
||
// 컬럼 매핑 적용
|
||
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
||
const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// JSON Path로 데이터 추출
|
||
if (source.jsonPath) {
|
||
const pathParts = source.jsonPath.split(".");
|
||
for (const part of pathParts) {
|
||
data = data?.[part];
|
||
}
|
||
}
|
||
|
||
// 데이터가 null/undefined면 빈 결과 반환
|
||
if (data === null || data === undefined) {
|
||
return { markers: [], polygons: [] };
|
||
}
|
||
|
||
const rows = Array.isArray(data) ? data : [data];
|
||
|
||
// 컬럼 매핑 적용
|
||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||
|
||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||
const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||
|
||
// ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용)
|
||
// - 모든 REST API 차량 위치 데이터는 자동으로 저장됨
|
||
if (mapData.markers.length > 0) {
|
||
try {
|
||
const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : "";
|
||
|
||
// 마커 데이터를 vehicle_location_history에 저장
|
||
for (const marker of mapData.markers) {
|
||
// user_id 추출 (마커 description에서 파싱)
|
||
let userId = "";
|
||
let vehicleId: number | undefined = undefined;
|
||
let vehicleName = "";
|
||
|
||
if (marker.description) {
|
||
try {
|
||
const parsed = JSON.parse(marker.description);
|
||
// 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분)
|
||
userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
|
||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
|
||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
|
||
parsed.id || parsed.code || "";
|
||
vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId;
|
||
vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName ||
|
||
parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || "";
|
||
} catch {
|
||
// 파싱 실패 시 무시
|
||
}
|
||
}
|
||
|
||
// user_id가 없으면 마커 이름이나 ID를 사용
|
||
if (!userId) {
|
||
userId = marker.name || marker.id || `marker_${Date.now()}`;
|
||
}
|
||
|
||
// vehicle_location_history에 저장
|
||
await fetch(getApiUrl("/api/dynamic-form/location-history"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${authToken}`,
|
||
},
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
latitude: marker.lat,
|
||
longitude: marker.lng,
|
||
userId: userId,
|
||
vehicleId: vehicleId,
|
||
tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시
|
||
departureName: source.name || "REST API",
|
||
destinationName: vehicleName || marker.name,
|
||
}),
|
||
});
|
||
|
||
console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng });
|
||
}
|
||
} catch (saveError) {
|
||
console.error("❌ [saveToHistory] 저장 실패:", saveError);
|
||
// 저장 실패해도 마커 표시는 계속
|
||
}
|
||
}
|
||
|
||
return mapData;
|
||
};
|
||
|
||
// Database 데이터 로딩
|
||
const loadDatabaseData = async (
|
||
source: ChartDataSource,
|
||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||
if (!source.query) {
|
||
throw new Error("SQL 쿼리가 없습니다.");
|
||
}
|
||
|
||
let rows: any[] = [];
|
||
|
||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||
// 외부 DB
|
||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||
parseInt(source.externalConnectionId),
|
||
source.query,
|
||
);
|
||
|
||
if (!externalResult.success || !externalResult.data) {
|
||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||
}
|
||
|
||
const resultData = externalResult.data as unknown as {
|
||
rows: Record<string, unknown>[];
|
||
};
|
||
|
||
rows = resultData.rows;
|
||
} else {
|
||
// 현재 DB
|
||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||
const result = await dashboardApi.executeQuery(source.query);
|
||
|
||
rows = result.rows;
|
||
}
|
||
|
||
// 컬럼 매핑 적용
|
||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||
|
||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||
return convertToMapData(mappedRows, source.name || source.id || "Database", source.mapDisplayType, source);
|
||
};
|
||
|
||
// XML 데이터 파싱 (UTIC API 등)
|
||
const parseXmlData = (xmlText: string): any[] => {
|
||
try {
|
||
const parser = new DOMParser();
|
||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||
|
||
const records = xmlDoc.getElementsByTagName("record");
|
||
const results: any[] = [];
|
||
|
||
for (let i = 0; i < records.length; i++) {
|
||
const record = records[i];
|
||
const obj: any = {};
|
||
|
||
for (let j = 0; j < record.children.length; j++) {
|
||
const child = record.children[j];
|
||
obj[child.tagName] = child.textContent || "";
|
||
}
|
||
|
||
results.push(obj);
|
||
}
|
||
|
||
return results;
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||
const parseTextData = (text: string): any[] => {
|
||
try {
|
||
// XML 형식 감지
|
||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||
return parseXmlData(text);
|
||
}
|
||
|
||
const lines = text.split("\n").filter((line) => {
|
||
const trimmed = line.trim();
|
||
return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---");
|
||
});
|
||
|
||
if (lines.length === 0) return [];
|
||
|
||
// CSV 형식으로 파싱
|
||
const result: any[] = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const values = line.split(",").map((v) => v.trim().replace(/,=$/g, ""));
|
||
|
||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||
if (values.length >= 4) {
|
||
const obj: any = {
|
||
code: values[0] || "", // 지역 코드 (예: L1070000)
|
||
region: values[1] || "", // 지역명 (예: 경상북도)
|
||
subCode: values[2] || "", // 하위 코드 (예: L1071600)
|
||
subRegion: values[3] || "", // 하위 지역명 (예: 영주시)
|
||
tmFc: values[4] || "", // 발표시각
|
||
type: values[5] || "", // 특보종류 (강풍, 호우 등)
|
||
level: values[6] || "", // 등급 (주의, 경보)
|
||
status: values[7] || "", // 발표상태
|
||
description: values.slice(8).join(", ").trim() || "",
|
||
};
|
||
|
||
// 지역 이름 설정 (하위 지역명 우선, 없으면 상위 지역명)
|
||
obj.name = obj.subRegion || obj.region || obj.code;
|
||
|
||
result.push(obj);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// 데이터를 마커와 폴리곤으로 변환
|
||
const convertToMapData = (
|
||
rows: any[],
|
||
sourceName: string,
|
||
mapDisplayType?: "auto" | "marker" | "polygon",
|
||
dataSource?: ChartDataSource,
|
||
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||
if (rows.length === 0) return { markers: [], polygons: [] };
|
||
|
||
const markers: MarkerData[] = [];
|
||
const polygons: PolygonData[] = [];
|
||
|
||
rows.forEach((row, index) => {
|
||
// null/undefined 체크
|
||
if (!row) {
|
||
return;
|
||
}
|
||
|
||
// 텍스트 데이터 체크 (기상청 API 등)
|
||
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
||
const parsedData = parseTextData(row.text);
|
||
|
||
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||
markers.push(...result.markers);
|
||
polygons.push(...result.polygons);
|
||
return; // 이 행은 처리 완료
|
||
}
|
||
|
||
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
||
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
||
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
||
const firstCoord = row.coordinates[0];
|
||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||
polygons.push({
|
||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||
name: row.name || row.title || `영역 ${index + 1}`,
|
||
coordinates: row.coordinates as [number, number][],
|
||
status: row.status || row.level,
|
||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||
source: sourceName,
|
||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||
});
|
||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||
}
|
||
}
|
||
|
||
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
||
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
||
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
||
polygons.push({
|
||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||
name: regionName,
|
||
coordinates: MARITIME_ZONES[regionName] as [number, number][],
|
||
status: row.status || row.level,
|
||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||
source: sourceName,
|
||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||
});
|
||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||
}
|
||
|
||
// 마커 데이터 처리 (위도/경도가 있는 경우)
|
||
let lat = row.lat || row.latitude || row.y || row.locationDataY;
|
||
let lng = row.lng || row.longitude || row.x || row.locationDataX;
|
||
|
||
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
||
if (
|
||
(lat === undefined || lng === undefined) &&
|
||
(row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)
|
||
) {
|
||
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
||
const coords = getCoordinatesByRegionCode(regionCode);
|
||
if (coords) {
|
||
lat = coords.lat;
|
||
lng = coords.lng;
|
||
}
|
||
}
|
||
|
||
// 지역명으로도 시도
|
||
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
||
const regionName = row.name || row.area || row.region || row.location;
|
||
const coords = getCoordinatesByRegionName(regionName);
|
||
if (coords) {
|
||
lat = coords.lat;
|
||
lng = coords.lng;
|
||
}
|
||
}
|
||
|
||
// mapDisplayType이 "polygon"이면 무조건 폴리곤으로 처리
|
||
if (mapDisplayType === "polygon") {
|
||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||
if (regionName) {
|
||
polygons.push({
|
||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||
name: regionName,
|
||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||
status: row.status || row.level,
|
||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||
source: sourceName,
|
||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||
});
|
||
}
|
||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||
}
|
||
|
||
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
|
||
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
|
||
markers.push({
|
||
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
|
||
// 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성
|
||
id: `${sourceName}-${row.id || row.code || "marker"}-${index}`,
|
||
lat: Number(lat),
|
||
lng: Number(lng),
|
||
latitude: Number(lat),
|
||
longitude: Number(lng),
|
||
name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`,
|
||
status: row.status || row.level,
|
||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||
source: sourceName,
|
||
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
|
||
});
|
||
} else {
|
||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||
if (regionName) {
|
||
polygons.push({
|
||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||
name: regionName,
|
||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||
status: row.status || row.level,
|
||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||
source: sourceName,
|
||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
return { markers, polygons };
|
||
};
|
||
|
||
// 상태에 따른 색상 반환
|
||
const getColorByStatus = (status?: string): string => {
|
||
if (!status) return "#3b82f6"; // 기본 파란색
|
||
|
||
const statusLower = status.toLowerCase();
|
||
if (statusLower.includes("경보") || statusLower.includes("위험")) return "#ef4444"; // 빨강
|
||
if (statusLower.includes("주의")) return "#f59e0b"; // 주황
|
||
if (statusLower.includes("정상")) return "#10b981"; // 초록
|
||
|
||
return "#3b82f6"; // 기본 파란색
|
||
};
|
||
|
||
// 지역 코드를 위도/경도로 변환
|
||
const getCoordinatesByRegionCode = (code: string): { lat: number; lng: number } | null => {
|
||
// 기상청 지역 코드 매핑 (예시)
|
||
const regionCodeMap: Record<string, { lat: number; lng: number }> = {
|
||
// 서울/경기
|
||
"11": { lat: 37.5665, lng: 126.978 }, // 서울
|
||
"41": { lat: 37.4138, lng: 127.5183 }, // 경기
|
||
|
||
// 강원
|
||
"42": { lat: 37.8228, lng: 128.1555 }, // 강원
|
||
|
||
// 충청
|
||
"43": { lat: 36.6357, lng: 127.4913 }, // 충북
|
||
"44": { lat: 36.5184, lng: 126.8 }, // 충남
|
||
|
||
// 전라
|
||
"45": { lat: 35.7175, lng: 127.153 }, // 전북
|
||
"46": { lat: 34.8679, lng: 126.991 }, // 전남
|
||
|
||
// 경상
|
||
"47": { lat: 36.4919, lng: 128.8889 }, // 경북
|
||
"48": { lat: 35.4606, lng: 128.2132 }, // 경남
|
||
|
||
// 제주
|
||
"50": { lat: 33.4996, lng: 126.5312 }, // 제주
|
||
|
||
// 광역시
|
||
"26": { lat: 35.1796, lng: 129.0756 }, // 부산
|
||
"27": { lat: 35.8714, lng: 128.6014 }, // 대구
|
||
"28": { lat: 35.1595, lng: 126.8526 }, // 광주
|
||
"29": { lat: 36.3504, lng: 127.3845 }, // 대전
|
||
"30": { lat: 35.5384, lng: 129.3114 }, // 울산
|
||
"31": { lat: 36.8, lng: 127.7 }, // 세종
|
||
};
|
||
|
||
return regionCodeMap[code] || null;
|
||
};
|
||
|
||
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준)
|
||
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
||
// 서해 해역
|
||
"인천·경기북부앞바다": [
|
||
[37.8, 125.8],
|
||
[37.8, 126.5],
|
||
[37.3, 126.5],
|
||
[37.3, 125.8],
|
||
],
|
||
"인천·경기남부앞바다": [
|
||
[37.3, 125.7],
|
||
[37.3, 126.4],
|
||
[36.8, 126.4],
|
||
[36.8, 125.7],
|
||
],
|
||
충남북부앞바다: [
|
||
[36.8, 125.6],
|
||
[36.8, 126.3],
|
||
[36.3, 126.3],
|
||
[36.3, 125.6],
|
||
],
|
||
충남남부앞바다: [
|
||
[36.3, 125.5],
|
||
[36.3, 126.2],
|
||
[35.8, 126.2],
|
||
[35.8, 125.5],
|
||
],
|
||
전북북부앞바다: [
|
||
[35.8, 125.4],
|
||
[35.8, 126.1],
|
||
[35.3, 126.1],
|
||
[35.3, 125.4],
|
||
],
|
||
전북남부앞바다: [
|
||
[35.3, 125.3],
|
||
[35.3, 126.0],
|
||
[34.8, 126.0],
|
||
[34.8, 125.3],
|
||
],
|
||
전남북부서해앞바다: [
|
||
[35.5, 125.2],
|
||
[35.5, 125.9],
|
||
[35.0, 125.9],
|
||
[35.0, 125.2],
|
||
],
|
||
전남중부서해앞바다: [
|
||
[35.0, 125.1],
|
||
[35.0, 125.8],
|
||
[34.5, 125.8],
|
||
[34.5, 125.1],
|
||
],
|
||
전남남부서해앞바다: [
|
||
[34.5, 125.0],
|
||
[34.5, 125.7],
|
||
[34.0, 125.7],
|
||
[34.0, 125.0],
|
||
],
|
||
서해중부안쪽먼바다: [
|
||
[37.5, 124.5],
|
||
[37.5, 126.0],
|
||
[36.0, 126.0],
|
||
[36.0, 124.5],
|
||
],
|
||
서해중부바깥먼바다: [
|
||
[37.5, 123.5],
|
||
[37.5, 125.0],
|
||
[36.0, 125.0],
|
||
[36.0, 123.5],
|
||
],
|
||
서해남부북쪽안쪽먼바다: [
|
||
[36.0, 124.5],
|
||
[36.0, 126.0],
|
||
[35.0, 126.0],
|
||
[35.0, 124.5],
|
||
],
|
||
서해남부북쪽바깥먼바다: [
|
||
[36.0, 123.5],
|
||
[36.0, 125.0],
|
||
[35.0, 125.0],
|
||
[35.0, 123.5],
|
||
],
|
||
서해남부남쪽안쪽먼바다: [
|
||
[35.0, 124.0],
|
||
[35.0, 125.5],
|
||
[34.0, 125.5],
|
||
[34.0, 124.0],
|
||
],
|
||
서해남부남쪽바깥먼바다: [
|
||
[35.0, 123.0],
|
||
[35.0, 124.5],
|
||
[33.5, 124.5],
|
||
[33.5, 123.0],
|
||
],
|
||
// 제주도 해역
|
||
제주도남부앞바다: [
|
||
[33.25, 126.0],
|
||
[33.25, 126.85],
|
||
[33.0, 126.85],
|
||
[33.0, 126.0],
|
||
],
|
||
제주도남쪽바깥먼바다: [
|
||
[33.15, 125.7],
|
||
[33.15, 127.3],
|
||
[32.5, 127.3],
|
||
[32.5, 125.7],
|
||
],
|
||
제주도동부앞바다: [
|
||
[33.4, 126.7],
|
||
[33.4, 127.25],
|
||
[33.05, 127.25],
|
||
[33.05, 126.7],
|
||
],
|
||
제주도남동쪽안쪽먼바다: [
|
||
[33.3, 126.85],
|
||
[33.3, 127.95],
|
||
[32.65, 127.95],
|
||
[32.65, 126.85],
|
||
],
|
||
제주도남서쪽안쪽먼바다: [
|
||
[33.3, 125.35],
|
||
[33.3, 126.45],
|
||
[32.7, 126.45],
|
||
[32.7, 125.35],
|
||
],
|
||
// 남해 해역
|
||
남해동부앞바다: [
|
||
[34.65, 128.3],
|
||
[34.65, 129.65],
|
||
[33.95, 129.65],
|
||
[33.95, 128.3],
|
||
],
|
||
남해동부안쪽먼바다: [
|
||
[34.25, 127.95],
|
||
[34.25, 129.75],
|
||
[33.45, 129.75],
|
||
[33.45, 127.95],
|
||
],
|
||
남해동부바깥먼바다: [
|
||
[33.65, 127.95],
|
||
[33.65, 130.35],
|
||
[32.45, 130.35],
|
||
[32.45, 127.95],
|
||
],
|
||
// 동해 해역
|
||
경북북부앞바다: [
|
||
[36.65, 129.2],
|
||
[36.65, 130.1],
|
||
[35.95, 130.1],
|
||
[35.95, 129.2],
|
||
],
|
||
경북남부앞바다: [
|
||
[36.15, 129.1],
|
||
[36.15, 129.95],
|
||
[35.45, 129.95],
|
||
[35.45, 129.1],
|
||
],
|
||
동해남부남쪽안쪽먼바다: [
|
||
[35.65, 129.35],
|
||
[35.65, 130.65],
|
||
[34.95, 130.65],
|
||
[34.95, 129.35],
|
||
],
|
||
동해남부남쪽바깥먼바다: [
|
||
[35.25, 129.45],
|
||
[35.25, 131.15],
|
||
[34.15, 131.15],
|
||
[34.15, 129.45],
|
||
],
|
||
동해남부북쪽안쪽먼바다: [
|
||
[36.6, 129.65],
|
||
[36.6, 130.95],
|
||
[35.85, 130.95],
|
||
[35.85, 129.65],
|
||
],
|
||
동해남부북쪽바깥먼바다: [
|
||
[36.65, 130.35],
|
||
[36.65, 132.15],
|
||
[35.85, 132.15],
|
||
[35.85, 130.35],
|
||
],
|
||
// 강원 해역
|
||
강원북부앞바다: [
|
||
[38.15, 128.4],
|
||
[38.15, 129.55],
|
||
[37.45, 129.55],
|
||
[37.45, 128.4],
|
||
],
|
||
강원중부앞바다: [
|
||
[37.65, 128.7],
|
||
[37.65, 129.6],
|
||
[36.95, 129.6],
|
||
[36.95, 128.7],
|
||
],
|
||
강원남부앞바다: [
|
||
[37.15, 128.9],
|
||
[37.15, 129.85],
|
||
[36.45, 129.85],
|
||
[36.45, 128.9],
|
||
],
|
||
동해중부안쪽먼바다: [
|
||
[38.55, 129.35],
|
||
[38.55, 131.15],
|
||
[37.25, 131.15],
|
||
[37.25, 129.35],
|
||
],
|
||
동해중부바깥먼바다: [
|
||
[38.6, 130.35],
|
||
[38.6, 132.55],
|
||
[37.65, 132.55],
|
||
[37.65, 130.35],
|
||
],
|
||
// 울릉도·독도
|
||
"울릉도.독도": [
|
||
[37.7, 130.7],
|
||
[37.7, 132.0],
|
||
[37.4, 132.0],
|
||
[37.4, 130.7],
|
||
],
|
||
};
|
||
|
||
// 지역명을 위도/경도로 변환
|
||
const getCoordinatesByRegionName = (name: string): { lat: number; lng: number } | null => {
|
||
// 먼저 해상 구역인지 확인
|
||
if (MARITIME_ZONES[name]) {
|
||
// 폴리곤의 중심점 계산
|
||
const coords = MARITIME_ZONES[name];
|
||
const centerLat = coords.reduce((sum, c) => sum + c[0], 0) / coords.length;
|
||
const centerLng = coords.reduce((sum, c) => sum + c[1], 0) / coords.length;
|
||
return { lat: centerLat, lng: centerLng };
|
||
}
|
||
|
||
const regionNameMap: Record<string, { lat: number; lng: number }> = {
|
||
// 서울/경기
|
||
서울: { lat: 37.5665, lng: 126.978 },
|
||
서울특별시: { lat: 37.5665, lng: 126.978 },
|
||
경기: { lat: 37.4138, lng: 127.5183 },
|
||
경기도: { lat: 37.4138, lng: 127.5183 },
|
||
인천: { lat: 37.4563, lng: 126.7052 },
|
||
인천광역시: { lat: 37.4563, lng: 126.7052 },
|
||
|
||
// 강원
|
||
강원: { lat: 37.8228, lng: 128.1555 },
|
||
강원도: { lat: 37.8228, lng: 128.1555 },
|
||
강원특별자치도: { lat: 37.8228, lng: 128.1555 },
|
||
|
||
// 충청
|
||
충북: { lat: 36.6357, lng: 127.4913 },
|
||
충청북도: { lat: 36.6357, lng: 127.4913 },
|
||
충남: { lat: 36.5184, lng: 126.8 },
|
||
충청남도: { lat: 36.5184, lng: 126.8 },
|
||
대전: { lat: 36.3504, lng: 127.3845 },
|
||
대전광역시: { lat: 36.3504, lng: 127.3845 },
|
||
세종: { lat: 36.8, lng: 127.7 },
|
||
세종특별자치시: { lat: 36.8, lng: 127.7 },
|
||
|
||
// 전라
|
||
전북: { lat: 35.7175, lng: 127.153 },
|
||
전북특별자치도: { lat: 35.7175, lng: 127.153 },
|
||
전라북도: { lat: 35.7175, lng: 127.153 },
|
||
전남: { lat: 34.8679, lng: 126.991 },
|
||
전라남도: { lat: 34.8679, lng: 126.991 },
|
||
광주: { lat: 35.1595, lng: 126.8526 },
|
||
광주광역시: { lat: 35.1595, lng: 126.8526 },
|
||
|
||
// 경상
|
||
경북: { lat: 36.4919, lng: 128.8889 },
|
||
경상북도: { lat: 36.4919, lng: 128.8889 },
|
||
포항: { lat: 36.019, lng: 129.3435 },
|
||
포항시: { lat: 36.019, lng: 129.3435 },
|
||
경주: { lat: 35.8562, lng: 129.2247 },
|
||
경주시: { lat: 35.8562, lng: 129.2247 },
|
||
안동: { lat: 36.5684, lng: 128.7294 },
|
||
안동시: { lat: 36.5684, lng: 128.7294 },
|
||
영주: { lat: 36.8056, lng: 128.6239 },
|
||
영주시: { lat: 36.8056, lng: 128.6239 },
|
||
경남: { lat: 35.4606, lng: 128.2132 },
|
||
경상남도: { lat: 35.4606, lng: 128.2132 },
|
||
창원: { lat: 35.228, lng: 128.6811 },
|
||
창원시: { lat: 35.228, lng: 128.6811 },
|
||
진주: { lat: 35.18, lng: 128.1076 },
|
||
진주시: { lat: 35.18, lng: 128.1076 },
|
||
부산: { lat: 35.1796, lng: 129.0756 },
|
||
부산광역시: { lat: 35.1796, lng: 129.0756 },
|
||
대구: { lat: 35.8714, lng: 128.6014 },
|
||
대구광역시: { lat: 35.8714, lng: 128.6014 },
|
||
울산: { lat: 35.5384, lng: 129.3114 },
|
||
울산광역시: { lat: 35.5384, lng: 129.3114 },
|
||
|
||
// 제주
|
||
제주: { lat: 33.4996, lng: 126.5312 },
|
||
제주도: { lat: 33.4996, lng: 126.5312 },
|
||
제주특별자치도: { lat: 33.4996, lng: 126.5312 },
|
||
|
||
// 울릉도/독도
|
||
울릉도: { lat: 37.4845, lng: 130.9057 },
|
||
"울릉도.독도": { lat: 37.4845, lng: 130.9057 },
|
||
독도: { lat: 37.2433, lng: 131.8642 },
|
||
};
|
||
|
||
// 정확한 매칭
|
||
if (regionNameMap[name]) {
|
||
return regionNameMap[name];
|
||
}
|
||
|
||
// 부분 매칭 (예: "서울시 강남구" → "서울")
|
||
for (const [key, value] of Object.entries(regionNameMap)) {
|
||
if (name.includes(key)) {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// 데이터를 마커로 변환 (하위 호환성 - 현재 미사용)
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
const convertToMarkers = (rows: any[]): MarkerData[] => {
|
||
if (rows.length === 0) return [];
|
||
|
||
// 위도/경도 컬럼 찾기
|
||
const firstRow = rows[0];
|
||
const columns = Object.keys(firstRow);
|
||
|
||
const latColumn = columns.find((col) => /^(lat|latitude|위도|y)$/i.test(col));
|
||
const lngColumn = columns.find((col) => /^(lng|lon|longitude|경도|x)$/i.test(col));
|
||
const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col));
|
||
|
||
if (!latColumn || !lngColumn) {
|
||
return [];
|
||
}
|
||
|
||
return rows
|
||
.map((row, index) => {
|
||
const lat = parseFloat(row[latColumn]);
|
||
const lng = parseFloat(row[lngColumn]);
|
||
|
||
if (isNaN(lat) || isNaN(lng)) {
|
||
return null;
|
||
}
|
||
|
||
const marker: MarkerData = {
|
||
id: row.id || `marker-${index}`,
|
||
lat,
|
||
lng,
|
||
latitude: lat,
|
||
longitude: lng,
|
||
name: row[nameColumn || "name"] || `위치 ${index + 1}`,
|
||
status: row.status,
|
||
description: JSON.stringify(row, null, 2),
|
||
};
|
||
return marker;
|
||
})
|
||
.filter((marker): marker is MarkerData => marker !== null);
|
||
};
|
||
|
||
// GeoJSON 데이터 로드
|
||
useEffect(() => {
|
||
const loadGeoJsonData = async () => {
|
||
try {
|
||
const response = await fetch("/geojson/korea-municipalities.json");
|
||
const data = await response.json();
|
||
setGeoJsonData(data);
|
||
} catch (err) {
|
||
// GeoJSON 로드 실패 처리
|
||
}
|
||
};
|
||
loadGeoJsonData();
|
||
}, []);
|
||
|
||
// 초기 로드 및 자동 새로고침 (마커 데이터만 polling)
|
||
useEffect(() => {
|
||
if (!dataSources || dataSources.length === 0) {
|
||
setMarkers([]);
|
||
setPolygons([]);
|
||
return;
|
||
}
|
||
|
||
// 즉시 첫 로드 (마커 데이터)
|
||
loadMultipleDataSources();
|
||
|
||
// 위젯 레벨의 새로고침 간격 사용 (초)
|
||
const refreshInterval = element?.chartConfig?.refreshInterval ?? 5;
|
||
|
||
// 0이면 자동 새로고침 비활성화
|
||
if (refreshInterval === 0) {
|
||
return;
|
||
}
|
||
|
||
const intervalId = setInterval(() => {
|
||
// Popup이 열려있으면 자동 새로고침 건너뛰기
|
||
if (!isPopupOpen) {
|
||
loadMultipleDataSources();
|
||
}
|
||
}, refreshInterval * 1000);
|
||
|
||
return () => {
|
||
clearInterval(intervalId);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]);
|
||
|
||
// 타일맵 URL (VWorld 한국 지도)
|
||
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||
|
||
// 지도 중심점 계산
|
||
const center: [number, number] =
|
||
markers.length > 0
|
||
? [
|
||
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
|
||
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
|
||
]
|
||
: [36.5, 127.5]; // 한국 중심
|
||
|
||
return (
|
||
<div className="bg-background flex h-full w-full flex-col">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between border-b p-4">
|
||
<div>
|
||
<h3 className="text-lg font-semibold">{element?.customTitle || "지도"}</h3>
|
||
<p className="text-muted-foreground text-xs">
|
||
{dataSources?.length || 0}개 데이터 소스 연결됨
|
||
{lastRefreshTime && (
|
||
<span className="ml-2">• 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")}</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{/* 지역 필터 */}
|
||
<Select value={selectedRegion} onValueChange={setSelectedRegion}>
|
||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||
<SelectValue placeholder="지역 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{regionOptions.map((option) => (
|
||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* 이동경로 날짜 선택 */}
|
||
{selectedUserId && (
|
||
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
|
||
<span className="text-xs text-blue-600">🛣️</span>
|
||
<input
|
||
type="date"
|
||
value={routeDate}
|
||
onChange={(e) => {
|
||
setRouteDate(e.target.value);
|
||
if (selectedUserId) {
|
||
loadRoute(selectedUserId, e.target.value);
|
||
}
|
||
}}
|
||
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
|
||
/>
|
||
<span className="text-xs text-blue-600">({routePoints.length}개)</span>
|
||
<button onClick={clearRoute} className="ml-1 text-xs text-blue-400 hover:text-blue-600">
|
||
✕
|
||
</button>
|
||
</div>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleManualRefresh}
|
||
disabled={loading}
|
||
className="h-8 gap-2 text-xs"
|
||
>
|
||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||
새로고침
|
||
</Button>
|
||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 지도 */}
|
||
<div className="relative flex-1">
|
||
{error ? (
|
||
<div className="flex h-full items-center justify-center">
|
||
<p className="text-destructive text-sm">{error}</p>
|
||
</div>
|
||
) : (
|
||
<MapContainer
|
||
key={`map-widget-${element.id}`}
|
||
center={center}
|
||
zoom={element.chartConfig?.initialZoom ?? 8}
|
||
minZoom={element.chartConfig?.minZoom ?? 8}
|
||
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||
scrollWheelZoom
|
||
doubleClickZoom
|
||
touchZoom
|
||
zoomControl
|
||
style={{ width: "100%", height: "100%" }}
|
||
className="z-0"
|
||
>
|
||
<TileLayer
|
||
url={tileMapUrl}
|
||
attribution="© VWorld"
|
||
minZoom={element.chartConfig?.minZoom ?? 8}
|
||
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||
/>
|
||
|
||
{/* 폴리곤 렌더링 */}
|
||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||
{geoJsonData && polygons.length > 0 ? (
|
||
<GeoJSON
|
||
key={JSON.stringify(polygons.map((p) => p.id))} // 폴리곤 변경 시 재렌더링
|
||
data={geoJsonData}
|
||
style={(feature: any) => {
|
||
const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도)
|
||
const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군)
|
||
|
||
// 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명)
|
||
const matchingPolygon = polygons.find((p) => {
|
||
if (!p.name) return false;
|
||
|
||
// 정확한 매칭
|
||
if (p.name === sigName) {
|
||
return true;
|
||
}
|
||
if (p.name === ctpName) {
|
||
return true;
|
||
}
|
||
|
||
// 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지)
|
||
if (sigName && sigName.includes(p.name)) {
|
||
return true;
|
||
}
|
||
if (ctpName && ctpName.includes(p.name)) {
|
||
return true;
|
||
}
|
||
|
||
// 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지)
|
||
if (sigName && p.name.includes(sigName)) {
|
||
return true;
|
||
}
|
||
if (ctpName && p.name.includes(ctpName)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
});
|
||
|
||
if (matchingPolygon) {
|
||
return {
|
||
fillColor: matchingPolygon.color || "#3b82f6",
|
||
fillOpacity: 0.3,
|
||
color: matchingPolygon.color || "#3b82f6",
|
||
weight: 2,
|
||
};
|
||
}
|
||
|
||
return {
|
||
fillOpacity: 0,
|
||
opacity: 0,
|
||
};
|
||
}}
|
||
onEachFeature={(feature: any, layer: any) => {
|
||
const ctpName = feature?.properties?.CTP_KOR_NM;
|
||
const sigName = feature?.properties?.SIG_KOR_NM;
|
||
|
||
const matchingPolygon = polygons.find((p) => {
|
||
if (!p.name) return false;
|
||
if (p.name === sigName || p.name === ctpName) return true;
|
||
if (sigName && sigName.includes(p.name)) return true;
|
||
if (ctpName && ctpName.includes(p.name)) return true;
|
||
if (sigName && p.name.includes(sigName)) return true;
|
||
if (ctpName && p.name.includes(ctpName)) return true;
|
||
return false;
|
||
});
|
||
|
||
if (matchingPolygon) {
|
||
// 폴리곤의 데이터 소스 찾기
|
||
const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source);
|
||
const popupFields = polygonDataSource?.popupFields;
|
||
|
||
let popupContent = "";
|
||
|
||
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||
if (popupFields && popupFields.length > 0 && matchingPolygon.description) {
|
||
try {
|
||
const parsed = JSON.parse(matchingPolygon.description);
|
||
popupContent = `
|
||
<div class="min-w-[200px]">
|
||
${matchingPolygon.source ? `<div class="mb-2 border-b pb-2"><div class="text-gray-500 text-xs">📡 ${matchingPolygon.source}</div></div>` : ""}
|
||
<div class="bg-gray-100 rounded p-2">
|
||
<div class="text-gray-900 mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div class="space-y-2">
|
||
${popupFields
|
||
.map((field) => {
|
||
const value = parsed[field.fieldName];
|
||
if (value === undefined || value === null) return "";
|
||
return `<div class="text-xs"><span class="text-gray-600 font-medium">${field.label}:</span> <span class="text-gray-900">${value}</span></div>`;
|
||
})
|
||
.join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch (error) {
|
||
// JSON 파싱 실패 시 기본 표시
|
||
popupContent = `
|
||
<div class="min-w-[200px]">
|
||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
// popupFields가 없으면 전체 데이터 표시
|
||
popupContent = `
|
||
<div class="min-w-[200px]">
|
||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
layer.bindPopup(popupContent);
|
||
}
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||
{polygons
|
||
.filter((p) => MARITIME_ZONES[p.name])
|
||
.map((polygon) => {
|
||
// 폴리곤의 데이터 소스 찾기
|
||
const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source);
|
||
const popupFields = polygonDataSource?.popupFields;
|
||
|
||
return (
|
||
<Polygon
|
||
key={polygon.id}
|
||
positions={polygon.coordinates}
|
||
pathOptions={{
|
||
color: polygon.color || "#3b82f6",
|
||
fillColor: polygon.color || "#3b82f6",
|
||
fillOpacity: 0.3,
|
||
weight: 2,
|
||
}}
|
||
eventHandlers={{
|
||
popupopen: () => setIsPopupOpen(true),
|
||
popupclose: () => setIsPopupOpen(false),
|
||
}}
|
||
>
|
||
<Popup>
|
||
<div className="min-w-[200px]">
|
||
{/* popupFields가 설정되어 있으면 설정된 필드만 표시 */}
|
||
{popupFields && popupFields.length > 0 && polygon.description ? (
|
||
(() => {
|
||
try {
|
||
const parsed = JSON.parse(polygon.description);
|
||
return (
|
||
<>
|
||
{polygon.source && (
|
||
<div className="mb-2 border-b pb-2">
|
||
<div className="text-muted-foreground text-xs">📡 {polygon.source}</div>
|
||
</div>
|
||
)}
|
||
<div className="bg-muted rounded p-2">
|
||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div className="space-y-2">
|
||
{popupFields.map((field, idx) => {
|
||
const value = parsed[field.fieldName];
|
||
if (value === undefined || value === null) return null;
|
||
return (
|
||
<div key={idx} className="text-xs">
|
||
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
|
||
<span className="text-foreground">{String(value)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
} catch (error) {
|
||
// JSON 파싱 실패 시 기본 표시
|
||
return (
|
||
<>
|
||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||
{polygon.source && (
|
||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||
)}
|
||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||
{polygon.description && (
|
||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
})()
|
||
) : (
|
||
// popupFields가 없으면 전체 데이터 표시
|
||
<>
|
||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||
{polygon.source && (
|
||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||
)}
|
||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||
{polygon.description && (
|
||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</Polygon>
|
||
);
|
||
})}
|
||
|
||
{/* 마커 렌더링 (지역 필터 적용) */}
|
||
{filterVehiclesByRegion(markers, selectedRegion).map((marker) => {
|
||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
|
||
const markerType = sourceDataSource?.markerType || "circle";
|
||
|
||
let markerIcon: any;
|
||
if (typeof window !== "undefined") {
|
||
const L = require("leaflet");
|
||
// heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함
|
||
const heading = marker.heading || 90;
|
||
|
||
if (markerType === "arrow") {
|
||
// 화살표 마커
|
||
markerIcon = L.divIcon({
|
||
className: "custom-arrow-marker",
|
||
html: `
|
||
<div style="
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transform: translate(-50%, -50%) rotate(${heading}deg);
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||
">
|
||
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- 이등변 삼각형 화살표 (뾰족한 방향 표시) -->
|
||
<polygon
|
||
points="20,5 28,30 12,30"
|
||
fill="${marker.color || "#3b82f6"}"
|
||
stroke="white"
|
||
stroke-width="2"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
`,
|
||
iconSize: [40, 40],
|
||
iconAnchor: [20, 20],
|
||
});
|
||
} else if (markerType === "truck") {
|
||
// 트럭 마커
|
||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||
const rotation = heading - 90;
|
||
|
||
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
||
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
||
const transformStyle = isFlipped
|
||
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
||
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
||
|
||
markerIcon = L.divIcon({
|
||
className: "custom-truck-marker",
|
||
html: `
|
||
<div style="
|
||
width: 48px;
|
||
height: 48px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transform: ${transformStyle};
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||
">
|
||
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||
<g>
|
||
<!-- 트럭 적재함 -->
|
||
<rect
|
||
x="10"
|
||
y="12"
|
||
width="12"
|
||
height="10"
|
||
fill="${marker.color || "#3b82f6"}"
|
||
stroke="white"
|
||
stroke-width="1.5"
|
||
rx="1"
|
||
/>
|
||
<!-- 트럭 운전석 -->
|
||
<path
|
||
d="M 22 14 L 22 22 L 28 22 L 28 18 L 26 14 Z"
|
||
fill="${marker.color || "#3b82f6"}"
|
||
stroke="white"
|
||
stroke-width="1.5"
|
||
/>
|
||
<!-- 운전석 창문 -->
|
||
<rect
|
||
x="23"
|
||
y="15"
|
||
width="3"
|
||
height="4"
|
||
fill="white"
|
||
opacity="0.8"
|
||
/>
|
||
<!-- 앞 바퀴 -->
|
||
<circle
|
||
cx="25"
|
||
cy="23"
|
||
r="2.5"
|
||
fill="#333"
|
||
stroke="white"
|
||
stroke-width="1"
|
||
/>
|
||
<!-- 뒷 바퀴 -->
|
||
<circle
|
||
cx="14"
|
||
cy="23"
|
||
r="2.5"
|
||
fill="#333"
|
||
stroke="white"
|
||
stroke-width="1"
|
||
/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
`,
|
||
iconSize: [48, 48],
|
||
iconAnchor: [24, 24],
|
||
});
|
||
} else {
|
||
// 동그라미 마커 (기본)
|
||
markerIcon = L.divIcon({
|
||
className: "custom-circle-marker",
|
||
html: `
|
||
<div style="
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transform: translate(-50%, -50%);
|
||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||
">
|
||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||
<!-- 외부 원 -->
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="14"
|
||
fill="${marker.color || "#3b82f6"}"
|
||
stroke="white"
|
||
stroke-width="2"
|
||
/>
|
||
<!-- 내부 점 -->
|
||
<circle
|
||
cx="16"
|
||
cy="16"
|
||
r="6"
|
||
fill="white"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
`,
|
||
iconSize: [32, 32],
|
||
iconAnchor: [16, 16],
|
||
});
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Marker
|
||
key={marker.id}
|
||
position={[marker.lat, marker.lng]}
|
||
icon={markerIcon}
|
||
eventHandlers={{
|
||
popupopen: () => setIsPopupOpen(true),
|
||
popupclose: () => setIsPopupOpen(false),
|
||
}}
|
||
>
|
||
<Popup maxWidth={350}>
|
||
<div className="max-w-[350px] min-w-[250px]" dir="ltr">
|
||
{/* 데이터 소스명만 표시 */}
|
||
{marker.source && (
|
||
<div className="mb-2 border-b pb-2">
|
||
<div className="text-muted-foreground text-xs">📡 {marker.source}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 상세 정보 */}
|
||
<div className="space-y-2">
|
||
{marker.description &&
|
||
(() => {
|
||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source);
|
||
const popupFields = sourceDataSource?.popupFields;
|
||
|
||
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||
if (popupFields && popupFields.length > 0) {
|
||
try {
|
||
const parsed = JSON.parse(marker.description);
|
||
return (
|
||
<div className="bg-muted rounded p-2">
|
||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div className="space-y-2">
|
||
{popupFields.map((field, idx) => {
|
||
const value = parsed[field.fieldName];
|
||
if (value === undefined || value === null) return null;
|
||
|
||
// 포맷팅 적용
|
||
let formattedValue = value;
|
||
if (field.format === "date" && value) {
|
||
formattedValue = new Date(value).toLocaleDateString("ko-KR");
|
||
} else if (field.format === "datetime" && value) {
|
||
formattedValue = new Date(value).toLocaleString("ko-KR");
|
||
} else if (field.format === "number" && typeof value === "number") {
|
||
formattedValue = value.toLocaleString();
|
||
} else if (
|
||
field.format === "url" &&
|
||
typeof value === "string" &&
|
||
value.startsWith("http")
|
||
) {
|
||
return (
|
||
<div key={idx} className="text-xs">
|
||
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
|
||
<a
|
||
href={value}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-primary hover:underline"
|
||
>
|
||
링크 열기
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div key={idx} className="text-xs">
|
||
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
|
||
<span className="text-foreground">{String(formattedValue)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
} catch (error) {
|
||
return (
|
||
<div className="bg-muted rounded p-2">
|
||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div className="text-muted-foreground text-xs">{marker.description}</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
// popupFields가 없으면 전체 데이터 표시 (기본 동작)
|
||
try {
|
||
const parsed = JSON.parse(marker.description);
|
||
return (
|
||
<div className="bg-muted rounded p-2">
|
||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div className="space-y-2">
|
||
{Object.entries(parsed).map(([key, value], idx) => {
|
||
if (value === undefined || value === null) return null;
|
||
|
||
// 좌표 필드는 제외 (하단에 별도 표시)
|
||
if (["lat", "lng", "latitude", "longitude", "x", "y"].includes(key)) return null;
|
||
|
||
return (
|
||
<div key={idx} className="text-xs">
|
||
<span className="text-muted-foreground font-medium">{key}:</span>{" "}
|
||
<span className="text-foreground">{String(value)}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
} catch (error) {
|
||
return (
|
||
<div className="bg-muted rounded p-2">
|
||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||
<div className="text-muted-foreground text-xs">{marker.description}</div>
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
|
||
{/* 공차/운행 정보 (동적 로딩) */}
|
||
{(() => {
|
||
try {
|
||
const parsed = JSON.parse(marker.description || "{}");
|
||
|
||
// 식별자 찾기 (user_id 또는 vehicle_number)
|
||
const identifier = parsed.user_id || parsed.userId || parsed.vehicle_number ||
|
||
parsed.vehicleNumber || parsed.plate_no || parsed.plateNo ||
|
||
parsed.car_number || parsed.carNumber || marker.name;
|
||
|
||
if (!identifier) return null;
|
||
|
||
// 동적으로 로드된 정보 또는 marker.description에서 가져온 정보 사용
|
||
const info = tripInfo[identifier] || parsed;
|
||
|
||
// 공차 정보가 있는지 확인
|
||
const hasEmptyTripInfo = info.last_empty_start || info.last_empty_end ||
|
||
info.last_empty_distance || info.last_empty_time;
|
||
// 운행 정보가 있는지 확인
|
||
const hasTripInfo = info.last_trip_start || info.last_trip_end ||
|
||
info.last_trip_distance || info.last_trip_time;
|
||
|
||
// 날짜/시간 포맷팅 함수
|
||
const formatDateTime = (dateStr: string) => {
|
||
if (!dateStr) return "-";
|
||
try {
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString("ko-KR", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return dateStr;
|
||
}
|
||
};
|
||
|
||
// 거리 포맷팅 (km)
|
||
const formatDistance = (dist: number | string) => {
|
||
if (dist === null || dist === undefined) return "-";
|
||
const num = typeof dist === "string" ? parseFloat(dist) : dist;
|
||
if (isNaN(num)) return "-";
|
||
return `${num.toFixed(1)} km`;
|
||
};
|
||
|
||
// 시간 포맷팅 (분)
|
||
const formatTime = (minutes: number | string) => {
|
||
if (minutes === null || minutes === undefined) return "-";
|
||
const num = typeof minutes === "string" ? parseInt(minutes) : minutes;
|
||
if (isNaN(num)) return "-";
|
||
if (num < 60) return `${num}분`;
|
||
const hours = Math.floor(num / 60);
|
||
const mins = num % 60;
|
||
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||
};
|
||
|
||
// 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시
|
||
if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) {
|
||
return (
|
||
<div className="border-t pt-2 mt-2">
|
||
<button
|
||
onClick={() => loadTripInfo(identifier)}
|
||
disabled={tripInfoLoading === identifier}
|
||
className="w-full rounded bg-gray-100 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-200 disabled:opacity-50"
|
||
>
|
||
{tripInfoLoading === identifier ? "로딩 중..." : "📊 운행/공차 정보 보기"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 데이터가 없으면 표시 안 함
|
||
if (!hasEmptyTripInfo && !hasTripInfo) return null;
|
||
|
||
return (
|
||
<div className="border-t pt-2 mt-2">
|
||
{/* 운행 정보 */}
|
||
{hasTripInfo && (
|
||
<div className="mb-2">
|
||
<div className="text-xs font-semibold text-blue-600 mb-1">🚛 최근 운행</div>
|
||
<div className="bg-blue-50 rounded p-2 space-y-1">
|
||
{(info.last_trip_start || info.last_trip_end) && (
|
||
<div className="text-[10px] text-gray-600">
|
||
<span className="font-medium">시간:</span>{" "}
|
||
{formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3 text-[10px]">
|
||
{info.last_trip_distance !== undefined && info.last_trip_distance !== null && (
|
||
<span>
|
||
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||
<span className="text-blue-700 font-semibold">{formatDistance(info.last_trip_distance)}</span>
|
||
</span>
|
||
)}
|
||
{info.last_trip_time !== undefined && info.last_trip_time !== null && (
|
||
<span>
|
||
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||
<span className="text-blue-700 font-semibold">{formatTime(info.last_trip_time)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{/* 출발지/도착지 */}
|
||
{(info.departure || info.arrival) && (
|
||
<div className="text-[10px] text-gray-600 pt-1 border-t border-blue-100">
|
||
{info.departure && <span>출발: {info.departure}</span>}
|
||
{info.departure && info.arrival && " → "}
|
||
{info.arrival && <span>도착: {info.arrival}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 공차 정보 */}
|
||
{hasEmptyTripInfo && (
|
||
<div>
|
||
<div className="text-xs font-semibold text-orange-600 mb-1">📦 최근 공차</div>
|
||
<div className="bg-orange-50 rounded p-2 space-y-1">
|
||
{(info.last_empty_start || info.last_empty_end) && (
|
||
<div className="text-[10px] text-gray-600">
|
||
<span className="font-medium">시간:</span>{" "}
|
||
{formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3 text-[10px]">
|
||
{info.last_empty_distance !== undefined && info.last_empty_distance !== null && (
|
||
<span>
|
||
<span className="font-medium text-gray-600">거리:</span>{" "}
|
||
<span className="text-orange-700 font-semibold">{formatDistance(info.last_empty_distance)}</span>
|
||
</span>
|
||
)}
|
||
{info.last_empty_time !== undefined && info.last_empty_time !== null && (
|
||
<span>
|
||
<span className="font-medium text-gray-600">소요:</span>{" "}
|
||
<span className="text-orange-700 font-semibold">{formatTime(info.last_empty_time)}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
})()}
|
||
|
||
{/* 좌표 */}
|
||
<div className="text-muted-foreground border-t pt-2 text-[10px]">
|
||
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
|
||
</div>
|
||
|
||
{/* 이동경로 버튼 */}
|
||
{(() => {
|
||
try {
|
||
const parsed = JSON.parse(marker.description || "{}");
|
||
// 다양한 필드명 지원 (plate_no 우선)
|
||
const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
|
||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
|
||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
|
||
parsed.id || parsed.code || marker.name;
|
||
if (visibleUserId) {
|
||
return (
|
||
<div className="mt-2 border-t pt-2">
|
||
<button
|
||
onClick={() => loadRoute(visibleUserId)}
|
||
disabled={routeLoading}
|
||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||
>
|
||
{routeLoading && selectedUserId === visibleUserId ? "로딩 중..." : "🛣️ 이동경로 보기"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
);
|
||
})}
|
||
|
||
{/* 이동경로 Polyline */}
|
||
{routePoints.length > 1 && (
|
||
<Polyline
|
||
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
|
||
pathOptions={{
|
||
color: "#3b82f6",
|
||
weight: 4,
|
||
opacity: 0.8,
|
||
dashArray: "10, 5",
|
||
}}
|
||
/>
|
||
)}
|
||
</MapContainer>
|
||
)}
|
||
</div>
|
||
|
||
{/* 하단 정보 */}
|
||
{(markers.length > 0 || polygons.length > 0) && (
|
||
<div className="text-muted-foreground border-t p-2 text-xs">
|
||
{markers.length > 0 && (
|
||
<>
|
||
마커 {filterVehiclesByRegion(markers, selectedRegion).length}개
|
||
{selectedRegion !== "all" && ` (전체 ${markers.length}개)`}
|
||
</>
|
||
)}
|
||
{markers.length > 0 && polygons.length > 0 && " · "}
|
||
{polygons.length > 0 && `영역 ${polygons.length}개`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|