feat: Enhance PropertyPanel, add RFID auto-gen, and fix node connections

This commit is contained in:
backuppc
2025-12-22 13:46:01 +09:00
parent df9be853ba
commit 4d1f131d3e
6 changed files with 1995 additions and 1515 deletions

29
App.tsx
View File

@@ -10,6 +10,7 @@ import AcsControls from './components/AcsControls';
import AgvStatusPanel from './components/AgvStatusPanel';
import AgvAutoRunControls from './components/AgvAutoRunControls';
import SystemLogPanel from './components/SystemLogPanel';
import PropertyPanel from './components/PropertyPanel';
import { SerialPortHandler } from './services/serialService';
// --- ACS CRC16 Table Generation (Matches C# Logic) ---
@@ -43,6 +44,7 @@ const calculateAcsCrc16 = (data: number[] | Uint8Array): number => {
const App: React.FC = () => {
// --- State ---
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
// Map Data
const [mapData, setMapData] = useState<SimulationMap>(INITIAL_MAP);
@@ -123,6 +125,20 @@ const App: React.FC = () => {
const [bmsPortInfo, setBmsPortInfo] = 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
const [agvBaudRate, setAgvBaudRate] = useState(57600);
const [bmsBaudRate, setBmsBaudRate] = useState(9600);
@@ -443,6 +459,11 @@ const App: React.FC = () => {
barr[22] = s.sensorStatus.charCodeAt(0);
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;
agvSerialRef.current.send(barr);
@@ -1206,6 +1227,13 @@ const App: React.FC = () => {
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Overlay: Property Panel (Floating) */}
<PropertyPanel
selectedItemIds={selectedItemIds}
mapData={mapData}
onUpdate={handlePropertyUpdate}
/>
{/* 1. Far Left: Toolbar */}
<div className="w-16 p-2 border-r border-gray-800 flex flex-col items-center shrink-0">
<EditorToolbar
@@ -1271,6 +1299,7 @@ const App: React.FC = () => {
agvState={agvState}
setAgvState={setAgvState}
onLog={(msg) => addLog('SYSTEM', 'INFO', msg)}
onSelectionChange={setSelectedItemIds}
/>
</div>

View File

@@ -13,7 +13,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
const isOn = (value & (1 << bitIndex)) !== 0;
// 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지
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>
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOn ? colorClass : 'bg-gray-700'} shadow-sm`} />
</div>

View 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;

View File

@@ -10,6 +10,7 @@ interface SimulationCanvasProps {
agvState: AgvState;
setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void;
onLog: (msg: string) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
}
interface HitObject {
@@ -19,7 +20,7 @@ interface HitObject {
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 containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
@@ -30,20 +31,33 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const isSpacePressedRef = useRef(false);
// Interaction State
const [dragStartPos, setDragStartPos] = useState<{x:number, y:number} | null>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number, y: number } | null>(null);
const [startNodeId, setStartNodeId] = useState<string | null>(null);
// 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
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)
const [dragTarget, setDragTarget] = useState<{
type: 'AGV' | 'NODE' | 'MAGNET' | 'MARK';
id?: string;
type: 'MULTI' | 'AGV';
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);
// Curve Drawing State
@@ -101,10 +115,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') isSpacePressedRef.current = true;
if (e.key === 'Escape') {
setSelectedItemId(null);
setSelectedItemIds(new Set());
setDragHandle(null);
setDragTarget(null);
setContextMenu(null);
setSelectionBox(null);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
@@ -128,7 +143,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
setDragTarget(null);
setDragHandle(null);
// 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]);
// Preload Images
@@ -178,12 +193,12 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
};
};
const getDistance = (p1: {x:number, y:number}, p2: {x:number, y:number}) => {
const getDistance = (p1: { x: number, y: number }, p2: { x: number, y: number }) => {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
};
// Distance from point P to line segment VW
const distanceToSegment = (p: {x:number, y:number}, v: {x:number, y:number}, w: {x:number, y:number}) => {
const distanceToSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => {
const l2 = Math.pow(getDistance(v, w), 2);
if (l2 === 0) return getDistance(p, v);
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
@@ -193,7 +208,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
};
// Get closest point on segment
const getClosestPointOnSegment = (p: {x:number, y:number}, v: {x:number, y:number}, w: {x:number, y:number}) => {
const getClosestPointOnSegment = (p: { x: number, y: number }, v: { x: number, y: number }, w: { x: number, y: number }) => {
const l2 = Math.pow(getDistance(v, w), 2);
if (l2 === 0) return v;
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
@@ -202,7 +217,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
};
// Quadratic Bezier Point Calculation
const getBezierPoint = (t: number, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}) => {
const getBezierPoint = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => {
const u = 1 - t;
const tt = t * t;
const uu = u * u;
@@ -214,7 +229,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
};
// Calculate derivative (tangent vector) at t for Quadratic Bezier
const getBezierDerivative = (t: number, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}) => {
const getBezierDerivative = (t: number, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }) => {
const u = 1 - t;
// B'(t) = 2(1-t)(P1 - P0) + 2t(P2 - P1)
const dx = 2 * u * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
@@ -224,7 +239,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Approximate distance to a Quadratic Bezier Curve by subdividing it
// Now uses 50 segments for higher resolution to prevent "warping"
const distanceToBezier = (p: {x:number, y:number}, p0: {x:number, y:number}, p1: {x:number, y:number}, p2: {x:number, y:number}, segments = 50) => {
const distanceToBezier = (p: { x: number, y: number }, p0: { x: number, y: number }, p1: { x: number, y: number }, p2: { x: number, y: number }, segments = 50) => {
let minDist = Number.MAX_VALUE;
let prevPoint = p0;
let closestProj = p0;
@@ -266,8 +281,8 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
return a;
};
const doSegmentsIntersect = (p1: {x:number, y:number}, p2: {x:number, y:number}, p3: {x:number, y:number}, p4: {x:number, y:number}) => {
const ccw = (a:{x:number,y:number}, b:{x:number,y:number}, c:{x:number,y:number}) => (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
const doSegmentsIntersect = (p1: { x: number, y: number }, p2: { x: number, y: number }, p3: { x: number, y: number }, p4: { x: number, y: number }) => {
const ccw = (a: { x: number, y: number }, b: { x: number, y: number }, c: { x: number, y: number }) => (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
return (ccw(p1, p2, p3) !== ccw(p1, p2, p4)) && (ccw(p3, p4, p1) !== ccw(p3, p4, p2));
};
@@ -285,7 +300,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
if (mag.type === 'STRAIGHT') {
dist = distanceToSegment(p, mag.p1, mag.p2);
magAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180/Math.PI);
magAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI);
} else if (mag.type === 'CURVE' && mag.controlPoint) {
const res = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2);
dist = res.dist;
@@ -366,7 +381,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
});
// Sort by Priority first, then Distance
return hits.sort((a,b) => {
return hits.sort((a, b) => {
const prioDiff = typePriority[a.type] - typePriority[b.type];
if (prioDiff !== 0) return prioDiff;
return a.dist - b.dist;
@@ -374,19 +389,29 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
};
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 => {
const next = { ...prev };
if (item.type === 'NODE') {
next.nodes = prev.nodes.filter(n => n.id !== item.id);
next.edges = prev.edges.filter(e => e.from !== item.id && e.to !== item.id);
} else if (item.type === 'MARK') {
next.marks = prev.marks.filter(m => m.id !== item.id);
} else if (item.type === 'MAGNET') {
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);
}
// Filter out all deleted IDs
next.nodes = prev.nodes.filter(n => !idsToDelete.has(n.id));
// Remove edges connected to deleted nodes
next.edges = prev.edges.filter(e => !idsToDelete.has(e.id) && !idsToDelete.has(e.from) && !idsToDelete.has(e.to));
next.marks = prev.marks.filter(m => !idsToDelete.has(m.id));
next.magnets = prev.magnets.filter(m => !idsToDelete.has(m.id));
return next;
});
setContextMenu(null);
@@ -464,10 +489,10 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const isFwd = state.runConfig.direction === 'FWD';
const sensorPos = isFwd ? frontSensor : rearSensor;
let bestMagnet: { mag: MagnetLine, dist: number, targetAngle: number, proj: {x:number, y:number} } | null = null;
let bestMagnet: { mag: MagnetLine, dist: number, targetAngle: number, proj: { x: number, y: number } } | null = null;
// Collect all candidate lines
const potentialLines: { mag: MagnetLine, dist: number, proj: {x:number, y:number}, targetAngle: number, angleDiff: number, category: string, isSticky: boolean, isBehind: boolean, isConnected: boolean, isSharpTurn: boolean }[] = [];
const potentialLines: { mag: MagnetLine, dist: number, proj: { x: number, y: number }, targetAngle: number, angleDiff: number, category: string, isSticky: boolean, isBehind: boolean, isConnected: boolean, isSharpTurn: boolean }[] = [];
map.magnets.forEach(mag => {
let dist = 999;
@@ -477,7 +502,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
if (mag.type === 'STRAIGHT') {
dist = distanceToSegment(sensorPos, mag.p1, mag.p2);
proj = getClosestPointOnSegment(sensorPos, mag.p1, mag.p2);
tangentAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180/Math.PI);
tangentAngle = Math.atan2(mag.p2.y - mag.p1.y, mag.p2.x - mag.p1.x) * (180 / Math.PI);
} else if (mag.type === 'CURVE' && mag.controlPoint) {
const res = distanceToBezier(sensorPos, mag.p1, mag.controlPoint, mag.p2);
dist = res.dist;
@@ -591,7 +616,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Debug Logging (Throttled ~ 30 frames)
if (logThrottleRef.current % 30 === 0 && isAutoMove) {
const logMsg = `Trk: found=${potentialLines.length} | ` +
potentialLines.map(p => `[${p.mag.id.substring(0,4)} D:${Math.round(p.dist)} S:${p.isSticky ? 'Y' : 'N'} M:${p.category === branchMode ? 'Y':'N'} !${p.isSharpTurn ? 'SHARP' : 'OK'}]`).join(' ');
potentialLines.map(p => `[${p.mag.id.substring(0, 4)} D:${Math.round(p.dist)} S:${p.isSticky ? 'Y' : 'N'} M:${p.category === branchMode ? 'Y' : 'N'} !${p.isSharpTurn ? 'SHARP' : 'OK'}]`).join(' ');
onLog(logMsg);
}
@@ -626,7 +651,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// CLAMP: Max lateral snap per frame to prevent teleportation
const MAX_SNAP = 2.0;
const moveDist = Math.sqrt(moveX*moveX + moveY*moveY);
const moveDist = Math.sqrt(moveX * moveX + moveY * moveY);
if (moveDist > MAX_SNAP) {
const ratio = MAX_SNAP / moveDist;
moveX *= ratio;
@@ -644,7 +669,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const otherSensor = isFwd ? rearSensor : frontSensor;
const dx = newSensorX - otherSensor.x;
const dy = newSensorY - otherSensor.y;
let newAngle = Math.atan2(dy, dx) * (180/Math.PI);
let newAngle = Math.atan2(dy, dx) * (180 / Math.PI);
if (!isFwd) newAngle += 180;
let diff = normalizeAngle(newAngle - state.rotation);
state.rotation += diff * 0.1; // Reduced significantly as derivative is more accurate
@@ -684,7 +709,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Debug Log
if (logThrottleRef.current % 10 === 0) {
onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y':'N'} RearLine=${lineRearDetected ? 'YES':'NO'}`);
onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} RearLine=${lineRearDetected ? 'YES' : 'NO'}`);
}
if (stopCondition || reachedLimit) {
@@ -708,7 +733,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Debug Log
if (logThrottleRef.current % 10 === 0) {
onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y':'N'} FrontLine=${lineFrontDetected ? 'YES':'NO'}`);
onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} FrontLine=${lineFrontDetected ? 'YES' : 'NO'}`);
}
if (stopCondition || reachedLimit) {
@@ -724,7 +749,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
state.sensorLineRear = lineRearDetected;
let rfidFound: string | null = null;
map.nodes.forEach(node => { if (node.rfidId && getDistance({x: state.x, y: state.y}, node) < 15) rfidFound = node.rfidId; });
map.nodes.forEach(node => { if (node.rfidId && getDistance({ x: state.x, y: state.y }, node) < 15) rfidFound = node.rfidId; });
state.detectedRfid = rfidFound;
let markFound = false;
@@ -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 (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(state);
setAgvState(prev => {
// 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.lineCap = 'round';
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.beginPath();
@@ -831,8 +863,9 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
}
ctx.stroke();
// Draw Controls for Selected Curve
if (isSelected && mag.type === 'CURVE' && mag.controlPoint) {
// Draw Controls for Selected Curve (Only if Single Selection for Simplicity handles)
if (isSelected && selectedItemIds.size === 1) {
if (mag.type === 'CURVE' && mag.controlPoint) {
// Helper Lines
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
ctx.lineWidth = 1;
@@ -845,25 +878,25 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
ctx.setLineDash([]);
// Control Points
const drawHandle = (p: {x:number, y:number}, color: string) => {
const drawHandle = (p: { x: number, y: number }, color: string) => {
ctx.fillStyle = color;
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();
};
drawHandle(mag.p1, '#3B82F6'); // Start (Blue)
drawHandle(mag.p2, '#3B82F6'); // End (Blue)
drawHandle(mag.controlPoint, '#EAB308'); // Control (Yellow)
} else if (isSelected && mag.type === 'STRAIGHT') {
} else if (mag.type === 'STRAIGHT') {
// 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.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x, p.y, 5 / t.scale, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
};
drawHandle(mag.p1, '#3B82F6');
drawHandle(mag.p2, '#3B82F6');
}
}
});
// Nodes (Unchanged)
@@ -873,7 +906,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const img = images[node.id];
if (img.complete) {
const w = 100; const h = (img.height / img.width) * w;
ctx.drawImage(img, node.x - w/2, node.y - h/2, w, h);
ctx.drawImage(img, node.x - w / 2, node.y - h / 2, w, h);
}
return;
}
@@ -881,7 +914,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
ctx.font = 'bold 20px Arial';
const tm = ctx.measureText(node.labelText);
const w = tm.width + 20; const h = 30;
if (node.backColor !== 'Transparent') { ctx.fillStyle = node.backColor!; ctx.fillRect(node.x - w/2, node.y - h/2, w, h); }
if (node.backColor !== 'Transparent') { ctx.fillStyle = node.backColor!; ctx.fillRect(node.x - w / 2, node.y - h / 2, w, h); }
ctx.fillStyle = node.foreColor || 'White'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.labelText, node.x, node.y);
return;
}
@@ -892,7 +925,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
else if (node.displayColor) color = node.displayColor;
// Highlight Selected Node
if (node.id === selectedItemId) {
if (selectedItemIds.has(node.id)) {
ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2;
ctx.strokeRect(node.x - 10, node.y - 10, 20, 20);
}
@@ -908,7 +941,8 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Marks
ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3;
map.marks.forEach(mark => {
if (mark.id === selectedItemId) {
const isSelected = selectedItemIds.has(mark.id);
if (isSelected) {
ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10;
} else {
ctx.shadowBlur = 0;
@@ -918,7 +952,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
ctx.shadowBlur = 0;
// Draw handles if selected
if (mark.id === selectedItemId) {
if (isSelected && selectedItemIds.size === 1) {
// Re-apply transform for simple drawing in local space
ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180);
@@ -926,7 +960,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Handles
const drawHandle = (x: number, y: number) => {
ctx.fillStyle = '#EAB308';
ctx.beginPath(); ctx.arc(x, y, 4 / t.scale, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(x, y, 4 / t.scale, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke();
};
drawHandle(-25, 0); // Start
@@ -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 --
if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = MAGNET_WIDTH;
ctx.beginPath();
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();
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
ctx.strokeStyle = '#3B82F6';
ctx.lineWidth = 1;
ctx.fillRect(minX, minY, w, h);
ctx.strokeRect(minX, minY, w, h);
}
// -- Logical Line --
// Drawing Helpers
// -- Logical Edge (Connection) --
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.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.stroke();
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) {
// ... (Keep existing drawing logic - ensuring it's not lost)
const p1 = tempMagnet.p1!;
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;
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);
} 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;
ctx.lineTo(control.x, control.y);
ctx.lineTo(p2.x, p2.y);
@@ -989,13 +1034,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
ctx.stroke();
ctx.setLineDash([]);
// Control Point Handle
if (control) {
ctx.fillStyle = '#60A5FA';
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.lineWidth = MAGNET_WIDTH;
ctx.beginPath();
@@ -1027,19 +1070,19 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const baseAngle = isFwd ? 0 : Math.PI; // 0 for Front, 180 for Rear
ctx.moveTo(sensorPos.x, sensorPos.y);
ctx.arc(sensorPos.x, sensorPos.y, 60, baseAngle - Math.PI/6, baseAngle + Math.PI/6); // +/- 30 degrees
ctx.arc(sensorPos.x, sensorPos.y, 60, baseAngle - Math.PI / 6, baseAngle + Math.PI / 6); // +/- 30 degrees
ctx.closePath();
ctx.fill();
ctx.stroke();
// Scanning line animation
const now = Date.now();
const scanOffset = Math.sin(now / 200) * (Math.PI/6);
const scanOffset = Math.sin(now / 200) * (Math.PI / 6);
const currentAngle = baseAngle + scanOffset;
ctx.beginPath();
ctx.moveTo(sensorPos.x, sensorPos.y);
ctx.lineTo(sensorPos.x + Math.cos(currentAngle)*60, sensorPos.y + Math.sin(currentAngle)*60);
ctx.lineTo(sensorPos.x + Math.cos(currentAngle) * 60, sensorPos.y + Math.sin(currentAngle) * 60);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.stroke();
@@ -1081,9 +1124,9 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
ctx.fillStyle = '#FFF';
const br = state.runConfig.branch;
if (br === 'LEFT') {
ctx.save(); ctx.translate(0, 10); ctx.rotate(-Math.PI/2); ctx.fillText('➜', 0, 0); ctx.restore();
ctx.save(); ctx.translate(0, 10); ctx.rotate(-Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore();
} else if (br === 'RIGHT') {
ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI/2); ctx.fillText('➜', 0, 0); ctx.restore();
ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore();
} else {
ctx.fillText('➜', 0, 10);
}
@@ -1099,10 +1142,10 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Key Head (Circle)
ctx.beginPath();
ctx.arc(0, -3, 3.5, 0, Math.PI*2);
ctx.arc(0, -3, 3.5, 0, Math.PI * 2);
ctx.stroke();
// Key Hole
ctx.beginPath(); ctx.arc(0, -3, 1, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(0, -3, 1, 0, Math.PI * 2); ctx.fill();
// Key Shaft
ctx.beginPath();
@@ -1119,7 +1162,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// Sensors
ctx.fillStyle = state.sensorLineFront ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_FRONT.x, SENSOR_OFFSET_FRONT.y, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = state.sensorLineRear ? '#F87171' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_REAR.x, SENSOR_OFFSET_REAR.y, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = state.sensorMark ? '#FFFF00' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_MARK.x, SENSOR_OFFSET_MARK.y, state.sensorMark ? 6 : 4, 0, Math.PI * 2); ctx.fill(); if(state.sensorMark) { ctx.strokeStyle='#FFF'; ctx.stroke(); }
ctx.fillStyle = state.sensorMark ? '#FFFF00' : '#4B5563'; ctx.beginPath(); ctx.arc(SENSOR_OFFSET_MARK.x, SENSOR_OFFSET_MARK.y, state.sensorMark ? 6 : 4, 0, Math.PI * 2); ctx.fill(); if (state.sensorMark) { ctx.strokeStyle = '#FFF'; ctx.stroke(); }
if (state.motionState === AgvMotionState.RUNNING) {
const activeOffset = state.runConfig.direction === 'FWD' ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR;
@@ -1137,7 +1180,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const interval = setInterval(updatePhysics, 16);
animationFrameId = requestAnimationFrame(draw);
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 ---
@@ -1213,13 +1256,20 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
return;
}
// Drag Handle (Editing)
if (dragHandle && selectedItemId) {
// Box Selection Update
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);
setMapData(prev => {
const next = { ...prev };
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) {
const newMags = [...next.magnets];
const mag = { ...newMags[magIndex] };
@@ -1231,7 +1281,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
}
} else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') {
// Rotate Mark
const markIndex = next.marks.findIndex(m => m.id === selectedItemId);
const markIndex = next.marks.findIndex(m => m.id === singleId);
if (markIndex !== -1) {
const newMarks = [...next.marks];
const mark = { ...newMarks[markIndex] };
@@ -1250,45 +1300,56 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
}
// Drag Object (Moving)
if (dragTarget && dragTarget.initialObjState) {
if (dragTarget) {
const dx = worldPos.x - dragTarget.startMouse.x;
const dy = worldPos.y - dragTarget.startMouse.y;
if (dragTarget.type === 'AGV') {
if (dragTarget.type === 'AGV' && dragTarget.initialAgvState) {
setAgvState(prev => ({
...prev,
x: dragTarget.initialObjState.x + dx,
y: dragTarget.initialObjState.y + dy
x: dragTarget.initialAgvState!.x + dx,
y: dragTarget.initialAgvState!.y + dy
}));
} else if (dragTarget.type === 'NODE' && dragTarget.id) {
const snapped = getSnappedPos(dragTarget.initialObjState.x + dx, dragTarget.initialObjState.y + dy);
setMapData(prev => ({
...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;
} else if (dragTarget.type === 'MULTI' && dragTarget.initialStates) {
const initialStates = dragTarget.initialStates;
setMapData(prev => {
const next = { ...prev };
setMapData(prev => ({
...prev,
magnets: prev.magnets.map(m => {
if (m.id === dragTarget.id) {
// Update Nodes
next.nodes = prev.nodes.map(n => {
const init = initialStates[n.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 };
newM.p1 = { x: m0.p1.x + dxw, y: m0.p1.y + dyw };
newM.p2 = { x: m0.p2.x + dxw, y: m0.p2.y + dyw };
if (m0.controlPoint) newM.controlPoint = { x: m0.controlPoint.x + dxw, y: m0.controlPoint.y + dyw };
newM.p1 = { x: init.p1.x + dx, y: init.p1.y + dy };
newM.p2 = { x: init.p2.x + dx, y: init.p2.y + dy };
if (init.controlPoint && newM.controlPoint) {
newM.controlPoint = { x: init.controlPoint.x + dx, y: init.controlPoint.y + dy };
}
return newM;
}
return m;
})
}));
});
return next;
});
}
return;
}
@@ -1299,10 +1360,43 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
setDragHandle(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
if (activeTool === ToolType.DRAW_LINE && dragStartPos && startNodeId) {
const { x, y } = mouseWorldPosRef.current;
const existingNode = mapData.nodes.find(n => getDistance(n, {x, y}) < 20);
const existingNode = mapData.nodes.find(n => getDistance(n, { x, y }) < 20);
if (existingNode && existingNode.id !== startNodeId) {
const edgeExists = mapData.edges.some(e =>
(e.from === startNodeId && e.to === existingNode.id) ||
@@ -1334,6 +1428,8 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
}
}
setDragStartPos(null);
setStartNodeId(null);
};
@@ -1367,18 +1463,50 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
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) {
// Check handles
// Check handles (Only if SINGLE item selected for now, to avoid complexity)
const worldTolerance = 15 / viewRef.current.scale;
if (selectedItemId) {
const selectedMag = mapData.magnets.find(m => m.id === selectedItemId);
if (selectedItemIds.size === 1) {
const singleId = Array.from(selectedItemIds)[0];
const selectedMag = mapData.magnets.find(m => m.id === singleId);
if (selectedMag) {
if (getDistance(worldPos, selectedMag.p1) < worldTolerance) { setDragHandle('p1'); return; }
if (getDistance(worldPos, selectedMag.p2) < worldTolerance) { setDragHandle('p2'); 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) {
const rad = (selectedMark.rotation * Math.PI) / 180;
const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25;
@@ -1392,33 +1520,51 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
// AGV
const agvDist = getDistance(worldPos, agvState);
if (agvDist < 30) {
setDragTarget({ type: 'AGV', startMouse: worldPos, initialObjState: { x: agvState.x, y: agvState.y } });
setSelectedItemId(null);
setDragTarget({ type: 'AGV', startMouse: worldPos, initialAgvState: { x: agvState.x, y: agvState.y } });
setSelectedItemIds(new Set());
return;
}
// Map Objects
const hits = getObjectsAt(x, y);
const movableHits = hits.filter(h => h.type !== 'EDGE');
if (movableHits.length > 0) {
const hit = movableHits[0];
setSelectedItemId(hit.id);
let initialObjState = null;
if (hit.type === 'NODE') {
const n = mapData.nodes.find(o => o.id === hit.id);
if (n) initialObjState = { x: n.x, y: n.y };
} else if (hit.type === 'MARK') {
const m = mapData.marks.find(o => o.id === 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 });
}
const isMultiSelect = e.shiftKey || e.ctrlKey;
let newSelection = new Set(selectedItemIds);
if (isMultiSelect) {
if (newSelection.has(hit.id)) newSelection.delete(hit.id);
else newSelection.add(hit.id);
} 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;
}
@@ -1428,6 +1574,11 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
const start = getSnappedPos(x, y);
setTempMagnet({ id: crypto.randomUUID(), type: 'CURVE', p1: start, p2: start, controlPoint: start });
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) {
// Finish curve
if (tempMagnet && tempMagnet.p1 && tempMagnet.p2) {
@@ -1488,19 +1639,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
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 (
@@ -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>}
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-70">
<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>
@@ -1537,7 +1676,7 @@ const SimulationCanvas: React.FC<SimulationCanvasProps> = ({ activeTool, mapData
>
<div className="flex items-center justify-between px-2 py-1 border-b border-gray-700 text-xs font-bold text-gray-400">
<span>Select object to delete</span>
<button onClick={() => setContextMenu(null)} className="hover:text-white"><X size={12}/></button>
<button onClick={() => setContextMenu(null)} className="hover:text-white"><X size={12} /></button>
</div>
<ul className="text-xs">
{contextMenu.items.map((item, idx) => (

View File

@@ -86,7 +86,12 @@ export class SerialPortHandler {
} else {
payload = data;
}
try {
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
}
}
}
@@ -94,7 +99,7 @@ export class SerialPortHandler {
if (this.port) {
const info = this.port.getInfo();
if (info.usbVendorId && info.usbProductId) {
return `ID:${info.usbVendorId.toString(16).padStart(4,'0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4,'0').toUpperCase()}`;
return `ID:${info.usbVendorId.toString(16).padStart(4, '0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4, '0').toUpperCase()}`;
}
return "USB Device";
}

View File

@@ -44,6 +44,18 @@ export interface MapNode extends Point {
// New props preservation
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 {