feat: Enhance PropertyPanel, add RFID auto-gen, and fix node connections
This commit is contained in:
29
App.tsx
29
App.tsx
@@ -10,6 +10,7 @@ import AcsControls from './components/AcsControls';
|
|||||||
import AgvStatusPanel from './components/AgvStatusPanel';
|
import AgvStatusPanel from './components/AgvStatusPanel';
|
||||||
import AgvAutoRunControls from './components/AgvAutoRunControls';
|
import AgvAutoRunControls from './components/AgvAutoRunControls';
|
||||||
import SystemLogPanel from './components/SystemLogPanel';
|
import SystemLogPanel from './components/SystemLogPanel';
|
||||||
|
import PropertyPanel from './components/PropertyPanel';
|
||||||
import { SerialPortHandler } from './services/serialService';
|
import { SerialPortHandler } from './services/serialService';
|
||||||
|
|
||||||
// --- ACS CRC16 Table Generation (Matches C# Logic) ---
|
// --- ACS CRC16 Table Generation (Matches C# Logic) ---
|
||||||
@@ -43,6 +44,7 @@ const calculateAcsCrc16 = (data: number[] | Uint8Array): number => {
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
|
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
|
||||||
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Map Data
|
// Map Data
|
||||||
const [mapData, setMapData] = useState<SimulationMap>(INITIAL_MAP);
|
const [mapData, setMapData] = useState<SimulationMap>(INITIAL_MAP);
|
||||||
@@ -123,6 +125,20 @@ const App: React.FC = () => {
|
|||||||
const [bmsPortInfo, setBmsPortInfo] = useState<string | null>(null);
|
const [bmsPortInfo, setBmsPortInfo] = useState<string | null>(null);
|
||||||
const [acsPortInfo, setAcsPortInfo] = useState<string | null>(null);
|
const [acsPortInfo, setAcsPortInfo] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePropertyUpdate = (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => {
|
||||||
|
setMapData(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (type === 'NODE') {
|
||||||
|
next.nodes = prev.nodes.map(n => n.id === id ? { ...n, ...data } : n);
|
||||||
|
} else if (type === 'MAGNET') {
|
||||||
|
next.magnets = prev.magnets.map(m => m.id === id ? { ...m, ...data } : m);
|
||||||
|
} else if (type === 'MARK') {
|
||||||
|
next.marks = prev.marks.map(m => m.id === id ? { ...m, ...data } : m);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Serial Configuration
|
// Serial Configuration
|
||||||
const [agvBaudRate, setAgvBaudRate] = useState(57600);
|
const [agvBaudRate, setAgvBaudRate] = useState(57600);
|
||||||
const [bmsBaudRate, setBmsBaudRate] = useState(9600);
|
const [bmsBaudRate, setBmsBaudRate] = useState(9600);
|
||||||
@@ -443,6 +459,11 @@ const App: React.FC = () => {
|
|||||||
barr[22] = s.sensorStatus.charCodeAt(0);
|
barr[22] = s.sensorStatus.charCodeAt(0);
|
||||||
barr.set(encodeHex(s.signalFlags, 2), 23);
|
barr.set(encodeHex(s.signalFlags, 2), 23);
|
||||||
|
|
||||||
|
// Fill bytes 25-30 with '0' padding
|
||||||
|
for (let i = 25; i <= 30; i++) {
|
||||||
|
barr[i] = 0x30; // '0'
|
||||||
|
}
|
||||||
|
|
||||||
barr[31] = '*'.charCodeAt(0); barr[32] = '*'.charCodeAt(0); barr[33] = 0x03;
|
barr[31] = '*'.charCodeAt(0); barr[32] = '*'.charCodeAt(0); barr[33] = 0x03;
|
||||||
|
|
||||||
agvSerialRef.current.send(barr);
|
agvSerialRef.current.send(barr);
|
||||||
@@ -1206,6 +1227,13 @@ const App: React.FC = () => {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
{/* Overlay: Property Panel (Floating) */}
|
||||||
|
<PropertyPanel
|
||||||
|
selectedItemIds={selectedItemIds}
|
||||||
|
mapData={mapData}
|
||||||
|
onUpdate={handlePropertyUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 1. Far Left: Toolbar */}
|
{/* 1. Far Left: Toolbar */}
|
||||||
<div className="w-16 p-2 border-r border-gray-800 flex flex-col items-center shrink-0">
|
<div className="w-16 p-2 border-r border-gray-800 flex flex-col items-center shrink-0">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
@@ -1271,6 +1299,7 @@ const App: React.FC = () => {
|
|||||||
agvState={agvState}
|
agvState={agvState}
|
||||||
setAgvState={setAgvState}
|
setAgvState={setAgvState}
|
||||||
onLog={(msg) => addLog('SYSTEM', 'INFO', msg)}
|
onLog={(msg) => addLog('SYSTEM', 'INFO', msg)}
|
||||||
|
onSelectionChange={setSelectedItemIds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
|
|||||||
const isOn = (value & (1 << bitIndex)) !== 0;
|
const isOn = (value & (1 << bitIndex)) !== 0;
|
||||||
// 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지
|
// 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-[10px] py-0.5 border-b border-gray-800 last:border-0 animate-in fade-in slide-in-from-left-1 duration-200">
|
<div key={label} className="flex items-center justify-between text-[10px] py-0.5 border-b border-gray-800 last:border-0 animate-in fade-in slide-in-from-left-1 duration-200">
|
||||||
<span className="text-gray-300 truncate pr-2" title={label}>{label.replace(/_/g, ' ')}</span>
|
<span className="text-gray-300 truncate pr-2" title={label}>{label.replace(/_/g, ' ')}</span>
|
||||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOn ? colorClass : 'bg-gray-700'} shadow-sm`} />
|
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOn ? colorClass : 'bg-gray-700'} shadow-sm`} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
295
components/PropertyPanel.tsx
Normal file
295
components/PropertyPanel.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { SimulationMap, MapNode, MagnetLine, FloorMark } from '../types';
|
||||||
|
import { Save, Wand2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PropertyPanelProps {
|
||||||
|
selectedItemIds: Set<string>;
|
||||||
|
mapData: SimulationMap;
|
||||||
|
onUpdate: (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyPanel: React.FC<PropertyPanelProps> = ({ selectedItemIds, mapData, onUpdate }) => {
|
||||||
|
const [formData, setFormData] = useState<any>(null);
|
||||||
|
const [position, setPosition] = useState({ x: 1100, y: 100 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragOffset = React.useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// When selection changes, update form data
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedItemIds.size === 1) {
|
||||||
|
const id = Array.from(selectedItemIds)[0];
|
||||||
|
const node = mapData.nodes.find(n => n.id === id);
|
||||||
|
const magnet = mapData.magnets.find(m => m.id === id);
|
||||||
|
const mark = mapData.marks.find(m => m.id === id);
|
||||||
|
|
||||||
|
if (node) {
|
||||||
|
setFormData({ ...node, formType: 'NODE' });
|
||||||
|
} else if (magnet) {
|
||||||
|
setFormData({ ...magnet, formType: 'MAGNET' });
|
||||||
|
} else if (mark) {
|
||||||
|
setFormData({ ...mark, formType: 'MARK' });
|
||||||
|
} else {
|
||||||
|
setFormData(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFormData(null);
|
||||||
|
}
|
||||||
|
}, [selectedItemIds, mapData.nodes, mapData.magnets, mapData.marks]);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string | number | boolean) => {
|
||||||
|
if (!formData) return;
|
||||||
|
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
// Validate RFID Duplication
|
||||||
|
if (formData.formType === 'NODE' && formData.rfidId && formData.rfidId !== '0000') {
|
||||||
|
const isDuplicate = mapData.nodes.some(n =>
|
||||||
|
n.rfidId === formData.rfidId && n.id !== formData.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
alert(`Error: RFID "${formData.rfidId}" is already used by another node.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(formData.formType, formData.id, formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto Generate RFID
|
||||||
|
const handleAutoRfid = () => {
|
||||||
|
const usedRfids = new Set(mapData.nodes.map(n => n.rfidId));
|
||||||
|
let nextRfid = 1; // Start from 1, assuming 0000 is default/null
|
||||||
|
while (nextRfid < 10000) {
|
||||||
|
const candidate = nextRfid.toString().padStart(4, '0');
|
||||||
|
if (!usedRfids.has(candidate)) {
|
||||||
|
handleChange('rfidId', candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nextRfid++;
|
||||||
|
}
|
||||||
|
alert('No available RFID found between 0001 and 9999');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag Logic
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
dragOffset.current = {
|
||||||
|
x: e.clientX - position.x,
|
||||||
|
y: e.clientY - position.y
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = React.useCallback((e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
setPosition({
|
||||||
|
x: e.clientX - dragOffset.current.x,
|
||||||
|
y: e.clientY - dragOffset.current.y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
const handleMouseUp = React.useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!formData || selectedItemIds.size !== 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
zIndex: 100
|
||||||
|
}}
|
||||||
|
className="w-64 bg-gray-800 border border-gray-600 rounded-lg shadow-xl overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Header / Drag Handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
className="bg-gray-700 p-2 cursor-move flex items-center justify-between border-b border-gray-600 select-none"
|
||||||
|
>
|
||||||
|
<span className="text-gray-200 font-bold text-xs">Properties</span>
|
||||||
|
<button
|
||||||
|
onMouseDown={(e) => e.stopPropagation()} // Prevent drag when clicking button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-1 bg-blue-600 hover:bg-blue-500 text-white px-2 py-0.5 rounded text-[10px] transition-colors"
|
||||||
|
>
|
||||||
|
<Save size={12} /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col gap-4 max-h-[400px] overflow-y-auto">
|
||||||
|
{/* Common Fields */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-400">ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.id}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-900 border border-gray-700 text-gray-400 text-sm p-1 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.formType === 'NODE' && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-400">Name (Alias)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-400">RFID ID</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.rfidId || ''}
|
||||||
|
onChange={(e) => handleChange('rfidId', e.target.value)}
|
||||||
|
className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoRfid}
|
||||||
|
title="Auto Generate Unique RFID"
|
||||||
|
className="p-1 bg-gray-600 hover:bg-gray-500 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Wand2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-400">X</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.x}
|
||||||
|
onChange={(e) => handleChange('x', parseFloat(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-gray-400">Y</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.y}
|
||||||
|
onChange={(e) => handleChange('y', parseFloat(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-600 my-2 pt-2">
|
||||||
|
<label className="text-xs font-bold text-gray-300 mb-2 block">Configuration</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] text-gray-400">Station Type</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.stationType || 0}
|
||||||
|
onChange={(e) => handleChange('stationType', parseInt(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] text-gray-400">Speed Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.speedLimit || 0}
|
||||||
|
onChange={(e) => handleChange('speedLimit', parseInt(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] text-gray-400">Dock Dir</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.dockDirection || 0}
|
||||||
|
onChange={(e) => handleChange('dockDirection', parseInt(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] text-gray-400">Text Size</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.nodeTextFontSize || 12}
|
||||||
|
onChange={(e) => handleChange('nodeTextFontSize', parseInt(e.target.value))}
|
||||||
|
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 mb-2">
|
||||||
|
<label className="text-[10px] text-gray-400">Text Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.nodeTextForeColor || '#FFFFFF'}
|
||||||
|
onChange={(e) => handleChange('nodeTextForeColor', e.target.value)}
|
||||||
|
className="bg-transparent w-6 h-6 p-0 border-0"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.nodeTextForeColor || '#FFFFFF'}
|
||||||
|
onChange={(e) => handleChange('nodeTextForeColor', e.target.value)}
|
||||||
|
className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
|
{[
|
||||||
|
{ k: 'canDocking', l: 'Can Docking' },
|
||||||
|
{ k: 'canTurnLeft', l: 'Can Turn Left' },
|
||||||
|
{ k: 'canTurnRight', l: 'Can Turn Right' },
|
||||||
|
{ k: 'disableCross', l: 'Disable Cross' },
|
||||||
|
{ k: 'isActive', l: 'Is Active' },
|
||||||
|
].map((item) => (
|
||||||
|
<label key={item.k} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!formData[item.k]}
|
||||||
|
onChange={(e) => handleChange(item.k, e.target.checked ? true : false)}
|
||||||
|
className="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-300">{item.l}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyPanel;
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ interface SimulationCanvasProps {
|
|||||||
agvState: AgvState;
|
agvState: AgvState;
|
||||||
setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void;
|
setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void;
|
||||||
onLog: (msg: string) => void;
|
onLog: (msg: string) => void;
|
||||||
|
onSelectionChange?: (selectedIds: Set<string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HitObject {
|
interface HitObject {
|
||||||
@@ -19,7 +20,7 @@ interface HitObject {
|
|||||||
dist: number;
|
dist: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog }) => {
|
const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog, onSelectionChange }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||||
@@ -34,16 +35,29 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
const [startNodeId, setStartNodeId] = useState<string | null>(null);
|
const [startNodeId, setStartNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Selection & Editing State
|
// Selection & Editing State
|
||||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
// Changed: Support multiple items
|
||||||
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||||
// dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks
|
// dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks
|
||||||
const [dragHandle, setDragHandle] = useState<string | null>(null);
|
const [dragHandle, setDragHandle] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Box Selection State
|
||||||
|
const [selectionBox, setSelectionBox] = useState<{ start: { x: number, y: number }, current: { x: number, y: number } } | null>(null);
|
||||||
|
|
||||||
|
// Notify parent of selection change
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSelectionChange) {
|
||||||
|
onSelectionChange(selectedItemIds);
|
||||||
|
}
|
||||||
|
}, [selectedItemIds, onSelectionChange]);
|
||||||
|
|
||||||
// Move/Drag State for Select Tool (Whole Object)
|
// Move/Drag State for Select Tool (Whole Object)
|
||||||
const [dragTarget, setDragTarget] = useState<{
|
const [dragTarget, setDragTarget] = useState<{
|
||||||
type: 'AGV' | 'NODE' | 'MAGNET' | 'MARK';
|
type: 'MULTI' | 'AGV';
|
||||||
id?: string;
|
|
||||||
startMouse: { x: number, y: number };
|
startMouse: { x: number, y: number };
|
||||||
initialObjState: any;
|
// For MULTI: store map of {id: initialState}
|
||||||
|
initialStates?: Record<string, { x: number, y: number, p1?: any, p2?: any, controlPoint?: any }>;
|
||||||
|
// For AGV single move
|
||||||
|
initialAgvState?: { x: number, y: number };
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Curve Drawing State
|
// Curve Drawing State
|
||||||
@@ -101,10 +115,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.code === 'Space') isSpacePressedRef.current = true;
|
if (e.code === 'Space') isSpacePressedRef.current = true;
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setSelectedItemId(null);
|
setSelectedItemIds(new Set());
|
||||||
setDragHandle(null);
|
setDragHandle(null);
|
||||||
setDragTarget(null);
|
setDragTarget(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
|
setSelectionBox(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
@@ -128,7 +143,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
setDragTarget(null);
|
setDragTarget(null);
|
||||||
setDragHandle(null);
|
setDragHandle(null);
|
||||||
// Note: We might want to keep selection when switching tools, but often resetting is safer
|
// Note: We might want to keep selection when switching tools, but often resetting is safer
|
||||||
if (activeTool !== ToolType.SELECT) setSelectedItemId(null);
|
if (activeTool !== ToolType.SELECT) setSelectedItemIds(new Set());
|
||||||
}, [activeTool]);
|
}, [activeTool]);
|
||||||
|
|
||||||
// Preload Images
|
// Preload Images
|
||||||
@@ -374,19 +389,29 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteObject = (item: HitObject) => {
|
const deleteObject = (item: HitObject) => {
|
||||||
if (item.id === selectedItemId) setSelectedItemId(null); // Clear selection if deleted
|
// If the item clicked is part of selection, delete ALL selected.
|
||||||
|
// If not part of selection, delete just that item.
|
||||||
|
const idsToDelete = new Set<string>();
|
||||||
|
if (selectedItemIds.has(item.id)) {
|
||||||
|
selectedItemIds.forEach(id => idsToDelete.add(id));
|
||||||
|
} else {
|
||||||
|
idsToDelete.add(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedItemIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
idsToDelete.forEach(id => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
setMapData(prev => {
|
setMapData(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (item.type === 'NODE') {
|
// Filter out all deleted IDs
|
||||||
next.nodes = prev.nodes.filter(n => n.id !== item.id);
|
next.nodes = prev.nodes.filter(n => !idsToDelete.has(n.id));
|
||||||
next.edges = prev.edges.filter(e => e.from !== item.id && e.to !== item.id);
|
// Remove edges connected to deleted nodes
|
||||||
} else if (item.type === 'MARK') {
|
next.edges = prev.edges.filter(e => !idsToDelete.has(e.id) && !idsToDelete.has(e.from) && !idsToDelete.has(e.to));
|
||||||
next.marks = prev.marks.filter(m => m.id !== item.id);
|
next.marks = prev.marks.filter(m => !idsToDelete.has(m.id));
|
||||||
} else if (item.type === 'MAGNET') {
|
next.magnets = prev.magnets.filter(m => !idsToDelete.has(m.id));
|
||||||
next.magnets = prev.magnets.filter(m => m.id !== item.id);
|
|
||||||
} else if (item.type === 'EDGE') {
|
|
||||||
next.edges = prev.edges.filter(e => e.id !== item.id);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
@@ -756,8 +781,15 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
if (markFound && state.motionState === AgvMotionState.MARK_STOPPING) { state.motionState = AgvMotionState.IDLE; hasChanged = true; }
|
if (markFound && state.motionState === AgvMotionState.MARK_STOPPING) { state.motionState = AgvMotionState.IDLE; hasChanged = true; }
|
||||||
|
|
||||||
if (hasChanged || state.sensorLineFront !== agvStateRef.current.sensorLineFront || state.sensorMark !== agvStateRef.current.sensorMark || state.error !== agvStateRef.current.error || state.rotation !== agvStateRef.current.rotation) {
|
if (hasChanged || state.sensorLineFront !== agvStateRef.current.sensorLineFront || state.sensorMark !== agvStateRef.current.sensorMark || state.error !== agvStateRef.current.error || state.rotation !== agvStateRef.current.rotation) {
|
||||||
agvStateRef.current = state;
|
setAgvState(prev => {
|
||||||
setAgvState(state);
|
// Conflict Resolution:
|
||||||
|
// If the motionState has changed externally (e.g. STOP command from App.tsx), we must yield to it.
|
||||||
|
// We compare the 'prev' (latest React state) with the 'agvStateRef.current' (state at start of frame).
|
||||||
|
if (prev.motionState !== agvStateRef.current.motionState) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -819,7 +851,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
ctx.lineWidth = MAGNET_WIDTH;
|
ctx.lineWidth = MAGNET_WIDTH;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
map.magnets.forEach(mag => {
|
map.magnets.forEach(mag => {
|
||||||
const isSelected = mag.id === selectedItemId;
|
const isSelected = selectedItemIds.has(mag.id);
|
||||||
ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected
|
ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -831,8 +863,9 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw Controls for Selected Curve
|
// Draw Controls for Selected Curve (Only if Single Selection for Simplicity handles)
|
||||||
if (isSelected && mag.type === 'CURVE' && mag.controlPoint) {
|
if (isSelected && selectedItemIds.size === 1) {
|
||||||
|
if (mag.type === 'CURVE' && mag.controlPoint) {
|
||||||
// Helper Lines
|
// Helper Lines
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
|
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@@ -850,11 +883,10 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill(); // Scale handle size
|
ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill(); // Scale handle size
|
||||||
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
|
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
};
|
};
|
||||||
|
|
||||||
drawHandle(mag.p1, '#3B82F6'); // Start (Blue)
|
drawHandle(mag.p1, '#3B82F6'); // Start (Blue)
|
||||||
drawHandle(mag.p2, '#3B82F6'); // End (Blue)
|
drawHandle(mag.p2, '#3B82F6'); // End (Blue)
|
||||||
drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow)
|
drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow)
|
||||||
} else if (isSelected && mag.type === 'STRAIGHT') {
|
} else if (mag.type === 'STRAIGHT') {
|
||||||
// Simple handles for straight lines
|
// Simple handles for straight lines
|
||||||
const drawHandle = (p: { x: number, y: number }, color: string) => {
|
const drawHandle = (p: { x: number, y: number }, color: string) => {
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
@@ -864,6 +896,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
drawHandle(mag.p1, '#3B82F6');
|
drawHandle(mag.p1, '#3B82F6');
|
||||||
drawHandle(mag.p2, '#3B82F6');
|
drawHandle(mag.p2, '#3B82F6');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nodes (Unchanged)
|
// Nodes (Unchanged)
|
||||||
@@ -892,7 +925,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
else if (node.displayColor) color = node.displayColor;
|
else if (node.displayColor) color = node.displayColor;
|
||||||
|
|
||||||
// Highlight Selected Node
|
// Highlight Selected Node
|
||||||
if (node.id === selectedItemId) {
|
if (selectedItemIds.has(node.id)) {
|
||||||
ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2;
|
ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(node.x - 10, node.y - 10, 20, 20);
|
ctx.strokeRect(node.x - 10, node.y - 10, 20, 20);
|
||||||
}
|
}
|
||||||
@@ -908,7 +941,8 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
// Marks
|
// Marks
|
||||||
ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3;
|
ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3;
|
||||||
map.marks.forEach(mark => {
|
map.marks.forEach(mark => {
|
||||||
if (mark.id === selectedItemId) {
|
const isSelected = selectedItemIds.has(mark.id);
|
||||||
|
if (isSelected) {
|
||||||
ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10;
|
ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10;
|
||||||
} else {
|
} else {
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
@@ -918,7 +952,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Draw handles if selected
|
// Draw handles if selected
|
||||||
if (mark.id === selectedItemId) {
|
if (isSelected && selectedItemIds.size === 1) {
|
||||||
|
|
||||||
// Re-apply transform for simple drawing in local space
|
// Re-apply transform for simple drawing in local space
|
||||||
ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180);
|
ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180);
|
||||||
@@ -936,34 +970,48 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drawing Helpers
|
// Selection Box
|
||||||
|
if (selectionBox) {
|
||||||
|
const minX = Math.min(selectionBox.start.x, selectionBox.current.x);
|
||||||
|
const minY = Math.min(selectionBox.start.y, selectionBox.current.y);
|
||||||
|
const w = Math.abs(selectionBox.current.x - selectionBox.start.x);
|
||||||
|
const h = Math.abs(selectionBox.current.y - selectionBox.start.y);
|
||||||
|
|
||||||
// -- Straight Magnet --
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||||
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) {
|
ctx.strokeStyle = '#3B82F6';
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
ctx.lineWidth = 1;
|
||||||
ctx.lineWidth = MAGNET_WIDTH;
|
ctx.fillRect(minX, minY, w, h);
|
||||||
ctx.beginPath();
|
ctx.strokeRect(minX, minY, w, h);
|
||||||
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
|
||||||
|
|
||||||
// Use snapped mouse pos for preview
|
|
||||||
const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
|
||||||
ctx.lineTo(curr.x, curr.y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Logical Line --
|
// Drawing Helpers
|
||||||
|
// -- Logical Edge (Connection) --
|
||||||
if (activeTool === ToolType.DRAW_LINE && dragStartPos) {
|
if (activeTool === ToolType.DRAW_LINE && dragStartPos) {
|
||||||
ctx.lineWidth = 1; ctx.strokeStyle = '#60A5FA'; ctx.setLineDash([5, 5]);
|
ctx.strokeStyle = '#9CA3AF'; // Gray
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]); // Dashed
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
||||||
const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
const curr = mouseWorldPosRef.current;
|
||||||
ctx.lineTo(curr.x, curr.y);
|
ctx.lineTo(curr.x, curr.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Curve Magnet (3-Step Drawing) --
|
// -- Straight Magnet --
|
||||||
|
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) {
|
||||||
|
// ... (Keep existing drawing logic)
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||||
|
ctx.lineWidth = MAGNET_WIDTH;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(dragStartPos.x, dragStartPos.y);
|
||||||
|
|
||||||
|
const curr = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
||||||
|
ctx.lineTo(curr.x, curr.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
if (activeTool === ToolType.DRAW_MAGNET_CURVE && tempMagnet) {
|
if (activeTool === ToolType.DRAW_MAGNET_CURVE && tempMagnet) {
|
||||||
|
// ... (Keep existing drawing logic - ensuring it's not lost)
|
||||||
const p1 = tempMagnet.p1!;
|
const p1 = tempMagnet.p1!;
|
||||||
const p2 = tempMagnet.p2 || getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
const p2 = tempMagnet.p2 || getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y);
|
||||||
|
|
||||||
@@ -976,12 +1024,9 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
|
|
||||||
let control = tempMagnet.controlPoint;
|
let control = tempMagnet.controlPoint;
|
||||||
if (curvePhase === 1) {
|
if (curvePhase === 1) {
|
||||||
// In phase 1, control is midpoint of straight line
|
|
||||||
control = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
control = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
||||||
ctx.lineTo(p2.x, p2.y);
|
ctx.lineTo(p2.x, p2.y);
|
||||||
} else if (curvePhase === 2) {
|
} else if (curvePhase === 2) {
|
||||||
// In phase 2, control follows mouse (no snapping for control point usually feels smoother, but can apply if needed)
|
|
||||||
// Let's use raw mouse for control point smooth dragging
|
|
||||||
control = mouseWorldPosRef.current;
|
control = mouseWorldPosRef.current;
|
||||||
ctx.lineTo(control.x, control.y);
|
ctx.lineTo(control.x, control.y);
|
||||||
ctx.lineTo(p2.x, p2.y);
|
ctx.lineTo(p2.x, p2.y);
|
||||||
@@ -989,13 +1034,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// Control Point Handle
|
|
||||||
if (control) {
|
if (control) {
|
||||||
ctx.fillStyle = '#60A5FA';
|
ctx.fillStyle = '#60A5FA';
|
||||||
ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill();
|
ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Final Curve Preview
|
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
ctx.lineWidth = MAGNET_WIDTH;
|
ctx.lineWidth = MAGNET_WIDTH;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -1137,7 +1180,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
const interval = setInterval(updatePhysics, 16);
|
const interval = setInterval(updatePhysics, 16);
|
||||||
animationFrameId = requestAnimationFrame(draw);
|
animationFrameId = requestAnimationFrame(draw);
|
||||||
return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); };
|
return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); };
|
||||||
}, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemId, dimensions]); // Re-draw when dimensions change
|
}, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemIds, dimensions]); // Re-draw when dimensions change
|
||||||
|
|
||||||
// --- Interaction Handlers ---
|
// --- Interaction Handlers ---
|
||||||
|
|
||||||
@@ -1213,13 +1256,20 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag Handle (Editing)
|
// Box Selection Update
|
||||||
if (dragHandle && selectedItemId) {
|
if (selectionBox) {
|
||||||
|
setSelectionBox(prev => prev ? { ...prev, current: worldPos } : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag Handle (Editing - Only if single selection)
|
||||||
|
if (dragHandle && selectedItemIds.size === 1) {
|
||||||
|
const singleId = Array.from(selectedItemIds)[0];
|
||||||
const snapped = getSnappedPos(worldPos.x, worldPos.y);
|
const snapped = getSnappedPos(worldPos.x, worldPos.y);
|
||||||
setMapData(prev => {
|
setMapData(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (dragHandle === 'p1' || dragHandle === 'p2' || dragHandle === 'control') {
|
if (dragHandle === 'p1' || dragHandle === 'p2' || dragHandle === 'control') {
|
||||||
const magIndex = next.magnets.findIndex(m => m.id === selectedItemId);
|
const magIndex = next.magnets.findIndex(m => m.id === singleId);
|
||||||
if (magIndex !== -1) {
|
if (magIndex !== -1) {
|
||||||
const newMags = [...next.magnets];
|
const newMags = [...next.magnets];
|
||||||
const mag = { ...newMags[magIndex] };
|
const mag = { ...newMags[magIndex] };
|
||||||
@@ -1231,7 +1281,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
}
|
}
|
||||||
} else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') {
|
} else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') {
|
||||||
// Rotate Mark
|
// Rotate Mark
|
||||||
const markIndex = next.marks.findIndex(m => m.id === selectedItemId);
|
const markIndex = next.marks.findIndex(m => m.id === singleId);
|
||||||
if (markIndex !== -1) {
|
if (markIndex !== -1) {
|
||||||
const newMarks = [...next.marks];
|
const newMarks = [...next.marks];
|
||||||
const mark = { ...newMarks[markIndex] };
|
const mark = { ...newMarks[markIndex] };
|
||||||
@@ -1250,45 +1300,56 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drag Object (Moving)
|
// Drag Object (Moving)
|
||||||
if (dragTarget && dragTarget.initialObjState) {
|
if (dragTarget) {
|
||||||
const dx = worldPos.x - dragTarget.startMouse.x;
|
const dx = worldPos.x - dragTarget.startMouse.x;
|
||||||
const dy = worldPos.y - dragTarget.startMouse.y;
|
const dy = worldPos.y - dragTarget.startMouse.y;
|
||||||
|
|
||||||
if (dragTarget.type === 'AGV') {
|
if (dragTarget.type === 'AGV' && dragTarget.initialAgvState) {
|
||||||
setAgvState(prev => ({
|
setAgvState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
x: dragTarget.initialObjState.x + dx,
|
x: dragTarget.initialAgvState!.x + dx,
|
||||||
y: dragTarget.initialObjState.y + dy
|
y: dragTarget.initialAgvState!.y + dy
|
||||||
}));
|
}));
|
||||||
} else if (dragTarget.type === 'NODE' && dragTarget.id) {
|
} else if (dragTarget.type === 'MULTI' && dragTarget.initialStates) {
|
||||||
const snapped = getSnappedPos(dragTarget.initialObjState.x + dx, dragTarget.initialObjState.y + dy);
|
const initialStates = dragTarget.initialStates;
|
||||||
setMapData(prev => ({
|
setMapData(prev => {
|
||||||
...prev,
|
const next = { ...prev };
|
||||||
nodes: prev.nodes.map(n => n.id === dragTarget.id ? { ...n, x: snapped.x, y: snapped.y } : n)
|
|
||||||
}));
|
|
||||||
} else if (dragTarget.type === 'MARK' && dragTarget.id) {
|
|
||||||
setMapData(prev => ({
|
|
||||||
...prev,
|
|
||||||
marks: prev.marks.map(m => m.id === dragTarget.id ? { ...m, x: dragTarget.initialObjState.x + dx, y: dragTarget.initialObjState.y + dy } : m)
|
|
||||||
}));
|
|
||||||
} else if (dragTarget.type === 'MAGNET' && dragTarget.id) {
|
|
||||||
const m0 = dragTarget.initialObjState as MagnetLine;
|
|
||||||
const dxw = worldPos.x - dragTarget.startMouse.x; // World delta
|
|
||||||
const dyw = worldPos.y - dragTarget.startMouse.y;
|
|
||||||
|
|
||||||
setMapData(prev => ({
|
// Update Nodes
|
||||||
...prev,
|
next.nodes = prev.nodes.map(n => {
|
||||||
magnets: prev.magnets.map(m => {
|
const init = initialStates[n.id];
|
||||||
if (m.id === dragTarget.id) {
|
if (init) {
|
||||||
|
return { ...n, x: init.x + dx, y: init.y + dy };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Marks
|
||||||
|
next.marks = prev.marks.map(m => {
|
||||||
|
const init = initialStates[m.id];
|
||||||
|
if (init) {
|
||||||
|
return { ...m, x: init.x + dx, y: init.y + dy };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Magnets
|
||||||
|
next.magnets = prev.magnets.map(m => {
|
||||||
|
const init = initialStates[m.id];
|
||||||
|
if (init) {
|
||||||
const newM = { ...m };
|
const newM = { ...m };
|
||||||
newM.p1 = { x: m0.p1.x + dxw, y: m0.p1.y + dyw };
|
newM.p1 = { x: init.p1.x + dx, y: init.p1.y + dy };
|
||||||
newM.p2 = { x: m0.p2.x + dxw, y: m0.p2.y + dyw };
|
newM.p2 = { x: init.p2.x + dx, y: init.p2.y + dy };
|
||||||
if (m0.controlPoint) newM.controlPoint = { x: m0.controlPoint.x + dxw, y: m0.controlPoint.y + dyw };
|
if (init.controlPoint && newM.controlPoint) {
|
||||||
|
newM.controlPoint = { x: init.controlPoint.x + dx, y: init.controlPoint.y + dy };
|
||||||
|
}
|
||||||
return newM;
|
return newM;
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
})
|
});
|
||||||
}));
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1360,39 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
setDragHandle(null);
|
setDragHandle(null);
|
||||||
setDragTarget(null);
|
setDragTarget(null);
|
||||||
|
|
||||||
|
// Box Selection End
|
||||||
|
if (selectionBox) {
|
||||||
|
const minX = Math.min(selectionBox.start.x, selectionBox.current.x);
|
||||||
|
const maxX = Math.max(selectionBox.start.x, selectionBox.current.x);
|
||||||
|
const minY = Math.min(selectionBox.start.y, selectionBox.current.y);
|
||||||
|
const maxY = Math.max(selectionBox.start.y, selectionBox.current.y);
|
||||||
|
|
||||||
|
// Avoid accidental clicks being box selections
|
||||||
|
if (maxX - minX > 5 || maxY - minY > 5) {
|
||||||
|
const newSelection = new Set<string>();
|
||||||
|
if (e.shiftKey || e.ctrlKey) {
|
||||||
|
selectedItemIds.forEach(id => newSelection.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find items in box
|
||||||
|
mapData.nodes.forEach(n => {
|
||||||
|
if (n.x >= minX && n.x <= maxX && n.y >= minY && n.y <= maxY) newSelection.add(n.id);
|
||||||
|
});
|
||||||
|
mapData.marks.forEach(m => {
|
||||||
|
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) newSelection.add(m.id);
|
||||||
|
});
|
||||||
|
mapData.magnets.forEach(m => {
|
||||||
|
// Simple center point check for magnets for now
|
||||||
|
const cx = (m.p1.x + m.p2.x) / 2;
|
||||||
|
const cy = (m.p1.y + m.p2.y) / 2;
|
||||||
|
if (cx >= minX && cx <= maxX && cy >= minY && cy <= maxY) newSelection.add(m.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedItemIds(newSelection);
|
||||||
|
}
|
||||||
|
setSelectionBox(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Finish DRAW_LINE
|
// Finish DRAW_LINE
|
||||||
if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) {
|
if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) {
|
||||||
const { x, y } = mouseWorldPosRef.current;
|
const { x, y } = mouseWorldPosRef.current;
|
||||||
@@ -1334,6 +1428,8 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setDragStartPos(null);
|
setDragStartPos(null);
|
||||||
setStartNodeId(null);
|
setStartNodeId(null);
|
||||||
};
|
};
|
||||||
@@ -1367,18 +1463,50 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTool === ToolType.ADD_RFID) {
|
||||||
|
// Smart ID Generation
|
||||||
|
const ids = mapData.nodes.map(n => parseInt(n.id)).filter(n => !isNaN(n));
|
||||||
|
const maxId = ids.length > 0 ? Math.max(...ids) : 0;
|
||||||
|
const newId = (maxId + 1).toString();
|
||||||
|
|
||||||
|
const newNode: MapNode = {
|
||||||
|
id: newId,
|
||||||
|
x: worldPos.x,
|
||||||
|
y: worldPos.y,
|
||||||
|
type: NodeType.Normal,
|
||||||
|
name: '',
|
||||||
|
rfidId: '0000', // Default RFID
|
||||||
|
connectedNodes: [],
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
stationType: 0,
|
||||||
|
speedLimit: 0,
|
||||||
|
canDocking: false,
|
||||||
|
dockDirection: 0,
|
||||||
|
canTurnLeft: false,
|
||||||
|
canTurnRight: false,
|
||||||
|
disableCross: false,
|
||||||
|
isActive: true,
|
||||||
|
nodeTextForeColor: '#FFFFFF',
|
||||||
|
nodeTextFontSize: 12
|
||||||
|
};
|
||||||
|
setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTool === ToolType.SELECT) {
|
if (activeTool === ToolType.SELECT) {
|
||||||
// Check handles
|
// Check handles (Only if SINGLE item selected for now, to avoid complexity)
|
||||||
const worldTolerance = 15 / viewRef.current.scale;
|
const worldTolerance = 15 / viewRef.current.scale;
|
||||||
|
|
||||||
if (selectedItemId) {
|
if (selectedItemIds.size === 1) {
|
||||||
const selectedMag = mapData.magnets.find(m => m.id === selectedItemId);
|
const singleId = Array.from(selectedItemIds)[0];
|
||||||
|
const selectedMag = mapData.magnets.find(m => m.id === singleId);
|
||||||
if (selectedMag) {
|
if (selectedMag) {
|
||||||
if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; }
|
if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; }
|
||||||
if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); return; }
|
if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); return; }
|
||||||
if (selectedMag.type === 'CURVE' && selectedMag.controlPoint && getDistance(worldPos, selectedMag.controlPoint) < worldTolerance) { setDragHandle('control'); return; }
|
if (selectedMag.type === 'CURVE' && selectedMag.controlPoint && getDistance(worldPos, selectedMag.controlPoint) < worldTolerance) { setDragHandle('control'); return; }
|
||||||
}
|
}
|
||||||
const selectedMark = mapData.marks.find(m => m.id === selectedItemId);
|
const selectedMark = mapData.marks.find(m => m.id === singleId);
|
||||||
if (selectedMark) {
|
if (selectedMark) {
|
||||||
const rad = (selectedMark.rotation * Math.PI) / 180;
|
const rad = (selectedMark.rotation * Math.PI) / 180;
|
||||||
const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25;
|
const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25;
|
||||||
@@ -1392,33 +1520,51 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
// AGV
|
// AGV
|
||||||
const agvDist = getDistance(worldPos, agvState);
|
const agvDist = getDistance(worldPos, agvState);
|
||||||
if (agvDist < 30) {
|
if (agvDist < 30) {
|
||||||
setDragTarget({ type: 'AGV', startMouse: worldPos, initialObjState: { x: agvState.x, y: agvState.y } });
|
setDragTarget({ type: 'AGV', startMouse: worldPos, initialAgvState: { x: agvState.x, y: agvState.y } });
|
||||||
setSelectedItemId(null);
|
setSelectedItemIds(new Set());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map Objects
|
// Map Objects
|
||||||
const hits = getObjectsAt(x, y);
|
const hits = getObjectsAt(x, y);
|
||||||
const movableHits = hits.filter(h => h.type !== 'EDGE');
|
const movableHits = hits.filter(h => h.type !== 'EDGE');
|
||||||
|
|
||||||
if (movableHits.length > 0) {
|
if (movableHits.length > 0) {
|
||||||
const hit = movableHits[0];
|
const hit = movableHits[0];
|
||||||
setSelectedItemId(hit.id);
|
const isMultiSelect = e.shiftKey || e.ctrlKey;
|
||||||
let initialObjState = null;
|
|
||||||
if (hit.type === 'NODE') {
|
let newSelection = new Set(selectedItemIds);
|
||||||
const n = mapData.nodes.find(o => o.id === hit.id);
|
|
||||||
if (n) initialObjState = { x: n.x, y: n.y };
|
if (isMultiSelect) {
|
||||||
} else if (hit.type === 'MARK') {
|
if (newSelection.has(hit.id)) newSelection.delete(hit.id);
|
||||||
const m = mapData.marks.find(o => o.id === hit.id);
|
else newSelection.add(hit.id);
|
||||||
if (m) initialObjState = { x: m.x, y: m.y };
|
|
||||||
} else if (hit.type === 'MAGNET') {
|
|
||||||
const m = mapData.magnets.find(o => o.id === hit.id);
|
|
||||||
if (m) initialObjState = JSON.parse(JSON.stringify(m));
|
|
||||||
}
|
|
||||||
if (initialObjState) {
|
|
||||||
setDragTarget({ type: hit.type as any, id: hit.id, startMouse: worldPos, initialObjState });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedItemId(null);
|
if (!newSelection.has(hit.id)) {
|
||||||
|
newSelection = new Set([hit.id]);
|
||||||
|
}
|
||||||
|
// If already selected, keep selection (to allow drag)
|
||||||
|
}
|
||||||
|
setSelectedItemIds(newSelection);
|
||||||
|
|
||||||
|
// Prepare Multi-Drag
|
||||||
|
const initialStates: Record<string, any> = {};
|
||||||
|
newSelection.forEach(id => {
|
||||||
|
const n = mapData.nodes.find(o => o.id === id);
|
||||||
|
if (n) initialStates[id] = { x: n.x, y: n.y };
|
||||||
|
const m = mapData.marks.find(o => o.id === id);
|
||||||
|
if (m) initialStates[id] = { x: m.x, y: m.y };
|
||||||
|
const mag = mapData.magnets.find(o => o.id === id);
|
||||||
|
if (mag) initialStates[id] = JSON.parse(JSON.stringify(mag));
|
||||||
|
});
|
||||||
|
|
||||||
|
setDragTarget({ type: 'MULTI', startMouse: worldPos, initialStates });
|
||||||
|
} else {
|
||||||
|
// Click on empty space
|
||||||
|
if (!e.shiftKey && !e.ctrlKey) {
|
||||||
|
setSelectedItemIds(new Set());
|
||||||
|
}
|
||||||
|
// Start Box Selection
|
||||||
|
setSelectionBox({ start: worldPos, current: worldPos });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1574,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
const start = getSnappedPos(x, y);
|
const start = getSnappedPos(x, y);
|
||||||
setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start });
|
setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start });
|
||||||
setCurvePhase(1);
|
setCurvePhase(1);
|
||||||
|
} else if (curvePhase === 1) {
|
||||||
|
// Set Endpoint
|
||||||
|
const end = getSnappedPos(x, y);
|
||||||
|
setTempMagnet(prev => prev ? { ...prev, p2: end } : null);
|
||||||
|
setCurvePhase(2);
|
||||||
} else if (curvePhase === 2) {
|
} else if (curvePhase === 2) {
|
||||||
// Finish curve
|
// Finish curve
|
||||||
if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) {
|
if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) {
|
||||||
@@ -1488,19 +1639,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool === ToolType.ADD_RFID) {
|
|
||||||
const hits = getObjectsAt(x, y);
|
|
||||||
const nodeHit = hits.find(h => h.type === 'NODE');
|
|
||||||
if (nodeHit) {
|
|
||||||
const rfid = prompt("Enter RFID ID:", "12345");
|
|
||||||
if (rfid) {
|
|
||||||
setMapData(prev => ({
|
|
||||||
...prev,
|
|
||||||
nodes: prev.nodes.map(n => n.id === nodeHit.id ? { ...n, rfidId: rfid } : n)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1525,7 +1664,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
|
|||||||
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && <span className="text-yellow-400 ml-2">Move mouse to bend curve, Click to finish</span>}
|
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && <span className="text-yellow-400 ml-2">Move mouse to bend curve, Click to finish</span>}
|
||||||
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-70">
|
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-70">
|
||||||
<Move size={10} /> <span>Middle Click / Space+Drag to Pan</span>
|
<Move size={10} /> <span>Middle Click / Space+Drag to Pan</span>
|
||||||
{selectedItemId && <span className="text-yellow-500 font-bold ml-2">Object Selected (Drag points to edit)</span>}
|
{selectedItemIds.size > 0 && <span className="text-yellow-500 font-bold ml-2">{selectedItemIds.size} Object(s) Selected</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ export class SerialPortHandler {
|
|||||||
} else {
|
} else {
|
||||||
payload = data;
|
payload = data;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await this.writer.write(payload);
|
await this.writer.write(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Serial Send Error", e);
|
||||||
|
throw e; // Re-throw to let caller know if needed, or handle gracefull
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
types.ts
12
types.ts
@@ -44,6 +44,18 @@ export interface MapNode extends Point {
|
|||||||
|
|
||||||
// New props preservation
|
// New props preservation
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
|
||||||
|
// Extended Properties (from C# Model)
|
||||||
|
stationType?: number;
|
||||||
|
speedLimit?: number;
|
||||||
|
canDocking?: boolean;
|
||||||
|
dockDirection?: number;
|
||||||
|
canTurnLeft?: boolean;
|
||||||
|
canTurnRight?: boolean;
|
||||||
|
disableCross?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
nodeTextForeColor?: string;
|
||||||
|
nodeTextFontSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapEdge {
|
export interface MapEdge {
|
||||||
|
|||||||
Reference in New Issue
Block a user