WMS map React components. 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, map editing, spatial, location
@rytass/wms-map-react-components 提供互動式倉庫空間編輯元件,基於 ReactFlow (XYFlow),支援背景圖、矩形/路徑繪製、多層編輯和歷史管理。
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);
// 提交到後端
}}
/>
</>
);
}
主要的地圖編輯模態框:
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;
}
| 元件 | 說明 |
| --------------- | ------------------ |
| WMSMapContent | 畫布內容與編輯控制 |
| Breadcrumb | 倉儲層級導航 |
| ContextMenu | 右鍵菜單 |
| Toolbar | 編輯工具列 |
| 節點 | 說明 |
| --------------- | --------------- |
| ImageNode | 背景圖節點 |
| RectangleNode | 矩形區域節點 |
| PathNode | 路徑/多邊形節點 |
enum ViewMode {
EDIT = 'EDIT', // 編輯模式
VIEW = 'VIEW', // 檢視模式
}
enum EditMode {
BACKGROUND = 'BACKGROUND', // 背景圖編輯
LAYER = 'LAYER', // 區域層編輯
}
enum DrawingMode {
NONE = 'NONE', // 無繪製
RECTANGLE = 'RECTANGLE', // 矩形繪製
PEN = 'PEN', // 自由繪製/路徑
}
enum LayerDrawingTool {
SELECT = 'SELECT', // 選取工具
RECTANGLE = 'RECTANGLE', // 矩形工具
PEN = 'PEN', // 鋼筆工具
}
enum MapRangeType {
RECTANGLE = 'RECTANGLE', // 矩形區域
POLYGON = 'POLYGON', // 多邊形區域
}
enum MapRangeColor {
RED = 'RED',
YELLOW = 'YELLOW',
GREEN = 'GREEN',
BLUE = 'BLUE',
BLACK = 'BLACK',
}
export type ID = string;
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;
}
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; // 可直接傳給後端的格式
}
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); // 輸出單一節點詳細資訊
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 路徑導入:
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']
Required:
@xyflow/react ^12.10.0Peer Dependencies:
@mezzanine-ui/reactreact, react-domreact-hook-form確保 ReactFlow Provider 正確包裝:
// WMSMapModal 內部已包含 ReactFlowProvider
// 不需要額外包裝
檢查 maxFileSizeKB 設定,預設為 30720KB (30MB)。
確認 viewMode 設為 ViewMode.EDIT。
@rytass/wms-map-react-components 提供以下內部 Hooks,用於地圖編輯器的核心功能。
注意: 這些 Hooks 目前為內部使用,未在主入口匯出。若需使用,請直接從相應檔案導入:
import { useContextMenu } from '@rytass/wms-map-react-components/hooks/use-context-menu';
管理節點的右鍵菜單與圖層排列操作。
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',
});
直接狀態歷史系統,支援 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);
}
鋼筆工具繪製多邊形/路徑區域。
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 度角
矩形區域繪製工具。
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
節點文字標籤編輯功能。
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:取消編輯
// - 點擊外部:自動保存
useReactFlowuseDirectStateHistory 記錄editMode 和 drawingMode 狀態Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer