UNPKG

sweet-diagram

Version:

A modern and intuitive diagram editor component for React applications with advanced auto-connect features, Sankey diagrams, Stack functionality, vertical text support, animation effects, and comprehensive component library

1,442 lines (1,440 loc) 243 kB
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { useState, useRef, useEffect, useCallback, createContext, useContext } from 'react'; import { Activity, Wrench, XCircle, AlertTriangle, CheckCircle, Settings, Plus, Zap, RotateCw, ArrowRight, ArrowLeft, Circle, Power, Square, Minus } from 'lucide-react'; const DiagramContext = createContext(); const useDiagram = () => { const context = useContext(DiagramContext); if (!context) { throw new Error("useDiagram must be used within a DiagramProvider"); } return context; }; const DiagramProvider = ({ children, className = "", style = {}, width = null, height = null }) => { const [boxes, setBoxes] = useState(/* @__PURE__ */ new Map()); const [connections, setConnections] = useState([]); const [selectedConnection, setSelectedConnection] = useState(null); const [selectedBoxes, setSelectedBoxes] = useState(/* @__PURE__ */ new Set()); const [isDragging, setIsDragging] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [connectionStartBox, setConnectionStartBox] = useState(null); const [diagramHistory, setDiagramHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [scale, setScale] = useState(1); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [dynamicBoxes, setDynamicBoxes] = useState(/* @__PURE__ */ new Map()); const [autoConnections, setAutoConnections] = useState([]); const [isAutoConnectMode, setIsAutoConnectMode] = useState(false); const [autoConnectStartBox, setAutoConnectStartBox] = useState(null); const [autoConnectSettings, setAutoConnectSettings] = useState({ connectionType: "smart", // "smart", "straight", "curved", "orthogonal", "stepped" color: "purple", strokeWidth: 3, arrowShape: "triangle", // "triangle", "diamond", "circle", "square", "none" arrowSize: 10, animationType: "flow", // "none", "flow", "pulse", "glow", "electric" animationSpeed: 2, curveStrength: 0.3, opacity: 1, showShadow: true, showConnectionPoints: true, autoCleanup: false, maxConnections: 20, smartSnap: true }); const [groups, setGroups] = useState(/* @__PURE__ */ new Map()); const [_maxZIndex, setMaxZIndex] = useState(1e3); const [boxZIndexes, setBoxZIndexes] = useState(/* @__PURE__ */ new Map()); const containerRef = useRef(null); const initialStateSavedRef = useRef(false); useEffect(() => { if (!initialStateSavedRef.current) { const initialState = { boxes: /* @__PURE__ */ new Map(), connections: [], dynamicBoxes: /* @__PURE__ */ new Map(), timestamp: Date.now() }; setDiagramHistory([initialState]); setHistoryIndex(0); initialStateSavedRef.current = true; } }, []); const registerBox = useCallback((id, boxInfo) => { setBoxes((prev) => { const newBoxes = new Map(prev); const boxData = { id, x: boxInfo.x, y: boxInfo.y, width: boxInfo.width, height: boxInfo.height, element: boxInfo.element, // DOM 요소 참조 ...boxInfo }; newBoxes.set(id, boxData); return newBoxes; }); }, []); const addDynamicBox = useCallback((boxConfig) => { const newId = boxConfig.id || `dynamic-box-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newBox = { id: newId, x: boxConfig.x || Math.random() * 400 + 50, y: boxConfig.y || Math.random() * 300 + 50, width: boxConfig.width || 120, height: boxConfig.height || 80, text: boxConfig.text || `박스 ${newId}`, className: boxConfig.className || "bg-blue-500 text-white border-blue-600 border-2 rounded-lg", ...boxConfig }; setDynamicBoxes((prev) => { const newDynamicBoxes = new Map(prev); newDynamicBoxes.set(newId, newBox); return newDynamicBoxes; }); setBoxes((prev) => { const newBoxes = new Map(prev); newBoxes.set(newId, newBox); return newBoxes; }); return newId; }, []); const removeDynamicBox = useCallback((id) => { setDynamicBoxes((prev) => { const newDynamicBoxes = new Map(prev); newDynamicBoxes.delete(id); return newDynamicBoxes; }); setBoxes((prev) => { const newBoxes = new Map(prev); newBoxes.delete(id); return newBoxes; }); setConnections((prev) => prev.filter((conn) => conn.fromBox?.id !== id && conn.toBox?.id !== id)); setSelectedBoxes((prev) => { const newSelected = new Set(prev); newSelected.delete(id); return newSelected; }); setGroups((prev) => { const newGroups = new Map(prev); for (const [groupId, group] of newGroups.entries()) { if (group.boxIds.includes(id)) { const updatedBoxIds = group.boxIds.filter((boxId) => boxId !== id); if (updatedBoxIds.length === 0) { newGroups.delete(groupId); } else { newGroups.set(groupId, { ...group, boxIds: updatedBoxIds, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } } } return newGroups; }); }, []); const updateBoxPosition = useCallback((id, newPosition) => { setBoxes((prev) => { const newBoxes = new Map(prev); const box = newBoxes.get(id); if (box) { if (box.x !== newPosition.x || box.y !== newPosition.y) { const updatedBox = { ...box, ...newPosition }; newBoxes.set(id, updatedBox); return newBoxes; } } return prev; }); setDynamicBoxes((prevDynamic) => { const newDynamicBoxes = new Map(prevDynamic); if (newDynamicBoxes.has(id)) { const box = newDynamicBoxes.get(id); if (box && (box.x !== newPosition.x || box.y !== newPosition.y)) { const updatedBox = { ...box, ...newPosition }; newDynamicBoxes.set(id, updatedBox); return newDynamicBoxes; } } return prevDynamic; }); }, []); const unregisterBox = useCallback((id) => { setBoxes((prev) => { const newBoxes = new Map(prev); newBoxes.delete(id); return newBoxes; }); setConnections((prev) => prev.filter((conn) => conn.fromBox?.id !== id && conn.toBox?.id !== id)); setSelectedBoxes((prev) => { const newSelected = new Set(prev); newSelected.delete(id); return newSelected; }); setGroups((prev) => { const newGroups = new Map(prev); for (const [groupId, group] of newGroups.entries()) { if (group.boxIds.includes(id)) { const updatedBoxIds = group.boxIds.filter((boxId) => boxId !== id); if (updatedBoxIds.length === 0) { newGroups.delete(groupId); } else { newGroups.set(groupId, { ...group, boxIds: updatedBoxIds, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } } } return newGroups; }); }, []); const getBox = useCallback( (id) => { const box = boxes.get(id); return box; }, [boxes] ); const getAllBoxes = useCallback(() => { return Array.from(boxes.values()); }, [boxes]); const selectBox = useCallback((id, multiSelect = false) => { setSelectedBoxes((prev) => { const newSelected = /* @__PURE__ */ new Set(); if (multiSelect) { newSelected.add(...prev); if (prev.has(id)) { newSelected.delete(id); } else { newSelected.add(id); } } else { if (prev.has(id) && prev.size === 1) ; else { newSelected.add(id); } } return newSelected; }); }, []); const clearSelection = useCallback(() => { setSelectedBoxes(/* @__PURE__ */ new Set()); }, []); const addConnection = useCallback((connectionInfo) => { const newConnection = { id: `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, ...connectionInfo }; setConnections((prev) => [...prev, newConnection]); return newConnection.id; }, []); const removeConnection = useCallback((connectionId) => { setConnections((prev) => prev.filter((conn) => conn.id !== connectionId)); }, []); const updateConnection = useCallback((connectionId, updates) => { setConnections((prev) => prev.map((conn) => conn.id === connectionId ? { ...conn, ...updates } : conn)); }, []); const getOptimalConnectionPoints = useCallback( (fromBoxId, toBoxId) => { const fromBox = boxes.get(fromBoxId); const toBox = boxes.get(toBoxId); if (!fromBox || !toBox) return null; const fromCenter = { x: fromBox.x + fromBox.width / 2, y: fromBox.y + fromBox.height / 2 }; const toCenter = { x: toBox.x + toBox.width / 2, y: toBox.y + toBox.height / 2 }; const dx = toCenter.x - fromCenter.x; const dy = toCenter.y - fromCenter.y; let fromPosition, toPosition; if (Math.abs(dx) > Math.abs(dy)) { if (dx > 0) { fromPosition = "right"; toPosition = "left"; } else { fromPosition = "left"; toPosition = "right"; } } else { if (dy > 0) { fromPosition = "bottom"; toPosition = "top"; } else { fromPosition = "top"; toPosition = "bottom"; } } return { fromPosition, toPosition }; }, [boxes] ); const saveState = useCallback(() => { setDiagramHistory((prev) => { const currentState = { boxes: new Map(boxes), connections: [...connections], dynamicBoxes: new Map(dynamicBoxes), timestamp: Date.now() }; const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(currentState); return newHistory.slice(-50); }); setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [boxes, connections, dynamicBoxes, historyIndex]); const undo = useCallback(() => { if (historyIndex > 0) { const previousState = diagramHistory[historyIndex - 1]; setBoxes(new Map(previousState.boxes)); setConnections([...previousState.connections]); setDynamicBoxes(new Map(previousState.dynamicBoxes || /* @__PURE__ */ new Map())); setHistoryIndex((prev) => prev - 1); } }, [diagramHistory, historyIndex]); const redo = useCallback(() => { if (historyIndex < diagramHistory.length - 1) { const nextState = diagramHistory[historyIndex + 1]; setBoxes(new Map(nextState.boxes)); setConnections([...nextState.connections]); setDynamicBoxes(new Map(nextState.dynamicBoxes || /* @__PURE__ */ new Map())); setHistoryIndex((prev) => prev + 1); } }, [diagramHistory, historyIndex]); const clearDiagram = useCallback(() => { setBoxes(/* @__PURE__ */ new Map()); setConnections([]); setDynamicBoxes(/* @__PURE__ */ new Map()); setSelectedBoxes(/* @__PURE__ */ new Set()); setSelectedConnection(null); const currentState = { boxes: /* @__PURE__ */ new Map(), connections: [], dynamicBoxes: /* @__PURE__ */ new Map(), timestamp: Date.now() }; setDiagramHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(currentState); return newHistory.slice(-50); }); setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [historyIndex]); const zoomIn = useCallback(() => { setScale((prev) => Math.min(prev * 1.2, 3)); }, []); const zoomOut = useCallback(() => { setScale((prev) => Math.max(prev / 1.2, 0.1)); }, []); const resetZoom = useCallback(() => { setScale(1); setPanOffset({ x: 0, y: 0 }); }, []); const getDiagramStats = useCallback(() => { return { boxCount: boxes.size, connectionCount: connections.length, selectedBoxCount: selectedBoxes.size, canUndo: historyIndex > 0, canRedo: historyIndex < diagramHistory.length - 1, scale, panOffset }; }, [boxes.size, connections.length, selectedBoxes.size, historyIndex, diagramHistory.length, scale, panOffset]); const findBoxes = useCallback( (predicate) => { return Array.from(boxes.values()).filter(predicate); }, [boxes] ); const findConnections = useCallback( (predicate) => { return connections.filter(predicate); }, [connections] ); const optimizeLayout = useCallback(() => { const boxArray = Array.from(boxes.values()); if (boxArray.length === 0) return; const updatedBoxes = /* @__PURE__ */ new Map(); const iterations = 50; const containerWidth = 800; const containerHeight = 600; boxArray.forEach((box) => { updatedBoxes.set(box.id, { ...box }); }); for (let iter = 0; iter < iterations; iter++) { const forces = /* @__PURE__ */ new Map(); boxArray.forEach((box) => { let fx = 0, fy = 0; const currentBox = updatedBoxes.get(box.id); boxArray.forEach((otherBox) => { if (box.id !== otherBox.id) { const otherCurrentBox = updatedBoxes.get(otherBox.id); const dx = currentBox.x - otherCurrentBox.x; const dy = currentBox.y - otherCurrentBox.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const force = Math.min(1e3 / (distance * distance), 10); fx += dx / distance * force; fy += dy / distance * force; } }); connections.forEach((conn) => { let targetBox = null; if (conn.fromBox?.id === box.id) { targetBox = updatedBoxes.get(conn.toBox?.id); } else if (conn.toBox?.id === box.id) { targetBox = updatedBoxes.get(conn.fromBox?.id); } if (targetBox) { const dx = targetBox.x - currentBox.x; const dy = targetBox.y - currentBox.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const idealDistance = 150; const force = (distance - idealDistance) * 0.05; fx += dx / distance * force; fy += dy / distance * force; } }); const centerX = containerWidth / 2; const centerY = containerHeight / 2; const toCenterX = (centerX - currentBox.x) * 1e-3; const toCenterY = (centerY - currentBox.y) * 1e-3; fx += toCenterX; fy += toCenterY; forces.set(box.id, { fx, fy }); }); boxArray.forEach((box) => { const force = forces.get(box.id); const currentBox = updatedBoxes.get(box.id); const damping = 0.1; let newX = currentBox.x + force.fx * damping; let newY = currentBox.y + force.fy * damping; newX = Math.max(10, Math.min(containerWidth - currentBox.width - 10, newX)); newY = Math.max(10, Math.min(containerHeight - currentBox.height - 10, newY)); updatedBoxes.set(box.id, { ...currentBox, x: newX, y: newY }); }); } setBoxes(updatedBoxes); setDynamicBoxes((prev) => { const newDynamicBoxes = new Map(prev); updatedBoxes.forEach((box, id) => { if (newDynamicBoxes.has(id)) { newDynamicBoxes.set(id, box); } }); return newDynamicBoxes; }); const currentState = { boxes: new Map(updatedBoxes), connections: [...connections], dynamicBoxes: new Map(dynamicBoxes), timestamp: Date.now() }; setDiagramHistory((prev) => { const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(currentState); return newHistory.slice(-50); }); setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [boxes, connections, dynamicBoxes, historyIndex]); const startAutoConnect = useCallback((boxId, clickPoint = null) => { setIsAutoConnectMode(true); setAutoConnectStartBox({ boxId, clickPoint }); }, []); const cancelAutoConnect = useCallback(() => { setIsAutoConnectMode(false); setAutoConnectStartBox(null); }, []); const addAutoConnection = useCallback( (toPoint) => { if (!autoConnectStartBox) return null; const newAutoConnection = { id: `auto-conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, fromBoxId: autoConnectStartBox.boxId || autoConnectStartBox, // 기존 호환성 유지 toPoint, userClickPoint: autoConnectStartBox.clickPoint || null, // 사용자 클릭 위치 저장 type: "auto", createdAt: (/* @__PURE__ */ new Date()).toISOString() }; setAutoConnections((prev) => [...prev, newAutoConnection]); setIsAutoConnectMode(false); setAutoConnectStartBox(null); return newAutoConnection.id; }, [autoConnectStartBox] ); const removeAutoConnection = useCallback((connectionId) => { setAutoConnections((prev) => prev.filter((conn) => conn.id !== connectionId)); }, []); const clearAutoConnections = useCallback(() => { setAutoConnections([]); }, []); const updateAutoConnectSettings = useCallback((newSettings) => { setAutoConnectSettings((prev) => ({ ...prev, ...newSettings })); }, []); const resetAutoConnectSettings = useCallback(() => { setAutoConnectSettings({ connectionType: "smart", color: "purple", strokeWidth: 3, arrowShape: "triangle", arrowSize: 10, animationType: "flow", animationSpeed: 2, curveStrength: 0.3, opacity: 1, showShadow: true, showConnectionPoints: true, autoCleanup: false, maxConnections: 20, smartSnap: true }); }, []); const registerGroup = useCallback((groupId, groupInfo) => { setGroups((prev) => { const newGroups = new Map(prev); const existingGroup = newGroups.get(groupId); if (existingGroup) { const updatedBoxIds = /* @__PURE__ */ new Set([...existingGroup.boxIds, ...groupInfo.boxIds]); newGroups.set(groupId, { ...existingGroup, ...groupInfo, boxIds: Array.from(updatedBoxIds) }); } else { newGroups.set(groupId, { id: groupId, label: groupInfo.label || groupId, style: groupInfo.style || {}, boxIds: groupInfo.boxIds || [], createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...groupInfo }); } return newGroups; }); }, []); const unregisterGroup = useCallback((groupId) => { setGroups((prev) => { const newGroups = new Map(prev); newGroups.delete(groupId); return newGroups; }); }, []); const getGroup = useCallback( (groupId) => { return groups.get(groupId); }, [groups] ); const getAllGroups = useCallback(() => { return Array.from(groups.values()); }, [groups]); const getGroupBoxes = useCallback( (groupId) => { const group = groups.get(groupId); if (!group) return []; return group.boxIds.map((boxId) => boxes.get(boxId)).filter(Boolean); }, [groups, boxes] ); const updateGroupInfo = useCallback((groupId, updates) => { setGroups((prev) => { const newGroups = new Map(prev); const existingGroup = newGroups.get(groupId); if (existingGroup) { newGroups.set(groupId, { ...existingGroup, ...updates, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } return newGroups; }); }, []); const addBoxToGroup = useCallback((groupId, boxId) => { setGroups((prev) => { const newGroups = new Map(prev); const group = newGroups.get(groupId); if (group && !group.boxIds.includes(boxId)) { newGroups.set(groupId, { ...group, boxIds: [...group.boxIds, boxId], updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } return newGroups; }); }, []); const removeBoxFromGroup = useCallback((groupId, boxId) => { setGroups((prev) => { const newGroups = new Map(prev); const group = newGroups.get(groupId); if (group) { const updatedBoxIds = group.boxIds.filter((id) => id !== boxId); if (updatedBoxIds.length === 0) { newGroups.delete(groupId); } else { newGroups.set(groupId, { ...group, boxIds: updatedBoxIds, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } } return newGroups; }); }, []); const getBoxGroup = useCallback( (boxId) => { for (const group of groups.values()) { if (group.boxIds.includes(boxId)) { return group; } } return null; }, [groups] ); const moveGroup = useCallback( (groupId, deltaX, deltaY) => { const group = groups.get(groupId); if (!group) return; group.boxIds.forEach((boxId) => { const box = boxes.get(boxId); if (box) { updateBoxPosition(boxId, { x: box.x + deltaX, y: box.y + deltaY }); } }); }, [groups, boxes, updateBoxPosition] ); const getBoxZIndex = useCallback( (boxId) => { return boxZIndexes.get(boxId) || 10; }, [boxZIndexes] ); const bringBoxToFront = useCallback((boxId) => { setMaxZIndex((prev) => { const newMaxZIndex = prev + 1; setBoxZIndexes((prevIndexes) => { const newIndexes = new Map(prevIndexes); newIndexes.set(boxId, newMaxZIndex); return newIndexes; }); return newMaxZIndex; }); }, []); const setBoxZIndex = useCallback((boxId, zIndex) => { setBoxZIndexes((prev) => { const newIndexes = new Map(prev); newIndexes.set(boxId, zIndex); return newIndexes; }); }, []); const resetZIndexes = useCallback(() => { setBoxZIndexes(/* @__PURE__ */ new Map()); setMaxZIndex(1e3); }, []); const value = { // 박스 관리 boxes, registerBox, unregisterBox, updateBoxPosition, getBox, getAllBoxes, selectBox, clearSelection, selectedBoxes, findBoxes, addDynamicBox, removeDynamicBox, dynamicBoxes, // 연결 관리 connections, addConnection, removeConnection, updateConnection, selectedConnection, setSelectedConnection, getOptimalConnectionPoints, findConnections, // 상태 관리 isDragging, setIsDragging, isConnecting, setIsConnecting, connectionStartBox, setConnectionStartBox, // 히스토리 관리 undo, redo, saveState, clearDiagram, historyIndex, // undo/redo 감지용 // 뷰 관리 scale, setScale, panOffset, setPanOffset, zoomIn, zoomOut, resetZoom, // 유틸리티 getDiagramStats, optimizeLayout, // 자동 연결 관련 autoConnections, isAutoConnectMode, autoConnectStartBox, startAutoConnect, cancelAutoConnect, addAutoConnection, removeAutoConnection, clearAutoConnections, autoConnectSettings, updateAutoConnectSettings, resetAutoConnectSettings, // 그룹 관리 groups, registerGroup, unregisterGroup, getGroup, getAllGroups, getGroupBoxes, updateGroupInfo, addBoxToGroup, removeBoxFromGroup, getBoxGroup, moveGroup, // 컨테이너 관련 containerRef, // 🆕 Z-Index 관리 getBoxZIndex, bringBoxToFront, setBoxZIndex, resetZIndexes }; const defaultStyle = { position: "relative", width: width !== null ? `${width}px` : "100%", height: height !== null ? `${height}px` : "100%", maxWidth: width !== null ? `${width}px` : "none", maxHeight: height !== null ? `${height}px` : "none", overflow: width !== null || height !== null ? "hidden" : "visible", ...style }; const combinedClassName = `sweet-diagram-provider diagram-container ${className}`; return /* @__PURE__ */ jsx(DiagramContext.Provider, { value, children: /* @__PURE__ */ jsx("div", { ref: containerRef, className: combinedClassName, style: defaultStyle, children }) }); }; const Connector = ({ // 기존 방식 (좌표 직접 지정) startPoint = null, endPoint = null, // 새로운 방식 (박스 연결 - 자동 감지) fromBox = null, // { id: "box1", position: "right", offset: { x: 0, y: 0 } } toBox = null, // { id: "box2", position: "left", offset: { x: 0, y: 0 } } // 자유 포인트 연결 방식 fromCustomPoint = null, // { x: number, y: number } - 절대 좌표 toCustomPoint = null, // { x: number, y: number } - 절대 좌표 fromBoxCustom = null, // { id: string, customPoint: { x: 0~1, y: 0~1 } } - 박스 내 상대 위치 toBoxCustom = null, // { id: string, customPoint: { x: 0~1, y: 0~1 } } - 박스 내 상대 위치 connectionType = "straight", // 'straight', 'curved', 'orthogonal', 'stepped', 'custom' strokeWidth = 2, animated = false, animationType = "dash", animationSpeed = 2, className = "", arrowSize = 8, arrowDirection = "forward", // 'forward', 'backward', 'both', 'none' arrowColor = "current", // 'current', 'red', 'blue', etc. arrowShape = "triangle", // 'triangle', 'diamond', 'circle', 'square' // 백워드 호환성을 위한 추가 props showArrow = null, // true | false - showArrow={true}는 arrowDirection="forward"와 동일 showStartArrow = null, // true | false - showStartArrow={true}는 양방향 화살표를 의미 bendPoints, // 중간 꺾임점들 [{ x: 150, y: 100 }, { x: 150, y: 200 }] - 기본값 제거 cornerRadius = 0, // 모서리 둥글기 orthogonalDirection = "auto", // 'horizontal-first', 'vertical-first', 'auto' stepOffset = 50, // orthogonal 연결에서 중간 지점 오프셋 // 새로운 기능: Junction Points (T자 분기점) junctionPoints = null // 분기점들 [{ id, position, positionType, showJunction, junctionSize, junctionShape, branches }] }) => { let getBox = null; try { const context = useDiagram(); getBox = context.getBox; } catch { getBox = null; } const safeBendPoints = Array.isArray(bendPoints) ? bendPoints : void 0; const getEffectiveArrowDirection = () => { if (showArrow !== null || showStartArrow !== null) { const endArrow = showArrow !== null ? showArrow : true; const startArrow = showStartArrow !== null ? showStartArrow : false; if (endArrow && startArrow) { return "both"; } else if (endArrow && !startArrow) { return "forward"; } else if (!endArrow && startArrow) { return "backward"; } else { return "none"; } } return arrowDirection; }; const effectiveArrowDirection = getEffectiveArrowDirection(); const shouldShowEndArrow = effectiveArrowDirection === "none" ? false : effectiveArrowDirection === "forward" || effectiveArrowDirection === "both"; const shouldShowStartArrow = effectiveArrowDirection === "none" ? false : effectiveArrowDirection === "backward" || effectiveArrowDirection === "both"; const getArrowColorClass = () => { if (arrowColor === "current") { return "fill-current"; } const colorMap = { red: "fill-red-500", blue: "fill-blue-500", green: "fill-green-500", yellow: "fill-yellow-500", purple: "fill-purple-500", pink: "fill-pink-500", indigo: "fill-indigo-500", gray: "fill-gray-500", black: "fill-black", white: "fill-white" }; return colorMap[arrowColor] || `fill-${arrowColor}` || "fill-current"; }; const createArrowShape = (centerX, centerY, head1X, head1Y, head2X, head2Y, size) => { switch (arrowShape) { case "triangle": return `${centerX},${centerY} ${head1X},${head1Y} ${head2X},${head2Y}`; case "diamond": { const angle = Math.atan2(head1Y - centerY, head1X - centerX); const frontX = centerX - size * 0.6 * Math.cos(angle); const frontY = centerY - size * 0.6 * Math.sin(angle); const backX = centerX + size * 0.6 * Math.cos(angle); const backY = centerY + size * 0.6 * Math.sin(angle); const topX = centerX + size * 0.4 * Math.cos(angle + Math.PI / 2); const topY = centerY + size * 0.4 * Math.sin(angle + Math.PI / 2); const bottomX = centerX + size * 0.4 * Math.cos(angle - Math.PI / 2); const bottomY = centerY + size * 0.4 * Math.sin(angle - Math.PI / 2); return `${frontX},${frontY} ${topX},${topY} ${backX},${backY} ${bottomX},${bottomY}`; } case "circle": return null; case "square": { const squareSize = size * 0.8; const halfSize = squareSize / 2; return `${centerX - halfSize},${centerY - halfSize} ${centerX + halfSize},${centerY - halfSize} ${centerX + halfSize},${centerY + halfSize} ${centerX - halfSize},${centerY + halfSize}`; } default: return `${centerX},${centerY} ${head1X},${head1Y} ${head2X},${head2Y}`; } }; const renderCircleArrow = (centerX, centerY, minX2, minY2) => { if (arrowShape !== "circle") return null; return /* @__PURE__ */ jsx("circle", { cx: centerX - minX2, cy: centerY - minY2, r: safeArrowSize * 0.6, className: getArrowColorClass() }); }; const getBoxConnectionPoint = (boxInfo, position, offset = { x: 0, y: 0 }) => { if (!boxInfo || typeof boxInfo.x !== "number" || typeof boxInfo.y !== "number") { return null; } const { x, y, width, height } = boxInfo; let point = { x: 0, y: 0 }; switch (position) { case "top": point = { x: x + width / 2, y }; break; case "right": point = { x: x + width, y: y + height / 2 }; break; case "bottom": point = { x: x + width / 2, y: y + height }; break; case "left": point = { x, y: y + height / 2 }; break; case "center": point = { x: x + width / 2, y: y + height / 2 }; break; default: point = { x: x + width / 2, y: y + height / 2 }; } return { x: point.x + (offset?.x || 0), y: point.y + (offset?.y || 0) }; }; const getBoxCustomPoint = (boxCustomInfo) => { if (!getBox) return null; const box = getBox(boxCustomInfo.id); if (!box) return null; const { x: boxX, y: boxY, width, height } = box; const { customPoint } = boxCustomInfo; const normalizedX = Math.max(0, Math.min(1, customPoint.x)); const normalizedY = Math.max(0, Math.min(1, customPoint.y)); const connectionX = boxX + width * normalizedX; const connectionY = boxY + height * normalizedY; return { x: connectionX, y: connectionY }; }; const calculateActualPoints = () => { let actualStartPoint2 = startPoint; let actualEndPoint2 = endPoint; if (fromCustomPoint && toCustomPoint) { actualStartPoint2 = fromCustomPoint; actualEndPoint2 = toCustomPoint; } else if (fromBoxCustom && toBoxCustom && getBox) { const calculatedStart = getBoxCustomPoint(fromBoxCustom); const calculatedEnd = getBoxCustomPoint(toBoxCustom); if (calculatedStart && calculatedEnd) { actualStartPoint2 = calculatedStart; actualEndPoint2 = calculatedEnd; } } else if ((fromBox || fromBoxCustom) && (toBox || toBoxCustom || fromCustomPoint || toCustomPoint)) { if (fromBoxCustom && getBox) { actualStartPoint2 = getBoxCustomPoint(fromBoxCustom); } else if (fromBox && fromBox.id && getBox) { const startBox = getBox(fromBox.id); if (startBox) { actualStartPoint2 = getBoxConnectionPoint(startBox, fromBox.position, fromBox.offset); } } else if (fromCustomPoint) { actualStartPoint2 = fromCustomPoint; } if (toBoxCustom && getBox) { actualEndPoint2 = getBoxCustomPoint(toBoxCustom); } else if (toBox && toBox.id && getBox) { const endBox = getBox(toBox.id); if (endBox) { actualEndPoint2 = getBoxConnectionPoint(endBox, toBox.position, toBox.offset); } } else if (toCustomPoint) { actualEndPoint2 = toCustomPoint; } } else if (fromBox && toBox && fromBox.id && toBox.id && getBox) { const startBox = getBox(fromBox.id); const endBox = getBox(toBox.id); if (startBox && endBox) { const calculatedStart = getBoxConnectionPoint(startBox, fromBox.position, fromBox.offset); const calculatedEnd = getBoxConnectionPoint(endBox, toBox.position, toBox.offset); if (calculatedStart && calculatedEnd) { actualStartPoint2 = calculatedStart; actualEndPoint2 = calculatedEnd; } } else { if (safeBendPoints && safeBendPoints.length >= 2) { actualStartPoint2 = safeBendPoints[0]; actualEndPoint2 = safeBendPoints[safeBendPoints.length - 1]; } } } return { actualStartPoint: actualStartPoint2, actualEndPoint: actualEndPoint2 }; }; const { actualStartPoint, actualEndPoint } = calculateActualPoints(); const safeStartPoint = { x: actualStartPoint?.x ?? 100, y: actualStartPoint?.y ?? 100 }; const safeEndPoint = { x: actualEndPoint?.x ?? 300, y: actualEndPoint?.y ?? 200 }; const safeArrowSize = typeof arrowSize === "number" && !isNaN(arrowSize) ? arrowSize : 8; const safeStrokeWidth = typeof strokeWidth === "number" && !isNaN(strokeWidth) ? strokeWidth : 2; const processJunctionPoints = () => { if (!junctionPoints || !Array.isArray(junctionPoints)) { return []; } const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; return junctionPoints.map((junction) => { const { id, position, positionType = "absolute", showJunction = true, junctionSize = 4, junctionShape = "circle", branches = [] } = junction; let junctionPoint; if (positionType === "relative") { const ratio = typeof position === "number" ? position : 0.5; junctionPoint = { x: x1 + (x2 - x1) * ratio, y: y1 + (y2 - y1) * ratio }; } else { junctionPoint = { x: position?.x || x1, y: position?.y || y1 }; } const processedBranches = branches.map((branch) => { const { id: branchId, to, arrowDirection: branchArrowDirection = "forward", arrowSize: branchArrowSize = arrowSize, className: branchClassName = "", strokeWidth: branchStrokeWidth = strokeWidth, connectionType: branchConnectionType = "straight" } = branch; let targetPoint; if (to?.boxId && getBox) { const targetBox = getBox(to.boxId); if (targetBox) { targetPoint = getBoxConnectionPoint( { }, to.position || "left" ); } else { console.warn(`⚠️ 박스 '${to.boxId}'를 찾을 수 없습니다.`); targetPoint = { x: junctionPoint.x + 50, y: junctionPoint.y }; } } else { targetPoint = { x: to?.x || junctionPoint.x + 50, y: to?.y || junctionPoint.y }; } return { id: branchId, startPoint: junctionPoint, endPoint: targetPoint, arrowDirection: branchArrowDirection, arrowSize: branchArrowSize, className: branchClassName, strokeWidth: branchStrokeWidth, connectionType: branchConnectionType }; }); return { id, point: junctionPoint, showJunction, junctionSize, junctionShape, branches: processedBranches }; }); }; const processedJunctions = processJunctionPoints(); const renderJunctionPoint = (junction, minX2, minY2) => { const { point, showJunction, junctionSize, junctionShape } = junction; if (!showJunction) return null; const adjustedX = point.x - minX2; const adjustedY = point.y - minY2; switch (junctionShape) { case "circle": return /* @__PURE__ */ jsx( "circle", { cx: adjustedX, cy: adjustedY, r: junctionSize, className: "fill-current" }, `junction-${junction.id}` ); case "square": return /* @__PURE__ */ jsx( "rect", { x: adjustedX - junctionSize, y: adjustedY - junctionSize, width: junctionSize * 2, height: junctionSize * 2, className: "fill-current" }, `junction-${junction.id}` ); case "diamond": return /* @__PURE__ */ jsx( "polygon", { points: `${adjustedX},${adjustedY - junctionSize} ${adjustedX + junctionSize},${adjustedY} ${adjustedX},${adjustedY + junctionSize} ${adjustedX - junctionSize},${adjustedY}`, className: "fill-current" }, `junction-${junction.id}` ); default: return /* @__PURE__ */ jsx( "circle", { cx: adjustedX, cy: adjustedY, r: junctionSize, className: "fill-current" }, `junction-${junction.id}` ); } }; const calculateBranchPath = (branch) => { const { startPoint: startPoint2, endPoint: endPoint2, connectionType: branchConnectionType } = branch; switch (branchConnectionType) { case "curved": { const dx = endPoint2.x - startPoint2.x; const dy = endPoint2.y - startPoint2.y; const distance = Math.sqrt(dx * dx + dy * dy); const controlOffset = Math.min(distance * 0.3, 50); const cp1x = startPoint2.x + controlOffset; const cp1y = startPoint2.y; const cp2x = endPoint2.x - controlOffset; const cp2y = endPoint2.y; return `M ${startPoint2.x} ${startPoint2.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${endPoint2.x} ${endPoint2.y}`; } case "orthogonal": { const midX = startPoint2.x + (endPoint2.x - startPoint2.x) * 0.5; return `M ${startPoint2.x} ${startPoint2.y} L ${midX} ${startPoint2.y} L ${midX} ${endPoint2.y} L ${endPoint2.x} ${endPoint2.y}`; } default: return `M ${startPoint2.x} ${startPoint2.y} L ${endPoint2.x} ${endPoint2.y}`; } }; const calculateBranchArrow = (branch) => { const { startPoint: startPoint2, endPoint: endPoint2, arrowDirection: branchArrowDirection, arrowSize: branchArrowSize } = branch; const dx = endPoint2.x - startPoint2.x; const dy = endPoint2.y - startPoint2.y; const angle = Math.atan2(dy, dx); const arrowHead12 = { x: endPoint2.x - branchArrowSize * Math.cos(angle - Math.PI / 6), y: endPoint2.y - branchArrowSize * Math.sin(angle - Math.PI / 6) }; const arrowHead22 = { x: endPoint2.x - branchArrowSize * Math.cos(angle + Math.PI / 6), y: endPoint2.y - branchArrowSize * Math.sin(angle + Math.PI / 6) }; const shouldShowArrow = branchArrowDirection === "forward" || branchArrowDirection === "both"; return shouldShowArrow ? { arrowHead1: arrowHead12, arrowHead2: arrowHead22 } : null; }; const getAutoConnectionType = () => { if (connectionType === "custom" && (!safeBendPoints || safeBendPoints.length === 0)) { console.warn( "⚠️ connectionType='custom'이지만 bendPoints가 정의되지 않았습니다. 'straight' 타입으로 fallback합니다." ); return "straight"; } if (connectionType !== "auto") return connectionType; if (fromBox && toBox && fromBox.position && toBox.position) { const { position: fromPos } = fromBox; if (fromPos === "right" && toBox.position === "left" || fromPos === "left" && toBox.position === "right" || fromPos === "top" && toBox.position === "bottom" || fromPos === "bottom" && toBox.position === "top") { return "straight"; } else { return "orthogonal"; } } return "straight"; }; const finalConnectionType = getAutoConnectionType(); const calculateOrthogonalPath = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; let path = `M ${x1} ${y1}`; if (fromBox && toBox && fromBox.position && toBox.position) { const { position: fromPos } = fromBox; if (fromPos === "right" || fromPos === "left") { const midX = fromPos === "right" ? x1 + stepOffset : x1 - stepOffset; path += ` L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`; } else { const midY = fromPos === "bottom" ? y1 + stepOffset : y1 - stepOffset; path += ` L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`; } } else { if (orthogonalDirection === "horizontal-first") { const midX = x1 + stepOffset; path += ` L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`; } else if (orthogonalDirection === "vertical-first") { const midY = y1 + stepOffset; path += ` L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`; } else { const dx = Math.abs(x2 - x1); const dy = Math.abs(y2 - y1); if (dx > dy) { const midX = x1 + (x2 - x1) * 0.5; path += ` L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`; } else { const midY = y1 + (y2 - y1) * 0.5; path += ` L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`; } } } return path; }; const calculateSteppedPath = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; const steps = 3; const stepX = (x2 - x1) / steps; const stepY = (y2 - y1) / steps; let path = `M ${x1} ${y1}`; for (let i = 1; i <= steps; i++) { const currentX = x1 + stepX * i; const currentY = y1 + stepY * (i - 1); const nextY = y1 + stepY * i; path += ` L ${currentX} ${currentY} L ${currentX} ${nextY}`; } return path; }; const calculateCustomPath = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; let path = `M ${x1} ${y1}`; if (safeBendPoints && safeBendPoints.length > 0) { safeBendPoints.forEach((point) => { if (point && typeof point.x === "number" && typeof point.y === "number") { path += ` L ${point.x} ${point.y}`; } }); const lastBend = safeBendPoints[safeBendPoints.length - 1]; if (lastBend && (lastBend.x !== x2 || lastBend.y !== y2)) { path += ` L ${x2} ${y2}`; } } else { path += ` L ${x2} ${y2}`; } return path; }; const calculateCurvedPath = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; const dx = x2 - x1; const dy = y2 - y1; if (fromBox && toBox && fromBox.position && toBox.position) { const { position: fromPos } = fromBox; let cp1x2 = x1, cp1y2 = y1, cp2x2 = x2, cp2y2 = y2; if (fromPos === "right") { cp1x2 = x1 + Math.abs(dx) * 0.5; } else if (fromPos === "left") { cp1x2 = x1 - Math.abs(dx) * 0.5; } else if (fromPos === "bottom") { cp1y2 = y1 + Math.abs(dy) * 0.5; } else if (fromPos === "top") { cp1y2 = y1 - Math.abs(dy) * 0.5; } if (toBox.position === "left") { cp2x2 = x2 - Math.abs(dx) * 0.5; } else if (toBox.position === "right") { cp2x2 = x2 + Math.abs(dx) * 0.5; } else if (toBox.position === "top") { cp2y2 = y2 - Math.abs(dy) * 0.5; } else if (toBox.position === "bottom") { cp2y2 = y2 + Math.abs(dy) * 0.5; } return `M ${x1} ${y1} C ${cp1x2} ${cp1y2} ${cp2x2} ${cp2y2} ${x2} ${y2}`; } const distance = Math.sqrt(dx * dx + dy * dy); const controlOffset = Math.min(distance * 0.3, 100); const cp1x = x1 + controlOffset; const cp1y = y1; const cp2x = x2 - controlOffset; const cp2y = y2; return `M ${x1} ${y1} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${x2} ${y2}`; }; const calculateStraightPath = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; return `M ${x1} ${y1} L ${x2} ${y2}`; }; const getPath = () => { switch (finalConnectionType) { case "curved": return calculateCurvedPath(); case "orthogonal": return calculateOrthogonalPath(); case "stepped": return calculateSteppedPath(); case "custom": return calculateCustomPath(); default: return calculateStraightPath(); } }; const getPathWithRadius = () => { const basePath = getPath(); if (cornerRadius <= 0 || finalConnectionType === "curved") { return basePath; } if (finalConnectionType === "orthogonal") { return basePath; } return basePath; }; const calculateArrowMarker = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; if (toBox && toBox.position && fromBox) { const { position: toPos } = toBox; let angle2; switch (toPos) { case "left": angle2 = 0; break; case "right": angle2 = Math.PI; break; case "top": angle2 = Math.PI / 2; break; case "bottom": angle2 = -Math.PI / 2; break; default: angle2 = Math.atan2(y2 - y1, x2 - x1); } const arrowHead13 = { x: x2 - safeArrowSize * Math.cos(angle2 - Math.PI / 6), y: y2 - safeArrowSize * Math.sin(angle2 - Math.PI / 6) }; const arrowHead23 = { x: x2 - safeArrowSize * Math.cos(angle2 + Math.PI / 6), y: y2 - safeArrowSize * Math.sin(angle2 + Math.PI / 6) }; return { arrowHead1: arrowHead13, arrowHead2: arrowHead23 }; } let finalX1 = x1, finalY1 = y1, finalX2 = x2, finalY2 = y2; if (finalConnectionType === "custom" && safeBendPoints && safeBendPoints.length > 0) { const lastBend = safeBendPoints[safeBendPoints.length - 1]; if (lastBend && typeof lastBend.x === "number" && typeof lastBend.y === "number") { finalX1 = lastBend.x; finalY1 = lastBend.y; } } else if (finalConnectionType === "orthogonal") { if (Math.abs(x2 - x1) > Math.abs(y2 - y1)) { finalY1 = y2; } else { finalX1 = x2; } } const dx = finalX2 - finalX1; const dy = finalY2 - finalY1; const angle = Math.atan2(dy, dx); const arrowHead12 = { x: x2 - safeArrowSize * Math.cos(angle - Math.PI / 6), y: y2 - safeArrowSize * Math.sin(angle - Math.PI / 6) }; const arrowHead22 = { x: x2 - safeArrowSize * Math.cos(angle + Math.PI / 6), y: y2 - safeArrowSize * Math.sin(angle + Math.PI / 6) }; return { arrowHead1: arrowHead12, arrowHead2: arrowHead22 }; }; const calculateStartArrowMarker = () => { const { x: x1, y: y1 } = safeStartPoint; const { x: x2, y: y2 } = safeEndPoint; if (fromBox && fromBox.position && toBox) { const { position: fromPos } = fromBox; let angle2; switch (fromPos) { case "right": angle2 = 0; break; case "left": angle2 = Math.PI; break; case "bottom": angle2 = Math.PI / 2; break; case "top": angle2 = -Math.PI / 2; break; default: angle2 = Math.atan2(y2 - y1, x2 - x1); } const startArrowHead13 = { x: x1 + safeArrowSize * Math.cos(angle2 - Math.PI / 6), y: y1 + safeArrowSize * Math.sin(angle2 - Math.PI / 6) }; const startArrowHead23 = { x: x1 + safeArrowSize * Math.cos(angle2 + Math.PI / 6), y: y1 + safeArrowSize * Math.sin(angle2 + Math.PI / 6) }; return { startArrowHead1: startArrowHead13, startArrowHead2: startArrowHead23 }; } let startX1 = x1, startY1 = y1, startX2 = x2, startY2 = y2; if (finalConnectionType === "custom" && safeBendPoints && safeBendPoints.length > 0) { const firstBend = safeBendPoints[0]; if (firstBend && typeof firstBend.x === "number" && typeof firstBend.y === "number") { startX2 = firstBend.x; startY2 = firstBend.y; } } else if (finalConnectionType === "orthogonal") { if (Math.abs(x2 - x1) > Math.abs(y2 - y1)) { startY2 = y1; } else { startX2 = x1; } } const dx = startX2 - startX1; const dy = startY2 - startY1; const angle = Math.atan2(dy, dx); const startArrowHead12 = { x: x1 + safeArrowSize * Math.cos(angle - Math.PI / 6), y: y1 + safeArrowSize * Math.sin(angle - Math.PI / 6) }; const startArrowHead22 = { x: x1 + safeArrowSize * Math.cos(angle + Math.PI / 6), y: y1 + safeArrowSize * Math.sin(angle + Math.PI / 6) }; return { startArrowHead1: startArrowHead12, startArrowHead2: startArrowHead22 }; }; const { arrowHead1, arrowHead2 } = shouldShowEndArrow ? calculateArrowMarker() : { arrowHead1: null, arrowHead2: null }; const { startArrowHead1, startArrowHead2 } = shouldShowStartArrow ? calculateStartArrowMarker() : { startArrowHead1: null, startArrowHead2: null }; const filteredBendPoints = safeBendPoints ? safeBendPoints.filter( (p) => p && typeof p.x === "number" && typeof p.y === "number" && !isNaN(p.x) && !isNaN(p.y) ) : []; const junctionAllPoints = []; processedJunctions.forEach((junction) => { junctionAllPoints.push(junction.point); junction.branches.forEach((branch) => { junctionAllPoints.push(branch.endPoint); }); }); const allPoints = [safeStartPoint, safeEndPoint, ...filteredBendPoints, ...junctionAllPoints]; const minX = Math.min(...allPoints.map((p) => p.x)) - safeArrowSize; const minY = Math.min(...allPoints.map((p) => p.y)) - safeArrowSize; const maxX = Math.max(...allPoints.map((p) => p.x)) + safeArrowSize; const maxY = Math.max(...allPoints.map(