차량위치 위젯 기존꺼 분할 완료

This commit is contained in:
leeheejin
2025-10-15 10:29:15 +09:00
parent 9599d34ba9
commit 36aec28708
21 changed files with 2346 additions and 640 deletions

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

View File

@@ -0,0 +1,439 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { RefreshCw } from "lucide-react";
interface MapConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
interface DbConnection {
id: number;
name: string;
db_type: string;
}
interface TableInfo {
table_name: string;
}
interface ColumnInfo {
column_name: string;
data_type: string;
}
export default function MapConfigPanel({ config, onChange }: MapConfigPanelProps) {
const [connections, setConnections] = useState<DbConnection[]>([]);
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
// DB 연결 목록 로드
useEffect(() => {
loadConnections();
}, []);
// 테이블 목록 로드
useEffect(() => {
if (config.dataSource?.type === "external" && config.dataSource?.connectionId) {
loadTables(config.dataSource.connectionId);
} else if (config.dataSource?.type === "internal") {
loadInternalTables();
}
}, [config.dataSource?.type, config.dataSource?.connectionId]);
// 컬럼 목록 로드
useEffect(() => {
if (config.dataSource?.tableName) {
if (config.dataSource.type === "external" && config.dataSource.connectionId) {
loadColumns(config.dataSource.connectionId, config.dataSource.tableName);
} else if (config.dataSource.type === "internal") {
loadInternalColumns(config.dataSource.tableName);
}
}
}, [config.dataSource?.tableName]);
const loadConnections = async () => {
setIsLoadingConnections(true);
try {
const response = await fetch("/api/external-db-connections");
const data = await response.json();
if (data.success) {
setConnections(data.data || []);
}
} catch (error) {
console.error("DB 연결 목록 로드 실패:", error);
} finally {
setIsLoadingConnections(false);
}
};
const loadTables = async (connectionId: number) => {
setIsLoadingTables(true);
try {
const response = await fetch(`/api/external-db-connections/${connectionId}/tables`);
const data = await response.json();
if (data.success) {
setTables(data.data || []);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
const loadInternalTables = async () => {
setIsLoadingTables(true);
try {
const response = await fetch("/api/table-management/tables");
const data = await response.json();
if (data.success) {
setTables(data.data.map((t: any) => ({ table_name: t.tableName })) || []);
}
} catch (error) {
console.error("내부 테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
const loadColumns = async (connectionId: number, tableName: string) => {
setIsLoadingColumns(true);
try {
const response = await fetch(
`/api/external-db-connections/${connectionId}/tables/${encodeURIComponent(tableName)}/columns`
);
const data = await response.json();
if (data.success) {
setColumns(data.data || []);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setIsLoadingColumns(false);
}
};
const loadInternalColumns = async (tableName: string) => {
setIsLoadingColumns(true);
try {
const response = await fetch(`/api/table-management/tables/${encodeURIComponent(tableName)}/columns`);
const data = await response.json();
if (data.success) {
setColumns(data.data.map((c: any) => ({ column_name: c.columnName, data_type: c.dataType })) || []);
}
} catch (error) {
console.error("내부 컬럼 목록 로드 실패:", error);
} finally {
setIsLoadingColumns(false);
}
};
const updateConfig = (path: string, value: any) => {
const keys = path.split(".");
const newConfig = { ...config };
let current: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
onChange(newConfig);
};
return (
<div className="space-y-4 p-4">
<div>
<h3 className="text-sm font-semibold mb-3">📊 </h3>
{/* DB 타입 선택 */}
<div className="space-y-2 mb-3">
<Label>DB </Label>
<Select
value={config.dataSource?.type || "internal"}
onValueChange={(value) => {
updateConfig("dataSource.type", value);
updateConfig("dataSource.tableName", "");
updateConfig("dataSource.connectionId", null);
setTables([]);
setColumns([]);
}}
>
<SelectTrigger>
<SelectValue placeholder="DB 타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> DB (PostgreSQL)</SelectItem>
<SelectItem value="external"> DB </SelectItem>
</SelectContent>
</Select>
</div>
{/* 외부 DB 연결 선택 */}
{config.dataSource?.type === "external" && (
<div className="space-y-2 mb-3">
<Label> DB </Label>
<div className="flex gap-2">
<Select
value={config.dataSource?.connectionId?.toString() || ""}
onValueChange={(value) => {
updateConfig("dataSource.connectionId", parseInt(value));
updateConfig("dataSource.tableName", "");
setTables([]);
setColumns([]);
}}
disabled={isLoadingConnections}
>
<SelectTrigger>
<SelectValue placeholder="DB 연결 선택" />
</SelectTrigger>
<SelectContent>
{connections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.db_type})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={loadConnections}
size="icon"
variant="outline"
disabled={isLoadingConnections}
>
<RefreshCw className={`h-4 w-4 ${isLoadingConnections ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
)}
{/* 테이블 선택 */}
<div className="space-y-2 mb-3">
<Label></Label>
<div className="flex gap-2">
<Select
value={config.dataSource?.tableName || ""}
onValueChange={(value) => {
updateConfig("dataSource.tableName", value);
setColumns([]);
}}
disabled={
isLoadingTables ||
(config.dataSource?.type === "external" && !config.dataSource?.connectionId)
}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
if (config.dataSource?.type === "external" && config.dataSource?.connectionId) {
loadTables(config.dataSource.connectionId);
} else if (config.dataSource?.type === "internal") {
loadInternalTables();
}
}}
size="icon"
variant="outline"
disabled={isLoadingTables}
>
<RefreshCw className={`h-4 w-4 ${isLoadingTables ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 위도 컬럼 */}
<div className="space-y-2 mb-3">
<Label> *</Label>
<Select
value={config.dataSource?.latColumn || ""}
onValueChange={(value) => updateConfig("dataSource.latColumn", value)}
disabled={isLoadingColumns || !config.dataSource?.tableName}
>
<SelectTrigger>
<SelectValue placeholder="위도 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} ({col.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 경도 컬럼 */}
<div className="space-y-2 mb-3">
<Label> *</Label>
<Select
value={config.dataSource?.lngColumn || ""}
onValueChange={(value) => updateConfig("dataSource.lngColumn", value)}
disabled={isLoadingColumns || !config.dataSource?.tableName}
>
<SelectTrigger>
<SelectValue placeholder="경도 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} ({col.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 라벨 컬럼 (선택) */}
<div className="space-y-2 mb-3">
<Label> ()</Label>
<Select
value={config.dataSource?.labelColumn || ""}
onValueChange={(value) => updateConfig("dataSource.labelColumn", value)}
disabled={isLoadingColumns || !config.dataSource?.tableName}
>
<SelectTrigger>
<SelectValue placeholder="라벨 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} ({col.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 컬럼 (선택) */}
<div className="space-y-2 mb-3">
<Label> ()</Label>
<Select
value={config.dataSource?.statusColumn || ""}
onValueChange={(value) => updateConfig("dataSource.statusColumn", value)}
disabled={isLoadingColumns || !config.dataSource?.tableName}
>
<SelectTrigger>
<SelectValue placeholder="상태 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} ({col.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* WHERE 조건 (선택) */}
<div className="space-y-2 mb-3">
<Label>WHERE ()</Label>
<Textarea
value={config.dataSource?.whereClause || ""}
onChange={(e) => updateConfig("dataSource.whereClause", e.target.value)}
placeholder="예: status = 'active' AND city = 'Seoul'"
rows={2}
className="font-mono text-sm"
/>
<p className="text-xs text-gray-500">SQL WHERE (WHERE )</p>
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-semibold mb-3">🗺 </h3>
{/* 중심 좌표 */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
step="0.0001"
value={config.mapConfig?.center?.lat || 36.5}
onChange={(e) =>
updateConfig("mapConfig.center.lat", parseFloat(e.target.value) || 36.5)
}
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
step="0.0001"
value={config.mapConfig?.center?.lng || 127.5}
onChange={(e) =>
updateConfig("mapConfig.center.lng", parseFloat(e.target.value) || 127.5)
}
/>
</div>
</div>
{/* 줌 레벨 */}
<div className="space-y-2 mb-3">
<Label> </Label>
<Input
type="number"
min="1"
max="18"
value={config.mapConfig?.zoom || 7}
onChange={(e) => updateConfig("mapConfig.zoom", parseInt(e.target.value) || 7)}
/>
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-semibold mb-3">🔄 </h3>
<div className="space-y-2">
<Label> ()</Label>
<Input
type="number"
min="0"
step="1"
value={(config.refreshInterval || 0) / 1000}
onChange={(e) =>
updateConfig("refreshInterval", parseInt(e.target.value) * 1000 || 0)
}
/>
<p className="text-xs text-gray-500">0 </p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import React from "react";
import { Map } from "lucide-react";
interface MapPreviewComponentProps {
component: {
config?: {
dataSource?: {
tableName?: string;
};
};
};
}
export default function MapPreviewComponent({ component }: MapPreviewComponentProps) {
const tableName = component.config?.dataSource?.tableName;
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-dashed border-blue-300">
<div className="text-center">
<Map className="mx-auto h-12 w-12 text-blue-600" />
<p className="mt-2 text-sm font-medium text-blue-900"> </p>
{tableName && (
<p className="mt-1 text-xs text-blue-600">: {tableName}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ComponentCategory } from "@/types/component";
import MapComponent from "./MapComponent";
import MapPreviewComponent from "./MapPreviewComponent";
import MapConfigPanel from "./MapConfigPanel";
/**
* Map 컴포넌트 렌더러 (자동 등록)
*/
export class MapRenderer extends AutoRegisteringComponentRenderer {
static definition = {
id: "map",
name: "지도",
nameEng: "Map Component",
description: "외부/내부 DB 데이터를 지도에 마커로 표시합니다 (위도/경도 필요)",
category: ComponentCategory.DISPLAY,
webType: "text" as const,
component: MapComponent,
previewComponent: MapPreviewComponent,
defaultConfig: {
// 데이터 소스 설정
dataSource: {
type: "internal", // "internal" | "external"
connectionId: null, // 외부 DB 연결 ID
tableName: "",
latColumn: "latitude", // 위도 컬럼명
lngColumn: "longitude", // 경도 컬럼명
labelColumn: "", // 마커 라벨 컬럼명
statusColumn: "", // 상태 컬럼명 (마커 색상용)
additionalColumns: [], // 추가 표시할 컬럼들
whereClause: "", // WHERE 조건절
},
// 지도 설정
mapConfig: {
center: {
lat: 36.5,
lng: 127.5,
},
zoom: 7,
minZoom: 5,
maxZoom: 18,
},
// 마커 설정
markerConfig: {
showLabel: true,
showPopup: true,
clusterMarkers: false, // 마커 클러스터링
statusColors: {
default: "#3b82f6", // 기본 파란색
active: "#22c55e", // 활성 녹색
inactive: "#94a3b8", // 비활성 회색
warning: "#f59e0b", // 경고 주황색
danger: "#ef4444", // 위험 빨간색
},
},
// 새로고침 설정
refreshInterval: 30000, // 30초 (0이면 자동 새로고침 없음)
},
defaultSize: { width: 800, height: 600 },
configPanel: MapConfigPanel,
icon: "Map",
tags: ["map", "location", "gps", "marker", "leaflet"],
version: "1.0.0",
author: "개발팀",
documentation:
"외부/내부 DB 데이터를 지도에 표시하는 컴포넌트입니다. 위도/경도 컬럼이 있는 테이블이면 어떤 데이터든 지도에 마커로 표시할 수 있습니다.",
};
}
// 자동 등록 실행
new MapRenderer();

View File

@@ -0,0 +1,59 @@
import { ComponentConfig } from "../../types";
export const mapComponent: ComponentConfig = {
type: "map",
name: "지도",
icon: "Map",
category: "data-display",
description: "DB 데이터를 지도에 마커로 표시합니다 (위도/경도 필요)",
defaultSize: {
width: 800,
height: 600,
},
defaultConfig: {
// 데이터 소스 설정
dataSource: {
type: "internal", // "internal" | "external"
connectionId: null, // 외부 DB 연결 ID
tableName: "",
latColumn: "latitude", // 위도 컬럼명
lngColumn: "longitude", // 경도 컬럼명
labelColumn: "", // 마커 라벨 컬럼명
statusColumn: "", // 상태 컬럼명 (마커 색상용)
additionalColumns: [], // 추가 표시할 컬럼들
whereClause: "", // WHERE 조건절
},
// 지도 설정
mapConfig: {
center: {
lat: 36.5,
lng: 127.5,
},
zoom: 7,
minZoom: 5,
maxZoom: 18,
},
// 마커 설정
markerConfig: {
showLabel: true,
showPopup: true,
clusterMarkers: false, // 마커 클러스터링
statusColors: {
default: "#3b82f6", // 기본 파란색
active: "#22c55e", // 활성 녹색
inactive: "#94a3b8", // 비활성 회색
warning: "#f59e0b", // 경고 주황색
danger: "#ef4444", // 위험 빨간색
},
},
// 새로고침 설정
refreshInterval: 30000, // 30초 (0이면 자동 새로고침 없음)
},
component: () => import("./MapComponent"),
previewComponent: () => import("./MapPreviewComponent"),
configPanel: () => import("./MapConfigPanel"),
};