414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { Button } from "@/components/ui/button";
|
||
import { RefreshCw } from "lucide-react";
|
||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||
import "leaflet/dist/leaflet.css";
|
||
|
||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||
if (typeof window !== "undefined") {
|
||
const L = require("leaflet");
|
||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||
});
|
||
}
|
||
|
||
// Leaflet 동적 import (SSR 방지)
|
||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||
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";
|
||
|
||
interface Vehicle {
|
||
id: string;
|
||
name: string;
|
||
driver: string;
|
||
lat: number;
|
||
lng: number;
|
||
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 {
|
||
element?: any; // 대시보드 요소 (dataSource, chartConfig 포함)
|
||
refreshInterval?: number;
|
||
}
|
||
|
||
export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 }: VehicleMapOnlyWidgetProps) {
|
||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||
|
||
// 이동경로 상태
|
||
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
|
||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||
const [isRouteLoading, setIsRouteLoading] = useState(false);
|
||
|
||
const loadVehicles = async () => {
|
||
setIsLoading(true);
|
||
|
||
// 설정된 쿼리가 없으면 로딩 중단
|
||
if (!element?.dataSource?.query) {
|
||
setIsLoading(false);
|
||
setVehicles([]);
|
||
return;
|
||
}
|
||
|
||
// 설정된 컬럼 매핑 확인
|
||
if (!element?.chartConfig?.latitudeColumn || !element?.chartConfig?.longitudeColumn) {
|
||
setIsLoading(false);
|
||
setVehicles([]);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
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: element.dataSource.query,
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.success && result.data.rows.length > 0) {
|
||
// 설정된 컬럼 매핑 가져오기
|
||
const latCol = element.chartConfig.latitudeColumn;
|
||
const lngCol = element.chartConfig.longitudeColumn;
|
||
const labelCol = element.chartConfig.labelColumn || "name";
|
||
const statusCol = element.chartConfig.statusColumn || "status";
|
||
|
||
// DB 데이터를 Vehicle 형식으로 변환
|
||
console.log("🗺️ [VehicleMapOnlyWidget] 원본 데이터:", result.data.rows);
|
||
console.log("🗺️ [VehicleMapOnlyWidget] 컬럼 매핑:", { latCol, lngCol, labelCol, statusCol });
|
||
|
||
const vehiclesFromDB: Vehicle[] = result.data.rows
|
||
.map((row: any, index: number) => {
|
||
const lat = parseFloat(row[latCol]);
|
||
const lng = parseFloat(row[lngCol]);
|
||
|
||
console.log(`🗺️ [VehicleMapOnlyWidget] 차량 ${index + 1}:`, {
|
||
id: row.id || row.vehicle_number,
|
||
latRaw: row[latCol],
|
||
lngRaw: row[lngCol],
|
||
latParsed: lat,
|
||
lngParsed: lng,
|
||
status: row[statusCol],
|
||
});
|
||
|
||
return {
|
||
id: row.id || row.vehicle_number || `V${index + 1}`,
|
||
name: row[labelCol] || `차량 ${index + 1}`,
|
||
driver: row.driver_name || row.driver || "미배정",
|
||
lat,
|
||
lng,
|
||
status:
|
||
row[statusCol] === "warning"
|
||
? "warning"
|
||
: row[statusCol] === "active"
|
||
? "active"
|
||
: row[statusCol] === "maintenance"
|
||
? "maintenance"
|
||
: "inactive",
|
||
speed: parseFloat(row.speed) || 0,
|
||
destination: row.destination || "대기 중",
|
||
userId: row.user_id || row.userId || undefined,
|
||
tripId: row.trip_id || row.tripId || undefined,
|
||
};
|
||
})
|
||
// 유효한 위도/경도가 있는 차량만 필터링
|
||
.filter((v: Vehicle) => !isNaN(v.lat) && !isNaN(v.lng) && v.lat !== 0 && v.lng !== 0);
|
||
|
||
console.log("🗺️ [VehicleMapOnlyWidget] 유효한 차량 수:", vehiclesFromDB.length);
|
||
setVehicles(vehiclesFromDB);
|
||
setLastUpdate(new Date());
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("차량 데이터 로드 실패:", error);
|
||
}
|
||
|
||
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();
|
||
const interval = setInterval(loadVehicles, refreshInterval);
|
||
return () => clearInterval(interval);
|
||
}, [
|
||
element?.dataSource?.query,
|
||
element?.chartConfig?.latitudeColumn,
|
||
element?.chartConfig?.longitudeColumn,
|
||
refreshInterval,
|
||
]);
|
||
|
||
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
||
|
||
const getStatusColor = (status: Vehicle["status"]) => {
|
||
switch (status) {
|
||
case "active":
|
||
return "#22c55e"; // 운행 중 - 초록
|
||
case "inactive":
|
||
return "#eab308"; // 대기 - 노랑
|
||
case "maintenance":
|
||
return "#f97316"; // 정비 - 주황
|
||
case "warning":
|
||
return "#ef4444"; // 고장 - 빨강
|
||
default:
|
||
return "#6b7280"; // 기타 - 회색
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status: Vehicle["status"]) => {
|
||
switch (status) {
|
||
case "active":
|
||
return "운행 중";
|
||
case "inactive":
|
||
return "대기";
|
||
case "maintenance":
|
||
return "정비";
|
||
case "warning":
|
||
return "고장";
|
||
default:
|
||
return "알 수 없음";
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="h-full w-full bg-gradient-to-br from-background to-primary/10 p-4">
|
||
{/* 헤더 */}
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-foreground">🗺️ 차량 위치 지도</h3>
|
||
<p className="text-xs text-muted-foreground">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||
<div className="h-[calc(100%-60px)]">
|
||
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-border bg-background">
|
||
<MapContainer
|
||
key={`vehicle-map-${element.id}`}
|
||
center={[36.5, 127.5]}
|
||
zoom={7}
|
||
style={{ height: "100%", width: "100%", zIndex: 0 }}
|
||
zoomControl={true}
|
||
preferCanvas={true}
|
||
className="z-0"
|
||
>
|
||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||
<TileLayer
|
||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||
maxZoom={19}
|
||
minZoom={7}
|
||
updateWhenIdle={true}
|
||
updateWhenZooming={false}
|
||
keepBuffer={2}
|
||
/>
|
||
|
||
{/* 이동경로 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",
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 차량 마커 */}
|
||
{vehicles.map((vehicle) => (
|
||
<React.Fragment key={vehicle.id}>
|
||
<Circle
|
||
center={[vehicle.lat, vehicle.lng]}
|
||
radius={150}
|
||
pathOptions={{
|
||
color: getStatusColor(vehicle.status),
|
||
fillColor: getStatusColor(vehicle.status),
|
||
fillOpacity: 0.3,
|
||
}}
|
||
/>
|
||
<Marker position={[vehicle.lat, vehicle.lng]}>
|
||
<Popup>
|
||
<div className="text-xs">
|
||
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
|
||
<div>
|
||
<strong>기사:</strong> {vehicle.driver}
|
||
</div>
|
||
<div>
|
||
<strong>상태:</strong> {getStatusText(vehicle.status)}
|
||
</div>
|
||
<div>
|
||
<strong>속도:</strong> {vehicle.speed} km/h
|
||
</div>
|
||
<div>
|
||
<strong>목적지:</strong> {vehicle.destination}
|
||
</div>
|
||
{/* 이동경로 버튼 */}
|
||
{(vehicle.userId || vehicle.tripId) && (
|
||
<div className="mt-2 border-t pt-2">
|
||
<button
|
||
onClick={() => loadRoute(vehicle)}
|
||
disabled={isRouteLoading}
|
||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||
>
|
||
{isRouteLoading && selectedVehicle?.id === vehicle.id
|
||
? "로딩 중..."
|
||
: "🛣️ 이동경로 보기"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
</React.Fragment>
|
||
))}
|
||
</MapContainer>
|
||
|
||
{/* 지도 정보 */}
|
||
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
|
||
<div className="text-xs text-foreground">
|
||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||
<div className="text-xs">국토교통부 공식 지도</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 차량 수 표시 또는 설정 안내 */}
|
||
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
|
||
{vehicles.length > 0 ? (
|
||
<div className="text-xs font-semibold text-foreground">총 {vehicles.length}대 모니터링 중</div>
|
||
) : (
|
||
<div className="text-xs text-foreground">데이터를 연결하세요</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
|
||
{/* {selectedVehicle && routePoints.length > 0 && (
|
||
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
|
||
<div className="flex items-center gap-2">
|
||
<div className="text-xs text-white">
|
||
<div className="font-semibold">🛣️ {selectedVehicle.name} 이동경로</div>
|
||
<div>{routePoints.length}개 포인트</div>
|
||
</div>
|
||
<button
|
||
onClick={clearRoute}
|
||
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
|
||
>
|
||
숨기기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)} */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|