diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 9cb2aa39..02cafe2b 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), 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"; @@ -78,6 +79,13 @@ interface PolygonData { opacity?: number; // 투명도 (0.0 ~ 1.0) } +// 이동경로 타입 +interface RoutePoint { + lat: number; + lng: number; + recordedAt: string; +} + export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); const prevMarkersRef = useRef([]); // 이전 마커 위치 저장 (useRef 사용) @@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); + + // 이동경로 상태 + const [routePoints, setRoutePoints] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [routeLoading, setRouteLoading] = useState(false); + const [routeDate, setRouteDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식 // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { @@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return heading; }, []); + // 이동경로 로드 함수 + const loadRoute = useCallback(async (userId: string, date?: string) => { + if (!userId) { + console.log("🛣️ 이동경로 조회 불가: 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`; + + console.log("🛣️ 이동경로 쿼리:", query); + + 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, + })); + + console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); + setRoutePoints(points); + } else { + console.log("🛣️ 이동경로 데이터 없음"); + setRoutePoints([]); + } + } + } catch (error) { + console.error("이동경로 로드 실패:", error); + setRoutePoints([]); + } + + setRouteLoading(false); + }, [routeDate]); + + // 이동경로 숨기기 + const clearRoute = useCallback(() => { + setSelectedUserId(null); + setRoutePoints([]); + }, []); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { status: row.status || row.level, description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, - color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 + color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑 }); } else { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) @@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

+ {/* 이동경로 날짜 선택 */} + {selectedUserId && ( +
+ 🛣️ + { + 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" + /> + + ({routePoints.length}개) + + +
+ )} +
+ ); + } + return null; + } catch { + return null; + } + })()} ); })} + + {/* 이동경로 Polyline */} + {routePoints.length > 1 && ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ + color: "#3b82f6", + weight: 4, + opacity: 0.8, + dashArray: "10, 5", + }} + /> + )} )} diff --git a/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx index 6e41fdaf..6234c984 100644 --- a/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx +++ b/frontend/components/dashboard/widgets/VehicleMapOnlyWidget.tsx @@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa 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 Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false }); +const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false }); // 브이월드 API 키 const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; @@ -37,6 +38,16 @@ interface Vehicle { status: "active" | "inactive" | "maintenance" | "warning" | "off"; speed: number; destination: string; + userId?: string; // 이동경로 조회용 + tripId?: string; // 현재 운행 ID +} + +// 이동경로 좌표 +interface RoutePoint { + lat: number; + lng: number; + recordedAt: string; + speed?: number; } interface VehicleMapOnlyWidgetProps { @@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 const [vehicles, setVehicles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [lastUpdate, setLastUpdate] = useState(new Date()); + + // 이동경로 상태 + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [routePoints, setRoutePoints] = useState([]); + const [isRouteLoading, setIsRouteLoading] = useState(false); const loadVehicles = async () => { setIsLoading(true); @@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 : "inactive", speed: parseFloat(row.speed) || 0, destination: row.destination || "대기 중", + userId: row.user_id || row.userId || undefined, + tripId: row.trip_id || row.tripId || undefined, }; }) // 유효한 위도/경도가 있는 차량만 필터링 @@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 setIsLoading(false); }; + // 이동경로 로드 함수 + const loadRoute = async (vehicle: Vehicle) => { + if (!vehicle.userId && !vehicle.tripId) { + console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음"); + return; + } + + setIsRouteLoading(true); + setSelectedVehicle(vehicle); + + try { + // 오늘 날짜 기준으로 최근 이동경로 조회 + const today = new Date(); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString(); + + // trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회 + let query = ""; + if (vehicle.tripId) { + query = `SELECT latitude, longitude, speed, recorded_at + FROM vehicle_location_history + WHERE trip_id = '${vehicle.tripId}' + ORDER BY recorded_at ASC`; + } else if (vehicle.userId) { + query = `SELECT latitude, longitude, speed, recorded_at + FROM vehicle_location_history + WHERE user_id = '${vehicle.userId}' + AND recorded_at >= '${startOfDay}' + ORDER BY recorded_at ASC`; + } + + console.log("🛣️ 이동경로 쿼리:", query); + + 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, + speed: row.speed ? parseFloat(row.speed) : undefined, + })); + + console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`); + setRoutePoints(points); + } else { + console.log("🛣️ 이동경로 데이터 없음"); + setRoutePoints([]); + } + } + } catch (error) { + console.error("이동경로 로드 실패:", error); + setRoutePoints([]); + } + + setIsRouteLoading(false); + }; + + // 이동경로 숨기기 + const clearRoute = () => { + setSelectedVehicle(null); + setRoutePoints([]); + }; + // useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치) useEffect(() => { loadVehicles(); @@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 keepBuffer={2} /> + {/* 이동경로 Polyline */} + {routePoints.length > 1 && ( + [p.lat, p.lng] as [number, number])} + pathOptions={{ + color: "#3b82f6", + weight: 4, + opacity: 0.8, + dashArray: "10, 5", + }} + /> + )} + {/* 차량 마커 */} {vehicles.map((vehicle) => ( @@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
목적지: {vehicle.destination}
+ {/* 이동경로 버튼 */} + {(vehicle.userId || vehicle.tripId) && ( +
+ +
+ )} @@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
데이터를 연결하세요
)} + + {/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */} + {/* {selectedVehicle && routePoints.length > 0 && ( +
+
+
+
🛣️ {selectedVehicle.name} 이동경로
+
{routePoints.length}개 포인트
+
+ +
+
+ )} */}