| name | wms-react-components |
| description | WMS map React components (WMS 地圖 React 元件). Use when building warehouse map editors (倉儲地圖編輯器), interactive floor plans, location visualization, or spatial management interfaces. Based on ReactFlow/XYFlow. Keywords: WMS, 倉儲地圖, warehouse map, floor plan, ReactFlow, XYFlow, 地圖編輯, spatial, location
|
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;
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;
warehouseIds?: string[];
onUpload: (files: File[]) => Promise<string[]>;
getFilenameFQDN?: (filename: string) => Promise<string> | string;
}
子元件
| 元件 | 說明 |
|---|
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[];
}
interface MapBackground {
id: string;
filename: string;
width: number;
height: number;
x: number;
y: number;
}
interface MapRectangleRange {
type: MapRangeType.RECTANGLE;
id: string;
x: number;
y: number;
width: number;
height: number;
color: string;
}
interface MapPolygonRange {
type: MapRangeType.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';
const clickInfo = transformNodeToClickInfo(node);
const mapData = transformNodesToMapData(nodes);
const nodes = transformApiDataToNodes(apiData);
const nodesWithCustomUrl = transformApiDataToNodes(apiData, filename => {
return `https://cdn.example.com/images/${filename}`;
});
const isValid = validateMapData(mapData);
const bounds = calculatePolygonBounds(points);
const zIndex = calculateNodeZIndex(existingNodes, 'rectangleNode');
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;
}, []);
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>
);
}
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') {
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;
DEFAULT_IMAGE_HEIGHT;
DEFAULT_RECTANGLE_WIDTH;
DEFAULT_RECTANGLE_HEIGHT;
DEFAULT_RECTANGLE_COLOR;
SELECTION_BORDER_COLOR;
DEFAULT_BACKGROUND_TOOL_COLOR;
MIN_RECTANGLE_SIZE;
MIN_RESIZE_WIDTH;
MIN_RESIZE_HEIGHT;
DEFAULT_RECTANGLE_LABEL;
DEFAULT_PATH_LABEL;
ACTIVE_OPACITY;
INACTIVE_OPACITY;
RECTANGLE_INACTIVE_OPACITY;
RESIZE_CONTROL_SIZE;
IMAGE_RESIZE_CONTROL_SIZE;
UI_CONFIG.HISTORY_SIZE;
UI_CONFIG.COLOR_CHANGE_DELAY;
UI_CONFIG.STAGGER_DELAY;
UI_CONFIG.NODE_SAVE_DELAY;
CANVAS_CONFIG.MIN_ZOOM;
CANVAS_CONFIG.MAX_ZOOM;
CANVAS_CONFIG.DEFAULT_VIEWPORT;
CANVAS_CONFIG.BACKGROUND_COLOR;
UPLOAD_CONFIG.DEFAULT_MAX_FILE_SIZE_KB;
UPLOAD_CONFIG.SUPPORTED_MIME_TYPES;
SUPPORTED_IMAGE_TYPES.ACCEPT;
SUPPORTED_IMAGE_TYPES.PATTERN;
SUPPORTED_IMAGE_TYPES.EXTENSIONS;
DEFAULT_WAREHOUSE_IDS;
Dependencies
Required:
Peer Dependencies:
@mezzanine-ui/react
react, react-dom
react-hook-form
Troubleshooting
地圖無法顯示
確保 ReactFlow Provider 正確包裝:
背景圖上傳失敗
檢查 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;
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;
debugMode?: boolean;
}
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[];
currentIndex?: number;
}
const { saveState, undo, redo, canUndo, canRedo, initializeHistory } = useDirectStateHistory({
maxHistorySize: 100,
debugMode: false,
});
initializeHistory(nodes, edges, EditMode.LAYER);
saveState(nodes, edges, 'add-rectangle', EditMode.LAYER);
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;
forceComplete: () => void;
}
const { containerRef, isDrawing, previewPath, canClose } = usePenDrawing({
editMode: EditMode.LAYER,
drawingMode: DrawingMode.PEN,
onCreatePath: points => {
createPathNode(points);
},
});
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);
},
});
useTextEditing
節點文字標籤編輯功能。
interface UseTextEditingProps {
id: string;
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);
},
});
Hooks 使用注意事項
- 必須在 ReactFlowProvider 內使用:所有 hooks 依賴
useReactFlow
- 內部使用為主:這些 hooks 主要供 WMS 元件內部使用
- 歷史記錄整合:繪製與編輯操作應搭配
useDirectStateHistory 記錄
- 模式檢查:各 hooks 會檢查
editMode 和 drawingMode 狀態