WMS Map React Components (WMS 地圖 React 元件)
Overview
@rytass/wms-map-react-components 提供互動式倉庫空間編輯元件,基於 ReactFlow (XYFlow),支援背景圖、矩形/路徑繪製、多層編輯和歷史管理。
Quick Start
安裝
npm install @rytass/wms-map-react-components @xyflow/react @mezzanine-ui/react
基本使用
import { WMSMapModal, ViewMode } from '@rytass/wms-map-react-components';
function App() { const [isOpen, setIsOpen] = useState(false);
return ( <> <button onClick={() => setIsOpen(true)}>開啟地圖編輯器</button>
<WMSMapModal
open={isOpen}
onClose={() => setIsOpen(false)}
viewMode={ViewMode.EDIT}
onSave={(mapData) => {
console.log('Saved:', mapData);
// 提交到後端
}}
/>
</>
); }
Core Components
WMSMapModal
主要的地圖編輯模態框:
interface WMSMapModalProps { open: boolean; onClose: () => void; viewMode?: ViewMode; // EDIT | VIEW(預設 EDIT) title?: string; // 模態框標題 colorPalette?: string[]; // 區域顏色選項 onNodeClick?: (info: WMSNodeClickInfo) => void; onSave?: (mapData: Map) => void; onBreadcrumbClick?: (warehouseId: string, index: number) => void; onNameChange?: (name: string) => Promise<void>; // 名稱變更回呼 initialNodes?: FlowNode[]; initialEdges?: FlowEdge[]; debugMode?: boolean; maxFileSizeKB?: number; // 背景圖大小限制(預設 30720 = 30MB) warehouseIds?: string[]; // 倉儲 ID 列表(用於 breadcrumb)
// 必要回呼:處理圖片上傳 onUpload: (files: File[]) => Promise<string[]>; // 回傳上傳後的 filename 陣列
// 可選:取得完整圖片 URL getFilenameFQDN?: (filename: string) => Promise<string> | string; }
子元件
元件 說明
WMSMapHeader
工具列與標題
WMSMapContent
畫布內容與編輯控制
Breadcrumb
倉儲層級導航
ContextMenu
右鍵菜單
Toolbar
編輯工具列
節點類型
節點 說明
ImageNode
背景圖節點
RectangleNode
矩形區域節點
PathNode
路徑/多邊形節點
Enums
ViewMode
enum ViewMode { EDIT = 'EDIT', // 編輯模式 VIEW = 'VIEW', // 檢視模式 }
EditMode
enum EditMode { BACKGROUND = 'BACKGROUND', // 背景圖編輯 LAYER = 'LAYER', // 區域層編輯 }
DrawingMode
enum DrawingMode { NONE = 'NONE', // 無繪製 RECTANGLE = 'RECTANGLE', // 矩形繪製 PEN = 'PEN', // 自由繪製/路徑 }
LayerDrawingTool
enum LayerDrawingTool { SELECT = 'SELECT', // 選取工具 RECTANGLE = 'RECTANGLE', // 矩形工具 PEN = 'PEN', // 鋼筆工具 }
MapRangeType
enum MapRangeType { RECTANGLE = 'RECTANGLE', // 矩形區域 POLYGON = 'POLYGON', // 多邊形區域 }
MapRangeColor
enum MapRangeColor { RED = 'RED', YELLOW = 'YELLOW', GREEN = 'GREEN', BLUE = 'BLUE', BLACK = 'BLACK', }
Data Structures
ID 型別別名
export type ID = string;
Map
interface Map { id: ID; backgrounds: MapBackground[]; ranges: MapRange[]; // MapRectangleRange | MapPolygonRange }
interface MapBackground { id: string; filename: string; // 檔案名稱(非完整 URL) width: number; height: number; x: number; y: number; }
interface MapRectangleRange { type: MapRangeType.RECTANGLE; // 'RECTANGLE' id: string; x: number; y: number; width: number; height: number; color: string; }
interface MapPolygonRange { type: MapRangeType.POLYGON; // 'POLYGON' id: string; points: MapPolygonRangePoint[]; color: string; }
interface MapPolygonRangePoint { x: number; y: number; }
WMSNodeClickInfo
type WMSNodeClickInfo = | ImageNodeClickInfo | RectangleNodeClickInfo | PathNodeClickInfo;
// 基礎介面 interface NodeClickInfo { id: string; type: string; position: { x: number; y: number }; zIndex?: number; selected?: boolean; }
interface ImageNodeClickInfo extends NodeClickInfo { type: 'imageNode'; imageData: { filename: string; size: { width: number; height: number }; originalSize: { width: number; height: number }; imageUrl: string; }; mapBackground: MapBackground; // 可直接傳給後端的格式 }
interface RectangleNodeClickInfo extends NodeClickInfo { type: 'rectangleNode'; rectangleData: { color: string; size: { width: number; height: number }; }; mapRectangleRange: MapRectangleRange; // 可直接傳給後端的格式 }
interface PathNodeClickInfo extends NodeClickInfo { type: 'pathNode'; pathData: { color: string; strokeWidth: number; pointCount: number; points: { x: number; y: number }[]; bounds: { minX: number; minY: number; maxX: number; maxY: number; } | null; }; mapPolygonRange: MapPolygonRange; // 可直接傳給後端的格式 }
Utility Functions
資料轉換
import { transformNodeToClickInfo, transformNodesToMapData, transformApiDataToNodes, validateMapData, loadMapDataFromApi, calculatePolygonBounds, calculateNodeZIndex, logMapData, logNodeData, } from '@rytass/wms-map-react-components';
// ReactFlow 節點 → 點擊資訊 const clickInfo = transformNodeToClickInfo(node);
// ReactFlow 節點 → 地圖資料 (後端格式) const mapData = transformNodesToMapData(nodes);
// API 資料 → ReactFlow 節點(支援自訂圖片 URL 產生器)
const nodes = transformApiDataToNodes(apiData);
const nodesWithCustomUrl = transformApiDataToNodes(apiData, (filename) => {
return https://cdn.example.com/images/${filename};
});
// 驗證地圖資料格式 const isValid = validateMapData(mapData);
// 輔助函數:計算多邊形邊界 const bounds = calculatePolygonBounds(points); // 回傳: { minX, maxX, minY, maxY, width, height }
// 輔助函數:計算節點 zIndex const zIndex = calculateNodeZIndex(existingNodes, 'rectangleNode');
// Debug 工具:格式化輸出資料到 console logMapData(mapData); // 輸出完整地圖資料 logNodeData(node); // 輸出單一節點詳細資訊
Complete Example
import { useState, useCallback } from 'react'; import { WMSMapModal, ViewMode, transformNodesToMapData, transformApiDataToNodes, WMSNodeClickInfo, Map, } from '@rytass/wms-map-react-components';
function WarehouseMapEditor() { const [isOpen, setIsOpen] = useState(false); const [mapData, setMapData] = useState<Map | null>(null);
// 從後端載入地圖資料 const loadMapData = async () => { const response = await fetch('/api/warehouse/map'); const data = await response.json(); setMapData(data); };
// 上傳圖片到後端(必要回呼) const handleUpload = useCallback(async (files: File[]): Promise<string[]> => { const formData = new FormData(); files.forEach((file) => formData.append('files', file));
const response = await fetch('/api/warehouse/upload', {
method: 'POST',
body: formData,
});
const { filenames } = await response.json();
return filenames; // 回傳 filename 陣列
}, []);
// 取得圖片完整 URL(可選)
const getFilenameFQDN = useCallback((filename: string) => {
return https://cdn.example.com/warehouse-images/${filename};
}, []);
// 儲存地圖資料到後端 const handleSave = useCallback(async (data: Map) => { await fetch('/api/warehouse/map', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); setMapData(data); setIsOpen(false); }, []);
// 處理區域點擊 const handleNodeClick = useCallback((info: WMSNodeClickInfo) => { if (info.type === 'rectangleNode') { // 取得後端格式的資料 console.log('Clicked zone:', info.mapRectangleRange); // 可以導航到該區域的庫存頁面 } }, []);
return ( <div> <button onClick={() => { loadMapData(); setIsOpen(true); }}> 編輯倉儲地圖 </button>
<WMSMapModal
open={isOpen}
onClose={() => setIsOpen(false)}
viewMode={ViewMode.EDIT}
title="倉庫 A 地圖"
colorPalette={[
'#FF6B6B', // 紅 - 危險品區
'#4ECDC4', // 青 - 冷藏區
'#45B7D1', // 藍 - 一般存放區
'#96CEB4', // 綠 - 出貨區
'#FFEAA7', // 黃 - 暫存區
]}
initialNodes={mapData ? transformApiDataToNodes(mapData, getFilenameFQDN) : undefined}
onSave={handleSave}
onNodeClick={handleNodeClick}
onUpload={handleUpload}
getFilenameFQDN={getFilenameFQDN}
maxFileSizeKB={30720} // 30MB(預設值)
warehouseIds={['10001', '10001A']}
/>
</div>
); }
// 唯讀檢視元件(VIEW 模式不需要 onUpload) function WarehouseMapViewer({ mapData }: { mapData: Map }) { const [selectedZone, setSelectedZone] = useState<string | null>(null);
const getFilenameFQDN = (filename: string) =>
https://cdn.example.com/warehouse-images/${filename};
return ( <WMSMapModal open={true} onClose={() => {}} viewMode={ViewMode.VIEW} initialNodes={transformApiDataToNodes(mapData, getFilenameFQDN)} onUpload={async () => []} // VIEW 模式下可用空實作 onNodeClick={(info) => { if (info.type === 'rectangleNode') { setSelectedZone(info.id); } }} /> ); }
多層級導航
function WarehouseNavigator() { const [breadcrumb, setBreadcrumb] = useState([ { id: 'warehouse-1', name: '主倉庫' }, ]);
const handleBreadcrumbClick = (warehouseId: string, index: number) => { // 導航到特定層級 setBreadcrumb(breadcrumb.slice(0, index + 1)); loadWarehouseMap(warehouseId); };
const handleZoneClick = (info: WMSNodeClickInfo) => {
if (info.type === 'rectangleNode') {
// 進入子區域(需自行維護 zone 名稱對應)
setBreadcrumb([...breadcrumb, { id: info.id, name: Zone ${info.id} }]);
loadWarehouseMap(info.id);
}
};
return ( <WMSMapModal open={true} onClose={() => {}} viewMode={ViewMode.VIEW} onBreadcrumbClick={handleBreadcrumbClick} onNodeClick={handleZoneClick} /> ); }
Constants
注意: Constants 目前未從主入口導出,若需使用請直接從 constants 路徑導入:
import { UI_CONFIG, CANVAS_CONFIG } from '@rytass/wms-map-react-components/constants';
// 預設尺寸 DEFAULT_IMAGE_WIDTH // 300 DEFAULT_IMAGE_HEIGHT // 200 DEFAULT_RECTANGLE_WIDTH // 150 DEFAULT_RECTANGLE_HEIGHT // 100
// 顏色 DEFAULT_RECTANGLE_COLOR // '#3b82f6' SELECTION_BORDER_COLOR // '#3b82f6' DEFAULT_BACKGROUND_TOOL_COLOR // '#3b82f6'
// 最小尺寸 MIN_RECTANGLE_SIZE // 10 MIN_RESIZE_WIDTH // 50 MIN_RESIZE_HEIGHT // 30
// 文字標籤 DEFAULT_RECTANGLE_LABEL // '矩形區域' DEFAULT_PATH_LABEL // '路徑區域'
// 透明度 ACTIVE_OPACITY // 1 INACTIVE_OPACITY // 0.4 RECTANGLE_INACTIVE_OPACITY // 0.6
// 調整大小控制項尺寸 RESIZE_CONTROL_SIZE // 16 IMAGE_RESIZE_CONTROL_SIZE // 20
// UI 配置 UI_CONFIG.HISTORY_SIZE // 50 (Undo/Redo 歷史大小) UI_CONFIG.COLOR_CHANGE_DELAY // 800ms UI_CONFIG.STAGGER_DELAY // 100ms (圖片上傳間隔) UI_CONFIG.NODE_SAVE_DELAY // 50ms
// Canvas 配置 CANVAS_CONFIG.MIN_ZOOM // 0.1 CANVAS_CONFIG.MAX_ZOOM // 4 CANVAS_CONFIG.DEFAULT_VIEWPORT // { x: 0, y: 0, zoom: 1 } CANVAS_CONFIG.BACKGROUND_COLOR // '#F5F5F5'
// 檔案上傳配置 UPLOAD_CONFIG.DEFAULT_MAX_FILE_SIZE_KB // 30720 (30MB) UPLOAD_CONFIG.SUPPORTED_MIME_TYPES // ['image/png', 'image/jpeg', ...]
// 支援的圖片類型 SUPPORTED_IMAGE_TYPES.ACCEPT // 'image/png,image/jpeg,image/jpg' SUPPORTED_IMAGE_TYPES.PATTERN // /^image/(png|jpeg|jpg)$/ SUPPORTED_IMAGE_TYPES.EXTENSIONS // ['png', 'jpg', 'jpeg']
// 預設倉庫 ID DEFAULT_WAREHOUSE_IDS // ['10001', '10001A', '10002', '100002B', '100003', '100003B']
Dependencies
Required:
- @xyflow/react ^12.10.0
Peer Dependencies:
-
@mezzanine-ui/react
-
@mezzanine-ui/react-hook-form-v2
-
react , react-dom
-
react-hook-form
Troubleshooting
地圖無法顯示
確保 ReactFlow Provider 正確包裝:
// WMSMapModal 內部已包含 ReactFlowProvider // 不需要額外包裝
背景圖上傳失敗
檢查 maxFileSizeKB 設定,預設為 30720KB (30MB)。
節點無法拖曳
確認 viewMode 設為 ViewMode.EDIT 。
React Hooks
@rytass/wms-map-react-components 提供以下內部 Hooks,用於地圖編輯器的核心功能。
注意: 這些 Hooks 目前為內部使用,未在主入口匯出。若需使用,請直接從相應檔案導入:
import { useContextMenu } from '@rytass/wms-map-react-components/hooks/use-context-menu';
useContextMenu
管理節點的右鍵菜單與圖層排列操作。
interface UseContextMenuProps { id: string; // 節點 ID editMode: EditMode; // 當前編輯模式 isEditable: boolean; // 是否可編輯 nodeType?: 'rectangleNode' | 'pathNode' | 'imageNode'; }
interface UseContextMenuReturn { contextMenu: { visible: boolean; x: number; y: number }; handleContextMenu: (event: React.MouseEvent) => void; handleCloseContextMenu: () => void; handleDelete: () => void; arrangeActions: { onBringToFront: () => void; // 移至最上層 onBringForward: () => void; // 上移一層 onSendBackward: () => void; // 下移一層 onSendToBack: () => void; // 移至最下層 }; arrangeStates: { canBringToFront: boolean; canBringForward: boolean; canSendBackward: boolean; canSendToBack: boolean; }; getNodes: () => Node[]; setNodes: (nodes: Node[] | ((nodes: Node[]) => Node[])) => void; }
const { contextMenu, handleContextMenu, arrangeActions } = useContextMenu({ id: nodeId, editMode: EditMode.LAYER, isEditable: true, nodeType: 'rectangleNode', });
useDirectStateHistory
直接狀態歷史系統,支援 Undo/Redo 功能。
interface UseDirectStateHistoryOptions { maxHistorySize?: number; // 預設 50 debugMode?: boolean; // 預設 false }
interface UseDirectStateHistoryReturn { saveState: (nodes: FlowNode[], edges: FlowEdge[], operation: string, editMode: EditMode) => void; undo: () => { nodes: FlowNode[]; edges: FlowEdge[]; editMode: EditMode } | null; redo: () => { nodes: FlowNode[]; edges: FlowEdge[]; editMode: EditMode } | null; canUndo: boolean; canRedo: boolean; initializeHistory: (initialNodes: FlowNode[], initialEdges: FlowEdge[], editMode: EditMode) => void; clearHistory: () => void; getHistorySummary: () => HistorySummary; history?: HistoryState[]; // debugMode 啟用時可用 currentIndex?: number; // debugMode 啟用時可用 }
const { saveState, undo, redo, canUndo, canRedo, initializeHistory, } = useDirectStateHistory({ maxHistorySize: 100, debugMode: false });
// 初始化 initializeHistory(nodes, edges, EditMode.LAYER);
// 保存操作後狀態 saveState(nodes, edges, 'add-rectangle', EditMode.LAYER);
// 執行 Undo const prevState = undo(); if (prevState) { setNodes(prevState.nodes); setEdges(prevState.edges); }
usePenDrawing
鋼筆工具繪製多邊形/路徑區域。
interface UsePenDrawingProps { editMode: EditMode; drawingMode: DrawingMode; onCreatePath: (points: { x: number; y: number }[]) => void; }
interface UsePenDrawingReturn { containerRef: React.RefObject<HTMLDivElement | null>; // 綁定到容器 isDrawing: boolean; // 是否正在繪製 previewPath: { x: number; y: number }[] | null; // 預覽路徑 currentPoints: { x: number; y: number }[]; // 已繪製的點 firstPoint: { x: number; y: number } | null; // 第一個點(閉合提示用) canClose: boolean; // 是否可閉合(>=3 點) forceComplete: () => void; // 強制完成繪製 }
const { containerRef, isDrawing, previewPath, canClose, } = usePenDrawing({ editMode: EditMode.LAYER, drawingMode: DrawingMode.PEN, onCreatePath: (points) => { // 建立路徑節點 createPathNode(points); }, });
// 操作方式: // - 單擊:添加點 // - 雙擊:完成並閉合路徑 // - 點擊第一個點:閉合路徑 // - Enter:完成並閉合路徑 // - Escape:取消繪製 // - Shift + 點擊:約束至 45 度角
useRectangleDrawing
矩形區域繪製工具。
interface UseRectangleDrawingProps { editMode: EditMode; drawingMode: DrawingMode; onCreateRectangle: (startX: number, startY: number, endX: number, endY: number) => void; }
interface UseRectangleDrawingReturn { containerRef: React.RefObject<HTMLDivElement | null>; isDrawing: boolean; previewRect: { x: number; y: number; width: number; height: number; } | null; }
const { containerRef, isDrawing, previewRect, } = useRectangleDrawing({ editMode: EditMode.LAYER, drawingMode: DrawingMode.RECTANGLE, onCreateRectangle: (x1, y1, x2, y2) => { createRectangleNode(x1, y1, x2, y2); }, });
// 操作方式: // - 按住滑鼠拖曳建立矩形 // - 放開滑鼠完成建立 // - 最小尺寸限制:MIN_RECTANGLE_SIZE
useTextEditing
節點文字標籤編輯功能。
interface UseTextEditingProps { id: string; // 節點 ID label: string; // 當前標籤文字 isEditable: boolean; // 是否可編輯 onTextEditComplete?: (id: string, oldText: string, newText: string) => void; }
interface UseTextEditingReturn { isEditing: boolean; editingText: string; inputRef: React.RefObject<HTMLInputElement | null>; setEditingText: React.Dispatch<React.SetStateAction<string>>; handleDoubleClick: (event: React.MouseEvent) => void; // 雙擊開始編輯 handleKeyDown: (event: React.KeyboardEvent) => void; // 鍵盤事件處理 handleBlur: () => void; // 失焦保存 updateNodeData: (updates: Record<string, unknown>) => void; }
const {
isEditing,
editingText,
inputRef,
setEditingText,
handleDoubleClick,
handleKeyDown,
handleBlur,
} = useTextEditing({
id: nodeId,
label: 'Zone A',
isEditable: true,
onTextEditComplete: (id, oldText, newText) => {
console.log(節點 ${id} 標籤從 "${oldText}" 改為 "${newText}");
saveState(nodes, edges, 'edit-label', editMode);
},
});
// 操作方式: // - 雙擊節點:開始編輯 // - Enter:確認保存 // - Escape:取消編輯 // - 點擊外部:自動保存
Hooks 使用注意事項
-
必須在 ReactFlowProvider 內使用:所有 hooks 依賴 useReactFlow
-
內部使用為主:這些 hooks 主要供 WMS 元件內部使用
-
歷史記錄整合:繪製與編輯操作應搭配 useDirectStateHistory 記錄
-
模式檢查:各 hooks 會檢查 editMode 和 drawingMode 狀態