Files
AGVEmulator/components/SimulationCanvas.tsx

1700 lines
76 KiB
TypeScript

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<string>) => void;
}
interface HitObject {
type: 'NODE' | 'EDGE' | 'MAGNET' | 'MARK';
id: string;
name: string; // For display
dist: number;
}
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 });
// --- 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<string | null>(null);
// Selection & Editing State
// 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: 'MULTI' | 'AGV';
startMouse: { x: number, y: number };
// 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
const [curvePhase, setCurvePhase] = useState<0 | 1 | 2>(0);
const [tempMagnet, setTempMagnet] = useState<Partial<MagnetLine> | null>(null);
// Eraser Context Menu State
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: HitObject[] } | null>(null);
// Image Cache
const imageCacheRef = useRef<Record<string, HTMLImageElement>>({});
// 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<string | null>(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<string, number> = {
'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<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 };
// 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<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);
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<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;
}
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 (
<div ref={containerRef} className={`absolute inset-0 bg-gray-900 overflow-hidden ${isPanning ? 'cursor-grabbing' : 'cursor-crosshair'}`} tabIndex={0}>
<canvas
ref={canvasRef}
width={dimensions.width}
height={dimensions.height}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsPanning(false)}
className="block bg-gray-950 outline-none w-full h-full"
/>
{/* HUD Info */}
<div className="absolute top-2 left-2 pointer-events-none text-xs text-gray-500 bg-gray-900/50 p-1 rounded backdrop-blur-sm">
Pos: {Math.round(agvState.x)}, {Math.round(agvState.y)} | Rot: {Math.round(agvState.rotation)}°
<span className="ml-2 text-gray-400">| Zoom: {Math.round(viewRef.current.scale * 100)}%</span>
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 1 && <span className="text-blue-400 ml-2">Drag to set Endpoint</span>}
{activeTool === ToolType.DRAW_MAGNET_CURVE && curvePhase === 2 && <span className="text-yellow-400 ml-2">Move mouse to bend curve, Click to finish</span>}
<div className="mt-1 flex items-center gap-1 text-[10px] opacity-70">
<Move size={10} /> <span>Middle Click / Space+Drag to Pan</span>
{selectedItemIds.size > 0 && <span className="text-yellow-500 font-bold ml-2">{selectedItemIds.size} Object(s) Selected</span>}
</div>
</div>
{/* Context Menu for Eraser */}
{contextMenu && (
<div
className="absolute z-10 bg-gray-800 border border-gray-600 rounded shadow-lg p-1 min-w-[150px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<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>
</div>
<ul className="text-xs">
{contextMenu.items.map((item, idx) => (
<li
key={`${item.type}-${item.id}-${idx}`}
onClick={() => deleteObject(item)}
className="px-2 py-1.5 hover:bg-red-900/50 hover:text-red-200 cursor-pointer flex items-center gap-2"
>
<Trash2 size={12} />
<span>{item.name}</span>
<span className="text-[10px] text-gray-500 ml-auto">{Math.round(item.dist)}px</span>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default SimulationCanvas;