차량위치 위젯 기존꺼 분할 완료
This commit is contained in:
285
frontend/lib/registry/components/map/MapComponent.tsx
Normal file
285
frontend/lib/registry/components/map/MapComponent.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 }
|
||||
);
|
||||
|
||||
interface MapMarker {
|
||||
id: string | number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
status?: string;
|
||||
additionalInfo?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface MapComponentProps {
|
||||
component: {
|
||||
id: string;
|
||||
config?: {
|
||||
dataSource?: {
|
||||
type?: "internal" | "external";
|
||||
connectionId?: number | null;
|
||||
tableName?: string;
|
||||
latColumn?: string;
|
||||
lngColumn?: string;
|
||||
labelColumn?: string;
|
||||
statusColumn?: string;
|
||||
additionalColumns?: string[];
|
||||
whereClause?: string;
|
||||
};
|
||||
mapConfig?: {
|
||||
center?: { lat: number; lng: number };
|
||||
zoom?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
};
|
||||
markerConfig?: {
|
||||
showLabel?: boolean;
|
||||
showPopup?: boolean;
|
||||
statusColors?: Record<string, string>;
|
||||
};
|
||||
refreshInterval?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function MapComponent({ component }: MapComponentProps) {
|
||||
const [markers, setMarkers] = useState<MapMarker[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
const dataSource = component.config?.dataSource;
|
||||
const mapConfig = component.config?.mapConfig;
|
||||
const markerConfig = component.config?.markerConfig;
|
||||
const refreshInterval = component.config?.refreshInterval || 0;
|
||||
|
||||
// 데이터 로드
|
||||
const loadMapData = async () => {
|
||||
if (!dataSource?.tableName || !dataSource?.latColumn || !dataSource?.lngColumn) {
|
||||
setError("테이블명, 위도 컬럼, 경도 컬럼을 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// API URL 구성
|
||||
const isExternal = dataSource.type === "external" && dataSource.connectionId;
|
||||
const baseUrl = isExternal
|
||||
? `/api/map-data/external/${dataSource.connectionId}`
|
||||
: `/api/map-data/internal`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
tableName: dataSource.tableName,
|
||||
latColumn: dataSource.latColumn,
|
||||
lngColumn: dataSource.lngColumn,
|
||||
});
|
||||
|
||||
if (dataSource.labelColumn) {
|
||||
params.append("labelColumn", dataSource.labelColumn);
|
||||
}
|
||||
if (dataSource.statusColumn) {
|
||||
params.append("statusColumn", dataSource.statusColumn);
|
||||
}
|
||||
if (dataSource.additionalColumns && dataSource.additionalColumns.length > 0) {
|
||||
params.append("additionalColumns", dataSource.additionalColumns.join(","));
|
||||
}
|
||||
if (dataSource.whereClause) {
|
||||
params.append("whereClause", dataSource.whereClause);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "데이터 조회 실패");
|
||||
}
|
||||
|
||||
setMarkers(result.data.markers || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("지도 데이터 로드 오류:", err);
|
||||
setError(err.message || "데이터를 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadMapData();
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadMapData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
dataSource?.type,
|
||||
dataSource?.connectionId,
|
||||
dataSource?.tableName,
|
||||
dataSource?.latColumn,
|
||||
dataSource?.lngColumn,
|
||||
dataSource?.whereClause,
|
||||
refreshInterval,
|
||||
]);
|
||||
|
||||
// 마커 색상 가져오기
|
||||
const getMarkerColor = (status?: string): string => {
|
||||
if (!status || !markerConfig?.statusColors) {
|
||||
return markerConfig?.statusColors?.default || "#3b82f6";
|
||||
}
|
||||
return markerConfig.statusColors[status] || markerConfig.statusColors.default || "#3b82f6";
|
||||
};
|
||||
|
||||
// 커스텀 마커 아이콘 생성
|
||||
const createMarkerIcon = (status?: string) => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
const L = require("leaflet");
|
||||
const color = getMarkerColor(status);
|
||||
|
||||
return new L.Icon({
|
||||
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="41" viewBox="0 0 25 41">
|
||||
<path d="M12.5 0C5.6 0 0 5.6 0 12.5c0 8.4 12.5 28.5 12.5 28.5S25 20.9 25 12.5C25 5.6 19.4 0 12.5 0z" fill="${color}"/>
|
||||
<circle cx="12.5" cy="12.5" r="6" fill="white"/>
|
||||
</svg>
|
||||
`)}`,
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [0, -41],
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
<Button onClick={loadMapData} className="mt-4" size="sm">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 지도 */}
|
||||
{typeof window !== "undefined" && (
|
||||
<MapContainer
|
||||
center={[
|
||||
mapConfig?.center?.lat || 36.5,
|
||||
mapConfig?.center?.lng || 127.5,
|
||||
]}
|
||||
zoom={mapConfig?.zoom || 7}
|
||||
minZoom={mapConfig?.minZoom || 5}
|
||||
maxZoom={mapConfig?.maxZoom || 18}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.latitude, marker.longitude]}
|
||||
icon={createMarkerIcon(marker.status)}
|
||||
>
|
||||
{markerConfig?.showPopup !== false && (
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
{marker.label && (
|
||||
<div className="mb-2 font-bold text-base">{marker.label}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<strong>위도:</strong> {marker.latitude.toFixed(6)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>경도:</strong> {marker.longitude.toFixed(6)}
|
||||
</div>
|
||||
{marker.status && (
|
||||
<div>
|
||||
<strong>상태:</strong> {marker.status}
|
||||
</div>
|
||||
)}
|
||||
{marker.additionalInfo &&
|
||||
Object.entries(marker.additionalInfo).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)}
|
||||
|
||||
{/* 상단 정보 바 */}
|
||||
<div className="absolute top-2 right-2 z-[1000] flex items-center gap-2 rounded-lg bg-white/90 backdrop-blur-sm px-3 py-2 shadow-lg">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
마커: {markers.length}개
|
||||
</span>
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={loadMapData}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user