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
JavaScript
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(