ERP-node/frontend/lib/registry/components/map/MapComponent.tsx

286 lines
9.0 KiB
TypeScript
Raw Normal View History

"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='&copy; <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>
);
}