258 lines
9.3 KiB
TypeScript
258 lines
9.3 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 });
|
||
|
||
// 브이월드 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";
|
||
speed: number;
|
||
destination: string;
|
||
}
|
||
|
||
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 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 형식으로 변환
|
||
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
|
||
id: row.id || row.vehicle_number || `V${index + 1}`,
|
||
name: row[labelCol] || `차량 ${index + 1}`,
|
||
driver: row.driver_name || row.driver || "미배정",
|
||
lat: parseFloat(row[latCol]),
|
||
lng: parseFloat(row[lngCol]),
|
||
status:
|
||
row[statusCol] === "warning"
|
||
? "warning"
|
||
: row[statusCol] === "active"
|
||
? "active"
|
||
: row[statusCol] === "maintenance"
|
||
? "maintenance"
|
||
: "inactive",
|
||
speed: parseFloat(row.speed) || 0,
|
||
destination: row.destination || "대기 중",
|
||
}));
|
||
|
||
setVehicles(vehiclesFromDB);
|
||
setLastUpdate(new Date());
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("차량 데이터 로드 실패:", error);
|
||
}
|
||
|
||
setIsLoading(false);
|
||
};
|
||
|
||
// 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}
|
||
/>
|
||
|
||
{/* 차량 마커 */}
|
||
{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>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|