import React, { useRef, useEffect, useState, useCallback } from 'react'; import { SimulationMap, AgvState, ToolType, MapNode, MapEdge, MagnetLine, AgvMotionState, NodeType, FloorMark } from '../types'; import { AGV_WIDTH, AGV_HEIGHT, SENSOR_OFFSET_FRONT, SENSOR_OFFSET_REAR, SENSOR_OFFSET_MARK, MAX_SPEED, TURN_SPEED, MARK_SEARCH_SPEED, SPEED_L, SPEED_M, SPEED_H, MAGNET_WIDTH, MAGNET_COLOR, GRID_SIZE } from '../constants'; import { Trash2, X, Move } from 'lucide-react'; interface SimulationCanvasProps { activeTool: ToolType; mapData: SimulationMap; setMapData: (data: SimulationMap | ((prev: SimulationMap) => SimulationMap)) => void; agvState: AgvState; setAgvState: (state: AgvState | ((prev: AgvState) => AgvState)) => void; onLog: (msg: string) => void; onSelectionChange?: (selectedIds: Set) => void; } interface HitObject { type: 'NODE' | 'EDGE' | 'MAGNET' | 'MARK'; id: string; name: string; // For display dist: number; } const SimulationCanvas: React.FC = ({ activeTool, mapData, setMapData, agvState, setAgvState, onLog, onSelectionChange }) => { const canvasRef = useRef(null); const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); // --- View Transform (Zoom/Pan) --- const viewRef = useRef({ x: 0, y: 0, scale: 1 }); const [isPanning, setIsPanning] = useState(false); const isSpacePressedRef = useRef(false); // Interaction State const [dragStartPos, setDragStartPos] = useState<{ x: number, y: number } | null>(null); const [startNodeId, setStartNodeId] = useState(null); // Selection & Editing State // Changed: Support multiple items const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); // dragHandle: 'p1'|'p2'|'control' for magnets, 'mark_p1'|'mark_p2' for marks const [dragHandle, setDragHandle] = useState(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: 'MULTI' | 'AGV'; startMouse: { x: number, y: number }; // For MULTI: store map of {id: initialState} initialStates?: Record; // For AGV single move initialAgvState?: { x: number, y: number }; } | null>(null); // Curve Drawing State const [curvePhase, setCurvePhase] = useState<0 | 1 | 2>(0); const [tempMagnet, setTempMagnet] = useState | null>(null); // Eraser Context Menu State const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: HitObject[] } | null>(null); // Image Cache const imageCacheRef = useRef>({}); // Use ref for mouse position (Screen Coords) const mouseScreenPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); // Use ref for mouse position (World Coords) const mouseWorldPosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 }); // Refs for loop access const mapDataRef = useRef(mapData); const agvStateRef = useRef(agvState); // Track previous sensor position for intersection checking const prevMarkSensorPosRef = useRef<{ x: number, y: number } | null>(null); // Track the ID of the magnet currently being followed to prevent jumping const lastMagnetIdRef = useRef(null); // Throttle log const logThrottleRef = useRef(0); // Sync refs useEffect(() => { mapDataRef.current = mapData; }, [mapData]); useEffect(() => { agvStateRef.current = agvState; }, [agvState]); // Handle Resize useEffect(() => { const updateSize = () => { if (containerRef.current) { setDimensions({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight }); } }; updateSize(); const observer = new ResizeObserver(updateSize); if (containerRef.current) { observer.observe(containerRef.current); } return () => observer.disconnect(); }, []); // Keyboard Event for Spacebar Panning useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === 'Space') isSpacePressedRef.current = true; if (e.key === 'Escape') { setSelectedItemIds(new Set()); setDragHandle(null); setDragTarget(null); setContextMenu(null); setSelectionBox(null); } }; const handleKeyUp = (e: KeyboardEvent) => { if (e.code === 'Space') isSpacePressedRef.current = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []); // Reset tool states when tool changes useEffect(() => { setCurvePhase(0); setTempMagnet(null); setDragStartPos(null); setStartNodeId(null); setContextMenu(null); setDragTarget(null); setDragHandle(null); // Note: We might want to keep selection when switching tools, but often resetting is safer if (activeTool !== ToolType.SELECT) setSelectedItemIds(new Set()); }, [activeTool]); // Preload Images useEffect(() => { const cache = imageCacheRef.current; mapData.nodes.forEach(node => { if (node.type === NodeType.Image && node.imageBase64 && !cache[node.id]) { const img = new Image(); let src = node.imageBase64; if (!src.startsWith('data:image')) { src = `data:image/png;base64,${src}`; } img.src = src; cache[node.id] = img; } }); }, [mapData]); // --- Coordinate Transformation Helpers --- const screenToWorld = (screenX: number, screenY: number) => { const t = viewRef.current; return { x: (screenX - t.x) / t.scale, y: (screenY - t.y) / t.scale }; }; const worldToScreen = (worldX: number, worldY: number) => { const t = viewRef.current; return { x: worldX * t.scale + t.x, y: worldY * t.scale + t.y }; }; // --- Physics Helper Functions --- const rotatePoint = (px: number, py: number, cx: number, cy: number, angleDeg: number) => { const rad = (angleDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const dx = px - cx; const dy = py - cy; return { x: cx + (dx * cos - dy * sin), y: cy + (dx * sin + dy * cos), }; }; 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 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; t = Math.max(0, Math.min(1, t)); const projection = { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; return getDistance(p, projection); }; // Get closest point on segment 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; t = Math.max(0, Math.min(1, t)); return { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) }; }; // Quadratic Bezier Point Calculation 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; return { x: (uu * p0.x) + (2 * u * t * p1.x) + (tt * p2.x), y: (uu * p0.y) + (2 * u * t * p1.y) + (tt * p2.y) }; }; // 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 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); const dy = 2 * u * (p1.y - p0.y) + 2 * t * (p2.y - p1.y); return { x: dx, y: dy }; }; // 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) => { let minDist = Number.MAX_VALUE; let prevPoint = p0; let closestProj = p0; let bestT = 0; // Scan segments to find approximate closest point for (let i = 1; i <= segments; i++) { const t = i / segments; const currPoint = getBezierPoint(t, p0, p1, p2); const dist = distanceToSegment(p, prevPoint, currPoint); if (dist < minDist) { minDist = dist; // Get linear projection on this segment to approximate t const proj = getClosestPointOnSegment(p, prevPoint, currPoint); closestProj = proj; // Approximate t at projection (Lerp) const segLen = getDistance(prevPoint, currPoint); const distOnSeg = getDistance(prevPoint, proj); const tStep = 1 / segments; const tStart = (i - 1) / segments; bestT = tStart + (distOnSeg / segLen) * tStep; } prevPoint = currPoint; } // Calculate Exact Tangent using Derivative at bestT const deriv = getBezierDerivative(bestT, p0, p1, p2); const tangentAngle = Math.atan2(deriv.y, deriv.x) * (180 / Math.PI); return { dist: minDist, proj: closestProj, angle: tangentAngle }; }; const normalizeAngle = (angle: number) => { let a = angle % 360; if (a > 180) a -= 360; if (a <= -180) a += 360; 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); return (ccw(p1, p2, p3) !== ccw(p1, p2, p4)) && (ccw(p3, p4, p1) !== ccw(p3, p4, p2)); }; // Helper to find closest magnet and its angle const getClosestMagnetInfo = (x: number, y: number) => { const map = mapDataRef.current; const p = { x, y }; let closestMag = null; let minDist = 30; // Search radius let angle = 0; map.magnets.forEach(mag => { let dist = 999; let magAngle = 0; 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); } else if (mag.type === 'CURVE' && mag.controlPoint) { const res = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2); dist = res.dist; magAngle = res.angle; } if (dist < minDist) { minDist = dist; closestMag = mag; angle = magAngle; } }); return { magnet: closestMag, angle }; }; // --- Hit Test Logic --- const getObjectsAt = (x: number, y: number, tolerance = 15): HitObject[] => { const hits: HitObject[] = []; const map = mapDataRef.current; const p = { x, y }; const worldTolerance = tolerance / viewRef.current.scale; // Type Priority Map: Smaller number = Higher Priority const typePriority: Record = { 'NODE': 1, 'MARK': 2, 'EDGE': 3, 'MAGNET': 4 }; // 1. Nodes map.nodes.forEach(node => { if (getDistance(p, node) < worldTolerance) { hits.push({ type: 'NODE', id: node.id, name: node.name || node.id, dist: getDistance(p, node) }); } }); // 2. Marks (Calculate as segment for better hit detection) map.marks.forEach(mark => { // Visual width is 50px (-25 to 25). const rad = (mark.rotation * Math.PI) / 180; const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; const p1 = { x: mark.x - dx, y: mark.y - dy }; const p2 = { x: mark.x + dx, y: mark.y + dy }; const dist = distanceToSegment(p, p1, p2); if (dist < worldTolerance) { hits.push({ type: 'MARK', id: mark.id, name: 'Mark', dist }); } }); // 3. Magnets map.magnets.forEach(mag => { let dist = 999; if (mag.type === 'STRAIGHT') { dist = distanceToSegment(p, mag.p1, mag.p2); } else if (mag.type === 'CURVE' && mag.controlPoint) { dist = distanceToBezier(p, mag.p1, mag.controlPoint, mag.p2).dist; } if (dist < worldTolerance) { hits.push({ type: 'MAGNET', id: mag.id, name: `Magnet (${mag.type})`, dist }); } }); // 4. Edges map.edges.forEach(edge => { const start = map.nodes.find(n => n.id === edge.from); const end = map.nodes.find(n => n.id === edge.to); if (start && end) { const dist = distanceToSegment(p, start, end); if (dist < worldTolerance / 2) { hits.push({ type: 'EDGE', id: edge.id, name: `Line ${edge.from}-${edge.to}`, dist }); } } }); // Sort by Priority first, then Distance return hits.sort((a, b) => { const prioDiff = typePriority[a.type] - typePriority[b.type]; if (prioDiff !== 0) return prioDiff; return a.dist - b.dist; }); }; const deleteObject = (item: HitObject) => { // If the item clicked is part of selection, delete ALL selected. // If not part of selection, delete just that item. const idsToDelete = new Set(); 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 }; // 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); }; // --- Main Simulation Loop --- useEffect(() => { let animationFrameId: number; const updatePhysics = () => { const state = { ...agvStateRef.current }; const map = mapDataRef.current; let hasChanged = false; logThrottleRef.current++; // Update sensors const frontSensor = rotatePoint( state.x + SENSOR_OFFSET_FRONT.x, state.y + SENSOR_OFFSET_FRONT.y, state.x, state.y, state.rotation ); const rearSensor = rotatePoint( state.x + SENSOR_OFFSET_REAR.x, state.y + SENSOR_OFFSET_REAR.y, state.x, state.y, state.rotation ); const markSensor = rotatePoint( state.x + SENSOR_OFFSET_MARK.x, state.y + SENSOR_OFFSET_MARK.y, state.x, state.y, state.rotation ); if (!prevMarkSensorPosRef.current) prevMarkSensorPosRef.current = markSensor; let lineFrontDetected = false; let lineRearDetected = false; // FIXED: Very tight threshold to ensure the sensor must physically be ON the line // Reduced from 12 to 8 to prevent "grazing" false positives during tight turns const DETECTION_THRESHOLD = 8; const SEARCH_RADIUS = 30; // 1. Check Strict Sensor Status (for UI/State) map.magnets.forEach(mag => { let distFront = 999; let distRear = 999; if (mag.type === 'STRAIGHT') { distFront = distanceToSegment(frontSensor, mag.p1, mag.p2); distRear = distanceToSegment(rearSensor, mag.p1, mag.p2); } else if (mag.type === 'CURVE' && mag.controlPoint) { distFront = distanceToBezier(frontSensor, mag.p1, mag.controlPoint, mag.p2).dist; distRear = distanceToBezier(rearSensor, mag.p1, mag.controlPoint, mag.p2).dist; } if (distFront < DETECTION_THRESHOLD) lineFrontDetected = true; if (distRear < DETECTION_THRESHOLD) lineRearDetected = true; }); // Handle Line Out Logic const isAutoMove = state.motionState === AgvMotionState.RUNNING || state.motionState === AgvMotionState.MARK_STOPPING; if (state.motionState !== AgvMotionState.IDLE && !state.error) { hasChanged = true; let moveSpeed = state.speed; if (state.motionState === AgvMotionState.RUNNING) { moveSpeed = state.runConfig.speedLevel === 'L' ? SPEED_L : state.runConfig.speedLevel === 'M' ? SPEED_M : SPEED_H; } else if (state.motionState === AgvMotionState.MARK_STOPPING) { moveSpeed = Math.min(state.speed, MARK_SEARCH_SPEED); } // --- LINE TRACING --- if (isAutoMove) { 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; // 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 }[] = []; map.magnets.forEach(mag => { let dist = 999; let proj = { x: 0, y: 0 }; let tangentAngle = 0; 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); } else if (mag.type === 'CURVE' && mag.controlPoint) { const res = distanceToBezier(sensorPos, mag.p1, mag.controlPoint, mag.p2); dist = res.dist; proj = res.proj; tangentAngle = res.angle; } if (dist < SEARCH_RADIUS) { let heading = state.rotation; if (!isFwd) heading += 180; heading = normalizeAngle(heading); // Align tangent to heading for basic diff calc let alignedTangent = tangentAngle; if (mag.type === 'STRAIGHT') { let diff = normalizeAngle(tangentAngle - heading); if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); } else { let diff = normalizeAngle(tangentAngle - heading); if (Math.abs(diff) > 90) alignedTangent = normalizeAngle(tangentAngle + 180); } let diff = normalizeAngle(alignedTangent - heading); // --- Node-based Lookahead / Category --- let comparisonAngle = alignedTangent; const d1 = getDistance(sensorPos, mag.p1); const d2 = getDistance(sensorPos, mag.p2); const farEnd = d1 > d2 ? mag.p1 : mag.p2; // Heuristic for segment end const targetNode = map.nodes.find(n => getDistance(n, farEnd) < 40); if (targetNode) { const nodeAngle = Math.atan2(targetNode.y - sensorPos.y, targetNode.x - sensorPos.x) * (180 / Math.PI); comparisonAngle = normalizeAngle(nodeAngle); } let finalDiff = normalizeAngle(comparisonAngle - heading); let category = 'STRAIGHT'; if (finalDiff < -45) category = 'LEFT'; else if (finalDiff > 45) category = 'RIGHT'; // --- Behind Check --- const dx = proj.x - sensorPos.x; const dy = proj.y - sensorPos.y; const hRad = (heading * Math.PI) / 180; const hx = Math.cos(hRad); const hy = Math.sin(hRad); let isSticky = lastMagnetIdRef.current === mag.id; // Release Sticky if End of Line Reached if (isSticky) { const distToP1 = getDistance(sensorPos, mag.p1); const distToP2 = getDistance(sensorPos, mag.p2); if (distToP1 < 25 || distToP2 < 25) { isSticky = false; } } // Dot product check const behindThreshold = isSticky ? -25 : -5; const isBehind = (dx * hx + dy * hy) < behindThreshold; // --- STRICT CONNECTIVITY CHECK --- const distToEndpoint = Math.min(getDistance(sensorPos, mag.p1), getDistance(sensorPos, mag.p2)); const isConnected = distToEndpoint < 30; // --- PHYSICAL CONSTRAINT (SHARP TURN) --- // If deflection is > 35 degrees (Internal angle < 145), it is too sharp for normal line tracking // This forces the AGV to ignore 90-degree junctions unless there is a curve magnet const isSharpTurn = Math.abs(diff) > 35; potentialLines.push({ mag, dist, proj, targetAngle: alignedTangent, angleDiff: diff, category, isSticky, isBehind, isConnected, isSharpTurn }); } }); // Priority Sort const branchMode = state.runConfig.branch; potentialLines.sort((a, b) => { // 1. Behind Check (Absolute disqualifier usually) if (a.isBehind !== b.isBehind) return a.isBehind ? 1 : -1; // 2. PHYSICAL CONSTRAINT: Disqualify Sharp Turns // A sharp turn is physically impossible in motion, so we deprioritize it heavily. if (a.isSharpTurn !== b.isSharpTurn) return a.isSharpTurn ? 1 : -1; const aValid = a.isSticky || a.isConnected; const bValid = b.isSticky || b.isConnected; // 3. Validity Check: We prefer lines that are physically anchored here if (aValid !== bValid) return aValid ? -1 : 1; // 4. Branch Matching (Only among valid lines) if (aValid && bValid) { const aMatch = a.category === branchMode; const bMatch = b.category === branchMode; if (aMatch !== bMatch) return aMatch ? -1 : 1; } // 5. Stickiness (Stability bias) if (a.isSticky !== b.isSticky) return a.isSticky ? -1 : 1; // 6. Connectivity (Prefer line starts over mid-lines) if (a.isConnected !== b.isConnected) return a.isConnected ? -1 : 1; // 7. Distance return a.dist - b.dist; }); // 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(' '); onLog(logMsg); } if (potentialLines.length > 0) { bestMagnet = potentialLines[0]; lastMagnetIdRef.current = bestMagnet.mag.id; } // *** LINE OUT CHECK INTEGRATED HERE *** // Only error out if NO valid lines are found in search radius if (!bestMagnet && isAutoMove) { state.motionState = AgvMotionState.IDLE; state.error = 'LINE_OUT'; hasChanged = true; onLog(`STOPPED: LINE_OUT. Sensors: F=${lineFrontDetected}, R=${lineRearDetected}`); } else if (bestMagnet) { // Adaptive Steering const angleError = normalizeAngle(bestMagnet.targetAngle - (isFwd ? state.rotation : state.rotation + 180)); // Sharp turn handling - Reduced aggression to prevent jitter const steerFactor = Math.abs(angleError) > 20 ? 0.1 : 0.05; state.rotation += angleError * steerFactor; const driftX = bestMagnet.proj.x - sensorPos.x; const driftY = bestMagnet.proj.y - sensorPos.y; // Position correction (Damping + Clamping) // This prevents "Warping" when switching to lines that are found far away (e.g. 60px) const driftFactor = 0.1; // Reduced from 0.3 for smoother path following let moveX = driftX * driftFactor; let moveY = driftY * driftFactor; // CLAMP: Max lateral snap per frame to prevent teleportation const MAX_SNAP = 2.0; const moveDist = Math.sqrt(moveX * moveX + moveY * moveY); if (moveDist > MAX_SNAP) { const ratio = MAX_SNAP / moveDist; moveX *= ratio; moveY *= ratio; } state.x += moveX; state.y += moveY; if (bestMagnet.mag.type === 'CURVE') { // Extra rotation correction for curves is now handled better by Derivative angle // But we keep a small correctional factor for drift const newSensorX = sensorPos.x + driftX; const newSensorY = sensorPos.y + driftY; const otherSensor = isFwd ? rearSensor : frontSensor; const dx = newSensorX - otherSensor.x; const dy = newSensorY - otherSensor.y; 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 } // Apply Movement const rad = (state.rotation * Math.PI) / 180; const dirMult = isFwd ? 1 : -1; state.x += Math.cos(rad) * moveSpeed * dirMult; state.y += Math.sin(rad) * moveSpeed * dirMult; } } else { // Manual Movements (unchanged) const rad = (state.rotation * Math.PI) / 180; if (state.motionState === AgvMotionState.FORWARD) { state.x += Math.cos(rad) * moveSpeed; state.y += Math.sin(rad) * moveSpeed; } else if (state.motionState === AgvMotionState.BACKWARD) { state.x -= Math.cos(rad) * moveSpeed; state.y -= Math.sin(rad) * moveSpeed; } else if (state.motionState === AgvMotionState.TURN_LEFT) state.rotation -= TURN_SPEED; else if (state.motionState === AgvMotionState.TURN_RIGHT) state.rotation += TURN_SPEED; else if (state.motionState === AgvMotionState.TURN_LEFT_180) { state.rotation -= TURN_SPEED; // 180 Turn Logic (L-Turn = CCW): // Stop when REAR sensor detects a new line (e.g. 90 degree cross), but ignore first ~60 degrees to clear original line. const startRot = state.targetRotation !== null ? state.targetRotation + 180 : state.rotation; const degreesTurned = startRot - state.rotation; // Positive increasing const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 to prevent early false detection // L-Turn: Check REAR sensor for stop condition const stopCondition = !ignoreSensors && lineRearDetected; const reachedLimit = (state.targetRotation !== null && state.rotation <= state.targetRotation); // Debug Log if (logThrottleRef.current % 10 === 0) { onLog(`Turn180 [L]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} RearLine=${lineRearDetected ? 'YES' : 'NO'}`); } if (stopCondition || reachedLimit) { if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; state.motionState = AgvMotionState.IDLE; state.targetRotation = null; onLog(`Turn180 [L] Complete. Reason: ${stopCondition ? 'Rear Sensor Hit' : 'Angle Reached'}`); } } else if (state.motionState === AgvMotionState.TURN_RIGHT_180) { state.rotation += TURN_SPEED; // 180 Turn Logic (R-Turn = CW): // Stop when FRONT sensor detects a new line, but ignore first ~60 degrees. const startRot = state.targetRotation !== null ? state.targetRotation - 180 : state.rotation; const degreesTurned = state.rotation - startRot; // Positive increasing const ignoreSensors = degreesTurned < 60; // Increased from 40 to 60 // R-Turn: Check FRONT sensor for stop condition const stopCondition = !ignoreSensors && lineFrontDetected; const reachedLimit = (state.targetRotation !== null && state.rotation >= state.targetRotation); // Debug Log if (logThrottleRef.current % 10 === 0) { onLog(`Turn180 [R]: Deg=${degreesTurned.toFixed(1)}/180 Ignore=${ignoreSensors ? 'Y' : 'N'} FrontLine=${lineFrontDetected ? 'YES' : 'NO'}`); } if (stopCondition || reachedLimit) { if (reachedLimit && state.targetRotation !== null) state.rotation = state.targetRotation; state.motionState = AgvMotionState.IDLE; state.targetRotation = null; onLog(`Turn180 [R] Complete. Reason: ${stopCondition ? 'Front Sensor Hit' : 'Angle Reached'}`); } } } } state.sensorLineFront = lineFrontDetected; 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; }); state.detectedRfid = rfidFound; let markFound = false; const prevSensorPos = prevMarkSensorPosRef.current; if (prevSensorPos) { map.marks.forEach(mark => { const rad = (mark.rotation * Math.PI) / 180; const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; const ms = { x: mark.x - dx, y: mark.y - dy }; const me = { x: mark.x + dx, y: mark.y + dy }; // Check 1: Dynamic Crossing (Intersection) if (doSegmentsIntersect(prevSensorPos, markSensor, ms, me)) { markFound = true; } // Check 2: Static Proximity (Distance to line segment) // This ensures sensor stays ON when sitting on the mark if (!markFound) { const distToMarkLine = distanceToSegment(markSensor, ms, me); // If within 10px of the mark tape (tape width is technically line width but visually 3px, sensor range is wider) if (distToMarkLine < 10) { markFound = true; } } }); } prevMarkSensorPosRef.current = { x: markSensor.x, y: markSensor.y }; state.sensorMark = markFound; 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) { 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; }); } }; const draw = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Clear Screen ctx.clearRect(0, 0, canvas.width, canvas.height); // --- Apply Zoom & Pan --- const t = viewRef.current; ctx.save(); ctx.translate(t.x, t.y); ctx.scale(t.scale, t.scale); // --- Draw Infinite Grid --- const visibleLeft = -t.x / t.scale; const visibleTop = -t.y / t.scale; const visibleRight = (canvas.width - t.x) / t.scale; const visibleBottom = (canvas.height - t.y) / t.scale; const startX = Math.floor(visibleLeft / GRID_SIZE) * GRID_SIZE; const endX = Math.floor(visibleRight / GRID_SIZE) * GRID_SIZE + GRID_SIZE; const startY = Math.floor(visibleTop / GRID_SIZE) * GRID_SIZE; const endY = Math.floor(visibleBottom / GRID_SIZE) * GRID_SIZE + GRID_SIZE; ctx.strokeStyle = '#374151'; ctx.lineWidth = 1; ctx.beginPath(); // Verticals for (let x = startX; x <= endX; x += GRID_SIZE) { ctx.moveTo(x, startY); ctx.lineTo(x, endY); } // Horizontals for (let y = startY; y <= endY; y += GRID_SIZE) { ctx.moveTo(startX, y); ctx.lineTo(endX, y); } ctx.stroke(); const map = mapDataRef.current; // 1. Logical Edges (Thin Gray) ctx.strokeStyle = '#4B5563'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); map.edges.forEach(edge => { const start = map.nodes.find(n => n.id === edge.from); const end = map.nodes.find(n => n.id === edge.to); if (start && end) { ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); } }); ctx.setLineDash([]); // 2. Magnet Lines (Wide, Transparent Blue) ctx.lineWidth = MAGNET_WIDTH; ctx.lineCap = 'round'; map.magnets.forEach(mag => { const isSelected = selectedItemIds.has(mag.id); ctx.strokeStyle = isSelected ? 'rgba(59, 130, 246, 0.8)' : MAGNET_COLOR; // Brighter if selected ctx.beginPath(); ctx.moveTo(mag.p1.x, mag.p1.y); if (mag.type === 'STRAIGHT') { ctx.lineTo(mag.p2.x, mag.p2.y); } else if (mag.type === 'CURVE' && mag.controlPoint) { ctx.quadraticCurveTo(mag.controlPoint.x, mag.controlPoint.y, mag.p2.x, mag.p2.y); } ctx.stroke(); // 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; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(mag.p1.x, mag.p1.y); ctx.lineTo(mag.controlPoint.x, mag.controlPoint.y); ctx.lineTo(mag.p2.x, mag.p2.y); ctx.stroke(); ctx.setLineDash([]); // Control Points 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.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 (mag.type === 'STRAIGHT') { // Simple handles for straight lines 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.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); }; drawHandle(mag.p1, '#3B82F6'); drawHandle(mag.p2, '#3B82F6'); } } }); // Nodes (Unchanged) const images = imageCacheRef.current; map.nodes.forEach(node => { if (node.type === NodeType.Image && images[node.id]) { 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); } return; } if (node.type === NodeType.Label && node.labelText) { 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); } ctx.fillStyle = node.foreColor || 'White'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(node.labelText, node.x, node.y); return; } let color = '#4B5563'; if (node.type === NodeType.Loader || node.type === NodeType.UnLoader) color = 'green'; else if (node.type === NodeType.Charging || node.type === NodeType.ChargerStation) color = 'magenta'; else if (node.type === NodeType.Buffer) color = 'green'; else if (node.displayColor) color = node.displayColor; // Highlight Selected Node if (selectedItemIds.has(node.id)) { ctx.strokeStyle = '#FDE047'; ctx.lineWidth = 2; ctx.strokeRect(node.x - 10, node.y - 10, 20, 20); } ctx.fillStyle = color; if (node.type !== NodeType.Normal) ctx.fillRect(node.x - 6, node.y - 6, 12, 12); else { ctx.beginPath(); ctx.arc(node.x, node.y, 4, 0, Math.PI * 2); ctx.fill(); } ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.font = '10px monospace'; ctx.fillStyle = '#9CA3AF'; const topText = node.rfidId ? `[${node.rfidId}]` : node.id; ctx.fillText(topText, node.x, node.y - 10); if (node.name) { ctx.font = 'bold 10px Arial'; ctx.fillStyle = '#FFFFFF'; ctx.fillText(node.name, node.x, node.y + 18); } }); // Marks ctx.strokeStyle = '#FCD34D'; ctx.lineWidth = 3; map.marks.forEach(mark => { const isSelected = selectedItemIds.has(mark.id); if (isSelected) { ctx.shadowColor = '#FDE047'; ctx.shadowBlur = 10; } else { ctx.shadowBlur = 0; } ctx.save(); ctx.translate(mark.x, mark.y); ctx.rotate((mark.rotation * Math.PI) / 180); ctx.beginPath(); ctx.moveTo(-25, 0); ctx.lineTo(25, 0); ctx.stroke(); ctx.restore(); ctx.shadowBlur = 0; // Draw handles if selected 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); // 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.strokeStyle = '#FFF'; ctx.lineWidth = 1; ctx.stroke(); }; drawHandle(-25, 0); // Start drawHandle(25, 0); // End ctx.restore(); } }); // 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); 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); } // Drawing Helpers // -- Logical Edge (Connection) -- if (activeTool === ToolType.DRAW_LINE && dragStartPos) { ctx.strokeStyle = '#9CA3AF'; // Gray ctx.lineWidth = 1; ctx.setLineDash([4, 4]); // Dashed ctx.beginPath(); ctx.moveTo(dragStartPos.x, dragStartPos.y); const curr = mouseWorldPosRef.current; ctx.lineTo(curr.x, curr.y); ctx.stroke(); ctx.setLineDash([]); } // -- 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); // Visualize Construction ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); let control = tempMagnet.controlPoint; if (curvePhase === 1) { control = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; ctx.lineTo(p2.x, p2.y); } else if (curvePhase === 2) { control = mouseWorldPosRef.current; ctx.lineTo(control.x, control.y); ctx.lineTo(p2.x, p2.y); } ctx.stroke(); ctx.setLineDash([]); if (control) { ctx.fillStyle = '#60A5FA'; ctx.beginPath(); ctx.arc(control.x, control.y, 4, 0, Math.PI * 2); ctx.fill(); } ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; ctx.lineWidth = MAGNET_WIDTH; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); if (control) { ctx.quadraticCurveTo(control.x, control.y, p2.x, p2.y); } else { ctx.lineTo(p2.x, p2.y); } ctx.stroke(); } // AGV Drawing (Same as before) const state = agvStateRef.current; ctx.save(); ctx.translate(state.x, state.y); ctx.rotate((state.rotation * Math.PI) / 180); // Draw LiDAR Scanner if active (Directional: Front or Rear based on Motor Direction) if (state.lidarEnabled) { const isFwd = state.runConfig.direction === 'FWD'; const sensorPos = isFwd ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; ctx.save(); // Arc Scanner ctx.fillStyle = 'rgba(6, 182, 212, 0.2)'; // Cyan transparent ctx.strokeStyle = 'rgba(6, 182, 212, 0.5)'; ctx.lineWidth = 1; ctx.beginPath(); 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.closePath(); ctx.fill(); ctx.stroke(); // Scanning line animation const now = Date.now(); 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.strokeStyle = 'rgba(255, 255, 255, 0.5)'; ctx.stroke(); ctx.restore(); } ctx.fillStyle = '#1F2937'; ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 2; ctx.fillRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); ctx.strokeRect(-AGV_WIDTH / 2, -AGV_HEIGHT / 2, AGV_WIDTH, AGV_HEIGHT); if (state.error) { ctx.fillStyle = '#EF4444'; ctx.beginPath(); ctx.arc(0, 0, 15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#FFF'; ctx.font = 'bold 8px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('ERR', 0, 0); } ctx.fillStyle = '#000'; ctx.fillRect(-AGV_WIDTH / 2 - 4, -10, 4, 20); ctx.fillRect(AGV_WIDTH / 2, -10, 4, 20); ctx.fillStyle = '#10B981'; ctx.fillRect(AGV_HEIGHT / 2 - 10, -AGV_WIDTH / 2 + 5, 10, AGV_WIDTH - 10); ctx.fillStyle = '#EF4444'; ctx.fillRect(-AGV_HEIGHT / 2 + 5, -AGV_WIDTH / 2 + 10, 15, 20); ctx.fillStyle = '#FFF'; ctx.font = '10px Arial'; ctx.textBaseline = 'alphabetic'; ctx.fillText(`Z:${state.liftHeight}`, -AGV_HEIGHT / 2 + 12, -AGV_WIDTH / 2 + 25); // Draw Status Info Panel (Rear) ctx.save(); ctx.translate(-12, 0); // Move towards rear center // Panel Background ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; ctx.strokeStyle = '#374151'; ctx.lineWidth = 1; ctx.fillRect(-10, -18, 20, 36); ctx.strokeRect(-10, -18, 20, 36); // Speed (Top) const spd = state.runConfig.speedLevel; ctx.fillStyle = spd === 'H' ? '#F87171' : spd === 'M' ? '#FACC15' : '#4ADE80'; // Red/Yellow/Green ctx.font = 'bold 12px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(spd, 0, -8); // Divider ctx.strokeStyle = '#4B5563'; ctx.beginPath(); ctx.moveTo(-8, 0); ctx.lineTo(8, 0); ctx.stroke(); // Branch (Bottom) 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(); } else if (br === 'RIGHT') { ctx.save(); ctx.translate(0, 10); ctx.rotate(Math.PI / 2); ctx.fillText('➜', 0, 0); ctx.restore(); } else { ctx.fillText('➜', 0, 10); } ctx.restore(); // Draw Magnet Key Icon if ON (Shifted forward to avoid status panel) if (state.magnetOn) { ctx.save(); ctx.translate(15, -6); // Position shifted forward ctx.fillStyle = '#FACC15'; // Yellow/Gold ctx.strokeStyle = '#FACC15'; ctx.lineWidth = 1.5; // Key Head (Circle) ctx.beginPath(); 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(); // Key Shaft ctx.beginPath(); ctx.moveTo(0, 0.5); ctx.lineTo(0, 8); // Key Teeth ctx.moveTo(0, 4); ctx.lineTo(3, 4); ctx.moveTo(0, 6.5); ctx.lineTo(3, 6.5); ctx.stroke(); ctx.restore(); } // 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(); } if (state.motionState === AgvMotionState.RUNNING) { const activeOffset = state.runConfig.direction === 'FWD' ? SENSOR_OFFSET_FRONT : SENSOR_OFFSET_REAR; ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(activeOffset.x, activeOffset.y, 6, 0, Math.PI * 2); ctx.stroke(); } ctx.strokeStyle = '#FFF'; ctx.beginPath(); ctx.moveTo(-10, 0); ctx.lineTo(10, 0); ctx.lineTo(5, -5); ctx.moveTo(10, 0); ctx.lineTo(5, 5); ctx.stroke(); ctx.restore(); // Restore Transform ctx.restore(); animationFrameId = requestAnimationFrame(draw); }; const interval = setInterval(updatePhysics, 16); animationFrameId = requestAnimationFrame(draw); return () => { clearInterval(interval); cancelAnimationFrame(animationFrameId); }; }, [activeTool, dragStartPos, tempMagnet, curvePhase, selectedItemIds, dimensions]); // Re-draw when dimensions change // --- Interaction Handlers --- const snapToGrid = (val: number) => Math.round(val / (GRID_SIZE / 2)) * (GRID_SIZE / 2); // Snap to half-grid // Intelligent Snapping: Prefer Nodes, else Raw Position const getSnappedPos = (rawX: number, rawY: number) => { const map = mapDataRef.current; const SNAP_DIST = 20; // 1. Try snapping to Nodes let closestNode: MapNode | null = null; let minDist = SNAP_DIST; map.nodes.forEach(node => { const d = Math.sqrt(Math.pow(node.x - rawX, 2) + Math.pow(node.y - rawY, 2)); if (d < minDist) { minDist = d; closestNode = node; } }); if (closestNode) { return { x: closestNode.x, y: closestNode.y }; } // 2. Fallback to Raw (No Grid Snap as per user request) return { x: rawX, y: rawY }; }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; // Zoom Logic const zoomIntensity = 0.1; const delta = e.deltaY < 0 ? 1 : -1; let newScale = viewRef.current.scale + delta * zoomIntensity; newScale = Math.min(Math.max(0.2, newScale), 5); // Limit zoom // World pos before zoom const worldX = (screenX - viewRef.current.x) / viewRef.current.scale; const worldY = (screenY - viewRef.current.y) / viewRef.current.scale; // Adjust offset so world pos remains at screen pos const newX = screenX - worldX * newScale; const newY = screenY - worldY * newScale; viewRef.current = { x: newX, y: newY, scale: newScale }; }; const handleMouseMove = (e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; mouseScreenPosRef.current = { x: screenX, y: screenY }; const worldPos = screenToWorld(screenX, screenY); mouseWorldPosRef.current = worldPos; // Pan if (isPanning) { viewRef.current = { ...viewRef.current, x: viewRef.current.x + e.movementX, y: viewRef.current.y + e.movementY }; return; } // 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 === singleId); if (magIndex !== -1) { const newMags = [...next.magnets]; const mag = { ...newMags[magIndex] }; if (dragHandle === 'p1') mag.p1 = snapped; else if (dragHandle === 'p2') mag.p2 = snapped; else if (dragHandle === 'control') mag.controlPoint = worldPos; // No snap for control newMags[magIndex] = mag; next.magnets = newMags; } } else if (dragHandle === 'mark_p1' || dragHandle === 'mark_p2') { // Rotate Mark const markIndex = next.marks.findIndex(m => m.id === singleId); if (markIndex !== -1) { const newMarks = [...next.marks]; const mark = { ...newMarks[markIndex] }; const dx = worldPos.x - mark.x; const dy = worldPos.y - mark.y; let angle = Math.atan2(dy, dx) * (180 / Math.PI); if (dragHandle === 'mark_p1') angle += 180; mark.rotation = normalizeAngle(angle); newMarks[markIndex] = mark; next.marks = newMarks; } } return next; }); return; } // Drag Object (Moving) if (dragTarget) { const dx = worldPos.x - dragTarget.startMouse.x; const dy = worldPos.y - dragTarget.startMouse.y; if (dragTarget.type === 'AGV' && dragTarget.initialAgvState) { setAgvState(prev => ({ ...prev, x: dragTarget.initialAgvState!.x + dx, y: dragTarget.initialAgvState!.y + dy })); } else if (dragTarget.type === 'MULTI' && dragTarget.initialStates) { const initialStates = dragTarget.initialStates; setMapData(prev => { const next = { ...prev }; // 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: 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; } }; const handleMouseUp = (e: React.MouseEvent) => { setIsPanning(false); 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(); 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); if (existingNode && existingNode.id !== startNodeId) { const edgeExists = mapData.edges.some(e => (e.from === startNodeId && e.to === existingNode.id) || (e.from === existingNode.id && e.to === startNodeId) ); if (!edgeExists) { const newEdge: MapEdge = { id: crypto.randomUUID(), from: startNodeId, to: existingNode.id }; setMapData(prev => ({ ...prev, edges: [...prev.edges, newEdge] })); } } } // Finish DRAW_MAGNET_STRAIGHT if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT && dragStartPos) { const p1 = dragStartPos; const p2 = getSnappedPos(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y); if (getDistance(p1, p2) > 5) { const newMag: MagnetLine = { id: crypto.randomUUID(), type: 'STRAIGHT', p1, p2 }; setMapData(prev => ({ ...prev, magnets: [...prev.magnets, newMag] })); } } setDragStartPos(null); setStartNodeId(null); }; const handleMouseDown = (e: React.MouseEvent) => { if (contextMenu) { setContextMenu(null); return; } const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; if (e.button === 1 || (e.button === 0 && isSpacePressedRef.current)) { setIsPanning(true); return; } const { x, y } = screenToWorld(screenX, screenY); const worldPos = { x, y }; if (activeTool === ToolType.ERASER) { const hits = getObjectsAt(x, y); if (hits.length === 1) { deleteObject(hits[0]); } else if (hits.length > 1) { setContextMenu({ x: screenX, y: screenY, items: hits }); } 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 (Only if SINGLE item selected for now, to avoid complexity) const worldTolerance = 15 / viewRef.current.scale; 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 === singleId); if (selectedMark) { const rad = (selectedMark.rotation * Math.PI) / 180; const dx = Math.cos(rad) * 25; const dy = Math.sin(rad) * 25; const p1 = { x: selectedMark.x - dx, y: selectedMark.y - dy }; const p2 = { x: selectedMark.x + dx, y: selectedMark.y + dy }; if (getDistance(worldPos, p1) < worldTolerance) { setDragHandle('mark_p1'); return; } if (getDistance(worldPos, p2) < worldTolerance) { setDragHandle('mark_p2'); return; } } } // AGV const agvDist = getDistance(worldPos, agvState); if (agvDist < 30) { 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]; 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 { 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 = {}; 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; } if (activeTool === ToolType.DRAW_MAGNET_CURVE) { if (curvePhase === 0) { 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) { const finalMagnet: MagnetLine = { id: tempMagnet.id!, type: 'CURVE', p1: tempMagnet.p1, p2: tempMagnet.p2, controlPoint: mouseWorldPosRef.current }; setMapData(prev => ({ ...prev, magnets: [...prev.magnets, finalMagnet] })); setTempMagnet(null); setCurvePhase(0); } } return; } if (activeTool === ToolType.DRAW_LINE) { const hits = getObjectsAt(x, y); const nodeHit = hits.find(h => h.type === 'NODE'); if (nodeHit) { const n = mapData.nodes.find(n => n.id === nodeHit.id); if (n) { setDragStartPos({ x: n.x, y: n.y }); setStartNodeId(n.id); } } else { const snapped = getSnappedPos(x, y); const newNode: MapNode = { id: `N${Date.now()}`, x: snapped.x, y: snapped.y, type: 0, name: "", rfidId: "", connectedNodes: [] }; setMapData(prev => ({ ...prev, nodes: [...prev.nodes, newNode] })); } return; } if (activeTool === ToolType.DRAW_MAGNET_STRAIGHT) { const snapped = getSnappedPos(x, y); setDragStartPos(snapped); return; } if (activeTool === ToolType.ADD_MARK) { const snapped = getSnappedPos(x, y); const newMark: FloorMark = { id: crypto.randomUUID(), x: snapped.x, y: snapped.y, rotation: 0 }; setMapData(prev => ({ ...prev, marks: [...prev.marks, newMark] })); return; } }; return (
setIsPanning(false)} className="block bg-gray-950 outline-none w-full h-full" /> {/* HUD Info */}
Pos: {Math.round(agvState.x)}, {Math.round(agvState.y)} | Rot: {Math.round(agvState.rotation)}° | Zoom: {Math.round(viewRef.current.scale * 100)}% {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 1 && Drag to set Endpoint} {activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && Move mouse to bend curve, Click to finish}
Middle Click / Space+Drag to Pan {selectedItemIds.size > 0 && {selectedItemIds.size} Object(s) Selected}
{/* Context Menu for Eraser */} {contextMenu && (
Select object to delete
    {contextMenu.items.map((item, idx) => (
  • deleteObject(item)} className="px-2 py-1.5 hover:bg-red-900/50 hover:text-red-200 cursor-pointer flex items-center gap-2" > {item.name} {Math.round(item.dist)}px
  • ))}
)}
); }; export default SimulationCanvas;