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(' '); +}