}
@@ -447,6 +447,36 @@ export default function Table(props) {
})}
+
+ {/* Perimeter connection points (shown when linking) */}
+ {isLinking && (() => {
+ const hasColorStrip = settings.notation === Notation.DEFAULT;
+ const perimeterPoints = getAllTablePerimeterPoints(tableData, hasColorStrip);
+
+ return perimeterPoints.map((point, idx) => (
+
+ {/* Outer ring for visibility */}
+
+ {/* Inner dot */}
+
+
+ ));
+ })()}
+
{
if (!e.isPrimary) return;
@@ -549,6 +580,8 @@ export default function Table(props) {
setHoveredField(-1);
}}
onPointerDown={(e) => {
+ // Call the table's onPointerDown handler for dragging
+ onPointerDown(e);
// Required for onPointerLeave to trigger when a touch pointer leaves
// https://stackoverflow.com/a/70976017/1137077
e.target.releasePointerCapture(e.pointerId);
diff --git a/src/components/EditorCanvas/WaypointHandle.jsx b/src/components/EditorCanvas/WaypointHandle.jsx
new file mode 100644
index 000000000..7f175212e
--- /dev/null
+++ b/src/components/EditorCanvas/WaypointHandle.jsx
@@ -0,0 +1,173 @@
+import { darkBgTheme } from "../../data/constants";
+
+/**
+ * Waypoint component - renders a draggable waypoint on a relationship line
+ * Inspired by WaypointShape from drawio
+ */
+export default function WaypointHandle({
+ x,
+ y,
+ index,
+ isSelected = false,
+ isHovered = false,
+ onMouseDown,
+ onMouseEnter,
+ onMouseLeave,
+ onDoubleClick,
+ onContextMenu,
+}) {
+ const theme = localStorage.getItem("theme");
+ const isDark = theme === darkBgTheme;
+
+ const radius = 6;
+ const strokeWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5;
+
+ const fillColor = isSelected
+ ? (isDark ? "#60a5fa" : "#3b82f6")
+ : isHovered
+ ? (isDark ? "#93c5fd" : "#60a5fa")
+ : (isDark ? "#e5e7eb" : "#fff");
+
+ const strokeColor = isDark ? "#374151" : "#1f2937";
+
+ return (
+ onMouseDown && onMouseDown(e, index)}
+ onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, index)}
+ onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, index)}
+ onDoubleClick={(e) => onDoubleClick && onDoubleClick(e, index)}
+ onContextMenu={(e) => onContextMenu && onContextMenu(e, index)}
+ >
+
+ {/* Larger invisible hit area for easier interaction */}
+
+
+ );
+}
+
+/**
+ * Virtual bend component - renders a semi-transparent point where a new waypoint can be added
+ * Appears at the midpoint of line segments
+ */
+export function VirtualBend({
+ x,
+ y,
+ segmentIndex,
+ isHovered = false,
+ onMouseDown,
+ onMouseEnter,
+ onMouseLeave,
+}) {
+ const theme = localStorage.getItem("theme");
+ const isDark = theme === darkBgTheme;
+
+ const radius = 5;
+ const opacity = isHovered ? 0.8 : 0.4;
+
+ const fillColor = isDark ? "#60a5fa" : "#3b82f6";
+ const strokeColor = isDark ? "#374151" : "#1f2937";
+
+ return (
+ onMouseDown && onMouseDown(e, segmentIndex)}
+ onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, segmentIndex)}
+ onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, segmentIndex)}
+ >
+
+ {/* Larger invisible hit area */}
+
+
+ );
+}
+
+/**
+ * Container component that renders all waypoints for a relationship
+ */
+export function WaypointContainer({
+ waypoints = [],
+ relationshipId,
+ selectedWaypointIndex = null,
+ hoveredWaypointIndex = null,
+ onWaypointMouseDown,
+ onWaypointMouseEnter,
+ onWaypointMouseLeave,
+ onWaypointDoubleClick,
+ onWaypointContextMenu,
+ showVirtualBends = false,
+ virtualBends = [],
+ hoveredVirtualBendIndex = null,
+ onVirtualBendMouseDown,
+ onVirtualBendMouseEnter,
+ onVirtualBendMouseLeave,
+}) {
+ return (
+
+ {/* Render virtual bends first (so they appear below waypoints) */}
+ {showVirtualBends && virtualBends.map((vb, index) => (
+
+ ))}
+
+ {/* Render actual waypoints */}
+ {waypoints.map((wp, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/EditorCanvas/subtypeFormats.jsx b/src/components/EditorCanvas/subtypeFormats.jsx
index e544ad10c..ae0e03c24 100644
--- a/src/components/EditorCanvas/subtypeFormats.jsx
+++ b/src/components/EditorCanvas/subtypeFormats.jsx
@@ -1,4 +1,5 @@
// Helper function to calculate angle for subtype notation based on parent-child relationship
+// Returns angles in 90-degree increments (0, 90, 180, 270) for cleaner visual appearance
function calculateSubtypeAngle(
parentTable,
childTable,
@@ -19,14 +20,20 @@ function calculateSubtypeAngle(
const dx = parentCenter.x - subtypePoint.x;
const dy = parentCenter.y - subtypePoint.y;
- // Calculate angle in degrees (Math.atan2 returns radians)
- let angle = Math.atan2(dy, dx) * (180 / Math.PI);
- // The notation is designed so that:
- // - The lines/bars extend to the right (positive X direction)
- // - The connection point for new subtypes is below (positive Y direction)
- // We want the circle to face toward the parent, so we rotate to point the
- // right-side elements (lines/bars) toward the parent
- return angle;
+ // Determine if relationship is primarily horizontal or vertical
+ const isHorizontal = Math.abs(dx) > Math.abs(dy);
+
+ // Return angle in 90-degree increments based on orientation
+ // The symbol should point AWAY from parent (towards children)
+ // Note: The symbol design has 0° pointing LEFT and 180° pointing RIGHT
+ if (isHorizontal) {
+ // Horizontal: if parent is to the left (dx < 0), children are to the right, so point right (180°)
+ // if parent is to the right (dx > 0), children are to the left, so point left (0°)
+ return dx < 0 ? 180 : 0;
+ } else {
+ // Vertical: if parent is above (dy > 0), point down (90°), if parent below, point up (-90°)
+ return dy > 0 ? 90 : -90;
+ }
}
export function subDT(
diff --git a/src/context/DiagramContext.jsx b/src/context/DiagramContext.jsx
index 3c8a3d533..dd84c84a2 100644
--- a/src/context/DiagramContext.jsx
+++ b/src/context/DiagramContext.jsx
@@ -5,7 +5,7 @@ import { Toast } from "@douyinfe/semi-ui";
import { useTranslation } from "react-i18next";
export const DiagramContext = createContext(null);
-
+// The undo/redo component must be updated with the waypoint logic.
export default function DiagramContextProvider({ children }) {
const { t } = useTranslation();
const [database, setDatabase] = useState(DB.GENERIC);
@@ -560,7 +560,11 @@ export default function DiagramContextProvider({ children }) {
const addRelationship = (relationshipData, autoGeneratedFkFields, childTableIdForFks, addToHistory = true) => {
if (addToHistory) {
const newRelationshipId = relationships.reduce((maxId, r) => Math.max(maxId, typeof r.id === 'number' ? r.id : -1), -1) + 1;
- const newRelationshipWithId = { ...relationshipData, id: newRelationshipId };
+ const newRelationshipWithId = {
+ ...relationshipData,
+ id: newRelationshipId,
+ waypoints: relationshipData.waypoints || [] // Initialize waypoints array
+ };
setRelationships((prev) => [...prev, newRelationshipWithId]);
pushUndo({
@@ -574,7 +578,10 @@ export default function DiagramContextProvider({ children }) {
message: t("add_relationship"),
});
} else {
- let relationshipToInsert = { ...relationshipData };
+ let relationshipToInsert = {
+ ...relationshipData,
+ waypoints: relationshipData.waypoints || [] // Initialize waypoints array
+ };
if (Array.isArray(autoGeneratedFkFields) && autoGeneratedFkFields.length > 0 && typeof childTableIdForFks === 'number') {
const childTable = tables.find((t) => t.id === childTableIdForFks);
@@ -728,6 +735,10 @@ export default function DiagramContextProvider({ children }) {
else if (updatedValues.subtype === false) {
finalUpdatedValues.relationshipType = 'one_to_one';
}
+ // Ensure waypoints array exists if not present
+ if (!finalUpdatedValues.waypoints && !rel.waypoints) {
+ finalUpdatedValues.waypoints = [];
+ }
return { ...rel, ...finalUpdatedValues };
}
return rel;
@@ -735,6 +746,79 @@ export default function DiagramContextProvider({ children }) {
);
};
+ const updateRelationshipWaypoints = (id, waypoints) => {
+ updateRelationship(id, { waypoints: waypoints || [] });
+ };
+
+ // Update waypoints for subtype relationships (multi-child)
+ const updateSubtypeWaypoints = (id, subtypeWaypoints) => {
+ updateRelationship(id, { subtypeWaypoints: subtypeWaypoints || { parentToSubtype: [], subtypeToChildren: {} } });
+ };
+
+ // Update perimeter points for subtype relationships (multi-child)
+ const updateSubtypePerimeterPoints = (id, perimeterPoints) => {
+ updateRelationship(id, { subtypePerimeterPoints: perimeterPoints });
+ };
+
+ // Adjust waypoints when a table moves
+ const adjustWaypointsForTableMove = (tableId, deltaX, deltaY) => {
+ // Update all relationships in a single state update
+ setRelationships((prevRelationships) => {
+ return prevRelationships.map(rel => {
+ // Check if this relationship is connected to the moved table
+ const isConnected =
+ rel.startTableId === tableId ||
+ rel.endTableId === tableId ||
+ (rel.endTableIds && rel.endTableIds.includes(tableId));
+
+ if (!isConnected) return rel;
+
+ const updatedRel = { ...rel };
+
+ // Adjust regular waypoints
+ if (rel.waypoints && rel.waypoints.length > 0) {
+ updatedRel.waypoints = rel.waypoints.map(waypoint => ({
+ x: waypoint.x + deltaX,
+ y: waypoint.y + deltaY
+ }));
+ }
+
+ // Adjust subtype waypoints for multi-child relationships
+ if (rel.subtypeWaypoints) {
+ const adjustedSubtypeWaypoints = { ...rel.subtypeWaypoints };
+
+ // Adjust parent-to-subtype waypoints if parent table moved
+ if (rel.startTableId === tableId && adjustedSubtypeWaypoints.parentToSubtype) {
+ adjustedSubtypeWaypoints.parentToSubtype = adjustedSubtypeWaypoints.parentToSubtype.map(waypoint => ({
+ x: waypoint.x + deltaX,
+ y: waypoint.y + deltaY
+ }));
+ }
+
+ // Adjust child waypoints if any child table moved
+ if (rel.endTableIds && rel.endTableIds.includes(tableId) && adjustedSubtypeWaypoints.subtypeToChildren) {
+ adjustedSubtypeWaypoints.subtypeToChildren = Object.fromEntries(
+ Object.entries(adjustedSubtypeWaypoints.subtypeToChildren).map(([childId, waypoints]) => {
+ // Only adjust waypoints for the moved child table
+ if (parseInt(childId) === tableId) {
+ return [childId, waypoints.map(waypoint => ({
+ x: waypoint.x + deltaX,
+ y: waypoint.y + deltaY
+ }))];
+ }
+ return [childId, waypoints];
+ })
+ );
+ }
+
+ updatedRel.subtypeWaypoints = adjustedSubtypeWaypoints;
+ }
+
+ return updatedRel;
+ });
+ });
+ };
+
// Subtype relationship management functions
const addChildToSubtype = (relationshipId, childTableId, shouldAddToUndoStack = true) => {
// Critical validation: Prevent infinite loops
@@ -1023,6 +1107,10 @@ export default function DiagramContextProvider({ children }) {
addRelationship,
deleteRelationship,
updateRelationship,
+ updateRelationshipWaypoints,
+ updateSubtypeWaypoints,
+ updateSubtypePerimeterPoints,
+ adjustWaypointsForTableMove,
addChildToSubtype,
removeChildFromSubtype,
restoreFieldsToTable,
diff --git a/src/hooks/index.js b/src/hooks/index.js
index e21eee159..f56f8b98f 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -12,3 +12,5 @@ export { default as useTransform } from "./useTransform";
export { default as useTypes } from "./useTypes";
export { default as useUndoRedo } from "./useUndoRedo";
export { default as useEnums } from "./useEnums";
+export { useWaypointEditor, useConnectionPoints } from "./useWaypoints";
+export { useSubtypeWaypoints } from "./useSubtypeWaypoints";
diff --git a/src/hooks/useSubtypeWaypoints.js b/src/hooks/useSubtypeWaypoints.js
new file mode 100644
index 000000000..4d0d115b1
--- /dev/null
+++ b/src/hooks/useSubtypeWaypoints.js
@@ -0,0 +1,211 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+
+/**
+ * Custom hook for managing waypoints in multi-child subtype relationships
+ * Handles separate waypoint arrays for:
+ * - Parent to subtype notation point
+ * - Each child line from subtype notation to child table
+ */
+export function useSubtypeWaypoints(relationship, tables, onUpdate) {
+ const [parentWaypoints, setParentWaypoints] = useState([]);
+ const [childWaypoints, setChildWaypoints] = useState({}); // { [childId]: [...waypoints] }
+ const [isDragging, setIsDragging] = useState(false);
+ const [draggedWaypoint, setDraggedWaypoint] = useState(null); // { type: 'parent'|'child', childId?, index }
+ const [showWaypoints, setShowWaypoints] = useState(false);
+
+ const dragStartPos = useRef({ x: 0, y: 0 });
+ const waypointStartPos = useRef({ x: 0, y: 0 });
+
+ // Refs to hold current waypoints state for dragging
+ const parentWaypointsRef = useRef(parentWaypoints);
+ const childWaypointsRef = useRef(childWaypoints);
+
+ // Update refs when state changes
+ useEffect(() => {
+ parentWaypointsRef.current = parentWaypoints;
+ childWaypointsRef.current = childWaypoints;
+ }, [parentWaypoints, childWaypoints]);
+
+ // Initialize waypoints from relationship data
+ useEffect(() => {
+ if (relationship && relationship.subtypeWaypoints) {
+ const { parentToSubtype = [], subtypeToChildren = {} } = relationship.subtypeWaypoints;
+ setParentWaypoints(parentToSubtype);
+ setChildWaypoints(subtypeToChildren);
+ } else {
+ setParentWaypoints([]);
+ setChildWaypoints({});
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [relationship?.id, relationship?.subtypeWaypoints]);
+
+ // Add waypoint to parent line
+ const addParentWaypoint = useCallback((x, y, insertIndex) => {
+ setParentWaypoints(prev => {
+ const newWaypoints = [...prev];
+ newWaypoints.splice(insertIndex, 0, { x, y });
+ return newWaypoints;
+ });
+ }, []);
+
+ // Add waypoint to child line
+ const addChildWaypoint = useCallback((childId, x, y, insertIndex) => {
+ setChildWaypoints(prev => {
+ const childWaypoints = prev[childId] || [];
+ const newChildWaypoints = [...childWaypoints];
+ newChildWaypoints.splice(insertIndex, 0, { x, y });
+ return {
+ ...prev,
+ [childId]: newChildWaypoints,
+ };
+ });
+ }, []);
+
+ // Move waypoint
+ const moveWaypoint = useCallback((type, index, x, y, childId = null) => {
+ if (type === 'parent') {
+ setParentWaypoints(prev => {
+ const newWaypoints = [...prev];
+ newWaypoints[index] = { x, y };
+ return newWaypoints;
+ });
+ } else if (type === 'child' && childId) {
+ setChildWaypoints(prev => {
+ const childWaypoints = prev[childId] || [];
+ const newChildWaypoints = [...childWaypoints];
+ newChildWaypoints[index] = { x, y };
+ return {
+ ...prev,
+ [childId]: newChildWaypoints,
+ };
+ });
+ }
+ }, []);
+
+ // Remove waypoint
+ const removeWaypoint = useCallback((type, index, childId = null) => {
+ if (type === 'parent') {
+ setParentWaypoints(prev => prev.filter((_, i) => i !== index));
+ } else if (type === 'child' && childId) {
+ setChildWaypoints(prev => {
+ const childWaypoints = prev[childId] || [];
+ return {
+ ...prev,
+ [childId]: childWaypoints.filter((_, i) => i !== index),
+ };
+ });
+ }
+ }, []);
+
+ // Handle waypoint mouse down (start drag)
+ const handleWaypointMouseDown = useCallback((e, type, index, childId = null) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ setIsDragging(true);
+ setDraggedWaypoint({ type, index, childId });
+
+ dragStartPos.current = { x: e.clientX, y: e.clientY };
+
+ // Get current waypoint position using refs
+ let waypoint;
+ if (type === 'parent') {
+ waypoint = parentWaypointsRef.current[index];
+ } else if (type === 'child' && childId !== null) {
+ waypoint = (childWaypointsRef.current[childId] || [])[index];
+ }
+
+ if (waypoint) {
+ waypointStartPos.current = { x: waypoint.x, y: waypoint.y };
+ }
+ }, []);
+
+ // Handle mouse move during drag
+ const handleMouseMove = useCallback((e) => {
+ if (!isDragging || !draggedWaypoint) return;
+
+ const dx = e.clientX - dragStartPos.current.x;
+ const dy = e.clientY - dragStartPos.current.y;
+
+ const newX = waypointStartPos.current.x + dx;
+ const newY = waypointStartPos.current.y + dy;
+
+ moveWaypoint(draggedWaypoint.type, draggedWaypoint.index, newX, newY, draggedWaypoint.childId);
+ }, [isDragging, draggedWaypoint, moveWaypoint]);
+
+ // Handle mouse up (end drag)
+ const handleMouseUp = useCallback(() => {
+ if (isDragging && onUpdate) {
+ // Save the updated waypoints using refs to get most current values
+ onUpdate({
+ parentToSubtype: parentWaypointsRef.current,
+ subtypeToChildren: childWaypointsRef.current,
+ });
+ }
+
+ setIsDragging(false);
+ setDraggedWaypoint(null);
+ }, [isDragging, onUpdate]);
+
+ // Handle double-click to remove waypoint
+ const handleWaypointDoubleClick = useCallback((e, type, index, childId = null) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ removeWaypoint(type, index, childId);
+
+ if (onUpdate) {
+ // Use refs to get updated values after removal
+ setTimeout(() => {
+ onUpdate({
+ parentToSubtype: parentWaypointsRef.current,
+ subtypeToChildren: childWaypointsRef.current,
+ });
+ }, 0);
+ }
+ }, [removeWaypoint, onUpdate]);
+
+ // Handle virtual bend click to add waypoint
+ const handleVirtualBendClick = useCallback((e, type, segmentIndex, childId = null) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const rect = e.currentTarget.ownerSVGElement.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ if (type === 'parent') {
+ addParentWaypoint(x, y, segmentIndex);
+ } else if (type === 'child' && childId) {
+ addChildWaypoint(childId, x, y, segmentIndex);
+ }
+
+ if (onUpdate) {
+ const updatedParent = type === 'parent' ? [...parentWaypoints.slice(0, segmentIndex), { x, y }, ...parentWaypoints.slice(segmentIndex)] : parentWaypoints;
+ const updatedChildren = type === 'child' ? {
+ ...childWaypoints,
+ [childId]: [...(childWaypoints[childId] || []).slice(0, segmentIndex), { x, y }, ...(childWaypoints[childId] || []).slice(segmentIndex)],
+ } : childWaypoints;
+
+ onUpdate({
+ parentToSubtype: updatedParent,
+ subtypeToChildren: updatedChildren,
+ });
+ }
+ }, [parentWaypoints, childWaypoints, addParentWaypoint, addChildWaypoint, onUpdate]);
+
+ return {
+ parentWaypoints,
+ childWaypoints,
+ showWaypoints,
+ setShowWaypoints,
+ isDragging,
+ handlers: {
+ onWaypointMouseDown: handleWaypointMouseDown,
+ onMouseMove: handleMouseMove,
+ onMouseUp: handleMouseUp,
+ onWaypointDoubleClick: handleWaypointDoubleClick,
+ onVirtualBendClick: handleVirtualBendClick,
+ },
+ };
+}
diff --git a/src/hooks/useWaypoints.js b/src/hooks/useWaypoints.js
new file mode 100644
index 000000000..3e93a94c2
--- /dev/null
+++ b/src/hooks/useWaypoints.js
@@ -0,0 +1,199 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { EdgeHandler } from "../utils/edgeHandler";
+import { getConnectionPoints } from "../utils/perimeter";
+
+/**
+ * Custom hook for managing waypoint editing on relationships
+ * Handles drag operations, virtual bends, and waypoint manipulation
+ */
+export function useWaypointEditor(relationship, tables, onUpdate) {
+ const [edgeHandler, setEdgeHandler] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [draggedWaypointIndex, setDraggedWaypointIndex] = useState(null);
+ const [hoveredWaypointIndex, setHoveredWaypointIndex] = useState(null);
+ const [hoveredVirtualBendIndex, setHoveredVirtualBendIndex] = useState(null);
+ const [showWaypoints, setShowWaypoints] = useState(false);
+
+ const dragStartPos = useRef({ x: 0, y: 0 });
+ const waypointStartPos = useRef({ x: 0, y: 0 });
+
+ // Initialize edge handler when relationship or tables change
+ useEffect(() => {
+ if (relationship && tables) {
+ const handler = new EdgeHandler(relationship, tables, {
+ snapToGrid: true,
+ gridSize: 10,
+ waypointRadius: 6,
+ virtualBendEnabled: true,
+ tolerance: 10,
+ });
+ setEdgeHandler(handler);
+ }
+ }, [relationship?.id, relationship, tables]);
+
+ // Handle waypoint mouse down (start drag)
+ const handleWaypointMouseDown = useCallback((e, index) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!edgeHandler) return;
+
+ const waypoint = edgeHandler.waypoints[index];
+ if (!waypoint) return;
+
+ setIsDragging(true);
+ setDraggedWaypointIndex(index);
+
+ dragStartPos.current = { x: e.clientX, y: e.clientY };
+ waypointStartPos.current = { x: waypoint.x, y: waypoint.y };
+ }, [edgeHandler]);
+
+ // Handle mouse move during drag
+ const handleMouseMove = useCallback((e) => {
+ if (!isDragging || draggedWaypointIndex === null || !edgeHandler) return;
+
+ const dx = e.clientX - dragStartPos.current.x;
+ const dy = e.clientY - dragStartPos.current.y;
+
+ const newX = waypointStartPos.current.x + dx;
+ const newY = waypointStartPos.current.y + dy;
+
+ edgeHandler.moveWaypoint(draggedWaypointIndex, newX, newY);
+
+ // Trigger re-render by updating a state
+ setEdgeHandler({ ...edgeHandler });
+ }, [isDragging, draggedWaypointIndex, edgeHandler]);
+
+ // Handle mouse up (end drag)
+ const handleMouseUp = useCallback(() => {
+ if (isDragging && edgeHandler && onUpdate) {
+ // Save the updated waypoints
+ const waypoints = edgeHandler.getWaypointsData();
+ onUpdate(waypoints);
+ }
+
+ setIsDragging(false);
+ setDraggedWaypointIndex(null);
+ }, [isDragging, edgeHandler, onUpdate]);
+
+ // Handle double-click to remove waypoint
+ const handleWaypointDoubleClick = useCallback((e, index) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!edgeHandler || !onUpdate) return;
+
+ edgeHandler.removeWaypoint(index);
+ const waypoints = edgeHandler.getWaypointsData();
+ onUpdate(waypoints);
+
+ // Trigger re-render
+ setEdgeHandler({ ...edgeHandler });
+ }, [edgeHandler, onUpdate]);
+
+ // Handle virtual bend click to add waypoint
+ const handleVirtualBendMouseDown = useCallback((e, segmentIndex) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!edgeHandler || !onUpdate) return;
+
+ const rect = e.currentTarget.ownerSVGElement.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ // Add waypoint at the virtual bend position
+ edgeHandler.addWaypoint(x, y, segmentIndex);
+ const waypoints = edgeHandler.getWaypointsData();
+ onUpdate(waypoints);
+
+ // Trigger re-render
+ setEdgeHandler({ ...edgeHandler });
+ }, [edgeHandler, onUpdate]);
+
+ // Hover handlers
+ const handleWaypointMouseEnter = useCallback((e, index) => {
+ setHoveredWaypointIndex(index);
+ }, []);
+
+ const handleWaypointMouseLeave = useCallback(() => {
+ setHoveredWaypointIndex(null);
+ }, []);
+
+ const handleVirtualBendMouseEnter = useCallback((e, index) => {
+ setHoveredVirtualBendIndex(index);
+ }, []);
+
+ const handleVirtualBendMouseLeave = useCallback(() => {
+ setHoveredVirtualBendIndex(null);
+ }, []);
+
+ // Get virtual bend positions (midpoints of segments)
+ const getVirtualBends = useCallback(() => {
+ if (!edgeHandler || !showWaypoints) return [];
+
+ const segments = edgeHandler.getSegments();
+ const virtualBends = [];
+
+ // Skip first and last segment (connected to tables)
+ for (let i = 1; i < segments.length - 1; i++) {
+ const segment = segments[i];
+ virtualBends.push({
+ x: (segment.start.x + segment.end.x) / 2,
+ y: (segment.start.y + segment.end.y) / 2,
+ segmentIndex: i,
+ });
+ }
+
+ return virtualBends;
+ }, [edgeHandler, showWaypoints]);
+
+ return {
+ edgeHandler,
+ waypoints: edgeHandler?.waypoints || [],
+ isDragging,
+ draggedWaypointIndex,
+ hoveredWaypointIndex,
+ hoveredVirtualBendIndex,
+ showWaypoints,
+ setShowWaypoints,
+ virtualBends: getVirtualBends(),
+ handlers: {
+ onWaypointMouseDown: handleWaypointMouseDown,
+ onWaypointMouseEnter: handleWaypointMouseEnter,
+ onWaypointMouseLeave: handleWaypointMouseLeave,
+ onWaypointDoubleClick: handleWaypointDoubleClick,
+ onVirtualBendMouseDown: handleVirtualBendMouseDown,
+ onVirtualBendMouseEnter: handleVirtualBendMouseEnter,
+ onVirtualBendMouseLeave: handleVirtualBendMouseLeave,
+ onMouseMove: handleMouseMove,
+ onMouseUp: handleMouseUp,
+ },
+ };
+}
+
+/**
+ * Hook for calculating connection points with waypoints
+ */
+export function useConnectionPoints(startTable, endTable, waypoints = []) {
+ return useCallback(() => {
+ if (!startTable || !endTable) {
+ return { startPoint: null, endPoint: null, points: [] };
+ }
+
+ const { startPoint, endPoint } = getConnectionPoints(
+ startTable,
+ endTable,
+ waypoints
+ );
+
+ // Build complete point array
+ const points = [startPoint];
+ waypoints.forEach(wp => {
+ points.push({ x: wp.x, y: wp.y });
+ });
+ points.push(endPoint);
+
+ return { startPoint, endPoint, points };
+ }, [startTable, endTable, waypoints]);
+}
diff --git a/src/utils/edgeHandler.js b/src/utils/edgeHandler.js
new file mode 100644
index 000000000..1f09b95d4
--- /dev/null
+++ b/src/utils/edgeHandler.js
@@ -0,0 +1,397 @@
+/**
+ * Waypoint and edge handler utilities inspired by mxEdgeHandler from drawio
+ * Handles creation, editing, and interaction with waypoints on relationships
+ */
+
+import { Point, distance, isPointNearLine, snapToGrid } from './perimeter';
+
+/**
+ * Waypoint class representing a breakpoint on a relationship line
+ */
+export class Waypoint {
+ constructor(x, y, id = null) {
+ this.x = x;
+ this.y = y;
+ this.id = id || `wp_${Date.now()}_${Math.random()}`;
+ }
+
+ clone() {
+ return new Waypoint(this.x, this.y, this.id);
+ }
+
+ toObject() {
+ return { x: this.x, y: this.y, id: this.id };
+ }
+
+ static fromObject(obj) {
+ return new Waypoint(obj.x, obj.y, obj.id);
+ }
+}
+
+/**
+ * Edge handler for managing waypoint interaction
+ */
+export class EdgeHandler {
+ constructor(relationship, tables, options = {}) {
+ this.relationship = relationship;
+ this.tables = tables;
+ this.options = {
+ snapToGrid: true,
+ gridSize: 10,
+ waypointRadius: 6,
+ virtualBendEnabled: true,
+ tolerance: 10,
+ ...options,
+ };
+
+ this.waypoints = this.loadWaypoints();
+ this.selectedWaypoint = null;
+ this.hoveredWaypoint = null;
+ this.hoveredVirtualBend = null;
+ }
+
+ /**
+ * Load waypoints from relationship data
+ */
+ loadWaypoints() {
+ if (!this.relationship.waypoints || !Array.isArray(this.relationship.waypoints)) {
+ return [];
+ }
+ return this.relationship.waypoints.map(wp => Waypoint.fromObject(wp));
+ }
+
+ /**
+ * Get absolute points for the edge (start, waypoints, end)
+ */
+ getAbsolutePoints() {
+ const startTable = this.tables[this.relationship.startTableId];
+ const endTable = this.tables[this.relationship.endTableId];
+
+ if (!startTable || !endTable) {
+ return [];
+ }
+
+ const points = [];
+
+ // Start point (table center for now, will be refined with perimeter calc)
+ points.push(new Point(
+ startTable.x + (startTable.width || 200) / 2,
+ startTable.y + (startTable.height || 100) / 2
+ ));
+
+ // Waypoints
+ this.waypoints.forEach(wp => {
+ points.push(new Point(wp.x, wp.y));
+ });
+
+ // End point
+ points.push(new Point(
+ endTable.x + (endTable.width || 200) / 2,
+ endTable.y + (endTable.height || 100) / 2
+ ));
+
+ return points;
+ }
+
+ /**
+ * Get all segments of the edge
+ * Returns array of { start, end } segment objects
+ */
+ getSegments() {
+ const points = this.getAbsolutePoints();
+ const segments = [];
+
+ for (let i = 0; i < points.length - 1; i++) {
+ segments.push({
+ start: points[i],
+ end: points[i + 1],
+ startIndex: i,
+ endIndex: i + 1,
+ });
+ }
+
+ return segments;
+ }
+
+ /**
+ * Find waypoint at given coordinates
+ */
+ findWaypointAt(x, y) {
+ const point = new Point(x, y);
+ const radius = this.options.waypointRadius;
+
+ for (let i = 0; i < this.waypoints.length; i++) {
+ const wp = this.waypoints[i];
+ if (distance(point, new Point(wp.x, wp.y)) <= radius) {
+ return { waypoint: wp, index: i };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Find virtual bend location (midpoint of segment where new waypoint can be added)
+ */
+ findVirtualBendAt(x, y) {
+ if (!this.options.virtualBendEnabled) {
+ return null;
+ }
+
+ const point = new Point(x, y);
+ const segments = this.getSegments();
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i];
+
+ // Skip first and last segment (connected to tables) for now
+ // Can be enabled later if needed
+ if (i === 0 || i === segments.length - 1) {
+ continue;
+ }
+
+ const midpoint = new Point(
+ (segment.start.x + segment.end.x) / 2,
+ (segment.start.y + segment.end.y) / 2
+ );
+
+ if (distance(point, midpoint) <= this.options.waypointRadius) {
+ return {
+ midpoint,
+ segmentIndex: i,
+ waypointIndex: i, // Insert after this index
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Add a waypoint at the given position
+ * If insertIndex is provided, insert at that position, otherwise add to end
+ */
+ addWaypoint(x, y, insertIndex = null) {
+ const point = this.options.snapToGrid
+ ? snapToGrid(new Point(x, y), this.options.gridSize)
+ : new Point(x, y);
+
+ const waypoint = new Waypoint(point.x, point.y);
+
+ if (insertIndex !== null && insertIndex >= 0 && insertIndex <= this.waypoints.length) {
+ this.waypoints.splice(insertIndex, 0, waypoint);
+ } else {
+ this.waypoints.push(waypoint);
+ }
+
+ return waypoint;
+ }
+
+ /**
+ * Remove waypoint at index
+ */
+ removeWaypoint(index) {
+ if (index >= 0 && index < this.waypoints.length) {
+ const removed = this.waypoints.splice(index, 1);
+ return removed[0];
+ }
+ return null;
+ }
+
+ /**
+ * Move waypoint to new position
+ */
+ moveWaypoint(index, x, y) {
+ if (index >= 0 && index < this.waypoints.length) {
+ const point = this.options.snapToGrid
+ ? snapToGrid(new Point(x, y), this.options.gridSize)
+ : new Point(x, y);
+
+ this.waypoints[index].x = point.x;
+ this.waypoints[index].y = point.y;
+
+ return this.waypoints[index];
+ }
+ return null;
+ }
+
+ /**
+ * Check if point is near any segment of the edge
+ */
+ isPointNearEdge(x, y) {
+ const point = new Point(x, y);
+ const segments = this.getSegments();
+
+ for (const segment of segments) {
+ if (isPointNearLine(point, segment.start, segment.end, this.options.tolerance)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get waypoints as plain objects for storage
+ */
+ getWaypointsData() {
+ return this.waypoints.map(wp => wp.toObject());
+ }
+
+ /**
+ * Clear all waypoints
+ */
+ clearWaypoints() {
+ this.waypoints = [];
+ }
+
+ /**
+ * Detect if a click should add a waypoint on a virtual bend
+ */
+ handleClick(x, y) {
+ // Check if clicking on existing waypoint
+ const existingWp = this.findWaypointAt(x, y);
+ if (existingWp) {
+ return { type: 'select-waypoint', ...existingWp };
+ }
+
+ // Check if clicking on virtual bend (to add new waypoint)
+ const virtualBend = this.findVirtualBendAt(x, y);
+ if (virtualBend) {
+ const newWp = this.addWaypoint(x, y, virtualBend.waypointIndex);
+ return { type: 'add-waypoint', waypoint: newWp, index: virtualBend.waypointIndex };
+ }
+
+ // Check if clicking near edge (for selection)
+ if (this.isPointNearEdge(x, y)) {
+ return { type: 'select-edge' };
+ }
+
+ return { type: 'none' };
+ }
+
+ /**
+ * Handle double-click to remove waypoint
+ */
+ handleDoubleClick(x, y) {
+ const wp = this.findWaypointAt(x, y);
+ if (wp) {
+ this.removeWaypoint(wp.index);
+ return { type: 'remove-waypoint', ...wp };
+ }
+ return { type: 'none' };
+ }
+}
+
+/**
+ * Connection handler for creating new relationships with waypoints
+ * Inspired by mxConnectionHandler from drawio
+ */
+export class ConnectionHandler {
+ constructor(options = {}) {
+ this.options = {
+ snapToGrid: true,
+ gridSize: 10,
+ waypointsEnabled: true,
+ ...options,
+ };
+
+ this.waypoints = [];
+ this.isConnecting = false;
+ this.sourceTable = null;
+ this.currentPoint = null;
+ }
+
+ /**
+ * Start creating a connection from a table
+ */
+ start(table, x, y) {
+ this.isConnecting = true;
+ this.sourceTable = table;
+ this.waypoints = [];
+ this.currentPoint = new Point(x, y);
+ }
+
+ /**
+ * Add a waypoint during connection creation
+ */
+ addWaypoint(x, y) {
+ if (!this.options.waypointsEnabled || !this.isConnecting) {
+ return null;
+ }
+
+ const point = this.options.snapToGrid
+ ? snapToGrid(new Point(x, y), this.options.gridSize)
+ : new Point(x, y);
+
+ const waypoint = new Waypoint(point.x, point.y);
+ this.waypoints.push(waypoint);
+
+ return waypoint;
+ }
+
+ /**
+ * Update current mouse position during connection creation
+ */
+ updatePosition(x, y) {
+ this.currentPoint = new Point(x, y);
+ }
+
+ /**
+ * Complete the connection
+ */
+ complete() {
+ const waypoints = this.getWaypointsData();
+ this.reset();
+ return waypoints;
+ }
+
+ /**
+ * Cancel connection creation
+ */
+ cancel() {
+ this.reset();
+ }
+
+ /**
+ * Reset handler state
+ */
+ reset() {
+ this.isConnecting = false;
+ this.sourceTable = null;
+ this.waypoints = [];
+ this.currentPoint = null;
+ }
+
+ /**
+ * Get waypoints as plain objects
+ */
+ getWaypointsData() {
+ return this.waypoints.map(wp => wp.toObject());
+ }
+
+ /**
+ * Get preview points for rendering
+ */
+ getPreviewPoints(startTable, endX, endY) {
+ const points = [];
+
+ // Start point
+ if (startTable) {
+ points.push(new Point(
+ startTable.x + (startTable.width || 200) / 2,
+ startTable.y + (startTable.height || 100) / 2
+ ));
+ }
+
+ // Waypoints
+ this.waypoints.forEach(wp => {
+ points.push(new Point(wp.x, wp.y));
+ });
+
+ // Current end point
+ points.push(new Point(endX, endY));
+
+ return points;
+ }
+}
diff --git a/src/utils/perimeter.js b/src/utils/perimeter.js
new file mode 100644
index 000000000..638f956da
--- /dev/null
+++ b/src/utils/perimeter.js
@@ -0,0 +1,216 @@
+/**
+ * Perimeter calculation utilities inspired by mxGraph/drawio
+ * Used to calculate anchor points on table boundaries for relationship connections
+ */
+
+/**
+ * Point class for coordinates
+ */
+export class Point {
+ constructor(x = 0, y = 0) {
+ this.x = x;
+ this.y = y;
+ }
+
+ clone() {
+ return new Point(this.x, this.y);
+ }
+}
+
+/**
+ * Rectangle bounds class
+ */
+export class Bounds {
+ constructor(x, y, width, height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ }
+
+ getCenterX() {
+ return this.x + this.width / 2;
+ }
+
+ getCenterY() {
+ return this.y + this.height / 2;
+ }
+
+ getCenter() {
+ return new Point(this.getCenterX(), this.getCenterY());
+ }
+
+ contains(x, y) {
+ return x >= this.x && x <= this.x + this.width &&
+ y >= this.y && y <= this.y + this.height;
+ }
+}
+
+/**
+ * Rectangle perimeter calculation
+ * Calculates the intersection point on a rectangle's perimeter given:
+ * - bounds: Rectangle bounds (x, y, width, height)
+ * - next: The next point along the line (determines which edge to intersect)
+ * - orthogonal: Whether to use orthogonal routing
+ *
+ * Based on mxPerimeter.RectanglePerimeter from drawio
+ */
+export function rectanglePerimeter(bounds, next, orthogonal = false) {
+ const cx = bounds.getCenterX();
+ const cy = bounds.getCenterY();
+ const dx = next.x - cx;
+ const dy = next.y - cy;
+
+ const alpha = Math.atan2(dy, dx);
+ const p = new Point(0, 0);
+ const pi = Math.PI;
+ const pi2 = Math.PI / 2;
+ const beta = pi2 - alpha;
+ const t = Math.atan2(bounds.height, bounds.width);
+
+ // Determine which edge the line intersects
+ if (alpha < -pi + t || alpha > pi - t) {
+ // Left edge
+ p.x = bounds.x;
+ p.y = cy - (bounds.width * Math.tan(alpha)) / 2;
+ } else if (alpha < -t) {
+ // Top edge
+ p.y = bounds.y;
+ p.x = cx - (bounds.height * Math.tan(beta)) / 2;
+ } else if (alpha < t) {
+ // Right edge
+ p.x = bounds.x + bounds.width;
+ p.y = cy + (bounds.width * Math.tan(alpha)) / 2;
+ } else {
+ // Bottom edge
+ p.y = bounds.y + bounds.height;
+ p.x = cx + (bounds.height * Math.tan(beta)) / 2;
+ }
+
+ // Apply orthogonal constraints
+ if (orthogonal) {
+ if (next.x >= bounds.x && next.x <= bounds.x + bounds.width) {
+ p.x = next.x;
+ } else if (next.y >= bounds.y && next.y <= bounds.y + bounds.height) {
+ p.y = next.y;
+ }
+
+ if (next.x < bounds.x) {
+ p.x = bounds.x;
+ } else if (next.x > bounds.x + bounds.width) {
+ p.x = bounds.x + bounds.width;
+ }
+
+ if (next.y < bounds.y) {
+ p.y = bounds.y;
+ } else if (next.y > bounds.y + bounds.height) {
+ p.y = bounds.y + bounds.height;
+ }
+ }
+
+ return p;
+}
+
+/**
+ * Get perimeter point for a table given a target point
+ * This is the main function to use for calculating where a relationship line
+ * should connect to a table's edge
+ */
+export function getTablePerimeterPoint(table, targetPoint, orthogonal = false) {
+ const bounds = new Bounds(
+ table.x,
+ table.y,
+ table.width || 200,
+ table.height || 100
+ );
+
+ return rectanglePerimeter(bounds, targetPoint, orthogonal);
+}
+
+/**
+ * Calculate connection points for a relationship between two tables
+ * Returns { startPoint, endPoint } representing where the line should connect
+ */
+export function getConnectionPoints(startTable, endTable, waypoints = []) {
+ // Get next point after start (first waypoint or end table center)
+ const endCenter = new Point(
+ endTable.x + (endTable.width || 200) / 2,
+ endTable.y + (endTable.height || 100) / 2
+ );
+
+ const nextAfterStart = waypoints.length > 0
+ ? new Point(waypoints[0].x, waypoints[0].y)
+ : endCenter;
+
+ // Get previous point before end (last waypoint or start table center)
+ const startCenter = new Point(
+ startTable.x + (startTable.width || 200) / 2,
+ startTable.y + (startTable.height || 100) / 2
+ );
+
+ const prevBeforeEnd = waypoints.length > 0
+ ? new Point(waypoints[waypoints.length - 1].x, waypoints[waypoints.length - 1].y)
+ : startCenter;
+
+ return {
+ startPoint: getTablePerimeterPoint(startTable, nextAfterStart),
+ endPoint: getTablePerimeterPoint(endTable, prevBeforeEnd),
+ };
+}
+
+/**
+ * Calculate distance between two points
+ */
+export function distance(p1, p2) {
+ const dx = p2.x - p1.x;
+ const dy = p2.y - p1.y;
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+/**
+ * Check if a point is near a line segment
+ * Returns true if point is within tolerance distance of the line
+ */
+export function isPointNearLine(point, lineStart, lineEnd, tolerance = 10) {
+ const A = point.x - lineStart.x;
+ const B = point.y - lineStart.y;
+ const C = lineEnd.x - lineStart.x;
+ const D = lineEnd.y - lineStart.y;
+
+ const dot = A * C + B * D;
+ const lenSq = C * C + D * D;
+ let param = -1;
+
+ if (lenSq !== 0) {
+ param = dot / lenSq;
+ }
+
+ let xx, yy;
+
+ if (param < 0) {
+ xx = lineStart.x;
+ yy = lineStart.y;
+ } else if (param > 1) {
+ xx = lineEnd.x;
+ yy = lineEnd.y;
+ } else {
+ xx = lineStart.x + param * C;
+ yy = lineStart.y + param * D;
+ }
+
+ const dx = point.x - xx;
+ const dy = point.y - yy;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ return dist <= tolerance;
+}
+
+/**
+ * Snap point to grid
+ */
+export function snapToGrid(point, gridSize = 10) {
+ return new Point(
+ Math.round(point.x / gridSize) * gridSize,
+ Math.round(point.y / gridSize) * gridSize
+ );
+}
diff --git a/src/utils/perimeterPoints.js b/src/utils/perimeterPoints.js
new file mode 100644
index 000000000..a5a8bef13
--- /dev/null
+++ b/src/utils/perimeterPoints.js
@@ -0,0 +1,263 @@
+/**
+ * Perimeter Points System for Table Relationships
+ *
+ * This module calculates perimeter connection points for each field/row in a table.
+ * Each field gets 4 connection points (top, right, bottom, left) on the table's perimeter.
+ */
+
+import {
+ tableHeaderHeight,
+ tableFieldHeight,
+ tableColorStripHeight
+} from '../data/constants';
+
+/**
+ * Calculate perimeter points for a specific field/row
+ * @param {Object} table - Table object with x, y, width, height
+ * @param {number} fieldIndex - Index of the field (0-based)
+ * @param {number} totalFields - Total number of fields in the table
+ * @param {boolean} hasColorStrip - Whether the table has a color strip (notation dependent)
+ * @returns {Object} Object with top, right, bottom, left points
+ */
+export function getFieldPerimeterPoints(table, fieldIndex, totalFields, hasColorStrip = false) {
+ const effectiveColorStripHeight = hasColorStrip ? tableColorStripHeight : 0;
+ const headerHeight = tableHeaderHeight + effectiveColorStripHeight;
+
+ // Calculate the Y position of the field's center
+ const fieldCenterY = table.y + headerHeight + (fieldIndex * tableFieldHeight) + (tableFieldHeight / 2);
+
+ // Table bounds
+ const tableLeft = table.x;
+ const tableRight = table.x + table.width;
+ const tableTop = table.y + headerHeight; // Top of first field (after header)
+ const tableBottom = table.y + headerHeight + (totalFields * tableFieldHeight);
+ const tableCenterX = table.x + (table.width / 2);
+
+ return {
+ // Left side point (at field's vertical center)
+ left: {
+ x: tableLeft,
+ y: fieldCenterY,
+ side: 'left',
+ fieldIndex
+ },
+ // Right side point (at field's vertical center)
+ right: {
+ x: tableRight,
+ y: fieldCenterY,
+ side: 'right',
+ fieldIndex
+ },
+ // Top side point (at table's horizontal center, but only if this is the first field)
+ top: fieldIndex === 0 ? {
+ x: tableCenterX,
+ y: tableTop,
+ side: 'top',
+ fieldIndex
+ } : null,
+ // Bottom side point (at table's horizontal center, but only if this is the last field)
+ bottom: fieldIndex === totalFields - 1 ? {
+ x: tableCenterX,
+ y: tableBottom,
+ side: 'bottom',
+ fieldIndex
+ } : null
+ };
+}
+
+/**
+ * Get all perimeter points for a table
+ * @param {Object} table - Table object
+ * @param {boolean} hasColorStrip - Whether the table has a color strip
+ * @returns {Array} Array of all perimeter points
+ */
+export function getAllTablePerimeterPoints(table, hasColorStrip = false) {
+ if (!table || !table.fields || table.fields.length === 0) {
+ return [];
+ }
+
+ const points = [];
+ const totalFields = table.fields.length;
+
+ table.fields.forEach((field, index) => {
+ const fieldPoints = getFieldPerimeterPoints(table, index, totalFields, hasColorStrip);
+
+ // Add left and right points (always present)
+ points.push(fieldPoints.left);
+ points.push(fieldPoints.right);
+
+ // Add top point (only for first field)
+ if (fieldPoints.top) {
+ points.push(fieldPoints.top);
+ }
+
+ // Add bottom point (only for last field)
+ if (fieldPoints.bottom) {
+ points.push(fieldPoints.bottom);
+ }
+ });
+
+ return points;
+}
+
+/**
+ * Find the closest perimeter point to a given coordinate
+ * @param {Array} points - Array of perimeter points
+ * @param {number} x - Mouse X coordinate
+ * @param {number} y - Mouse Y coordinate
+ * @param {number} threshold - Maximum distance to consider (default: 30)
+ * @returns {Object|null} Closest point or null if none within threshold
+ */
+export function findClosestPerimeterPoint(points, x, y, threshold = 30) {
+ if (!points || points.length === 0) {
+ return null;
+ }
+
+ let closestPoint = null;
+ let minDistance = threshold;
+
+ points.forEach(point => {
+ const dx = point.x - x;
+ const dy = point.y - y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestPoint = { ...point, distance };
+ }
+ });
+
+ return closestPoint;
+}
+
+/**
+ * Calculate orthogonal path between two points
+ * Uses Manhattan routing (right angles only) and avoids crossing tables
+ * @param {Object} start - Start point {x, y, side}
+ * @param {Object} end - End point {x, y, side}
+ * @param {Array} waypoints - Optional intermediate waypoints
+ * @returns {string} SVG path string
+ */
+export function calculateOrthogonalPath(start, end, waypoints = []) {
+ if (!start || !end) {
+ return '';
+ }
+
+ // If there are waypoints, create path through them
+ if (waypoints && waypoints.length > 0) {
+ const points = [start, ...waypoints, end];
+ let path = `M ${points[0].x} ${points[0].y}`;
+
+ for (let i = 1; i < points.length; i++) {
+ path += ` L ${points[i].x} ${points[i].y}`;
+ }
+
+ return path;
+ }
+
+ // Simple orthogonal routing based on connection sides
+ const path = [];
+ path.push(`M ${start.x} ${start.y}`);
+
+ // Determine routing based on sides
+ const startSide = start.side || 'right';
+ const endSide = end.side || 'left';
+
+ // Calculate offset distance to clear the table edges
+ const offsetDistance = 30;
+
+ // Route based on start and end sides
+ if (startSide === 'left') {
+ const exitX = start.x - offsetDistance;
+ path.push(`L ${exitX} ${start.y}`);
+
+ if (endSide === 'right') {
+ const enterX = end.x + offsetDistance;
+ const midY = (start.y + end.y) / 2;
+ path.push(`L ${exitX} ${midY}`);
+ path.push(`L ${enterX} ${midY}`);
+ path.push(`L ${enterX} ${end.y}`);
+ } else if (endSide === 'left') {
+ const midY = (start.y + end.y) / 2;
+ const minX = Math.min(exitX, end.x - offsetDistance);
+ path.push(`L ${minX} ${start.y}`);
+ path.push(`L ${minX} ${midY}`);
+ path.push(`L ${end.x - offsetDistance} ${midY}`);
+ path.push(`L ${end.x - offsetDistance} ${end.y}`);
+ } else {
+ // top or bottom
+ path.push(`L ${exitX} ${end.y}`);
+ }
+ } else if (startSide === 'right') {
+ const exitX = start.x + offsetDistance;
+ path.push(`L ${exitX} ${start.y}`);
+
+ if (endSide === 'left') {
+ const enterX = end.x - offsetDistance;
+ const midY = (start.y + end.y) / 2;
+ path.push(`L ${exitX} ${midY}`);
+ path.push(`L ${enterX} ${midY}`);
+ path.push(`L ${enterX} ${end.y}`);
+ } else if (endSide === 'right') {
+ const midY = (start.y + end.y) / 2;
+ const maxX = Math.max(exitX, end.x + offsetDistance);
+ path.push(`L ${maxX} ${start.y}`);
+ path.push(`L ${maxX} ${midY}`);
+ path.push(`L ${end.x + offsetDistance} ${midY}`);
+ path.push(`L ${end.x + offsetDistance} ${end.y}`);
+ } else {
+ // top or bottom
+ path.push(`L ${exitX} ${end.y}`);
+ }
+ } else if (startSide === 'top') {
+ const exitY = start.y - offsetDistance;
+ path.push(`L ${start.x} ${exitY}`);
+
+ if (endSide === 'bottom') {
+ const enterY = end.y + offsetDistance;
+ const midX = (start.x + end.x) / 2;
+ path.push(`L ${midX} ${exitY}`);
+ path.push(`L ${midX} ${enterY}`);
+ path.push(`L ${end.x} ${enterY}`);
+ } else if (endSide === 'top') {
+ const midX = (start.x + end.x) / 2;
+ const minY = Math.min(exitY, end.y - offsetDistance);
+ path.push(`L ${start.x} ${minY}`);
+ path.push(`L ${midX} ${minY}`);
+ path.push(`L ${end.x} ${minY}`);
+ path.push(`L ${end.x} ${end.y - offsetDistance}`);
+ } else {
+ // left or right - need to route around
+ const midX = (start.x + end.x) / 2;
+ path.push(`L ${midX} ${exitY}`);
+ path.push(`L ${midX} ${end.y}`);
+ }
+ } else if (startSide === 'bottom') {
+ const exitY = start.y + offsetDistance;
+ path.push(`L ${start.x} ${exitY}`);
+
+ if (endSide === 'top') {
+ const enterY = end.y - offsetDistance;
+ const midX = (start.x + end.x) / 2;
+ path.push(`L ${midX} ${exitY}`);
+ path.push(`L ${midX} ${enterY}`);
+ path.push(`L ${end.x} ${enterY}`);
+ } else if (endSide === 'bottom') {
+ const midX = (start.x + end.x) / 2;
+ const maxY = Math.max(exitY, end.y + offsetDistance);
+ path.push(`L ${start.x} ${maxY}`);
+ path.push(`L ${midX} ${maxY}`);
+ path.push(`L ${end.x} ${maxY}`);
+ path.push(`L ${end.x} ${end.y + offsetDistance}`);
+ } else {
+ // left or right - need to route around
+ const midX = (start.x + end.x) / 2;
+ path.push(`L ${midX} ${exitY}`);
+ path.push(`L ${midX} ${end.y}`);
+ }
+ }
+
+ path.push(`L ${end.x} ${end.y}`);
+
+ return path.join(' ');
+}