diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 29825952..c6429121 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -277,7 +277,7 @@ export function CanvasElement({ const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // ๐Ÿ”ฅ ์ž๋™ ์Šคํฌ๋กค ๋ฐฉํ–ฅ const autoScrollFrameRef = useRef(null); // ๐Ÿ”ฅ requestAnimationFrame ID const lastMouseYRef = useRef(window.innerHeight / 2); // ๐Ÿ”ฅ ๋งˆ์ง€๋ง‰ ๋งˆ์šฐ์Šค Y ์œ„์น˜ (์ดˆ๊ธฐ๊ฐ’: ํ™”๋ฉด ์ค‘๊ฐ„) - const [resizeStart, setResizeStart] = useState({ + const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, @@ -302,26 +302,26 @@ export function CanvasElement({ return; } - // ๋‹ซ๊ธฐ ๋ฒ„ํŠผ์ด๋‚˜ ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค ํด๋ฆญ ์‹œ ๋ฌด์‹œ + // ๋‹ซ๊ธฐ ๋ฒ„ํŠผ์ด๋‚˜ ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค ํด๋ฆญ ์‹œ ๋ฌด์‹œ if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { return; } // ์œ„์ ฏ ๋‚ด๋ถ€ (ํ—ค๋” ์ œ์™ธ) ํด๋ฆญ ์‹œ ๋“œ๋ž˜๊ทธ ๋ฌด์‹œ - ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ if ((e.target as HTMLElement).closest(".widget-interactive-area")) { - return; - } + return; + } // ์„ ํƒ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋งŒ ์„ ํƒ ์ฒ˜๋ฆฌ if (!isSelected) { - onSelect(element.id); + onSelect(element.id); } - setIsDragging(true); + setIsDragging(true); const startPos = { - x: e.clientX, - y: e.clientY, - elementX: element.position.x, + x: e.clientX, + y: e.clientY, + elementX: element.position.x, elementY: element.position.y, initialScrollY: window.pageYOffset, // ๐Ÿ”ฅ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ ์‹œ์ ์˜ ์Šคํฌ๋กค ์œ„์น˜ }; @@ -348,7 +348,7 @@ export function CanvasElement({ onMultiDragStart(element.id, offsets); } - e.preventDefault(); + e.preventDefault(); }, [ element.id, @@ -370,17 +370,17 @@ export function CanvasElement({ return; } - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: element.size.width, - height: element.size.height, - elementX: element.position.x, - elementY: element.position.y, + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, handle, - }); + }); }, [element.size.width, element.size.height, element.position.x, element.position.y], ); @@ -388,7 +388,7 @@ export function CanvasElement({ // ๋งˆ์šฐ์Šค ์ด๋™ ์ฒ˜๋ฆฌ (๊ทธ๋ฆฌ๋“œ ์Šค๋ƒ… ์ ์šฉ) const handleMouseMove = useCallback( (e: MouseEvent) => { - if (isDragging) { + if (isDragging) { // ๐Ÿ”ฅ ์ž๋™ ์Šคํฌ๋กค: ๋‹ค์ค‘ ์„ ํƒ ์‹œ ์ฒซ ๋ฒˆ์งธ ์œ„์ ฏ์—์„œ๋งŒ ์ฒ˜๋ฆฌ const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id; @@ -425,14 +425,14 @@ export function CanvasElement({ if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { onMultiDragMove(element, { x: rawX, y: rawY }); } - } else if (isResizing) { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; - - let newWidth = resizeStart.width; - let newHeight = resizeStart.height; - let newX = resizeStart.elementX; - let newY = resizeStart.elementY; + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; // ์ตœ์†Œ ํฌ๊ธฐ ์„ค์ •: ๋ชจ๋“  ์œ„์ ฏ 1x1 const minWidthCells = 1; @@ -440,28 +440,28 @@ export function CanvasElement({ const minWidth = cellSize * minWidthCells; const minHeight = cellSize * minHeightCells; - switch (resizeStart.handle) { + switch (resizeStart.handle) { case "se": // ์˜ค๋ฅธ์ชฝ ์•„๋ž˜ newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - break; + break; case "sw": // ์™ผ์ชฝ ์•„๋ž˜ newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - newX = resizeStart.elementX + deltaX; - break; + newX = resizeStart.elementX + deltaX; + break; case "ne": // ์˜ค๋ฅธ์ชฝ ์œ„ newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newY = resizeStart.elementY + deltaY; - break; + newY = resizeStart.elementY + deltaY; + break; case "nw": // ์™ผ์ชฝ ์œ„ newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newX = resizeStart.elementX + deltaX; - newY = resizeStart.elementY + deltaY; - break; - } + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } // ๊ฐ€๋กœ ๋„ˆ๋น„๊ฐ€ ์บ”๋ฒ„์Šค๋ฅผ ๋ฒ—์–ด๋‚˜์ง€ ์•Š๋„๋ก ์ œํ•œ const maxWidth = canvasWidth - newX; @@ -664,7 +664,7 @@ export function CanvasElement({ if (isDragging || isResizing) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -685,7 +685,7 @@ export function CanvasElement({ // ํ•„ํ„ฐ ์ ์šฉ (๋‚ ์งœ ํ•„ํ„ฐ ๋“ฑ) const { applyQueryFilters } = await import("./utils/queryHelpers"); const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); - + // ์™ธ๋ถ€ DB vs ํ˜„์žฌ DB ๋ถ„๊ธฐ if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // ์™ธ๋ถ€ DB @@ -709,13 +709,13 @@ export function CanvasElement({ // ํ˜„์žฌ DB const { dashboardApi } = await import("@/lib/api/dashboard"); result = await dashboardApi.executeQuery(filteredQuery); - - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, executionTime: 0, - }); + }); } } catch (error) { // console.error("Chart data loading error:", error); @@ -859,7 +859,7 @@ export function CanvasElement({ ) ) : null} - + )} {/* ์‚ญ์ œ ๋ฒ„ํŠผ - ํ•ญ์ƒ ํ‘œ์‹œ (์šฐ์ธก ์ƒ๋‹จ ์ ˆ๋Œ€ ์œ„์น˜) */} diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index fada50de..c312b9f4 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -5,8 +5,8 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; -import YardEditor from "./yard-3d/YardEditor"; -import Yard3DViewer from "./yard-3d/Yard3DViewer"; +import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor"; +import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer"; import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import type { YardManagementConfig } from "../types"; @@ -125,11 +125,15 @@ export default function YardManagement3DWidget({ } }; - // ํŽธ์ง‘ ๋ชจ๋“œ: ํŽธ์ง‘ ์ค‘์ธ ๊ฒฝ์šฐ YardEditor ํ‘œ์‹œ + // ํŽธ์ง‘ ๋ชจ๋“œ: ํŽธ์ง‘ ์ค‘์ธ ๊ฒฝ์šฐ DigitalTwinEditor ํ‘œ์‹œ if (isEditMode && editingLayout) { return (
- +
); } @@ -269,10 +273,10 @@ export default function YardManagement3DWidget({ ); } - // ์„ ํƒ๋œ ๋ ˆ์ด์•„์›ƒ์˜ 3D ๋ทฐ์–ด ํ‘œ์‹œ + // ์„ ํƒ๋œ ๋ ˆ์ด์•„์›ƒ์˜ ๋””์ง€ํ„ธ ํŠธ์œˆ ๋ทฐ์–ด ํ‘œ์‹œ return (
- +
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx new file mode 100644 index 00000000..e51fe131 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -0,0 +1,641 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Save, Loader2, Grid3x3, Combine, Move, Box, Package } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import dynamic from "next/dynamic"; +import { useToast } from "@/hooks/use-toast"; + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +interface DigitalTwinEditorProps { + layoutId: number; + layoutName: string; + onBack: () => void; +} + +type ToolType = "yard" | "gantry-crane" | "mobile-crane" | "rack" | "plate-stack"; + +interface PlacedObject { + id: number; + type: ToolType; + name: string; + position: { x: number; y: number; z: number }; + size: { x: number; y: number; z: number }; + color: string; + // ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ ์ •๋ณด + externalDbConnectionId?: number; + dataBindingConfig?: any; +} + +export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) { + const { toast } = useToast(); + const [placedObjects, setPlacedObjects] = useState([]); + const [selectedObject, setSelectedObject] = useState(null); + const [draggedTool, setDraggedTool] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [externalDbConnections, setExternalDbConnections] = useState([]); + const [selectedDbConnection, setSelectedDbConnection] = useState(null); + const [nextObjectId, setNextObjectId] = useState(-1); + + // ์™ธ๋ถ€ DB ์—ฐ๊ฒฐ ๋ชฉ๋ก ๋กœ๋“œ + useEffect(() => { + const loadExternalDbConnections = async () => { + try { + // TODO: ์‹ค์ œ API ํ˜ธ์ถœ + // const response = await externalDbConnectionApi.getConnections({ is_active: 'Y' }); + + // ์ž„์‹œ ๋ฐ์ดํ„ฐ + setExternalDbConnections([ + { id: 1, name: "DO_DY (๋™์—ฐ ์•ผ๋“œ)", db_type: "mariadb" }, + { id: 2, name: "GY_YARD (๊ด‘์–‘ ์•ผ๋“œ)", db_type: "mariadb" }, + ]); + } catch (error) { + console.error("์™ธ๋ถ€ DB ์—ฐ๊ฒฐ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ:", error); + } + }; + + loadExternalDbConnections(); + }, []); + + // ๋ ˆ์ด์•„์›ƒ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + useEffect(() => { + const loadLayout = async () => { + try { + setIsLoading(true); + // TODO: ์‹ค์ œ API ํ˜ธ์ถœ + // const response = await digitalTwinApi.getLayout(layoutId); + + // ์ž„์‹œ ๋ฐ์ดํ„ฐ + setPlacedObjects([]); + } catch (error) { + console.error("๋ ˆ์ด์•„์›ƒ ๋กœ๋“œ ์‹คํŒจ:", error); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [layoutId]); + + // ๋„๊ตฌ ํƒ€์ž…๋ณ„ ๊ธฐ๋ณธ ์„ค์ • + const getToolDefaults = (type: ToolType): Partial => { + switch (type) { + case "yard": + return { + name: "์˜์—ญ", + size: { x: 20, y: 0.1, z: 20 }, // 4x4 ์นธ + color: "#3b82f6", // ํŒŒ๋ž€์ƒ‰ + }; + case "gantry-crane": + return { + name: "๊ฒํŠธ๋ฆฌ ํฌ๋ ˆ์ธ", + size: { x: 5, y: 8, z: 5 }, // 1x1 ์นธ + color: "#22c55e", // ๋…น์ƒ‰ + }; + case "mobile-crane": + return { + name: "ํฌ๋ ˆ์ธ", + size: { x: 5, y: 6, z: 5 }, // 1x1 ์นธ + color: "#eab308", // ๋…ธ๋ž€์ƒ‰ + }; + case "rack": + return { + name: "๋ž™", + size: { x: 5, y: 3, z: 5 }, // 1x1 ์นธ + color: "#a855f7", // ๋ณด๋ผ์ƒ‰ + }; + case "plate-stack": + return { + name: "ํ›„ํŒ ์Šคํƒ", + size: { x: 5, y: 2, z: 5 }, // 1x1 ์นธ + color: "#ef4444", // ๋นจ๊ฐ„์ƒ‰ + }; + } + }; + + // ๋„๊ตฌ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + const handleToolDragStart = (toolType: ToolType) => { + setDraggedTool(toolType); + }; + + // ์บ”๋ฒ„์Šค์— ๋“œ๋กญ + const handleCanvasDrop = (x: number, z: number) => { + if (!draggedTool) return; + + const defaults = getToolDefaults(draggedTool); + + // ์•ผ๋“œ๋Š” ๋ฐ”๋‹ฅ(y=0.05)์—, ๋‹ค๋ฅธ ๊ฐ์ฒด๋Š” ์ค‘์•™ ์ •๋ ฌ + const yPosition = draggedTool === "yard" ? 0.05 : (defaults.size?.y || 1) / 2; + + const newObject: PlacedObject = { + id: nextObjectId, + type: draggedTool, + name: defaults.name || "์ƒˆ ๊ฐ์ฒด", + position: { x, y: yPosition, z }, + size: defaults.size || { x: 5, y: 5, z: 5 }, + color: defaults.color || "#9ca3af", + }; + + setPlacedObjects((prev) => [...prev, newObject]); + setSelectedObject(newObject); + setNextObjectId((prev) => prev - 1); + setHasUnsavedChanges(true); + setDraggedTool(null); + }; + + // ๊ฐ์ฒด ํด๋ฆญ + const handleObjectClick = (objectId: number | null) => { + if (objectId === null) { + setSelectedObject(null); + return; + } + + const obj = placedObjects.find((o) => o.id === objectId); + setSelectedObject(obj || null); + }; + + // ๊ฐ์ฒด ์ด๋™ + const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { + // Yard3DCanvas์—์„œ ์ด๋ฏธ ์Šค๋ƒ…+์˜คํ”„์…‹์ด ์™„๋ฃŒ๋œ ์ขŒํ‘œ๋ฅผ ๋ฐ›์Œ + // ๊ทธ๋Œ€๋กœ ์ €์žฅํ•˜๋ฉด ๋จ + setPlacedObjects((prev) => + prev.map((obj) => { + if (obj.id === objectId) { + const newPosition = { ...obj.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...obj, position: newPosition }; + } + return obj; + }), + ); + + if (selectedObject?.id === objectId) { + setSelectedObject((prev) => { + if (!prev) return null; + const newPosition = { ...prev.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...prev, position: newPosition }; + }); + } + + setHasUnsavedChanges(true); + }; + + // ๊ฐ์ฒด ์†์„ฑ ์—…๋ฐ์ดํŠธ + const handleObjectUpdate = (updates: Partial) => { + if (!selectedObject) return; + + let finalUpdates = { ...updates }; + + // ํฌ๊ธฐ ๋ณ€๊ฒฝ ์‹œ์—๋งŒ 5 ๋‹จ์œ„๋กœ ์Šค๋ƒ…ํ•˜๊ณ  ์œ„์น˜ ์กฐ์ • (position ๋ณ€๊ฒฝ์€ ์ œ์™ธ) + if (updates.size && !updates.position) { + // placedObjects ๋ฐฐ์—ด์—์„œ ์‹ค์ œ ์ €์žฅ๋œ ๊ฐ์ฒด๋ฅผ ๊ฐ€์ ธ์˜ด (selectedObject ์ƒํƒœ๊ฐ€ ์•„๋‹Œ) + const actualObject = placedObjects.find((obj) => obj.id === selectedObject.id); + if (!actualObject) return; + + const oldSize = actualObject.size; + const newSize = { ...oldSize, ...updates.size }; + + // W, D๋ฅผ 5 ๋‹จ์œ„๋กœ ์Šค๋ƒ… + newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); + newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); + + // H๋Š” ์ž์œ ๋กญ๊ฒŒ (์•ผ๋“œ ์ œ์™ธ) + if (actualObject.type !== "yard") { + newSize.y = Math.max(0.1, newSize.y); + } + + // ํฌ๊ธฐ ์ฐจ์ด ๊ณ„์‚ฐ + const deltaX = newSize.x - oldSize.x; + const deltaZ = newSize.z - oldSize.z; + const deltaY = newSize.y - oldSize.y; + + // ์œ„์น˜ ์กฐ์ •: ์™ผ์ชฝ/๋’ค์ชฝ/๋ฐ”๋‹ฅ ๋ชจ์„œ๋ฆฌ ๊ณ ์ •, ์˜ค๋ฅธ์ชฝ/์•ž์ชฝ/์œ„์ชฝ์œผ๋กœ๋งŒ ๋Š˜์–ด๋‚จ + // Three.js๋Š” ์ค‘์‹ฌ์  ๊ธฐ์ค€์ด๋ฏ€๋กœ ํฌ๊ธฐ ์ฐจ์ด์˜ ์ ˆ๋ฐ˜๋งŒํผ ์œ„์น˜ ์ด๋™ + // actualObject.position (์‹ค์ œ ๋ฐฐ์—ด์˜ position)์„ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ + const newPosition = { + ...actualObject.position, + x: actualObject.position.x + deltaX / 2, // ์˜ค๋ฅธ์ชฝ์œผ๋กœ ๋Š˜์–ด๋‚จ + y: actualObject.position.y + deltaY / 2, // ์œ„์ชฝ์œผ๋กœ ๋Š˜์–ด๋‚จ (๋ฐ”๋‹ฅ ๊ณ ์ •) + z: actualObject.position.z + deltaZ / 2, // ์•ž์ชฝ์œผ๋กœ ๋Š˜์–ด๋‚จ + }; + + finalUpdates = { + ...finalUpdates, + size: newSize, + position: newPosition, + }; + } + + setPlacedObjects((prev) => prev.map((obj) => (obj.id === selectedObject.id ? { ...obj, ...finalUpdates } : obj))); + + setSelectedObject((prev) => (prev ? { ...prev, ...finalUpdates } : null)); + setHasUnsavedChanges(true); + }; + + // ๊ฐ์ฒด ์‚ญ์ œ + const handleObjectDelete = () => { + if (!selectedObject) return; + + setPlacedObjects((prev) => prev.filter((obj) => obj.id !== selectedObject.id)); + setSelectedObject(null); + setHasUnsavedChanges(true); + }; + + // ์ €์žฅ + const handleSave = async () => { + if (!selectedDbConnection) { + toast({ + title: "์™ธ๋ถ€ DB ์„ ํƒ ํ•„์š”", + description: "์™ธ๋ถ€ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ์„ ์„ ํƒํ•˜์„ธ์š”.", + variant: "destructive", + }); + return; + } + + setIsSaving(true); + try { + // TODO: ์‹ค์ œ API ํ˜ธ์ถœ + // await digitalTwinApi.saveLayout(layoutId, { + // externalDbConnectionId: selectedDbConnection, + // objects: placedObjects, + // }); + + toast({ + title: "์ €์žฅ ์™„๋ฃŒ", + description: "๋ ˆ์ด์•„์›ƒ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }); + + setHasUnsavedChanges(false); + } catch (error) { + console.error("์ €์žฅ ์‹คํŒจ:", error); + toast({ + title: "์ €์žฅ ์‹คํŒจ", + description: "๋ ˆ์ด์•„์›ƒ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* ์ƒ๋‹จ ํˆด๋ฐ” */} +
+
+ +
+

{layoutName}

+

๋””์ง€ํ„ธ ํŠธ์œˆ ์•ผ๋“œ ํŽธ์ง‘

+
+
+ +
+ {hasUnsavedChanges && ๋ฏธ์ €์žฅ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์žˆ์Œ} + +
+
+ + {/* ๋„๊ตฌ ํŒ”๋ ˆํŠธ */} +
+ ๋„๊ตฌ: + {[ + { type: "yard" as ToolType, label: "์˜์—ญ", icon: Grid3x3, color: "text-blue-500" }, + // { type: "gantry-crane" as ToolType, label: "๊ฒํŠธ๋ฆฌ", icon: Combine, color: "text-green-500" }, + { type: "mobile-crane" as ToolType, label: "ํฌ๋ ˆ์ธ", icon: Move, color: "text-yellow-500" }, + { type: "rack" as ToolType, label: "๋ž™", icon: Box, color: "text-purple-500" }, + { type: "plate-stack" as ToolType, label: "ํ›„ํŒ", icon: Package, color: "text-red-500" }, + ].map((tool) => { + const Icon = tool.icon; + return ( +
handleToolDragStart(tool.type)} + className="bg-background hover:bg-accent flex cursor-grab items-center gap-1 rounded-md border px-3 py-2 transition-colors active:cursor-grabbing" + title={`${tool.label} ๋“œ๋ž˜๊ทธํ•˜์—ฌ ๋ฐฐ์น˜`} + > + + {tool.label} +
+ ); + })} +
+ + {/* ๋ฉ”์ธ ์˜์—ญ */} +
+ {/* ์ขŒ์ธก: ์™ธ๋ถ€ DB ์„ ํƒ + ๊ฐ์ฒด ๋ชฉ๋ก */} +
+ {/* ์™ธ๋ถ€ DB ์„ ํƒ */} +
+ + +

์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์„ ํƒํ•˜์„ธ์š”

+
+ + {/* ๋ฐฐ์น˜๋œ ๊ฐ์ฒด ๋ชฉ๋ก */} +
+

๋ฐฐ์น˜๋œ ๊ฐ์ฒด ({placedObjects.length})

+ + {placedObjects.length === 0 ? ( +
์ƒ๋‹จ ๋„๊ตฌ๋ฅผ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ๋ฐฐ์น˜ํ•˜์„ธ์š”
+ ) : ( +
+ {placedObjects.map((obj) => ( +
handleObjectClick(obj.id)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" + }`} + > +
+ {obj.name} +
+
+

+ ์œ„์น˜: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) +

+
+ ))} +
+ )} +
+
+ + {/* ์ค‘์•™: 3D ์บ”๋ฒ„์Šค */} +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; + const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; + + // ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ (5 ๋‹จ์œ„) + const gridSize = 5; + + // ๊ทธ๋ฆฌ๋“œ์— ์Šค๋ƒ… + // ์•ผ๋“œ(20x20)๋Š” ๊ทธ๋ฆฌ๋“œ ๊ต์ฐจ์ ์—, ๋‹ค๋ฅธ ๊ฐ์ฒด(5x5)๋Š” ํƒ€์ผ ์ค‘์•™์— + let snappedX = Math.round(rawX / gridSize) * gridSize; + let snappedZ = Math.round(rawZ / gridSize) * gridSize; + + // 5x5 ๊ฐ์ฒด๋Š” ํƒ€์ผ ์ค‘์•™์œผ๋กœ ์˜คํ”„์…‹ (์•ผ๋“œ๋Š” ์ œ์™ธ) + if (draggedTool !== "yard") { + snappedX += gridSize / 2; + snappedZ += gridSize / 2; + } + + handleCanvasDrop(snappedX, snappedZ); + }} + > + {isLoading ? ( +
+ +
+ ) : ( + ({ + id: obj.id, + yard_layout_id: layoutId, + material_code: null, + material_name: obj.name, + name: obj.name, // ๊ฐ์ฒด ์ด๋ฆ„ (์•ผ๋“œ ์ด๋ฆ„ ํ‘œ์‹œ์šฉ) + quantity: null, + unit: null, + position_x: obj.position.x, + position_y: obj.position.y, + position_z: obj.position.z, + size_x: obj.size.x, + size_y: obj.size.y, + size_z: obj.size.z, + color: obj.color, + data_source_type: obj.type, + data_source_config: null, + data_binding: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }))} + selectedPlacementId={selectedObject?.id || null} + onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
+ + {/* ์šฐ์ธก: ๊ฐ์ฒด ์†์„ฑ ํŽธ์ง‘ */} +
+ {selectedObject ? ( +
+

๊ฐ์ฒด ์†์„ฑ

+ +
+ {/* ์ด๋ฆ„ */} +
+ + handleObjectUpdate({ name: e.target.value })} + className="mt-1.5 h-9 text-sm" + /> +
+ + {/* ์œ„์น˜ */} +
+ +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* ํฌ๊ธฐ */} +
+ +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + y: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* ์ƒ‰์ƒ */} +
+ + handleObjectUpdate({ color: e.target.value })} + className="mt-1.5 h-9" + /> +
+ + {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} + +
+
+ ) : ( +
+

๊ฐ์ฒด๋ฅผ ์„ ํƒํ•˜๋ฉด ์†์„ฑ์„ ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx new file mode 100644 index 00000000..6520ea29 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import dynamic from "next/dynamic"; +import { Loader2 } from "lucide-react"; + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +interface DigitalTwinViewerProps { + layoutId: number; +} + +// ์ž„์‹œ ํƒ€์ž… ์ •์˜ +interface Material { + id: number; + plate_no: string; // ํ›„ํŒ๋ฒˆํ˜ธ + steel_grade: string; // ๊ฐ•์ข… + thickness: number; // ๋‘๊ป˜ + width: number; // ํญ + length: number; // ๊ธธ์ด + weight: number; // ์ค‘๋Ÿ‰ + location: string; // ์œ„์น˜ + status: string; // ์ƒํƒœ + arrival_date: string; // ์ž…๊ณ ์ผ์ž +} + +export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedYard, setSelectedYard] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [dateRange, setDateRange] = useState({ from: "", to: "" }); + const [selectedMaterial, setSelectedMaterial] = useState(null); + const [materials, setMaterials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // ๋ ˆ์ด์•„์›ƒ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true); + // TODO: ์‹ค์ œ API ํ˜ธ์ถœ + // const response = await digitalTwinApi.getLayoutData(layoutId); + + // ์ž„์‹œ ๋ฐ์ดํ„ฐ + setMaterials([ + { + id: 1, + plate_no: "P-2024-001", + steel_grade: "SM490A", + thickness: 25, + width: 2000, + length: 6000, + weight: 2355, + location: "A๋™-101", + status: "์ž…๊ณ ", + arrival_date: "2024-11-15", + }, + { + id: 2, + plate_no: "P-2024-002", + steel_grade: "SS400", + thickness: 30, + width: 2500, + length: 8000, + weight: 4710, + location: "B๋™-205", + status: "๊ฐ€๊ณต์ค‘", + arrival_date: "2024-11-16", + }, + ]); + } catch (error) { + console.error("๋””์ง€ํ„ธ ํŠธ์œˆ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ:", error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [layoutId]); + + // ํ•„ํ„ฐ๋ง๋œ ์ž์žฌ ๋ชฉ๋ก + const filteredMaterials = useMemo(() => { + return materials.filter((material) => { + // ๊ฒ€์ƒ‰์–ด ํ•„ํ„ฐ + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + const matchSearch = + material.plate_no.toLowerCase().includes(searchLower) || + material.steel_grade.toLowerCase().includes(searchLower) || + material.location.toLowerCase().includes(searchLower); + if (!matchSearch) return false; + } + + // ์•ผ๋“œ ํ•„ํ„ฐ + if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) { + return false; + } + + // ์ƒํƒœ ํ•„ํ„ฐ + if (selectedStatus !== "all" && material.status !== selectedStatus) { + return false; + } + + // ๋‚ ์งœ ํ•„ํ„ฐ + if (dateRange.from && material.arrival_date < dateRange.from) { + return false; + } + if (dateRange.to && material.arrival_date > dateRange.to) { + return false; + } + + return true; + }); + }, [materials, searchTerm, selectedYard, selectedStatus, dateRange]); + + // 3D ๊ฐ์ฒด ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ + const handleObjectClick = (objectId: number) => { + const material = materials.find((m) => m.id === objectId); + setSelectedMaterial(material || null); + }; + + return ( +
+ {/* ์ขŒ์ธก: ํ•„ํ„ฐ ํŒจ๋„ */} +
+ {/* ๊ฒ€์ƒ‰๋ฐ” */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="ํ›„ํŒ๋ฒˆํ˜ธ, ๊ฐ•์ข…, ์œ„์น˜ ๊ฒ€์ƒ‰..." + className="h-10 pl-10 text-sm" + /> +
+
+ + {/* ํ•„ํ„ฐ ์˜ต์…˜ */} +
+
+ {/* ์•ผ๋“œ ์„ ํƒ */} +
+

์•ผ๋“œ

+
+ {["all", "A๋™", "B๋™", "C๋™", "๊ฒํŠธ๋ฆฌ"].map((yard) => ( + + ))} +
+
+ + {/* ์ƒํƒœ ํ•„ํ„ฐ */} +
+

์ƒํƒœ

+
+ {["all", "์ž…๊ณ ", "๊ฐ€๊ณต์ค‘", "์ถœ๊ณ ๋Œ€๊ธฐ", "์ถœ๊ณ ์™„๋ฃŒ"].map((status) => ( + + ))} +
+
+ + {/* ๊ธฐ๊ฐ„ ํ•„ํ„ฐ */} +
+

์ž…๊ณ  ๊ธฐ๊ฐ„

+
+ setDateRange((prev) => ({ ...prev, from: e.target.value }))} + className="h-9 text-sm" + placeholder="์‹œ์ž‘์ผ" + /> + setDateRange((prev) => ({ ...prev, to: e.target.value }))} + className="h-9 text-sm" + placeholder="์ข…๋ฃŒ์ผ" + /> +
+
+
+
+
+ + {/* ์ค‘์•™: 3D ์บ”๋ฒ„์Šค */} +
+ {isLoading ? ( +
+ +
+ ) : ( + { + if (placement) { + handleObjectClick(placement.id); + } else { + setSelectedMaterial(null); + } + }} + onPlacementDrag={() => {}} // ๋ทฐ์–ด ๋ชจ๋“œ์—์„œ๋Š” ๋“œ๋ž˜๊ทธ ๋น„ํ™œ์„ฑํ™” + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
+ + {/* ์šฐ์ธก: ์ƒ์„ธ์ •๋ณด ํŒจ๋„ (ํ›„ํŒ ๋ชฉ๋ก ํ…Œ์ด๋ธ”) */} +
+
+

ํ›„ํŒ ๋ชฉ๋ก

+ + {filteredMaterials.length === 0 ? ( +
+

์กฐ๊ฑด์— ๋งž๋Š” ํ›„ํŒ์ด ์—†์Šต๋‹ˆ๋‹ค.

+
+ ) : ( +
+ {filteredMaterials.map((material) => ( +
setSelectedMaterial(material)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedMaterial?.id === material.id + ? "border-primary bg-primary/10" + : "border-border hover:border-primary/50" + }`} + > +
+ {material.plate_no} + + {material.status} + +
+ +
+
+ ๊ฐ•์ข…: + {material.steel_grade} +
+
+ ๊ทœ๊ฒฉ: + + {material.thickness}ร—{material.width}ร—{material.length} + +
+
+ ์ค‘๋Ÿ‰: + {material.weight.toLocaleString()} kg +
+
+ ์œ„์น˜: + {material.location} +
+
+ ์ž…๊ณ ์ผ: + {material.arrival_date} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index eba640cf..911afcb9 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -10,6 +10,7 @@ interface YardPlacement { yard_layout_id?: number; material_code?: string | null; material_name?: string | null; + name?: string | null; // ๊ฐ์ฒด ์ด๋ฆ„ (์•ผ๋“œ ์ด๋ฆ„ ๋“ฑ) quantity?: number | null; unit?: string | null; position_x: number; @@ -37,12 +38,9 @@ interface Yard3DCanvasProps { // ์ขŒํ‘œ๋ฅผ ๊ทธ๋ฆฌ๋“œ ์นธ์˜ ์ค‘์‹ฌ์— ์Šค๋ƒ… (๋งˆ์ธํฌ๋ž˜ํ”„ํŠธ ์Šคํƒ€์ผ) // Three.js Box์˜ position์€ ์ค‘์‹ฌ์ ์ด๋ฏ€๋กœ, ๊ทธ๋ฆฌ๋“œ ์นธ์˜ ์ค‘์‹ฌ์— ๋ฐฐ์น˜ํ•ด์•ผ ์นธ์— ๋”ฑ ๋งž์Œ function snapToGrid(value: number, gridSize: number): number { - // ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๊ทธ๋ฆฌ๋“œ ์นธ ์ฐพ๊ธฐ - const gridIndex = Math.round(value / gridSize); - // ๊ทธ๋ฆฌ๋“œ ์นธ์˜ ์ค‘์‹ฌ์  ๋ฐ˜ํ™˜ - // gridSize=5์ผ ๋•Œ: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5... - // ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด Box๊ฐ€ ์นธ ์•ˆ์— ์ •ํ™•ํžˆ ๋“ค์–ด๊ฐ - return gridIndex * gridSize + gridSize / 2; + // ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๊ทธ๋ฆฌ๋“œ ๊ต์ฐจ์ ์œผ๋กœ ์Šค๋ƒ… (์˜คํ”„์…‹ ์—†์Œ) + // DigitalTwinEditor์—์„œ ์˜คํ”„์…‹ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ๋Š” ์ˆœ์ˆ˜ ์Šค๋ƒ…๋งŒ + return Math.round(value / gridSize) * gridSize; } // ์ž์žฌ ๋ฐ•์Šค ์ปดํฌ๋„ŒํŠธ (๋“œ๋ž˜๊ทธ ๊ฐ€๋Šฅ) @@ -55,7 +53,6 @@ function MaterialBox({ onDragEnd, gridSize = 5, allPlacements = [], - onCollisionDetected, }: { placement: YardPlacement; isSelected: boolean; @@ -71,19 +68,70 @@ function MaterialBox({ const [isDragging, setIsDragging] = useState(false); const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }); const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragOffset = useRef<{ x: number; z: number }>({ x: 0, z: 0 }); // ๋งˆ์šฐ์Šค์™€ ๊ฐ์ฒด ์ค‘์‹ฌ ๊ฐ„ ์˜คํ”„์…‹ const { camera, gl } = useThree(); + const [glowIntensity, setGlowIntensity] = useState(1); + + // ์„ ํƒ ์‹œ ๋น›๋‚˜๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜ + useEffect(() => { + if (!isSelected) { + setGlowIntensity(1); + return; + } + + let animationId: number; + const startTime = Date.now(); + + const animate = () => { + const elapsed = Date.now() - startTime; + const intensity = 1 + Math.sin(elapsed * 0.003) * 0.5; // 0.5 ~ 1.5 ์‚ฌ์ด ์ง„๋™ + setGlowIntensity(intensity); + animationId = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + }; + }, [isSelected]); // ํŠน์ • ์ขŒํ‘œ์— ์š”์†Œ๋ฅผ ๋ฐฐ์น˜ํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ํ•„์š”ํ•˜๋ฉด Y ์œ„์น˜๋ฅผ ์กฐ์ • const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { - const palletHeight = 0.3; // ํŒ”๋ ˆํŠธ ๋†’์ด - const palletGap = 0.05; // ํŒ”๋ ˆํŠธ์™€ ๋ฐ•์Šค ์‚ฌ์ด ๊ฐ„๊ฒฉ + if (!allPlacements || allPlacements.length === 0) { + // ๋‹ค๋ฅธ ๊ฐ์ฒด๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ๋ณธ ๋†’์ด + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2; + return { + hasCollision: false, + adjustedY: defaultY, + }; + } - const mySize = placement.size_x || gridSize; // ๋‚ด ํฌ๊ธฐ (5) - const myHalfSize = mySize / 2; // 2.5 - const mySizeY = placement.size_y || gridSize; // ๋ฐ•์Šค ๋†’์ด (5) - const myTotalHeight = mySizeY + palletHeight + palletGap; // ํŒ”๋ ˆํŠธ ํฌํ•จํ•œ ์ „์ฒด ๋†’์ด + // ๋‚ด ํฌ๊ธฐ ์ •๋ณด + const mySizeX = placement.size_x || gridSize; + const mySizeZ = placement.size_z || gridSize; + const mySizeY = placement.size_y || gridSize; - let maxYBelow = gridSize / 2; // ๊ธฐ๋ณธ ๋ฐ”๋‹ฅ ๋†’์ด (2.5) + // ๋‚ด ๋ฐ”์šด๋”ฉ ๋ฐ•์Šค (์ขŒ์ธก ํ•˜๋‹จ ๋ชจ์„œ๋ฆฌ ๊ธฐ์ค€) + const myMinX = x - mySizeX / 2; + const myMaxX = x + mySizeX / 2; + const myMinZ = z - mySizeZ / 2; + const myMaxZ = z + mySizeZ / 2; + + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2; + let maxYBelow = defaultY; + + // ์•ผ๋“œ๋Š” ์Šคํƒ๋˜์ง€ ์•Š์Œ (ํ•ญ์ƒ ๋ฐ”๋‹ฅ์— ๋ฐฐ์น˜) + if (objectType === "yard") { + return { + hasCollision: false, + adjustedY: defaultY, + }; + } for (const p of allPlacements) { // ์ž๊ธฐ ์ž์‹ ์€ ์ œ์™ธ @@ -91,39 +139,31 @@ function MaterialBox({ continue; } - const pSize = p.size_x || gridSize; // ์ƒ๋Œ€๋ฐฉ ํฌ๊ธฐ (5) - const pHalfSize = pSize / 2; // 2.5 - const pSizeY = p.size_y || gridSize; // ์ƒ๋Œ€๋ฐฉ ๋ฐ•์Šค ๋†’์ด (5) - const pTotalHeight = pSizeY + palletHeight + palletGap; // ์ƒ๋Œ€๋ฐฉ ํŒ”๋ ˆํŠธ ํฌํ•จ ์ „์ฒด ๋†’์ด + // ์ƒ๋Œ€๋ฐฉ ํฌ๊ธฐ ์ •๋ณด + const pSizeX = p.size_x || gridSize; + const pSizeZ = p.size_z || gridSize; + const pSizeY = p.size_y || gridSize; - // 1๋‹จ๊ณ„: ๋„“์€ ๋ฒ”์œ„๋กœ ๊ฒน์นจ ๊ฐ์ง€ (์‚ด์ง๋งŒ ๊ฐ€๊นŒ์ด ๊ฐ€๋„ ๊ฐ์ง€) - const detectionMargin = 0.5; // ๊ฐ์ง€ ๋ฒ”์œ„ ํ™•์žฅ (0.5 ์œ ๋‹›) - const isNearby = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X์ถ• ๊ทผ์ ‘ - Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z์ถ• ๊ทผ์ ‘ + // ์ƒ๋Œ€๋ฐฉ ๋ฐ”์šด๋”ฉ ๋ฐ•์Šค + const pMinX = p.position_x - pSizeX / 2; + const pMaxX = p.position_x + pSizeX / 2; + const pMinZ = p.position_z - pSizeZ / 2; + const pMaxZ = p.position_z + pSizeZ / 2; - if (isNearby) { - // 2๋‹จ๊ณ„: ์‹ค์ œ๋กœ ๊ฒน์น˜๋Š”์ง€ ์ •ํ™•ํžˆ ํŒ๋‹จ (๋ฐ”๋‹ฅ์— ๋‘˜์ง€, ์œ„์— ๋‘˜์ง€ ๊ฒฐ์ •) - const isActuallyOverlapping = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X์ถ• ์‹ค์ œ ๊ฒน์นจ - Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z์ถ• ์‹ค์ œ ๊ฒน์นจ + // AABB ์ถฉ๋Œ ๊ฐ์ง€ (2D ํ‰๋ฉด์—์„œ) + const isOverlapping = myMinX < pMaxX && myMaxX > pMinX && myMinZ < pMaxZ && myMaxZ > pMinZ; - if (isActuallyOverlapping) { - // ์‹ค์ œ๋กœ ๊ฒน์นจ: ์œ„์— ๋ฐฐ์น˜ - // ์ƒ๋Œ€๋ฐฉ ์ „์ฒด ๋†’์ด (๋ฐ•์Šค + ํŒ”๋ ˆํŠธ)์˜ ์œ—๋ฉด ๊ณ„์‚ฐ - const topOfOtherElement = p.position_y + pTotalHeight / 2; - // ๋‚ด ์ „์ฒด ๋†’์ด์˜ ์ ˆ๋ฐ˜์„ ๋”ํ•ด์„œ ๋‚ด๊ฐ€ ์˜ฌ๋ผ๊ฐˆ Y ์œ„์น˜ ๊ณ„์‚ฐ - const myYOnTop = topOfOtherElement + myTotalHeight / 2; + if (isOverlapping) { + // ๊ฒน์นจ: ์ƒ๋Œ€๋ฐฉ ์œ„์— ๋ฐฐ์น˜ + const topOfOtherElement = p.position_y + pSizeY / 2; + const myYOnTop = topOfOtherElement + mySizeY / 2; - if (myYOnTop > maxYBelow) { - maxYBelow = myYOnTop; - } + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; } - // ๊ทผ์ฒ˜์—๋งŒ ์žˆ๊ณ  ์‹ค์ œ๋กœ ์•ˆ ๊ฒน์นจ: ๋ฐ”๋‹ฅ์— ๋ฐฐ์น˜ (maxYBelow ์œ ์ง€) } } - // ์š”์ฒญํ•œ Y์™€ ์กฐ์ •๋œ Y๊ฐ€ ๋‹ค๋ฅด๋ฉด ์ถฉ๋Œ๋กœ ๊ฐ„์ฃผ (์œ„๋กœ ์˜ฌ๋ ค์•ผ ํ•จ) const needsAdjustment = Math.abs(y - maxYBelow) > 0.1; return { @@ -160,46 +200,60 @@ function MaterialBox({ e.preventDefault(); e.stopPropagation(); - // ๋งˆ์šฐ์Šค ์ด๋™ ๊ฑฐ๋ฆฌ ๊ณ„์‚ฐ (ํ”ฝ์…€) - const deltaX = e.clientX - mouseStartPos.current.x; - const deltaY = e.clientY - mouseStartPos.current.y; + // ๋งˆ์šฐ์Šค ์ขŒํ‘œ๋ฅผ ์ •๊ทœํ™” (-1 ~ 1) + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; - // ์นด๋ฉ”๋ผ ๊ฑฐ๋ฆฌ๋ฅผ ๊ณ ๋ คํ•œ ์Šค์ผ€์ผ ํŒฉํ„ฐ - const distance = camera.position.distanceTo(meshRef.current.position); - const scaleFactor = distance / 500; // ์กฐ์ • ๊ฐ€๋Šฅํ•œ ๊ฐ’ + // Raycaster๋กœ ๋ฐ”๋‹ฅ ํ‰๋ฉด๊ณผ์˜ ๊ต์ฐจ์  ๊ณ„์‚ฐ + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); - // ์นด๋ฉ”๋ผ ๋ฐฉํ–ฅ ๋ฒกํ„ฐ - const cameraDirection = new THREE.Vector3(); - camera.getWorldDirection(cameraDirection); + // ๋ฐ”๋‹ฅ ํ‰๋ฉด (y = 0) + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); - // ์นด๋ฉ”๋ผ์˜ ์šฐ์ธก ๋ฒกํ„ฐ (X์ถ• ์ด๋™์šฉ) - const right = new THREE.Vector3(); - right.crossVectors(camera.up, cameraDirection).normalize(); + if (!hasIntersection) { + return; + } - // ์‹ค์ œ 3D ๊ณต๊ฐ„์—์„œ์˜ ์ด๋™๋Ÿ‰ ๊ณ„์‚ฐ - const moveRight = right.multiplyScalar(-deltaX * scaleFactor); - const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z) - .normalize() - .multiplyScalar(deltaY * scaleFactor); - - // ์ตœ์ข… ์œ„์น˜ ๊ณ„์‚ฐ - const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + // ๋งˆ์šฐ์Šค ์œ„์น˜์— ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ ์‹œ ์ €์žฅํ•œ ์˜คํ”„์…‹ ์ ์šฉ + const finalX = intersectPoint.x + dragOffset.current.x; + const finalZ = intersectPoint.z + dragOffset.current.z; // NaN ๊ฒ€์ฆ if (isNaN(finalX) || isNaN(finalZ)) { return; } - // ๊ทธ๋ฆฌ๋“œ์— ์Šค๋ƒ… - const snappedX = snapToGrid(finalX, gridSize); - const snappedZ = snapToGrid(finalZ, gridSize); + // ๊ฐ์ฒด์˜ ์ขŒ์ธก ํ•˜๋‹จ ๋ชจ์„œ๋ฆฌ ์ขŒํ‘œ ๊ณ„์‚ฐ (ํฌ๊ธฐ / 2๋ฅผ ๋นผ์„œ) + const sizeX = placement.size_x || 5; + const sizeZ = placement.size_z || 5; + + const cornerX = finalX - sizeX / 2; + const cornerZ = finalZ - sizeZ / 2; + + // ์ขŒ์ธก ํ•˜๋‹จ ๋ชจ์„œ๋ฆฌ๋ฅผ ๊ทธ๋ฆฌ๋“œ์— ์Šค๋ƒ… + const snappedCornerX = snapToGrid(cornerX, gridSize); + const snappedCornerZ = snapToGrid(cornerZ, gridSize); + + // ์Šค๋ƒ…๋œ ๋ชจ์„œ๋ฆฌ๋กœ๋ถ€ํ„ฐ ์ค‘์‹ฌ ์œ„์น˜ ๊ณ„์‚ฐ + const finalSnappedX = snappedCornerX + sizeX / 2; + const finalSnappedZ = snappedCornerZ + sizeZ / 2; + + console.log("๐Ÿ› ๋“œ๋ž˜๊ทธ ์ค‘:", { + ๋งˆ์šฐ์Šค_ํ™”๋ฉด: { x: e.clientX, y: e.clientY }, + ์ •๊ทœํ™”_๋งˆ์šฐ์Šค: { x: mouseX, y: mouseY }, + ๊ต์ฐจ์ : { x: finalX, z: finalZ }, + ์Šค๋ƒ…ํ›„: { x: finalSnappedX, z: finalSnappedZ }, + }); // ์ถฉ๋Œ ์ฒดํฌ ๋ฐ Y ์œ„์น˜ ์กฐ์ • - const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); + const { adjustedY } = checkCollisionAndAdjustY(finalSnappedX, dragStartPos.current.y, finalSnappedZ); - // ์ฆ‰์‹œ mesh ์œ„์น˜ ์—…๋ฐ์ดํŠธ (์กฐ์ •๋œ Y ์œ„์น˜๋กœ) - meshRef.current.position.set(finalX, adjustedY, finalZ); + // ์ฆ‰์‹œ mesh ์œ„์น˜ ์—…๋ฐ์ดํŠธ (์Šค๋ƒ…๋œ ์œ„์น˜๋กœ) + meshRef.current.position.set(finalSnappedX, adjustedY, finalSnappedZ); // โš ๏ธ ๋“œ๋ž˜๊ทธ ์ค‘์—๋Š” ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์•ˆ ํ•จ (๋ฏธ๋ฆฌ๋ณด๊ธฐ๋งŒ) // ์‹ค์ œ ์ €์žฅ์€ handleGlobalMouseUp์—์„œ๋งŒ ์ˆ˜ํ–‰ @@ -217,23 +271,21 @@ function MaterialBox({ const hasMoved = deltaX > minMovement || deltaZ > minMovement; if (hasMoved) { - // ์‹ค์ œ๋กœ ๋“œ๋ž˜๊ทธํ•œ ๊ฒฝ์šฐ: ๊ทธ๋ฆฌ๋“œ์— ์Šค๋ƒ… - const snappedX = snapToGrid(currentPos.x, gridSize); - const snappedZ = snapToGrid(currentPos.z, gridSize); - - // Y ์œ„์น˜ ์กฐ์ • (๋งˆ์ธํฌ๋ž˜ํ”„ํŠธ์ฒ˜๋Ÿผ ์Œ“๊ธฐ) - const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ); + // ์‹ค์ œ๋กœ ๋“œ๋ž˜๊ทธํ•œ ๊ฒฝ์šฐ: ์ด๋ฏธ handleGlobalMouseMove์—์„œ ์Šค๋ƒ…๋จ + // currentPos๋Š” ์ด๋ฏธ ์Šค๋ƒ…+์˜คํ”„์…‹์ด ์ ์šฉ๋œ ๊ฐ’์ด๋ฏ€๋กœ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + const finalX = currentPos.x; + const finalY = currentPos.y; + const finalZ = currentPos.z; // โœ… ํ•ญ์ƒ ๋ฐฐ์น˜ ๊ฐ€๋Šฅ (์œ„๋กœ ์˜ฌ๋ผ๊ฐ€๋ฏ€๋กœ) - console.log("โœ… ๋ฐฐ์น˜ ์™„๋ฃŒ! ์ €์žฅ:", { x: snappedX, y: adjustedY, z: snappedZ }); - meshRef.current.position.set(snappedX, adjustedY, snappedZ); + console.log("โœ… ๋ฐฐ์น˜ ์™„๋ฃŒ! ์ €์žฅ:", { x: finalX, y: finalY, z: finalZ }); - // ์ตœ์ข… ์œ„์น˜ ์ €์žฅ (์กฐ์ •๋œ Y ์œ„์น˜๋กœ) + // ์ตœ์ข… ์œ„์น˜ ์ €์žฅ if (onDrag) { onDrag({ - x: snappedX, - y: adjustedY, - z: snappedZ, + x: finalX, + y: finalY, + z: finalZ, }); } } else { @@ -284,6 +336,29 @@ function MaterialBox({ y: e.clientY, }; + // ๋งˆ์šฐ์Šค ํด๋ฆญ ์œ„์น˜๋ฅผ 3D ์ขŒํ‘œ๋กœ ๋ณ€ํ™˜ + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); + + // ๋ฐ”๋‹ฅ ํ‰๋ฉด๊ณผ์˜ ๊ต์ฐจ์  ๊ณ„์‚ฐ + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); + + if (hasIntersection) { + // ๋งˆ์šฐ์Šค ํด๋ฆญ ์œ„์น˜์™€ ๊ฐ์ฒด ์ค‘์‹ฌ ๊ฐ„์˜ ์˜คํ”„์…‹ ์ €์žฅ + dragOffset.current = { + x: currentPos.x - intersectPoint.x, + z: currentPos.z - intersectPoint.z, + }; + } else { + dragOffset.current = { x: 0, z: 0 }; + } + setIsDragging(true); gl.domElement.style.cursor = "grabbing"; if (onDragStart) { @@ -304,6 +379,407 @@ function MaterialBox({ // ํŒ”๋ ˆํŠธ ์œ„์น˜ ๊ณ„์‚ฐ: ๋ฐ•์Šค ํ•˜๋‹จ๋ถ€ํ„ฐ ์‹œ์ž‘ const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap; + // ๊ฐ์ฒด ํƒ€์ž… (data_source_type์— ์ €์žฅ๋จ) + const objectType = placement.data_source_type as string | null; + + // ํƒ€์ž…๋ณ„ ๋ Œ๋”๋ง + const renderObjectByType = () => { + switch (objectType) { + case "yard": + // ์•ผ๋“œ: ํˆฌ๋ช…ํ•œ ๋ฐ”๋‹ฅ + ๋‘๊บผ์šด ์™ธ๊ณฝ์„  (mesh) + ์ด๋ฆ„ ํ…์ŠคํŠธ + const borderThickness = 0.3; // ์™ธ๊ณฝ์„  ๋‘๊ป˜ + return ( + <> + {/* ํˆฌ๋ช…ํ•œ ๋ฉ”์‰ฌ (ํด๋ฆญ ์˜์—ญ) */} + + + + + + {/* ๋‘๊บผ์šด ์™ธ๊ณฝ์„  - 4๊ฐœ์˜ ๋ง‰๋Œ€๋กœ ๊ตฌํ˜„ */} + {/* ์ƒ๋‹จ */} + + + + + {/* ํ•˜๋‹จ */} + + + + + {/* ์ขŒ์ธก */} + + + + + {/* ์šฐ์ธก */} + + + + + + {/* ์„ ํƒ ์‹œ ๋น›๋‚˜๋Š” ํšจ๊ณผ */} + {isSelected && ( + <> + + + + + + + + + + + + + + + + + + )} + + {/* ์•ผ๋“œ ์ด๋ฆ„ ํ…์ŠคํŠธ */} + {placement.name && ( + + {placement.name} + + )} + + ); + + // case "gantry-crane": + // // ๊ฒํŠธ๋ฆฌ ํฌ๋ ˆ์ธ: ๊ธฐ๋‘ฅ 2๊ฐœ + ์ƒ๋‹จ ๋น” + // return ( + // + // {/* ์™ผ์ชฝ ๊ธฐ๋‘ฅ */} + // + // + // + // {/* ์˜ค๋ฅธ์ชฝ ๊ธฐ๋‘ฅ */} + // + // + // + // {/* ์ƒ๋‹จ ๋น” */} + // + // + // + // {/* ํ˜ธ์ด์ŠคํŠธ (ํฌ๋ ˆ์ธ ํ›…) */} + // + // + // + // + // ); + + case "mobile-crane": + // ์ด๋™์‹ ํฌ๋ ˆ์ธ: ํ•˜๋ถ€(ํŠธ๋ž™) + ํšŒ์ „๋Œ€ + ์บ๋นˆ + ๋ถ๋Œ€ + ์นด์šดํ„ฐ์›จ์ดํŠธ + ํ›„ํฌ + return ( + + {/* ํ•˜๋ถ€ - ํฌ๋กค๋Ÿฌ ํŠธ๋ž™ (์ขŒ์ธก) */} + + + + {/* ํ•˜๋ถ€ - ํฌ๋กค๋Ÿฌ ํŠธ๋ž™ (์šฐ์ธก) */} + + + + + {/* ํšŒ์ „ ํ”Œ๋žซํผ */} + + + + + {/* ์—”์ง„๋ฃธ (๋’ค์ชฝ) */} + + + + + {/* ์บ๋นˆ (์šด์ „์‹ค) - ์•ž์ชฝ */} + + + + + {/* ๋ถ๋Œ€ ๋ฒ ์ด์Šค (ํšŒ์ „ ์ง€์ ) */} + + + + + {/* ๋ฉ”์ธ ๋ถ๋Œ€ (ํ•˜๋‹จ ์„น์…˜) */} + + + + + {/* ๋ฉ”์ธ ๋ถ๋Œ€ (์ƒ๋‹จ ์„น์…˜ - ์—ฐ์žฅ) */} + + + + + {/* ์นด์šดํ„ฐ์›จ์ดํŠธ (๋’ค์ชฝ ๊ท ํ˜•์ถ”) */} + + + + + {/* ํ›„ํฌ ์ผ€์ด๋ธ” */} + + + + + {/* ํ›„ํฌ */} + + + + + {/* ์ง€๋ธŒ ์™€์ด์–ด (์ง€์ง€ ์ผ€์ด๋ธ”) */} + + + + + ); + + case "rack": + // ๋ž™: ํ”„๋ ˆ์ž„ ๊ตฌ์กฐ + return ( + + {/* 4๊ฐœ ๊ธฐ๋‘ฅ */} + {[ + [-boxWidth * 0.4, -boxDepth * 0.4], + [boxWidth * 0.4, -boxDepth * 0.4], + [-boxWidth * 0.4, boxDepth * 0.4], + [boxWidth * 0.4, boxDepth * 0.4], + ].map(([x, z], idx) => ( + + + + ))} + {/* ์„ ๋ฐ˜ (3๋‹จ) */} + {[-boxHeight * 0.3, 0, boxHeight * 0.3].map((y, idx) => ( + + + + ))} + + ); + + case "plate-stack": + default: + // ํ›„ํŒ ์Šคํƒ: ํŒ”๋ ˆํŠธ + ๋ฐ•์Šค (๊ธฐ์กด ๋ Œ๋”๋ง) + return ( + <> + {/* ํŒ”๋ ˆํŠธ ๊ทธ๋ฃน - ๋ฐ•์Šค ํ•˜๋‹จ์— ๋ถ™์–ด์žˆ๋„๋ก */} + + {/* ์ƒ๋‹จ ๊ฐ€๋กœ ํŒ์ž๋“ค (5๊ฐœ) */} + {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + + + + + + + + ))} + + {/* ์ค‘๊ฐ„ ์„ธ๋กœ ๋ฐ›์นจ๋Œ€ (3๊ฐœ) */} + {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( + + + + + + + + ))} + + {/* ํ•˜๋‹จ ๊ฐ€๋กœ ํŒ์ž๋“ค (3๊ฐœ) */} + {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( + + + + + + + + ))} + + + {/* ๋ฉ”์ธ ๋ฐ•์Šค */} + + {/* ๋ฉ”์ธ ์žฌ์งˆ - ๊ณจํŒ์ง€ ๋А๋‚Œ */} + + + {/* ์™ธ๊ณฝ์„  - ๋” ์ง„ํ•˜๊ฒŒ */} + + + + + + + ); + } + }; + return ( - {/* ํŒ”๋ ˆํŠธ ๊ทธ๋ฃน - ๋ฐ•์Šค ํ•˜๋‹จ์— ๋ถ™์–ด์žˆ๋„๋ก */} - - {/* ์ƒ๋‹จ ๊ฐ€๋กœ ํŒ์ž๋“ค (5๊ฐœ) */} - {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( - - - - - - - - ))} - - {/* ์ค‘๊ฐ„ ์„ธ๋กœ ๋ฐ›์นจ๋Œ€ (3๊ฐœ) */} - {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( - - - - - - - - ))} - - {/* ํ•˜๋‹จ ๊ฐ€๋กœ ํŒ์ž๋“ค (3๊ฐœ) */} - {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( - - - - - - - - ))} - - - {/* ๋ฉ”์ธ ๋ฐ•์Šค */} - - {/* ๋ฉ”์ธ ์žฌ์งˆ - ๊ณจํŒ์ง€ ๋А๋‚Œ */} - - - {/* ์™ธ๊ณฝ์„  - ๋” ์ง„ํ•˜๊ฒŒ */} - - - - - - - {/* ํฌ์žฅ ํ…Œ์ดํ”„ (๊ฐ€๋กœ) - ์œ—๋ฉด */} - {isConfigured && ( - <> - {/* ํ…Œ์ดํ”„ ์„ธ๋กœ */} - - - - - )} - - {/* ์ž์žฌ๋ช… ๋ผ๋ฒจ ์Šคํ‹ฐ์ปค (์•ž๋ฉด) - ํฐ์ƒ‰ ๋ฐฐ๊ฒฝ */} - {isConfigured && placement.material_name && ( - - {/* ๋ผ๋ฒจ ๋ฐฐ๊ฒฝ (ํฐ์ƒ‰ ์Šคํ‹ฐ์ปค) */} - - - - - - - - {/* ๋ผ๋ฒจ ํ…์ŠคํŠธ */} - - {placement.material_name} - - - )} - - {/* ์ˆ˜๋Ÿ‰ ๋ผ๋ฒจ (์œ—๋ฉด) - ํฐ ๊ธ€์”จ */} - {isConfigured && placement.quantity && ( - - {placement.quantity} {placement.unit || ""} - - )} - - {/* ๋””ํ…Œ์ผ ํ‘œ์‹œ */} - {isConfigured && ( - <> - {/* ํ™”์‚ดํ‘œ ํ‘œ์‹œ (์ด ์ชฝ์ด ์œ„) */} - - - โ–ฒ - - - UP - - - - )} + {renderObjectByType()} ); } @@ -563,15 +905,18 @@ function Scene({ + {/* ๋ฐฐ๊ฒฝ์ƒ‰ */} + + {/* ๋ฐ”๋‹ฅ ๊ทธ๋ฆฌ๋“œ (ํƒ€์ผ์„ 4๋“ฑ๋ถ„) */} +
+ {/* ========== ํ•˜๋ถ€ ํฌ๋กค๋Ÿฌ ํŠธ๋ž™ ์‹œ์Šคํ…œ ========== */} + {/* ์ขŒ์ธก ํŠธ๋ž™ ๋ฉ”์ธ */} + + + + {/* ์ขŒ์ธก ํŠธ๋ž™ ์ƒ๋ถ€ ๋กค๋Ÿฌ */} + + + + + + + + + + + {/* ์šฐ์ธก ํŠธ๋ž™ ๋ฉ”์ธ */} + + + + {/* ์šฐ์ธก ํŠธ๋ž™ ์ƒ๋ถ€ ๋กค๋Ÿฌ */} + + + + + + + + + + + {/* ํŠธ๋ž™ ์—ฐ๊ฒฐ ํ”„๋ ˆ์ž„ */} + + + + + {/* ========== ํšŒ์ „ ์ƒ๋ถ€ ๊ตฌ์กฐ ========== */} + {/* ๋ฉ”์ธ ํšŒ์ „ ํ”Œ๋žซํผ */} + + + + {/* ํšŒ์ „ ๋ฒ ์–ด๋ง ํ•˜์šฐ์ง• */} + + + + + {/* ========== ์—”์ง„ ๋ฐ ์œ ์•• ์‹œ์Šคํ…œ ========== */} + {/* ์—”์ง„๋ฃธ ๋ฉ”์ธ */} + + + + {/* ์œ ์•• ํŽŒํ”„ ํ•˜์šฐ์ง• */} + + + + + + + {/* ๋ฐฐ๊ธฐ ํŒŒ์ดํ”„ */} + + + + + {/* ========== ์šด์ „์‹ค (์บ๋นˆ) ========== */} + {/* ์บ๋นˆ ๋ฉ”์ธ ๋ฐ”๋”” */} + + + + {/* ์บ๋นˆ ์ฐฝ๋ฌธ */} + + + + {/* ์บ๋นˆ ์ง€๋ถ• */} + + + + + {/* ========== ๋ถ๋Œ€ ์‹œ์Šคํ…œ ========== */} + {/* ๋ถ๋Œ€ ๋งˆ์šดํŠธ ๋ฒ ์ด์Šค */} + + + + {/* ๋ถ๋Œ€ ํžŒ์ง€ ์‹ค๋ฆฐ๋” (์œ ์••) */} + + + + + {/* ๋ฉ”์ธ ๋ถ๋Œ€ ํ•˜๋‹จ ์„น์…˜ */} + + + + {/* ๋ถ๋Œ€ ์ƒ๋‹จ ์„น์…˜ (ํ…”๋ ˆ์Šค์ฝ”ํ”ฝ) */} + + + + {/* ๋ถ๋Œ€ ์ตœ์ƒ๋‹จ ์„น์…˜ */} + + + + + {/* ๋ถ๋Œ€ ํŠธ๋Ÿฌ์Šค ๊ตฌ์กฐ (๋””ํ…Œ์ผ) */} + {[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => ( + + + + ))} + + {/* ========== ์นด์šดํ„ฐ์›จ์ดํŠธ ์‹œ์Šคํ…œ ========== */} + {/* ์นด์šดํ„ฐ์›จ์ดํŠธ ๋ฉ”์ธ ๋ธ”๋ก */} + + + + {/* ์นด์šดํ„ฐ์›จ์ดํŠธ ์ถ”๊ฐ€ ๋ธ”๋ก (์ƒ๋‹จ) */} + + + + {/* ์นด์šดํ„ฐ์›จ์ดํŠธ ํ”„๋ ˆ์ž„ */} + + + + + {/* ========== ํ›„ํฌ ๋ฐ ์ผ€์ด๋ธ” ์‹œ์Šคํ…œ ========== */} + {/* ๋ถ๋Œ€ ๋๋‹จ ํ’€๋ฆฌ */} + + + + + {/* ๋ฉ”์ธ ํ˜ธ์ด์ŠคํŠธ ์ผ€์ด๋ธ” */} + + + + + {/* ํ›„ํฌ ๋ธ”๋ก ์ƒ๋‹จ */} + + + + {/* ํ›„ํฌ ๋ฉ”์ธ (๋นจ๊ฐ„์ƒ‰ ์•ˆ์ „์ƒ‰) */} + + + + + {/* ์ง€๋ธŒ ์ง€์ง€ ์ผ€์ด๋ธ” (์ขŒ์ธก) */} + + + + {/* ์ง€๋ธŒ ์ง€์ง€ ์ผ€์ด๋ธ” (์šฐ์ธก) */} + + + + + {/* ========== ์กฐ๋ช… ๋ฐ ์•ˆ์ „ ์žฅ์น˜ ========== */} + {/* ์ž‘์—…๋“ฑ (์ „๋ฐฉ) */} + + + + {/* ๊ฒฝ๊ณ ๋“ฑ (๋ถ๋Œ€ ์ƒ๋‹จ) */} + + + + + ); +