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

2131 lines
86 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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],
}));
} else {
// 데이터가 없는 경우에도 "로드 완료" 상태로 표시 (빈 객체 저장)
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
} else {
// API 실패 시에도 "로드 완료" 상태로 표시
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
} catch (err) {
console.error("공차/운행 정보 로드 실패:", err);
// 에러 시에도 "로드 완료" 상태로 표시
setTripInfo((prev) => ({
...prev,
[identifier]: { _noData: true },
}));
}
setTripInfoLoading(null);
}, [tripInfo]);
// 마커 로드 시 운행/공차 정보 미리 일괄 조회
const preloadTripInfo = useCallback(async (loadedMarkers: MarkerData[]) => {
if (!loadedMarkers || loadedMarkers.length === 0) return;
// 마커에서 identifier 추출 (user_id 또는 vehicle_number)
const identifiers: string[] = [];
loadedMarkers.forEach((marker) => {
try {
const parsed = JSON.parse(marker.description || "{}");
const identifier = parsed.user_id || parsed.vehicle_number || parsed.id;
if (identifier && !tripInfo[identifier]) {
identifiers.push(identifier);
}
} catch {
// 파싱 실패 시 무시
}
});
if (identifiers.length === 0) return;
try {
// 모든 마커의 운행/공차 정보를 한 번에 조회
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
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 IN (${identifiers.map(id => `'${id}'`).join(", ")})
OR vehicle_number IN (${identifiers.map(id => `'${id}'`).join(", ")})`;
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 newTripInfo: Record<string, any> = {};
// 조회된 데이터를 identifier별로 매핑
result.data.rows.forEach((row: any) => {
const hasData = row.last_trip_start || row.last_trip_end ||
row.last_trip_distance || row.last_trip_time ||
row.last_empty_start || row.last_empty_end ||
row.last_empty_distance || row.last_empty_time;
if (row.user_id) {
newTripInfo[row.user_id] = hasData ? row : { _noData: true };
}
if (row.vehicle_number) {
newTripInfo[row.vehicle_number] = hasData ? row : { _noData: true };
}
});
// 조회되지 않은 identifier는 _noData로 표시
identifiers.forEach((id) => {
if (!newTripInfo[id]) {
newTripInfo[id] = { _noData: true };
}
});
setTripInfo((prev) => ({ ...prev, ...newTripInfo }));
} else {
// 결과가 없으면 모든 identifier를 _noData로 표시
const noDataInfo: Record<string, any> = {};
identifiers.forEach((id) => {
noDataInfo[id] = { _noData: true };
});
setTripInfo((prev) => ({ ...prev, ...noDataInfo }));
}
}
} catch (err) {
console.error("운행/공차 정보 미리 로드 실패:", err);
}
}, [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());
// 마커 로드 후 운행/공차 정보 미리 일괄 조회
preloadTripInfo(markersWithHeading);
} 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="&copy; 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}시간`;
};
// 이미 로드했는데 데이터가 없는 경우 (버튼 숨김)
const loadedInfo = tripInfo[identifier];
if (loadedInfo && loadedInfo._noData) {
return null; // 데이터 없음 - 버튼도 정보도 표시 안 함
}
// 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시
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>
);
}