From dbf6cfc995ae7dd7e7c45f141a55e80b6ae8a9c3 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Thu, 4 Dec 2025 10:30:15 +0900
Subject: [PATCH 1/2] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=88=98=EC=A0=95=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=ED=99=95=EC=9D=B8=20=EA=B0=80?=
=?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../dashboard/widgets/MapTestWidgetV2.tsx | 145 +++++++++++++++++-
.../widgets/VehicleMapOnlyWidget.tsx | 135 ++++++++++++++++
2 files changed, 279 insertions(+), 1 deletion(-)
diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
index 9cb2aa39..8170aa11 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}개)
+
+
+
+ )}
+
+ {/* 이동경로 버튼 */}
+ {(() => {
+ try {
+ const parsed = JSON.parse(marker.description || "{}");
+ const userId = parsed.user_id;
+ if (userId) {
+ return (
+
+
+
+ );
+ }
+ 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}개 포인트
+
+
+
+
+ )} */}
From 532c56f9977af2176b3206eca5626ea6c440b504 Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Thu, 4 Dec 2025 10:46:37 +0900
Subject: [PATCH 2/2] =?UTF-8?q?=EC=B0=A8=EB=9F=89=20=EC=95=84=EC=9D=B4?=
=?UTF-8?q?=EC=BD=98=20=EC=95=88=EB=92=A4=EC=A7=91=ED=9E=88=EA=B2=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/dashboard/widgets/MapTestWidgetV2.tsx | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
index 8170aa11..02cafe2b 100644
--- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
+++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
@@ -1409,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 트럭 마커
// 트럭 아이콘이 오른쪽(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",
@@ -1419,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex;
align-items: center;
justify-content: center;
- transform: translate(-50%, -50%) rotate(${rotation}deg);
+ transform: ${transformStyle};
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">