diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 00000000..e36c796e
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,306 @@
+# Waypoint System Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ DrawDB with Waypoints │
+│ (Inspired by drawio/mxGraph) │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ USER INTERACTIONS │
+├─────────────────────────────────────────────────────────────────────┤
+│ 1. Select relationship → Waypoint handles appear │
+│ 2. Click virtual bend → Add waypoint │
+│ 3. Drag waypoint → Move it (with grid snap) │
+│ 4. Double-click waypoint → Remove it │
+│ 5. Move table → Connection points update automatically │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ REACT COMPONENTS │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐ │
+│ │ Relationship.jsx │ │
+│ │ ┌────────────────────────────────────────────────────┐ │ │
+│ │ │ - Renders relationship path with waypoints │ │ │
+│ │ │ - Uses useWaypointEditor hook │ │ │
+│ │ │ - Calculates perimeter points │ │ │
+│ │ │ - Shows/hides waypoint handles on selection │ │ │
+│ │ └────────────────────────────────────────────────────┘ │ │
+│ │ │ │ │
+│ │ ▼ │ │
+│ │ ┌────────────────────────────────────────────────────┐ │ │
+│ │ │ WaypointContainer │ │ │
+│ │ │ ├─ WaypointHandle (draggable circles) │ │ │
+│ │ │ └─ VirtualBend (add waypoint indicators) │ │ │
+│ │ └────────────────────────────────────────────────────┘ │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ REACT HOOKS │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────────────────────────────┐ │
+│ │ useWaypointEditor(relationship, tables, onUpdate) │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ State Management: │ │ │
+│ │ │ - isDragging, draggedWaypointIndex │ │ │
+│ │ │ - hoveredWaypointIndex, hoveredVirtualBendIndex │ │ │
+│ │ │ - showWaypoints (based on selection) │ │ │
+│ │ │ │ │ │
+│ │ │ Event Handlers: │ │ │
+│ │ │ - onWaypointMouseDown → Start drag │ │ │
+│ │ │ - onMouseMove → Update position │ │ │
+│ │ │ - onMouseUp → Save changes │ │ │
+│ │ │ - onWaypointDoubleClick → Remove waypoint │ │ │
+│ │ │ - onVirtualBendMouseDown → Add waypoint │ │ │
+│ │ │ │ │ │
+│ │ │ Returns: waypoints, handlers, state │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌───────────────────────────────────────────────────────────────┐ │
+│ │ useConnectionPoints(startTable, endTable, waypoints) │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ - Calculates perimeter connection points │ │ │
+│ │ │ - Returns: { startPoint, endPoint, points } │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ CORE UTILITIES │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────────────────────────────┐ │
+│ │ edgeHandler.js (from drawio mxEdgeHandler.js) │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Classes: │ │ │
+│ │ │ │ │ │
+│ │ │ Waypoint │ │ │
+│ │ │ ├─ x, y, id │ │ │
+│ │ │ └─ toObject(), fromObject() │ │ │
+│ │ │ │ │ │
+│ │ │ EdgeHandler │ │ │
+│ │ │ ├─ loadWaypoints() │ │ │
+│ │ │ ├─ getAbsolutePoints() │ │ │
+│ │ │ ├─ getSegments() │ │ │
+│ │ │ ├─ findWaypointAt(x, y) │ │ │
+│ │ │ ├─ findVirtualBendAt(x, y) │ │ │
+│ │ │ ├─ addWaypoint(x, y, index) │ │ │
+│ │ │ ├─ removeWaypoint(index) │ │ │
+│ │ │ ├─ moveWaypoint(index, x, y) │ │ │
+│ │ │ ├─ isPointNearEdge(x, y) │ │ │
+│ │ │ └─ getWaypointsData() │ │ │
+│ │ │ │ │ │
+│ │ │ ConnectionHandler (for creating new relationships) │ │ │
+│ │ │ ├─ start(table, x, y) │ │ │
+│ │ │ ├─ addWaypoint(x, y) │ │ │
+│ │ │ ├─ updatePosition(x, y) │ │ │
+│ │ │ └─ complete(targetTable) → waypoints[] │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌───────────────────────────────────────────────────────────────┐ │
+│ │ perimeter.js (from drawio mxPerimeter.js) │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Classes: │ │ │
+│ │ │ - Point(x, y) │ │ │
+│ │ │ - Bounds(x, y, width, height) │ │ │
+│ │ │ │ │ │
+│ │ │ Functions: │ │ │
+│ │ │ rectanglePerimeter(bounds, next, orthogonal) │ │ │
+│ │ │ ┌─────────────────────────────────────────────┐ │ │ │
+│ │ │ │ 1. Calculate angle from center to next │ │ │ │
+│ │ │ │ 2. Determine which edge (L/T/R/B) │ │ │ │
+│ │ │ │ 3. Calculate intersection point │ │ │ │
+│ │ │ │ 4. Apply orthogonal constraints if needed │ │ │ │
+│ │ │ └─────────────────────────────────────────────┘ │ │ │
+│ │ │ │ │ │
+│ │ │ getTablePerimeterPoint(table, target) │ │ │
+│ │ │ getConnectionPoints(startTable, endTable, waypoints) │ │ │
+│ │ │ │ │ │
+│ │ │ Helpers: │ │ │
+│ │ │ - distance(p1, p2) │ │ │
+│ │ │ - isPointNearLine(point, start, end, tolerance) │ │ │
+│ │ │ - snapToGrid(point, gridSize) │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ STATE MANAGEMENT │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────────────────────────────┐ │
+│ │ DiagramContext (Modified) │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ relationships: [ │ │ │
+│ │ │ { │ │ │
+│ │ │ id: 1, │ │ │
+│ │ │ startTableId: 0, │ │ │
+│ │ │ endTableId: 1, │ │ │
+│ │ │ waypoints: [ ← NEW! │ │ │
+│ │ │ { x: 300, y: 200, id: 'wp_...' }, │ │ │
+│ │ │ { x: 400, y: 300, id: 'wp_...' } │ │ │
+│ │ │ ], │ │ │
+│ │ │ ... │ │ │
+│ │ │ } │ │ │
+│ │ │ ] │ │ │
+│ │ │ │ │ │
+│ │ │ New function: │ │ │
+│ │ │ updateRelationshipWaypoints(id, waypoints) │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ DATA FLOW │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ User Action → Event Handler → EdgeHandler → Hook State │
+│ │ │ │ │ │
+│ │ │ │ ▼ │
+│ │ │ │ Re-render UI │
+│ │ │ │ │ │
+│ │ │ ▼ │ │
+│ │ │ Update waypoints │ │
+│ │ │ │ │ │
+│ │ ▼ │ │ │
+│ │ Calculate deltas │ │ │
+│ │ │ │ │ │
+│ ▼ │ │ │ │
+│ onMouseDown ────────┼───────────────┼──────────────┘ │
+│ onMouseMove ────────┤ │ │
+│ onMouseUp ──────────┼───────────────┼─→ Save to Context │
+│ onDoubleClick ──────┘ └─→ Calculate points │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ RENDERING PIPELINE │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. Get tables from context │
+│ 2. Load waypoints from relationship data │
+│ 3. Calculate connection points (with perimeter math) │
+│ ├─ Start point: startTable + first waypoint/endTable │
+│ └─ End point: endTable + last waypoint/startTable │
+│ 4. Build point array: [startPoint, ...waypoints, endPoint] │
+│ 5. Generate SVG path: M x1 y1 L x2 y2 L x3 y3 ... │
+│ 6. Render path │
+│ 7. If selected: │
+│ ├─ Calculate virtual bend positions (segment midpoints) │
+│ ├─ Render waypoint handles │
+│ └─ Render virtual bends │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ KEY ALGORITHMS (from drawio) │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. PERIMETER INTERSECTION (mxPerimeter.js:84-158) │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Input: Rectangle bounds, Next point │ │
+│ │ Output: Point on rectangle edge │ │
+│ │ │ │
+│ │ alpha = atan2(dy, dx) // Angle to next point │ │
+│ │ t = atan2(height, width) // Rectangle diagonal angle │ │
+│ │ │ │
+│ │ if alpha in [-π+t, π-t]: Left edge │ │
+│ │ elif alpha < -t: Top edge │ │
+│ │ elif alpha < t: Right edge │ │
+│ │ else: Bottom edge │ │
+│ │ │ │
+│ │ Calculate intersection using tan(alpha) or tan(beta) │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ 2. WAYPOINT SNAPPING (mxConnectionHandler.js:1707-1716) │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ point = new Point( │ │
+│ │ graph.snap(mouseX / scale) * scale, │ │
+│ │ graph.snap(mouseY / scale) * scale │ │
+│ │ ) │ │
+│ │ waypoints.push(point) │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ 3. HIT DETECTION │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Point-to-point: sqrt((x2-x1)² + (y2-y1)²) <= radius │ │
+│ │ Point-to-line: Project point onto line, calc distance │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ VISUAL ELEMENTS │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ Relationship Line │
+│ ════════════════════════════════════════════ │
+│ ├─ Main path (stroke, visible) │
+│ └─ Hit area path (transparent, wider for easier clicking) │
+│ │
+│ Waypoint Handle │
+│ ●──────────────────────────────────────────────── │
+│ ├─ Visible circle (6px radius) │
+│ │ ├─ Fill: white/blue (normal/selected) │
+│ │ └─ Stroke: dark border │
+│ └─ Hit area circle (10px radius, transparent) │
+│ │
+│ Virtual Bend │
+│ ◉──────────────────────────────────────────────── │
+│ ├─ Semi-transparent circle (5px radius) │
+│ │ ├─ Fill: blue, opacity 0.4 │
+│ │ └─ Hover: opacity 0.8 │
+│ └─ Hit area circle (9px radius, transparent) │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────────────┐
+│ FILE DEPENDENCIES │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ Relationship.jsx │
+│ ↓ imports │
+│ ├─ useWaypoints.js │
+│ │ ↓ imports │
+│ │ ├─ edgeHandler.js │
+│ │ │ ↓ imports │
+│ │ │ └─ perimeter.js │
+│ │ └─ perimeter.js │
+│ ├─ WaypointHandle.jsx │
+│ └─ useDiagram (from context) │
+│ ↓ provides │
+│ └─ updateRelationshipWaypoints() │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Legend
+
+- `═` : Main data/control flow
+- `─` : Import/dependency
+- `┌┐└┘├┤` : Component/module boundaries
+- `▼` : Flow direction
+- `●` : Waypoint handle (draggable)
+- `◉` : Virtual bend (clickable to add waypoint)
+
+## Key Takeaways
+
+1. **Perimeter calculations** replace center-to-center connections
+2. **EdgeHandler** manages all waypoint operations (CRUD)
+3. **React hook** bridges utility logic with component state
+4. **Event delegation** handles all user interactions
+5. **Virtual bends** provide intuitive UX for adding waypoints
+6. **Grid snapping** ensures clean, aligned waypoints
+7. **State management** persists waypoints in relationship data
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 00000000..583ed4bd
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,164 @@
+# draw.io: Shapes, Connections, Waypoints and Edge Editing — Code Map
+
+This README documents where in the draw.io (jgraph/drawio) codebase to find the logic that:
+
+- Renders vertex shapes (squares, circles, custom shapes).
+- Renders edges / connectors (lines, arrows, wire shapes).
+- Computes anchor/connection points (perimeter math and connection constraints).
+- Creates and stores breakpoints / waypoints during connection creation.
+- Supports interactive editing: moving waypoints, moving the start/end terminals of edges.
+
+Use this as a navigation / change guide when you want to inspect or modify behavior (visuals, snapping, attachment, editing) related to shapes and edges.
+
+---
+
+## High-level flow (how connection creation & editing works)
+
+1. User starts creating a connection:
+ - `mxConnectionHandler` creates a preview shape and computes source/target perimeter points for the preview.
+2. While previewing, intermediate points (waypoints) can be added; these are stored in the handler while creating the edge.
+3. When the edge is inserted, the edge's `mxGeometry` is created with `relative = true` and the waypoints are stored in `geometry.points`.
+4. After creation, `mxEdgeHandler` is used to edit existing edges:
+ - It creates draggable handles for waypoints and for the start/end terminals.
+ - Moving handles updates edge geometry and recomputes `state.absolutePoints`.
+5. Rendering reads `state.absolutePoints` (or waypoints) and paints the polyline/connector using shape painters.
+
+---
+
+## Files of interest (what each implements + direct links)
+
+- Shapes & waypoint visuals (many vertex shapes + edge-shape implementations)
+ - src/main/webapp/js/grapheditor/Shapes.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/js/grapheditor/Shapes.js
+ - Notes: registers shapes, implements `WaypointShape` (visual for breakpoints), `WireShape`, `LinkShape` and many vertex shape `paintVertexShape` / `redrawPath` functions.
+
+- Base shape rendering used by vertex and edge shapes
+ - src/main/webapp/mxgraph/src/shape/mxShape.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/shape/mxShape.js
+ - Notes: decides whether to paint an edge (by checking `getWaypoints()` and `pts`) or a vertex; calls `paintEdgeShape` with the computed points.
+
+- Cell renderer (shape creation + lifecycle)
+ - src/main/webapp/mxgraph/src/view/mxCellRenderer.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxCellRenderer.js
+ - Notes: creates shapes from states and manages DOM nodes.
+
+- Perimeter math (anchor point calculation for shapes)
+ - src/main/webapp/mxgraph/src/view/mxPerimeter.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxPerimeter.js
+ - Notes: contains perimeter functions (Rectangle, Circle, Ellipse, Rhombus, Triangle, custom). Perimeter functions compute intersection on shape boundary given a `next` point (point along the edge).
+
+- Graph view: calling perimeter functions and computing perimeter points
+ - src/main/webapp/mxgraph/src/view/mxGraphView.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxGraphView.js
+ - Notes: `mxGraphView.prototype.getPerimeterPoint` calls the perimeter function; also provides `getPerimeterBounds`.
+
+- Connection constraint object (fixed anchor points)
+ - src/main/webapp/mxgraph/src/view/mxConnectionConstraint.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxConnectionConstraint.js
+ - Notes: `mxConnectionConstraint` stores a relative point and whether it should be projected to the perimeter.
+
+- Graph-level helpers for connection points, style flags and connection constraint handling
+ - src/main/webapp/mxgraph/src/view/mxGraph.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxGraph.js
+ - Notes: `getConnectionPoint`, `getConnectionConstraint`, `setConnectionConstraint` and style flags for entry / exit perimeter (`STYLE_ENTRY_PERIMETER`, `STYLE_EXIT_PERIMETER`), `ENTRY_DX` / `ENTRY_DY`, `EXIT_DX` / `EXIT_DY`.
+
+- Connection creation & preview (adding waypoints while creating an edge)
+ - src/main/webapp/mxgraph/src/handler/mxConnectionHandler.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/handler/mxConnectionHandler.js
+ - Notes:
+ - Creates preview `shape` (polyline) and updates `shape.points` as it moves.
+ - Has `this.waypoints` and pushes snapped `mxPoint` entries during connection creation: `this.waypoints.push(point)`.
+ - Uses `getSourcePerimeterPoint` / `getTargetPerimeterPoint` to compute terminal attach points for preview.
+
+- Constraint UI (displaying fixed anchor points on hover)
+ - src/main/webapp/mxgraph/src/handler/mxConstraintHandler.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/handler/mxConstraintHandler.js
+ - Notes: shows small points/icons on vertices and snaps to them when creating connections.
+
+- Edge editing (moving waypoints, moving start/end terminals)
+ - src/main/webapp/mxgraph/src/handler/mxEdgeHandler.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/handler/mxEdgeHandler.js
+ - Notes:
+ - Creates bend handles and virtual bends.
+ - Handles hit detection for bends and virtual bends.
+ - Handles `mouseDown`, `start`, `getPointForEvent`, snapping behavior, adding/removing points and moving handles.
+ - Uses `this.bends` and `this.virtualBends` arrays for current handles.
+
+- Where edge waypoints are stored in the model
+ - Edge geometry: `mxGeometry.points` on the edge `mxCell` contains the waypoints (absolute or relative depending on geometry). Edge handlers and graph methods manage persisting these to the model.
+
+---
+
+## Key code spots to inspect / edit for common tasks
+
+- Change how vertex shapes render (square/circle/custom):
+ - Edit the specific shape implementation in `Shapes.js` (e.g., `CubeShape`, `IsoRectangleShape`, `StateShape`, `Ellipse`/`Circle` implementations).
+ - If you need global behavior changes, inspect `mxShape` in `mxShape.js`.
+
+- Change how anchor points are calculated (where edges attach on a vertex):
+ - Add/modify perimeter functions inside `mxPerimeter.js`. Add the new function to `mxPerimeter` and register via `mxStyleRegistry` if needed.
+ - Adjust how `mxGraphView.getPerimeterPoint` uses the perimeter (e.g., border handling, orthogonal behavior, or transform for rotation).
+
+- Add or change fixed connection points (named connection points / explicit anchors):
+ - `mxConnectionConstraint` is the data object. See where constraints are read/written in `mxGraph.getConnectionConstraint` & `mxGraph.setConnectionConstraint`.
+ - Modify `mxConstraintHandler` to change UI for showing anchor points or snapping behavior.
+
+- Change how waypoints are created when drawing a new edge:
+ - Edit `mxConnectionHandler`: it creates `this.waypoints` and pushes snapped points. You can modify snapping, quantization, or add alternate ways to create/remove waypoints here.
+
+- Change how breakpoints (waypoints) are moved after creation:
+ - Edit `mxEdgeHandler`: the drag behavior (snap tolerance, handle shapes, movement constraints, whether terminals can be disconnected) is implemented here. `getPointForEvent` contains snap-to-terminal logic.
+
+- Change how start/end terminals move around the object:
+ - This behavior uses `getConnectionPoint` + `getPerimeterPoint` to compute where the terminal should move to when dragged, and `mxEdgeHandler` orchestrates the drag. Look at `mxEdgeHandler.start` and the code changing the edge's terminal (reconnecting logic) and `mxConnectionHandler.updateEdgeState`.
+
+---
+
+## Example references (specific small code excerpts)
+- Waypoint visual (paint): WaypointShape in Shapes.js — search for `WaypointShape.prototype.paintVertexShape` in `Shapes.js`.
+ Link: Shapes.js (see waypoint sections)
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/js/grapheditor/Shapes.js
+
+- Where waypoints are pushed during connection creation:
+ - In `mxConnectionHandler` there is code that does:
+ ```
+ if (this.waypoints == null) { this.waypoints = []; }
+ var point = new mxPoint(this.graph.snap(me.getGraphX() / scale) * scale,
+ this.graph.snap(me.getGraphY() / scale) * scale);
+ this.waypoints.push(point);
+ ```
+ - Search for `this.waypoints.push` in `mxConnectionHandler.js`.
+ Link: mxConnectionHandler.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/handler/mxConnectionHandler.js
+
+- Edge editing / bend handles: `mxEdgeHandler` manages detection and dragging of bend handles. Search for `getHandleForEvent`, `mouseDown`, `start`, and `getPointForEvent`.
+ Link: mxEdgeHandler.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/handler/mxEdgeHandler.js
+
+- Perimeter intersection math is implemented in `mxPerimeter.js`. Look for `RectanglePerimeter`, `EllipsePerimeter`, `CenterPerimeter`, or other functions depending on the shape.
+ Link: mxPerimeter.js
+ https://github.com/jgraph/drawio/blob/34466eba2331b75cbf409b09240b01009cb4f600/src/main/webapp/mxgraph/src/view/mxPerimeter.js
+
+---
+
+## Practical next steps / suggestions
+
+- To change visual appearance of waypoints:
+ - Edit `WaypointShape` in `Shapes.js` (change size, stroke, fill).
+- To add a new anchor behavior (e.g., fixed named ports on a vertex):
+ - Add a new perimeter function or use `mxConnectionConstraint` with `perimeter=false` and compute the offset in `mxGraph.getConnectionPoint`.
+- To customize snapping when moving bend handles:
+ - Edit `mxEdgeHandler.getPointForEvent` (it currently uses `getSnapToTerminalTolerance` and `snapToPoint`).
+- To change how the start/end attachment moves around rotated shapes:
+ - Review `mxConnectionHandler.getSourcePerimeterPoint` and `getTargetPerimeterPoint` (they rotate/perimeter-project points).
+- To persist custom data for attachments (e.g., named ports):
+ - Use edge style entries or store attributes in the cell (edge or vertex) and adjust `mxGraph.getConnectionPoint` and `mxConnectionHandler` to consult them.
+
+---
+
+## If you want, I can:
+- Produce specific code snippets/patches for one of the tasks above (e.g., add a new perimeter function, change waypoint visuals, tweak snap tolerance).
+- Create a short PR that implements a minimal, well-scoped change (please tell me which repo/branch to target).
+- Walk through the exact lines to change for a chosen behavior and explain the implications (model vs view vs handler changes).
+
+Tell me which specific change you want implemented and I will prepare a minimal concrete patch (or step-by-step edits) for that work.
\ No newline at end of file
diff --git a/src/components/EditorCanvas/Canvas.jsx b/src/components/EditorCanvas/Canvas.jsx
index e0c1695e..c07594ce 100644
--- a/src/components/EditorCanvas/Canvas.jsx
+++ b/src/components/EditorCanvas/Canvas.jsx
@@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next";
import { useEventListener } from "usehooks-ts";
import { areFieldsCompatible } from "../../utils/utils";
import { useLocation, useNavigate } from "react-router-dom";
+import { getAllTablePerimeterPoints, findClosestPerimeterPoint, getFieldPerimeterPoints, calculateOrthogonalPath } from "../../utils/perimeterPoints";
export default function Canvas() {
const { t } = useTranslation();
@@ -111,6 +112,7 @@ export default function Canvas() {
useUndoRedo();
const { selectedElement, setSelectedElement } = useSelect();
+
const [dragging, setDragging] = useState({
element: ObjectType.NONE,
id: -1,
@@ -133,6 +135,8 @@ export default function Canvas() {
startY: 0,
endX: 0,
endY: 0,
+ startPoint: null, // Perimeter point info: {x, y, side, fieldIndex}
+ previewEndPoint: null, // Preview point for mouse hover
});
// Estado para conexiones de jerarquía
@@ -2250,11 +2254,40 @@ export default function Canvas() {
}
if (linking) {
- setLinkingLine({
+ const updates = {
...linkingLine,
endX: pointer.spaces.diagram.x,
endY: pointer.spaces.diagram.y,
- });
+ };
+
+ // If hovering over a table, calculate preview endpoint on perimeter
+ if (hoveredTable.tableId >= 0 && hoveredTable.fieldId >= 0) {
+ const targetTable = tables.find(t => t.id === hoveredTable.tableId);
+ if (targetTable) {
+ const hasColorStrip = settings.notation === Notation.DEFAULT;
+ const allPerimeterPoints = getAllTablePerimeterPoints(targetTable, hasColorStrip);
+
+ // Find closest perimeter point to mouse position
+ const closestPoint = findClosestPerimeterPoint(
+ allPerimeterPoints,
+ pointer.spaces.diagram.x,
+ pointer.spaces.diagram.y,
+ 100 // threshold
+ );
+
+ if (closestPoint) {
+ updates.previewEndPoint = closestPoint;
+ updates.endTableId = hoveredTable.tableId;
+ } else {
+ updates.previewEndPoint = null;
+ }
+ }
+ } else {
+ updates.previewEndPoint = null;
+ updates.endTableId = -1;
+ }
+
+ setLinkingLine(updates);
} else if (resizing.element === ObjectType.TABLE && resizing.id >= 0) {
const table = tables.find((t) => t.id === resizing.id);
const newWidth = Math.max(-(table.x - pointer.spaces.diagram.x), 180);
@@ -2802,16 +2835,39 @@ export default function Canvas() {
}
setPanning((old) => ({ ...old, isPanning: false }));
setDragging({ element: ObjectType.NONE, id: -1, prevX: 0, prevY: 0 });
+
+ // Calculate startPoint with perimeter information
+ const parentTable = tables.find((t) => t.id === fieldTableid);
+ let startPoint = null;
+
+ if (parentTable) {
+ const fieldIndex = parentTable.fields.findIndex(f => f.id === field.id);
+ const hasColorStrip = settings.notation === Notation.DEFAULT;
+
+ // Get perimeter points for this field
+ const fieldPerimeterPoints = getFieldPerimeterPoints(
+ parentTable,
+ fieldIndex,
+ parentTable.fields.length,
+ hasColorStrip
+ );
+
+ // Use the right side by default (common starting point)
+ startPoint = fieldPerimeterPoints.right;
+ }
+
setLinkingLine({
...linkingLine,
startTableId: fieldTableid,
startFieldId: field.id,
- startX: pointer.spaces.diagram.x,
- startY: pointer.spaces.diagram.y,
- endX: pointer.spaces.diagram.x,
- endY: pointer.spaces.diagram.y,
+ startX: startPoint ? startPoint.x : pointer.spaces.diagram.x,
+ startY: startPoint ? startPoint.y : pointer.spaces.diagram.y,
+ endX: startPoint ? startPoint.x : pointer.spaces.diagram.x,
+ endY: startPoint ? startPoint.y : pointer.spaces.diagram.y,
endTableId: -1,
endFieldId: -1,
+ startPoint: startPoint,
+ previewEndPoint: null,
});
setLinking(true);
};
@@ -2906,6 +2962,26 @@ export default function Canvas() {
(f) => f.id === linkingLine.startFieldId,
);
const relationshipName = `${parentTable.name}_${actualStartFieldId ? actualStartFieldId.name : "table"}`;
+
+ // Calculate perimeter points for start and end
+ const hasColorStrip = settings.notation === Notation.DEFAULT;
+ const startFieldIndex = parentTable.fields.findIndex(f => f.id === linkingLine.startFieldId);
+ const startPerimeterPoints = getFieldPerimeterPoints(
+ parentTable,
+ startFieldIndex,
+ parentTable.fields.length,
+ hasColorStrip
+ );
+
+ // Get all perimeter points for end table to find closest one
+ const endTablePerimeterPoints = getAllTablePerimeterPoints(childTable, hasColorStrip);
+ const closestEndPoint = findClosestPerimeterPoint(
+ endTablePerimeterPoints,
+ linkingLine.endX,
+ linkingLine.endY,
+ 50 // threshold
+ );
+
// Use the updated childTable fields to create the new relationship
const newRelationship = {
startTableId: linkingLine.startTableId,
@@ -2918,6 +2994,10 @@ export default function Canvas() {
updateConstraint: Constraint.NONE,
deleteConstraint: Constraint.NONE,
name: relationshipName,
+ // Store perimeter connection points
+ startPoint: startPerimeterPoints.right, // Default to right side of start field
+ endPoint: closestEndPoint || endTablePerimeterPoints[0], // Use closest or first point
+ waypoints: [], // Initialize empty waypoints for orthogonal routing
};
delete newRelationship.startX;
@@ -3165,14 +3245,16 @@ export default function Canvas() {
}
return true;
})
- .map((e, i) => (
-
- ))}
+ .map((e, i) => {
+ return (
+
+ );
+ })}
{tables.map((table) => {
const isMoving =
dragging.element === ObjectType.TABLE &&
@@ -3187,6 +3269,7 @@ export default function Canvas() {
setHoveredTable={setHoveredTable}
handleGripField={handleGripField}
setLinkingLine={setLinkingLine}
+ isLinking={linking}
onPointerDown={(e) =>
handlePointerDownOnElement(e, table.id, ObjectType.TABLE)
}
@@ -3209,7 +3292,33 @@ export default function Canvas() {
/>
)
}
- {linking && (
+ {linking && linkingLine.startPoint && (
+ {
+ // If we have both start and preview end points, use orthogonal routing
+ if (linkingLine.previewEndPoint) {
+ const startTable = tables.find(t => t.id === linkingLine.startTableId);
+ const endTable = tables.find(t => t.id === linkingLine.endTableId);
+
+ if (startTable && endTable) {
+ return calculateOrthogonalPath(
+ linkingLine.startPoint,
+ linkingLine.previewEndPoint,
+ []
+ );
+ }
+ }
+ // Fallback to simple line
+ return `M ${linkingLine.startX} ${linkingLine.startY} L ${linkingLine.endX} ${linkingLine.endY}`;
+ })()}
+ stroke="#3b82f6"
+ strokeDasharray="8,8"
+ strokeWidth="2"
+ fill="none"
+ className="pointer-events-none touch-none"
+ />
+ )}
+ {linking && !linkingLine.startPoint && (
+ {/* Render all available points */}
+ {points.map((point, idx) => {
+ const isCurrent = currentPoint &&
+ point.x === currentPoint.x &&
+ point.y === currentPoint.y &&
+ point.side === currentPoint.side;
+ const isClosest = closestPointIndex === idx;
+
+ return (
+
+ {/* Visual indicator */}
+
+
+ {/* Inner dot */}
+ {(isCurrent || isClosest) && (
+
+ )}
+
+ {/* Label on closest */}
+ {isClosest && (
+
+ {point.side} • Field {point.fieldIndex + 1}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * Connection Point Handle Component
+ * Renders draggable handles for start/end points of relationships
+ */
+export function ConnectionPointHandle({
+ point,
+ type, // 'start' or 'end'
+ isSelected,
+ isDragging,
+ onMouseDown,
+}) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ if (!point) return null;
+
+ return (
+
+ {/* Larger invisible hit area for easier clicking */}
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onMouseDown={(e) => onMouseDown(e, type)}
+ />
+
+ {/* Outer ring */}
+
+
+ {/* Inner dot to indicate connection point */}
+
+
+ {/* Label */}
+ {(isHovered || isSelected || isDragging) && (
+
+ {isDragging ? 'Drag to point...' : (type === 'start' ? 'Start' : 'End')}
+
+ )}
+
+ );
+}
+
+/**
+ * Helper function to find closest point
+ */
+function findClosest(points, x, y) {
+ if (!points || points.length === 0) return -1;
+
+ let minDist = Infinity;
+ let closestIdx = -1;
+
+ points.forEach((point, idx) => {
+ const dist = Math.sqrt(
+ Math.pow(point.x - x, 2) + Math.pow(point.y - y, 2)
+ );
+ if (dist < minDist) {
+ minDist = dist;
+ closestIdx = idx;
+ }
+ });
+
+ return closestIdx;
+}
+
+/**
+ * Container for connection point handles
+ */
+export function ConnectionPointHandles({
+ startPoint,
+ endPoint,
+ isSelected,
+ onStartPointChange,
+ onEndPointChange,
+ availableStartPoints, // Array of perimeter points for start table
+ availableEndPoints, // Array of perimeter points for end table
+}) {
+ const [draggingType, setDraggingType] = useState(null);
+ const [currentDragPos, setCurrentDragPos] = useState({ x: 0, y: 0 });
+
+ // Use refs to keep track of current values in event handlers
+ const draggingTypeRef = useRef(null);
+ const availableStartPointsRef = useRef(availableStartPoints);
+ const availableEndPointsRef = useRef(availableEndPoints);
+ const onStartPointChangeRef = useRef(onStartPointChange);
+ const onEndPointChangeRef = useRef(onEndPointChange);
+
+ // Update refs whenever props change
+ availableStartPointsRef.current = availableStartPoints;
+ availableEndPointsRef.current = availableEndPoints;
+ onStartPointChangeRef.current = onStartPointChange;
+ onEndPointChangeRef.current = onEndPointChange;
+
+ const handleMouseMove = useCallback((e) => {
+ const currentDraggingType = draggingTypeRef.current;
+
+ console.log('Handle mouse move, draggingType:', currentDraggingType);
+
+ if (!currentDraggingType) return;
+
+ // Get canvas coordinates
+ const svg = document.getElementById('diagram');
+ if (!svg) {
+ console.warn('Canvas SVG not found');
+ return;
+ }
+
+ const pt = svg.createSVGPoint();
+ pt.x = e.clientX;
+ pt.y = e.clientY;
+ const canvasPt = pt.matrixTransform(svg.getScreenCTM().inverse());
+
+ // Update current position
+ setCurrentDragPos({ x: canvasPt.x, y: canvasPt.y });
+
+ // Find and snap to closest perimeter point in real-time
+ if (currentDraggingType === 'start' && availableStartPointsRef.current && onStartPointChangeRef.current) {
+ const closestIdx = findClosest(availableStartPointsRef.current, canvasPt.x, canvasPt.y);
+ if (closestIdx >= 0 && availableStartPointsRef.current[closestIdx]) {
+ const closestPoint = availableStartPointsRef.current[closestIdx];
+ console.log('Updating start point to:', closestPoint);
+ onStartPointChangeRef.current(closestPoint);
+ }
+ } else if (currentDraggingType === 'end' && availableEndPointsRef.current && onEndPointChangeRef.current) {
+ const closestIdx = findClosest(availableEndPointsRef.current, canvasPt.x, canvasPt.y);
+ if (closestIdx >= 0 && availableEndPointsRef.current[closestIdx]) {
+ const closestPoint = availableEndPointsRef.current[closestIdx];
+ console.log('Updating end point to:', closestPoint);
+ onEndPointChangeRef.current(closestPoint);
+ }
+ }
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ draggingTypeRef.current = null;
+ setDraggingType(null);
+ console.log('Handle mouse up');
+ }, [handleMouseMove]);
+
+ const handleMouseDown = useCallback((e, type) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ console.log('Handle mouse down:', type, 'availablePoints:',
+ type === 'start' ? availableStartPoints?.length : availableEndPoints?.length);
+
+ draggingTypeRef.current = type;
+ setDraggingType(type);
+
+ // Get canvas coordinates
+ const svg = e.currentTarget.ownerSVGElement;
+ const pt = svg.createSVGPoint();
+ pt.x = e.clientX;
+ pt.y = e.clientY;
+ const canvasPt = pt.matrixTransform(svg.getScreenCTM().inverse());
+
+ setCurrentDragPos({ x: canvasPt.x, y: canvasPt.y });
+
+ // Add global mouse listeners
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }, [availableStartPoints, availableEndPoints, handleMouseMove, handleMouseUp]);
+
+ // Calculate closest point during drag
+ const closestStartIdx = draggingType === 'start' && availableStartPoints
+ ? findClosest(availableStartPoints, currentDragPos.x, currentDragPos.y)
+ : -1;
+
+ const closestEndIdx = draggingType === 'end' && availableEndPoints
+ ? findClosest(availableEndPoints, currentDragPos.x, currentDragPos.y)
+ : -1;
+
+ return (
+ <>
+ {/* Show perimeter points during drag */}
+ {draggingType === 'start' && availableStartPoints && availableStartPoints.length > 0 && (
+
+ )}
+
+ {draggingType === 'end' && availableEndPoints && availableEndPoints.length > 0 && (
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/EditorCanvas/Relationship.jsx b/src/components/EditorCanvas/Relationship.jsx
index 4700d9ad..2887a362 100644
--- a/src/components/EditorCanvas/Relationship.jsx
+++ b/src/components/EditorCanvas/Relationship.jsx
@@ -1,4 +1,4 @@
-import { useRef, useMemo } from "react";
+import { useRef, useMemo, useEffect } from "react";
import {
RelationshipType,
RelationshipCardinalities,
@@ -13,7 +13,7 @@ import {
SubtypeRestriction,
} from "../../data/constants";
import { calcPath } from "../../utils/calcPath";
-import { useDiagram, useSettings, useLayout, useSelect } from "../../hooks";
+import { useDiagram, useSettings, useLayout, useSelect, useWaypointEditor } from "../../hooks";
import { useTranslation } from "react-i18next";
import { SideSheet } from "@douyinfe/semi-ui";
import RelationshipInfo from "../EditorSidePanel/RelationshipsTab/RelationshipInfo";
@@ -25,6 +25,14 @@ import {
DefaultNotation,
} from "./RelationshipFormat";
import { subDT, subDP, subOT, subOP } from "./subtypeFormats";
+import { WaypointContainer } from "./WaypointHandle";
+import { ConnectionPointHandles } from "./ConnectionPointHandle";
+import { getConnectionPoints } from "../../utils/perimeter";
+import {
+ calculateOrthogonalPath,
+ getFieldPerimeterPoints,
+ getAllTablePerimeterPoints
+} from "../../utils/perimeterPoints";
const labelFontSize = 16;
@@ -34,7 +42,7 @@ export default function Relationship({
onContextMenu,
}) {
const { settings } = useSettings();
- const { tables } = useDiagram();
+ const { tables, updateRelationshipWaypoints, updateRelationship } = useDiagram();
const { layout } = useLayout();
const { selectedElement, setSelectedElement } = useSelect();
const { t } = useTranslation();
@@ -193,6 +201,83 @@ export default function Relationship({
};
}, [data.startTableId, data.endTableIds, data.subtype, tables]); // Dependencies for memoization
+ // Waypoint editor hook - always call hook but may not be active for subtype relationships
+ const shouldUseWaypoints = !data.subtype && startTable && endTable;
+ const waypointsData = useWaypointEditor(
+ shouldUseWaypoints ? data : null,
+ tables,
+ (updatedWaypoints) => {
+ if (shouldUseWaypoints) {
+ updateRelationshipWaypoints(data.id, updatedWaypoints);
+ }
+ }
+ );
+
+ const {
+ waypoints = [],
+ isDragging = false,
+ draggedWaypointIndex = -1,
+ hoveredWaypointIndex = -1,
+ hoveredVirtualBendIndex = -1,
+ showWaypoints = false,
+ setShowWaypoints = () => {},
+ virtualBends = [],
+ handlers = {},
+ } = (shouldUseWaypoints && waypointsData) ? waypointsData : {};
+
+ // Show waypoints when relationship is selected
+ useEffect(() => {
+ if (!shouldUseWaypoints) return;
+
+ const isSelected =
+ selectedElement.element === ObjectType.RELATIONSHIP &&
+ selectedElement.id === data.id;
+ setShowWaypoints(isSelected);
+ }, [selectedElement, data.id, setShowWaypoints, shouldUseWaypoints]);
+
+ // Add global mouse event listeners for dragging
+ useEffect(() => {
+ if (!shouldUseWaypoints || !isDragging) return;
+
+ if (handlers.onMouseMove && handlers.onMouseUp) {
+ document.addEventListener("mousemove", handlers.onMouseMove);
+ document.addEventListener("mouseup", handlers.onMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handlers.onMouseMove);
+ document.removeEventListener("mouseup", handlers.onMouseUp);
+ };
+ }
+ }, [isDragging, handlers, shouldUseWaypoints]);
+
+ // Handlers for changing connection points (start/end)
+ // These are called when user drags a handle to a different perimeter point
+ const handleStartPointChange = (newPoint) => {
+ if (!newPoint) return;
+
+ updateRelationship(data.id, {
+ startPoint: {
+ x: newPoint.x,
+ y: newPoint.y,
+ side: newPoint.side,
+ fieldIndex: newPoint.fieldIndex
+ }
+ });
+ };
+
+ const handleEndPointChange = (newPoint) => {
+ if (!newPoint) return;
+
+ updateRelationship(data.id, {
+ endPoint: {
+ x: newPoint.x,
+ y: newPoint.y,
+ side: newPoint.side,
+ fieldIndex: newPoint.fieldIndex
+ }
+ });
+ };
+
try {
if (data.subtype && settings.notation === Notation.DEFAULT) {
return null;
@@ -748,6 +833,68 @@ export default function Relationship({
};
}
+ // Calculate path - use orthogonal routing with stored connection points
+ let pathString;
+ let actualStartPoint = null;
+ let actualEndPoint = null;
+ let availableStartPoints = [];
+ let availableEndPoints = [];
+
+ // Check if relationship has stored connection points
+ if (data.startPoint && data.endPoint && startTable && endTable) {
+ // Recalculate actual positions based on current table positions
+ const hasColorStrip = settings.notation === Notation.DEFAULT;
+
+ // Recalculate start point based on stored side and fieldIndex
+ const startFieldPerimeter = getFieldPerimeterPoints(
+ startTable,
+ data.startPoint.fieldIndex,
+ startTable.fields.length,
+ hasColorStrip
+ );
+ actualStartPoint = startFieldPerimeter[data.startPoint.side] || startFieldPerimeter.right;
+
+ // Recalculate end point based on stored side and fieldIndex
+ const endFieldPerimeter = getFieldPerimeterPoints(
+ endTable,
+ data.endPoint.fieldIndex || 0,
+ endTable.fields.length,
+ hasColorStrip
+ );
+ actualEndPoint = endFieldPerimeter[data.endPoint.side] || endFieldPerimeter.left;
+
+ // Calculate all available perimeter points for double-click selection
+ availableStartPoints = getAllTablePerimeterPoints(startTable, hasColorStrip);
+ availableEndPoints = getAllTablePerimeterPoints(endTable, hasColorStrip);
+
+ // Use orthogonal routing with recalculated points and table bounds
+ pathString = calculateOrthogonalPath(
+ actualStartPoint,
+ actualEndPoint,
+ waypoints || []
+ );
+ } else if (shouldUseWaypoints && waypoints && waypoints.length > 0) {
+ // Fallback: Use perimeter-based connection with waypoints
+ const { startPoint, endPoint } = getConnectionPoints(
+ startTable,
+ endTable,
+ waypoints
+ );
+
+ actualStartPoint = startPoint;
+ actualEndPoint = endPoint;
+
+ // Build orthogonal path through waypoints
+ pathString = calculateOrthogonalPath(startPoint, endPoint, waypoints);
+ } else {
+ // Fallback: Use original calcPath for backward compatibility
+ pathString = calcPath(
+ pathData,
+ startTable?.width || settings.tableWidth,
+ endTable?.width || settings.tableWidth,
+ );
+ }
+
return (
<>
{/* Invisible path for larger hit area */}
0 ? pathString : calcPath(
pathData,
startTable?.width || settings.tableWidth,
endTable?.width || settings.tableWidth,
@@ -771,16 +918,12 @@ export default function Relationship({
{/* Visible path */}
{/* Show parent/child notations for all relationships */}
{parentFormat && !data.subtype &&
@@ -937,6 +1080,39 @@ export default function Relationship({
>
)}
+
+ {/* Waypoint handles (only visible when selected and waypoints enabled) */}
+ {shouldUseWaypoints && showWaypoints && handlers.onWaypointMouseDown && (
+
+ )}
+
+ {/* Connection point handles (start/end points - shown when relationship is selected) */}
+ {showWaypoints && data.startPoint && data.endPoint && actualStartPoint && actualEndPoint && (
+
+ )}
{
@@ -44,6 +45,7 @@ export default function Table(props) {
moving,
onContextMenu,
onFieldContextMenu,
+ isLinking = false, // New prop to know when linking is active
} = props;
const { layout } = useLayout();
const { deleteTable, deleteField } = useDiagram();
@@ -447,6 +449,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 */}
+
+
+ ));
+ })()}
+
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/context/DiagramContext.jsx b/src/context/DiagramContext.jsx
index 3c8a3d53..10eb8477 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,10 @@ export default function DiagramContextProvider({ children }) {
);
};
+ const updateRelationshipWaypoints = (id, waypoints) => {
+ updateRelationship(id, { waypoints: waypoints || [] });
+ };
+
// Subtype relationship management functions
const addChildToSubtype = (relationshipId, childTableId, shouldAddToUndoStack = true) => {
// Critical validation: Prevent infinite loops
@@ -1023,6 +1038,7 @@ export default function DiagramContextProvider({ children }) {
addRelationship,
deleteRelationship,
updateRelationship,
+ updateRelationshipWaypoints,
addChildToSubtype,
removeChildFromSubtype,
restoreFieldsToTable,
diff --git a/src/hooks/index.js b/src/hooks/index.js
index e21eee15..f48d027a 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -12,3 +12,4 @@ 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";
diff --git a/src/hooks/useWaypoints.js b/src/hooks/useWaypoints.js
new file mode 100644
index 00000000..3e93a94c
--- /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 00000000..1f09b95d
--- /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 00000000..638f956d
--- /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 00000000..a0761099
--- /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;
+ 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(' ');
+}