286 lines
9.0 KiB
TypeScript
286 lines
9.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|
|
|