using AGVNavigationCore.Models; using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using System.Windows.Forms; using System.Xml.Linq; namespace AGVNavigationCore.Controls { public partial class UnifiedAGVCanvas { #region Mouse Events private void UnifiedAGVCanvas_MouseClick(object sender, MouseEventArgs e) { Focus(); // 포커스 설정 var worldPoint = ScreenToWorld(e.Location); var hitNode = GetItemAt(worldPoint); // 에뮬레이터 모드 처리 if (_canvasMode == CanvasMode.Emulator) { if (e.Button == MouseButtons.Right && hitNode != null) { NodeRightClicked?.Invoke(this, hitNode); } return; } // 🔥 어떤 모드에서든 노드/빈 공간 클릭 시 선택 이벤트 발생 (속성창 업데이트) bool ctrlPressed = (ModifierKeys & Keys.Control) == Keys.Control; if (hitNode != null) { // 노드 클릭 if (ctrlPressed && _editMode == EditMode.Select) { // Ctrl+클릭: 다중 선택 토글 if (_selectedNodes.Contains(hitNode)) { _selectedNodes.Remove(hitNode); } else { _selectedNodes.Add(hitNode); } // 마지막 선택된 노드 업데이트 (단일 참조용) _selectedNode = _selectedNodes.Count > 0 ? _selectedNodes[_selectedNodes.Count - 1] : null; // 단일/다중 선택 이벤트 발생 if (_selectedNodes.Count == 1) { NodeSelect?.Invoke(this, _selectedNodes[0], e); } NodesSelected?.Invoke(this, _selectedNodes); Invalidate(); } else { // 일반 클릭: 단일 선택 _selectedNode = hitNode; _selectedNodes.Clear(); _selectedNodes.Add(hitNode); // 단일/다중 선택 이벤트 발생 NodeSelect?.Invoke(this, hitNode, e); NodesSelected?.Invoke(this, _selectedNodes); Invalidate(); } } else if (_editMode == EditMode.Select) { // 빈 공간 클릭 (Select 모드에서만) - 선택 해제 _selectedNode = null; _selectedNodes.Clear(); // NodesSelected 이벤트만 발생 (OnNodesSelected에서 빈 리스트 처리) NodesSelected?.Invoke(this, _selectedNodes); Invalidate(); } switch (_editMode) { case EditMode.Select: HandleSelectClick(hitNode, worldPoint); break; case EditMode.AddNode: HandleAddNodeClick(worldPoint); break; case EditMode.AddLabel: HandleAddLabelClick(worldPoint); break; case EditMode.AddImage: HandleAddImageClick(worldPoint); break; case EditMode.Connect: HandleConnectClick(hitNode as MapNode); break; case EditMode.Delete: HandleDeleteClick(hitNode); break; case EditMode.DeleteConnection: HandleDeleteConnectionClick(worldPoint); break; } } private void UnifiedAGVCanvas_MouseDoubleClick(object sender, MouseEventArgs e) { var worldPoint = ScreenToWorld(e.Location); var hitNode = GetItemAt(worldPoint); // 가동 모드에서는 더블클릭 편집 방지 if (_canvasMode == CanvasMode.Run) return; if (hitNode == null) return; if (hitNode.Type == NodeType.Normal) { HandleNormalNodeDoubleClick(hitNode as MapNode); } else if (hitNode.Type == NodeType.Label) { HandleLabelDoubleClick(hitNode as MapLabel); } else if (hitNode.Type == NodeType.Image) { HandleImageDoubleClick(hitNode as MapImage); } else if (hitNode.Type == NodeType.Mark) { HandleMarkDoubleClick(hitNode as MapMark); } else if (hitNode.Type == NodeType.Magnet) { HandleMagnetDoubleClick(hitNode as MapMagnet); } } private void HandleNormalNodeDoubleClick(MapNode node) { // RFID 입력창 표시 var currentRfid = node.RfidId; string newRfid = Microsoft.VisualBasic.Interaction.InputBox( $"노드 '{node.RfidId}[{node.Id}]'의 RFID를 입력하세요:", "RFID 설정", currentRfid.ToString()); if (ushort.TryParse(newRfid, out ushort newrfidvalue) == false) return; if (newrfidvalue < 1) return; if (newrfidvalue != currentRfid) { node.RfidId = newrfidvalue; MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } // 더블클릭 시 해당 노드만 선택 (다중 선택 해제) _selectedNode = node; _selectedNodes.Clear(); _selectedNodes.Add(node); NodesSelected?.Invoke(this, _selectedNodes); } private void HandleMarkDoubleClick(MapMark label) { //TODO: } private void HandleMagnetDoubleClick(MapMagnet label) { //TODO: } private void HandleLabelDoubleClick(MapLabel label) { // 라벨 텍스트 입력창 표시 string currentText = label.Text ?? "새 라벨"; string newText = Microsoft.VisualBasic.Interaction.InputBox( "라벨 텍스트를 입력하세요:", "라벨 편집", currentText); if (!string.IsNullOrWhiteSpace(newText) && newText != currentText) { label.Text = newText.Trim(); MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } _selectedNode = label; LabelDoubleClicked?.Invoke(this, label); Invalidate(); } private void HandleImageDoubleClick(MapImage image) { _selectedNode = image; // 이미지 편집 이벤트 발생 (MainForm에서 처리) ImageDoubleClicked?.Invoke(this, image); Invalidate(); } private void UnifiedAGVCanvas_MouseDown(object sender, MouseEventArgs e) { var worldPoint = ScreenToWorld(e.Location); if (e.Button == MouseButtons.Left) { if (_editMode == EditMode.Move) { // 1. 노드 선택 확인 var hitNode = GetItemAt(worldPoint); if (hitNode != null) { _isDragging = true; _isPanning = false; _selectedNode = hitNode; _dragStartPosition = hitNode.Position; _dragOffset = new Point(worldPoint.X - hitNode.Position.X, worldPoint.Y - hitNode.Position.Y); _mouseMoveCounter = 0; Cursor = Cursors.SizeAll; Capture = true; Invalidate(); return; } } // 팬 시작 (좌클릭 - 모드에 따라) _isPanning = true; _lastMousePosition = e.Location; Cursor = Cursors.SizeAll; Capture = true; // 🔥 마우스 캡처 활성화 } else if (e.Button == MouseButtons.Middle) { // 중간 버튼: 항상 팬 (어떤 모드든 상관없이) _isPanning = true; _lastMousePosition = e.Location; Cursor = Cursors.Hand; Capture = true; // 🔥 마우스 캡처 활성화 } else if (e.Button == MouseButtons.Right) { // 컨텍스트 메뉴 (편집 모드에서만) if (_canvasMode == CanvasMode.Edit) { var hitNode = GetItemAt(worldPoint); // TODO: 라벨/이미지에 대한 컨텍스트 메뉴도 지원하려면 여기서 hitLabel/hitImage 확인해서 전달 // 현재는 ShowContextMenu가 MapNode만 받으므로 노드만 처리 ShowContextMenu(e.Location, hitNode); } } } private void UnifiedAGVCanvas_MouseMove(object sender, MouseEventArgs e) { var worldPoint = ScreenToWorld(e.Location); // 디버그: MouseMove 카운터 증가 if (_isDragging) { _mouseMoveCounter++; } // 호버 업데이트 var newHoveredNode = GetItemAt(worldPoint); bool hoverChanged = (newHoveredNode != _hoveredNode); if (hoverChanged) { _hoveredNode = newHoveredNode; Invalidate(); } if (_isPanning) { // 팬 처리 - 줌 레벨 보정 적용 var deltaX = e.X - _lastMousePosition.X; var deltaY = e.Y - _lastMousePosition.Y; // 🔥 스크린 좌표 델타를 월드 좌표 델타로 변환 (줌 레벨 보정) // PointF로 float 정밀도 유지 (소수점 손실 방지) _panOffset.X += deltaX / _zoomFactor; _panOffset.Y += deltaY / _zoomFactor; _lastMousePosition = e.Location; Invalidate(); Update(); // 팬도 실시간 업데이트 } else if (_isDragging && _canvasMode == CanvasMode.Edit) { // 드래그 위치 계산 var newPosition = new Point( worldPoint.X - _dragOffset.X, worldPoint.Y - _dragOffset.Y ); // 그리드 스냅 if (ModifierKeys.HasFlag(Keys.Control)) { newPosition.X = (newPosition.X / GRID_SIZE) * GRID_SIZE; newPosition.Y = (newPosition.Y / GRID_SIZE) * GRID_SIZE; } bool moved = false; // 노드 드래그 if (_selectedNode != null) { _selectedNode.Position = newPosition; NodeMoved?.Invoke(this, _selectedNode); moved = true; } if (moved) { MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); Update(); // 🔥 즉시 Paint 이벤트 처리하여 화면 업데이트 } } else if (_isConnectionMode && _canvasMode == CanvasMode.Edit) { // 임시 연결선 업데이트 _connectionEndPoint = worldPoint; Invalidate(); } // 툴팁 표시 (호버된 노드/AGV 정보) UpdateTooltip(worldPoint); } private void UnifiedAGVCanvas_MouseUp(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { if (_isDragging && _canvasMode == CanvasMode.Edit) { _isDragging = false; Capture = false; // 🔥 마우스 캡처 해제 Cursor = GetCursorForMode(_editMode); } if (_isPanning) { _isPanning = false; Capture = false; // 🔥 마우스 캡처 해제 Cursor = Cursors.Default; } } else if (e.Button == MouseButtons.Middle) { // 중간 버튼 드래그 종료 if (_isPanning) { _isPanning = false; Capture = false; // 🔥 마우스 캡처 해제 Cursor = Cursors.Default; } } } private void UnifiedAGVCanvas_MouseWheel(object sender, MouseEventArgs e) { // 마우스 커서 위치 (스크린 좌표) Point mouseScreenPos = e.Location; // 줌 전 마우스가 가리키는 월드 좌표 // Graphics Transform: Screen = (World + Pan) * Zoom // 역변환: World = Screen / Zoom - Pan float worldX_before = mouseScreenPos.X / _zoomFactor - _panOffset.X; float worldY_before = mouseScreenPos.Y / _zoomFactor - _panOffset.Y; // 이전 줌 팩터 저장 float oldZoom = _zoomFactor; // 줌 팩터 계산 (휠 델타 기반) if (e.Delta > 0) _zoomFactor = Math.Min(_zoomFactor * 1.15f, 5.0f); // 확대 else _zoomFactor = Math.Max(_zoomFactor / 1.15f, 0.1f); // 축소 // 줌 후에도 마우스가 같은 월드 좌표를 가리키도록 팬 오프셋 조정 // mouseScreen = (worldBefore + newPan) * newZoom // newPan = mouseScreen / newZoom - worldBefore _panOffset.X = (int)(mouseScreenPos.X / _zoomFactor - worldX_before); _panOffset.Y = (int)(mouseScreenPos.Y / _zoomFactor - worldY_before); Invalidate(); } #endregion #region Mouse Helper Methods private Point ScreenToWorld(Point screenPoint) { // 스크린 좌표를 월드 좌표로 변환 // Graphics Transform: Screen = (World + Pan) * Zoom // 역변환: World = Screen / Zoom - Pan float worldX = screenPoint.X / _zoomFactor - _panOffset.X; float worldY = screenPoint.Y / _zoomFactor - _panOffset.Y; return new Point((int)worldX, (int)worldY); } private Point WorldToScreen(Point worldPoint) { // 월드 좌표를 스크린 좌표로 변환 // Graphics Transform: Screen = (World + Pan) * Zoom return new Point( (int)((worldPoint.X + _panOffset.X) * _zoomFactor), (int)((worldPoint.Y + _panOffset.Y) * _zoomFactor) ); } private NodeBase GetItemAt(Point worldPoint) { if (_labels != null) { // 역순으로 검사하여 위에 그려진 노드부터 확인 for (int i = _labels.Count - 1; i >= 0; i--) { var node = _labels[i]; if (IsPointInNode(worldPoint, node)) return node; } } if (_nodes != null) { // 역순으로 검사하여 위에 그려진 노드부터 확인 for (int i = _nodes.Count - 1; i >= 0; i--) { var node = _nodes[i]; if (IsPointInNode(worldPoint, node)) return node; } } if (_images != null) { // 역순으로 검사하여 위에 그려진 노드부터 확인 for (int i = _images.Count - 1; i >= 0; i--) { var node = _images[i]; if (IsPointInNode(worldPoint, node)) return node; } } return null; } private bool IsPointInNode(Point point, NodeBase node) { if (node is MapLabel label) { return IsPointInLabel(point, label); } if (node is MapImage image) { return IsPointInImage(point, image); } // 라벨과 이미지는 별도 리스트로 관리되므로 여기서 처리하지 않음 // 하지만 혹시 모를 하위 호환성을 위해 타입 체크는 유지하되, // 실제 로직은 CircularNode 등으로 분기 return IsPointInCircularNode(point, node as MapNode); } private bool IsPointInCircularNode(Point point, MapNode node) { switch (node.StationType) { case StationType.Loader: case StationType.UnLoader: case StationType.Clearner: case StationType.Buffer: return IsPointInPentagon(point, node); case StationType.Charger2: case StationType.Charger1: return IsPointInTriangle(point, node); default: return IsPointInCircle(point, node); } } private bool IsPointInCircle(Point point, NodeBase node) { // 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함 var minHitRadiusInScreen = 20; var hitRadius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor); var distance = Math.Sqrt( Math.Pow(node.Position.X - point.X, 2) + Math.Pow(node.Position.Y - point.Y, 2) ); return distance <= hitRadius; } private bool IsPointInPentagon(Point point, NodeBase node) { // 화면에서 최소 20픽셀 정도의 히트 영역을 확보 var minHitRadiusInScreen = 20; var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor); var center = node.Position; // 5각형 꼭짓점 계산 var points = new Point[5]; for (int i = 0; i < 5; i++) { var angle = (Math.PI * 2 * i / 5) - Math.PI / 2; points[i] = new Point( (int)(center.X + radius * Math.Cos(angle)), (int)(center.Y + radius * Math.Sin(angle)) ); } return IsPointInPolygon(point, points); } private bool IsPointInTriangle(Point point, NodeBase node) { // 화면에서 최소 20픽셀 정도의 히트 영역을 확보하되, 노드 크기보다 작아지지 않게 함 var minHitRadiusInScreen = 20; var radius = Math.Max(NODE_RADIUS, minHitRadiusInScreen / _zoomFactor); var center = node.Position; // 삼각형 꼭짓점 계산 var points = new Point[3]; for (int i = 0; i < 3; i++) { var angle = (Math.PI * 2 * i / 3) - Math.PI / 2; points[i] = new Point( (int)(center.X + radius * Math.Cos(angle)), (int)(center.Y + radius * Math.Sin(angle)) ); } return IsPointInPolygon(point, points); } private bool IsPointInPolygon(Point point, Point[] polygon) { // Ray casting 알고리즘 사용 bool inside = false; int j = polygon.Length - 1; for (int i = 0; i < polygon.Length; i++) { var xi = polygon[i].X; var yi = polygon[i].Y; var xj = polygon[j].X; var yj = polygon[j].Y; if (((yi > point.Y) != (yj > point.Y)) && (point.X < (xj - xi) * (point.Y - yi) / (yj - yi) + xi)) { inside = !inside; } j = i; } return inside; } private bool IsPointInLabel(Point point, MapLabel label) { var text = string.IsNullOrEmpty(label.Text) ? label.Id : label.Text; // Graphics 객체 임시 생성 (Using CreateGraphics is faster than new Bitmap) using (var g = this.CreateGraphics()) { // Font 생성 로직: 사용자 정의 폰트가 있으면 생성, 없으면 기본 폰트 사용 (Dispose 주의) Font fontToUse = null; bool shouldDisposeFont = false; try { if (string.IsNullOrEmpty(label.FontFamily) || label.FontSize <= 0) { fontToUse = this.Font; shouldDisposeFont = false; // 컨트롤 폰트는 Dispose하면 안됨 } else { try { fontToUse = new Font(label.FontFamily, label.FontSize, label.FontStyle); shouldDisposeFont = true; } catch { fontToUse = this.Font; shouldDisposeFont = false; } } var textSize = g.MeasureString(text, fontToUse); var textRect = new Rectangle( (int)(label.Position.X - textSize.Width / 2), (int)(label.Position.Y - textSize.Height / 2), (int)textSize.Width, (int)textSize.Height ); return textRect.Contains(point); } finally { if (shouldDisposeFont && fontToUse != null) { fontToUse.Dispose(); } } } } private bool IsPointInImage(Point point, MapImage image) { var displaySize = image.GetDisplaySize(); if (displaySize.IsEmpty) displaySize = new Size(50, 50); // 기본 크기 var imageRect = new Rectangle( image.Position.X - displaySize.Width / 2, image.Position.Y - displaySize.Height / 2, displaySize.Width, displaySize.Height ); return imageRect.Contains(point); } //private MapLabel GetLabelAt(Point worldPoint) //{ // if (_labels == null) return null; // // 역순으로 검사 // for (int i = _labels.Count - 1; i >= 0; i--) // { // var label = _labels[i]; // if (IsPointInLabel(worldPoint, label)) // return label; // } // return null; //} //private MapImage GetImageAt(Point worldPoint) //{ // if (_images == null) return null; // // 역순으로 검사 // for (int i = _images.Count - 1; i >= 0; i--) // { // var image = _images[i]; // if (IsPointInImage(worldPoint, image)) // return image; // } // return null; //} private IAGV GetAGVAt(Point worldPoint) { if (_agvList == null) return null; var hitRadius = Math.Max(AGV_SIZE / 2, 15 / _zoomFactor); return _agvList.FirstOrDefault(agv => { if (!_agvPositions.ContainsKey(agv.AgvId)) return false; var agvPos = _agvPositions[agv.AgvId]; var distance = Math.Sqrt( Math.Pow(agvPos.X - worldPoint.X, 2) + Math.Pow(agvPos.Y - worldPoint.Y, 2) ); return distance <= hitRadius; }); } private void HandleSelectClick(NodeBase hitNode, Point worldPoint) { if (hitNode != null) { // 노드 선택은 위쪽 MouseClick에서 이미 처리됨 (NodesSelected 이벤트 발생) // 여기서는 추가 처리 없음 } else { // 노드가 없으면 연결선 체크 var connection = GetConnectionAt(worldPoint); if (connection != null) { // 연결선을 클릭했을 때 삭제 확인 var (fromNode, toNode) = connection.Value; string fromDisplay = fromNode.HasRfid() ? fromNode.RfidId.ToString("0000") : fromNode.Id; string toDisplay = toNode.HasRfid() ? toNode.RfidId.ToString("0000") : toNode.Id; var result = MessageBox.Show( $"연결을 삭제하시겠습니까?\n\n{fromDisplay} ↔ {toDisplay}", "연결 삭제 확인", MessageBoxButtons.YesNo, MessageBoxIcon.Question); if (result == DialogResult.Yes) { // 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제) if (fromNode.ConnectedNodes.Contains(toNode.Id)) { fromNode.RemoveConnection(toNode.Id); } else if (toNode.ConnectedNodes.Contains(fromNode.Id)) { toNode.RemoveConnection(fromNode.Id); } // 이벤트 발생 ConnectionDeleted?.Invoke(this, (fromNode, toNode)); MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } } else { // 빈 공간 클릭 시 선택 해제 if (_selectedNode != null || _selectedNodes.Count > 0) { _selectedNode = null; _selectedNodes.Clear(); NodesSelected?.Invoke(this, _selectedNodes); Invalidate(); } } } } private void HandleAddNodeClick(Point worldPoint) { // 그리드 스냅 if (ModifierKeys.HasFlag(Keys.Control)) { worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE; worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE; } // 고유한 NodeId 생성 string newNodeId = GenerateUniqueNodeId(); var newNode = new MapNode { Id = newNodeId, Position = worldPoint }; _nodes.Add(newNode); NodeAdded?.Invoke(this, newNode); MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } private void HandleAddLabelClick(Point worldPoint) { // 그리드 스냅 if (ModifierKeys.HasFlag(Keys.Control)) { worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE; worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE; } // 고유한 NodeId 생성 (라벨도 ID 공유 권장) string newNodeId = GenerateUniqueNodeId(); var newLabel = new MapLabel { Id = newNodeId, Position = worldPoint, Text = "New Label", FontSize = 10, FontFamily = "Arial" }; if (_labels == null) _labels = new List(); _labels.Add(newLabel); //NodeAdded?.Invoke(this, newNode); // TODO: 라벨 추가 이벤트 필요? MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } private void HandleAddImageClick(Point worldPoint) { // 그리드 스냅 if (ModifierKeys.HasFlag(Keys.Control)) { worldPoint.X = (worldPoint.X / GRID_SIZE) * GRID_SIZE; worldPoint.Y = (worldPoint.Y / GRID_SIZE) * GRID_SIZE; } // 고유한 NodeId 생성 string newNodeId = GenerateUniqueNodeId(); var newImage = new MapImage { Id = newNodeId, Position = worldPoint, Name = "New Image" }; if (_images == null) _images = new List(); _images.Add(newImage); //NodeAdded?.Invoke(this, newNode); // TODO: 이미지 추가 이벤트 필요? MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } /// /// 중복되지 않는 고유한 NodeId 생성 /// private string GenerateUniqueNodeId() { string nodeId; int counter = _nodeCounter; do { nodeId = $"N{counter:D3}"; counter++; } while (_nodes.Any(n => n.Id == nodeId) || (_labels != null && _labels.Any(l => l.Id == nodeId)) || (_images != null && _images.Any(i => i.Id == nodeId))); _nodeCounter = counter; return nodeId; } private void HandleConnectClick(MapNode hitNode) { if (hitNode == null) return; if (!_isConnectionMode) { // 연결 시작 _isConnectionMode = true; _connectionStartNode = hitNode; _selectedNode = hitNode; } else { // 연결 완료 if (_connectionStartNode != null && _connectionStartNode != hitNode) { CreateConnection(_connectionStartNode, hitNode); } CancelConnection(); } Invalidate(); } private void HandleDeleteClick(MapNode hitNode) { if (hitNode == null) return; // 연결된 모든 연결선도 제거 foreach (var node in _nodes) { node.RemoveConnection(hitNode.Id); } _nodes.Remove(hitNode); if (_selectedNode == hitNode) _selectedNode = null; NodeDeleted?.Invoke(this, hitNode); MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } private void CreateConnection(MapNode fromNode, MapNode toNode) { // 중복 연결 체크 (양방향) if (fromNode.ConnectedNodes.Contains(toNode.Id) || toNode.ConnectedNodes.Contains(fromNode.Id)) return; // 양방향 연결 생성 (AGV가 양쪽 방향으로 이동 가능하도록) fromNode.AddConnection(toNode.Id); toNode.AddConnection(fromNode.Id); MapChanged?.Invoke(this, EventArgs.Empty); } private float CalculateDistance(Point from, Point to) { var dx = to.X - from.X; var dy = to.Y - from.Y; return (float)Math.Sqrt(dx * dx + dy * dy); } private void ShowContextMenu(Point location, NodeBase hitItem) { _contextMenu.Items.Clear(); if (hitItem != null) { string typeName = "항목"; if (hitItem is MapNode) typeName = "노드"; else if (hitItem is MapLabel) typeName = "라벨"; else if (hitItem is MapImage) typeName = "이미지"; _contextMenu.Items.Add($"{typeName} 속성...", null, (s, e) => { _selectedNode = hitItem; _selectedNodes.Clear(); _selectedNodes.Add(hitItem); NodesSelected?.Invoke(this, _selectedNodes); Invalidate(); }); _contextMenu.Items.Add($"{typeName} 삭제", null, (s, e) => HandleDeleteClick(hitItem)); _contextMenu.Items.Add("-"); } _contextMenu.Items.Add("노드 추가", null, (s, e) => { var worldPoint = ScreenToWorld(location); HandleAddNodeClick(worldPoint); }); _contextMenu.Items.Add("전체 맞춤", null, (s, e) => FitToNodes()); _contextMenu.Items.Add("줌 리셋", null, (s, e) => ResetZoom()); _contextMenu.Show(this, location); } private void HandleDeleteConnectionClick(Point worldPoint) { // 클릭한 위치 근처의 연결선을 찾아 삭제 var connection = GetConnectionAt(worldPoint); if (connection != null) { var (fromNode, toNode) = connection.Value; // 단일 연결 삭제 (어느 방향에 저장되어 있는지 확인 후 삭제) if (fromNode.ConnectedNodes.Contains(toNode.Id)) { fromNode.RemoveConnection(toNode.Id); } else if (toNode.ConnectedNodes.Contains(fromNode.Id)) { toNode.RemoveConnection(fromNode.Id); } // 이벤트 발생 ConnectionDeleted?.Invoke(this, (fromNode, toNode)); MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } } private (MapNode From, MapNode To)? GetConnectionAt(Point worldPoint) { const int CONNECTION_HIT_TOLERANCE = 10; if (_nodes == null) return null; // 모든 연결선을 확인하여 클릭한 위치와 가장 가까운 연결선 찾기 foreach (var fromNode in _nodes) { foreach (var toNodeId in fromNode.ConnectedNodes) { var toNode = _nodes.FirstOrDefault(n => n.Id == toNodeId); if (toNode != null) { // 연결선과 클릭 위치 간의 거리 계산 var distance = CalculatePointToLineDistance(worldPoint, fromNode.Position, toNode.Position); if (distance <= CONNECTION_HIT_TOLERANCE / _zoomFactor) { return (fromNode, toNode); } } } } return null; } private void HandleDeleteClick(NodeBase item) { if (item == null) return; if (item is MapNode hitNode) { // 연결된 모든 연결선도 제거 foreach (var node in _nodes) { node.RemoveConnection(hitNode.Id); } _nodes.Remove(hitNode); if (_selectedNode == hitNode) _selectedNode = null; NodeDeleted?.Invoke(this, hitNode); } else if (item is MapLabel label) { if (_labels != null) _labels.Remove(label); if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null; } else if (item is MapImage image) { if (_images != null) _images.Remove(image); if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null; } else if (item is MapMark mark) { if (_marks != null) _marks.Remove(mark); if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null; } else if (item is MapMagnet magnet) { if (_magnets != null) _magnets.Remove(magnet); if (_selectedNode.Id.Equals(item.Id)) _selectedNode = null; } MapChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } private float CalculatePointToLineDistance(Point point, Point lineStart, Point lineEnd) { // 점에서 선분까지의 거리 계산 var A = point.X - lineStart.X; var B = point.Y - lineStart.Y; var C = lineEnd.X - lineStart.X; var D = lineEnd.Y - lineStart.Y; var dot = A * C + B * D; var lenSq = C * C + D * D; if (lenSq == 0) return CalculateDistance(point, lineStart); var param = dot / lenSq; Point xx, yy; if (param < 0) { xx = lineStart; yy = lineStart; } else if (param > 1) { xx = lineEnd; yy = lineEnd; } else { xx = new Point((int)(lineStart.X + param * C), (int)(lineStart.Y + param * D)); yy = xx; } return CalculateDistance(point, xx); } private void UpdateTooltip(Point worldPoint) { string tooltipText = ""; var hitNode = GetItemAt(worldPoint); var hitAGV = GetAGVAt(worldPoint); if (hitNode != null) tooltipText = $"노드: {hitNode.Id}\n타입: {hitNode.Type}\n위치: ({hitNode.Position.X}, {hitNode.Position.Y})"; else if (hitAGV != null) { var state = _agvStates.ContainsKey(hitAGV.AgvId) ? _agvStates[hitAGV.AgvId] : AGVState.Idle; tooltipText = $"AGV: {hitAGV.AgvId}\n상태: {state}\n배터리: {hitAGV.BatteryLevel:F1}%\n위치: ({hitAGV.CurrentPosition.X}, {hitAGV.CurrentPosition.Y})"; } // 툴팁 텍스트 갱신 (변경되었을 때만) if (_tooltip != null && _tooltip.GetToolTip(this) != tooltipText) { _tooltip.SetToolTip(this, tooltipText); } } #endregion #region View Control Methods /// /// 모든 노드가 보이도록 뷰 조정 /// public void FitToNodes() { if (_nodes == null || _nodes.Count == 0) return; var minX = _nodes.Min(n => n.Position.X); var maxX = _nodes.Max(n => n.Position.X); var minY = _nodes.Min(n => n.Position.Y); var maxY = _nodes.Max(n => n.Position.Y); var margin = 50; var contentWidth = maxX - minX + margin * 2; var contentHeight = maxY - minY + margin * 2; var zoomX = (float)Width / contentWidth; var zoomY = (float)Height / contentHeight; _zoomFactor = Math.Min(zoomX, zoomY) * 0.9f; var centerX = (minX + maxX) / 2; var centerY = (minY + maxY) / 2; // Graphics Transform: Screen = (World + Pan) * Zoom // 중앙에 위치: Width/2 = (centerX + Pan) * Zoom // Pan = Width/2 / Zoom - centerX _panOffset.X = (int)(Width / 2 / _zoomFactor - centerX); _panOffset.Y = (int)(Height / 2 / _zoomFactor - centerY); Invalidate(); } /// /// 줌 리셋 /// public void ResetZoom() { _zoomFactor = 1.0f; _panOffset = PointF.Empty; Invalidate(); } /// /// 특정 위치로 이동 /// public void PanTo(Point worldPoint) { // Graphics Transform: Screen = (World + Pan) * Zoom // 중앙에 위치: Width/2 = (worldPoint + Pan) * Zoom // Pan = Width/2 / Zoom - worldPoint _panOffset.X = (int)(Width / 2 / _zoomFactor - worldPoint.X); _panOffset.Y = (int)(Height / 2 / _zoomFactor - worldPoint.Y); Invalidate(); } /// /// 특정 노드로 이동 /// public void PanToNode(string nodeId) { var node = _nodes?.FirstOrDefault(n => n.Id == nodeId); if (node != null) { PanTo(node.Position); } } /// /// 특정 AGV로 이동 /// public void PanToAGV(string agvId) { if (_agvPositions.ContainsKey(agvId)) { PanTo(_agvPositions[agvId]); } } #endregion } }