diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..e36c796e0 --- /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 000000000..583ed4bdd --- /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 e0c1695e5..4c16bd590 100644 --- a/src/components/EditorCanvas/Canvas.jsx +++ b/src/components/EditorCanvas/Canvas.jsx @@ -18,6 +18,7 @@ import { Toast, Modal, Input, InputNumber } from "@douyinfe/semi-ui"; import Table from "./Table"; import Area from "./Area"; import Relationship from "./Relationship"; +import RelationshipControls from "./RelationshipControls"; import Note from "./Note"; import TableContextMenu from "./TableContextMenu"; import RelationshipContextMenu from "./RelationshipContextMenu"; @@ -40,6 +41,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(); @@ -96,6 +98,8 @@ export default function Canvas() { setRelationships, deleteField, removeChildFromSubtype, + adjustWaypointsForTableMove, + updateSubtypePerimeterPoints, } = useDiagram(); const { areas, updateArea, addArea, deleteArea } = useAreas(); const { notes, updateNote, addNote, deleteNote } = useNotes(); @@ -111,6 +115,7 @@ export default function Canvas() { useUndoRedo(); const { selectedElement, setSelectedElement } = useSelect(); + const [dragging, setDragging] = useState({ element: ObjectType.NONE, id: -1, @@ -133,6 +138,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 @@ -145,6 +152,7 @@ export default function Canvas() { startY: 0, endX: 0, endY: 0, + selectedPerimeterPoint: null, // {x, y, side, fieldIndex} - punto perimetral seleccionado }); const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 }); @@ -2250,11 +2258,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); @@ -2262,11 +2299,22 @@ export default function Canvas() { width: newWidth, }); } else if (hierarchyLinking) { - setHierarchyLinkingLine({ + const updates = { ...hierarchyLinkingLine, endX: pointer.spaces.diagram.x, endY: pointer.spaces.diagram.y, - }); + selectedPerimeterPoint: null, // Reset selection while moving + }; + + // If hovering over a table, keep endpoint at mouse position + // (perimeter points will be shown for clicking) + if (hoveredTable.tableId >= 0) { + updates.endTableId = hoveredTable.tableId; + } else { + updates.endTableId = -1; + } + + setHierarchyLinkingLine(updates); } else if ( panning.isPanning && dragging.element === ObjectType.NONE && @@ -2686,6 +2734,28 @@ export default function Canvas() { if (coordsDidUpdate(dragging.element)) { const info = getMovedElementDetails(); + + // Adjust waypoints for moved tables + if (dragging.element === ObjectType.TABLE) { + if (Array.isArray(dragging.id)) { + // Multiple tables moved - adjust waypoints for each + dragging.id.forEach((tableId) => { + const initPos = dragging.initialPositions[tableId]; + const currentTable = tables.find(t => t.id === tableId); + if (initPos && currentTable) { + const deltaX = currentTable.x - initPos.x; + const deltaY = currentTable.y - initPos.y; + adjustWaypointsForTableMove(tableId, deltaX, deltaY); + } + }); + } else { + // Single table moved + const deltaX = info.x - dragging.prevX; + const deltaY = info.y - dragging.prevY; + adjustWaypointsForTableMove(dragging.id, deltaX, deltaY); + } + } + // Use pushUndo to ensure centralized filtering/deduplication pushUndo( (() => { @@ -2802,16 +2872,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 +2999,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 +3031,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; @@ -2947,6 +3064,7 @@ export default function Canvas() { startY: hierarchyLineStartPoint.y, endX: hierarchyLineStartPoint.x, endY: hierarchyLineStartPoint.y, + selectedPerimeterPoint: null, // Initialize without selection }); setHierarchyLinking(true); }; @@ -3009,6 +3127,30 @@ export default function Canvas() { } // Add the new child table to the existing subtype relationship addChildToSubtype(hierarchyLinkingLine.relationshipId, targetTableId); + + // If a perimeter point was selected, update the relationship + // Use setTimeout to ensure this happens after auto-initialization + if (hierarchyLinkingLine.selectedPerimeterPoint) { + setTimeout(() => { + const relationship = relationships.find( + (r) => r.id === hierarchyLinkingLine.relationshipId, + ); + if (relationship && updateSubtypePerimeterPoints) { + const currentChildPoints = relationship.subtypePerimeterPoints?.childPoints || {}; + updateSubtypePerimeterPoints(hierarchyLinkingLine.relationshipId, { + parentPoint: relationship.subtypePerimeterPoints?.parentPoint || {}, + childPoints: { + ...currentChildPoints, + [targetTableId]: { + side: hierarchyLinkingLine.selectedPerimeterPoint.side, + fieldIndex: hierarchyLinkingLine.selectedPerimeterPoint.fieldIndex, + } + } + }); + } + }, 50); + } + setHierarchyLinking(false); // Force a re-render to ensure UI updates properly setTimeout(() => {}, 100); @@ -3165,14 +3307,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 +3331,7 @@ export default function Canvas() { setHoveredTable={setHoveredTable} handleGripField={handleGripField} setLinkingLine={setLinkingLine} + isLinking={linking} onPointerDown={(e) => handlePointerDownOnElement(e, table.id, ObjectType.TABLE) } @@ -3195,6 +3340,132 @@ export default function Canvas() { /> ); })} + {/* Perimeter points durante hierarchy linking */} + {hierarchyLinking && (() => { + const originalRelationship = relationships.find( + (r) => r.id === hierarchyLinkingLine.relationshipId + ); + if (!originalRelationship) return null; + + const parentTable = tables.find(t => t.id === originalRelationship.startTableId); + if (!parentTable) return null; + + const hasColorStrip = settings.notation === Notation.DEFAULT; + + // show perimeter points for hovered table (potential child) + const hoveredTablePerimeters = hoveredTable.tableId >= 0 && (() => { + const targetTable = tables.find(t => t.id === hoveredTable.tableId); + if (!targetTable) return null; + + const perimeterPoints = getAllTablePerimeterPoints(targetTable, hasColorStrip); + + const handlePerimeterPointClick = (e, point) => { + e.stopPropagation(); + e.preventDefault(); + + // Update the hierarchy linking line with the selected point + setHierarchyLinkingLine(prev => ({ + ...prev, + selectedPerimeterPoint: { + x: point.x, + y: point.y, + side: point.side, + fieldIndex: point.fieldIndex + }, + endX: point.x, + endY: point.y + })); + + // Complete the hierarchy linking + handleHierarchyLinking(); + }; + + return ( + + {perimeterPoints.map((point, idx) => { + if (!point) return null; + + const isSelected = hierarchyLinkingLine.selectedPerimeterPoint && + hierarchyLinkingLine.selectedPerimeterPoint.side === point.side && + hierarchyLinkingLine.selectedPerimeterPoint.fieldIndex === point.fieldIndex; + + return ( + + handlePerimeterPointClick(e, point)} + onClick={(e) => handlePerimeterPointClick(e, point)} + /> + + ); + })} + + ); + })(); + + // show parent perimeter points if no parent point assigned yet (only allow setting one parent point, but show all until one is selected) + const parentTablePerimeters = (!originalRelationship.subtypePerimeterPoints || + !originalRelationship.subtypePerimeterPoints.parentPoint) && (() => { + + const parentPerimeterPoints = getAllTablePerimeterPoints(parentTable, hasColorStrip); + + const handleParentPerimeterClick = (e, point) => { + e.stopPropagation(); + e.preventDefault(); + + // Update the parent perimeter point + if (updateSubtypePerimeterPoints) { + const currentChildPoints = originalRelationship.subtypePerimeterPoints?.childPoints || {}; + updateSubtypePerimeterPoints(hierarchyLinkingLine.relationshipId, { + parentPoint: { + side: point.side, + fieldIndex: point.fieldIndex + }, + childPoints: currentChildPoints + }); + + Toast.success(t("parent_perimeter_point_set") || "Punto perimetral padre establecido"); + } + }; + + return ( + + {parentPerimeterPoints.map((point, idx) => { + if (!point) return null; + + return ( + + handleParentPerimeterClick(e, point)} + onClick={(e) => handleParentPerimeterClick(e, point)} + /> + + ); + })} + + ); + })(); + + return ( + <> + {hoveredTablePerimeters} + {parentTablePerimeters} + + ); + })()} { /*Draw the selection areas*/ isAreaSelecting && isDrawingSelectionArea && ( @@ -3209,7 +3480,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 relationship controls (waypoints/handles) AFTER tables to ensure they're on top */} + {relationships + .filter((rel) => { + // Same filter logic as above + if ( + rel.subtype && + rel.endTableId !== undefined && + !rel.endTableIds + ) { + return true; + } + if ( + rel.subtype && + rel.endTableIds && + rel.endTableIds.length > 1 + ) { + return true; + } + if (!rel.subtype) { + return true; + } + if ( + rel.subtype && + rel.endTableIds && + rel.endTableIds.length === 1 + ) { + return true; + } + return true; + }) + .map((e, i) => { + return ( + + ); + })} {settings.showDebugCoordinates && ( diff --git a/src/components/EditorCanvas/ConnectionPointHandle.jsx b/src/components/EditorCanvas/ConnectionPointHandle.jsx new file mode 100644 index 000000000..00e46c562 --- /dev/null +++ b/src/components/EditorCanvas/ConnectionPointHandle.jsx @@ -0,0 +1,270 @@ +import { useState, useRef, useCallback } from "react"; + +/** + * Perimeter Point Selector + * Shows all available perimeter points during drag + */ +function PerimeterPointSelector({ + points, + closestPointIndex, // Index of the closest point to drag position + type, // 'start' or 'end' - determines color of inner dot +}) { + // Determine inner dot color based on type + const innerDotColor = type === 'start' ? '#ef4444' : '#3b82f6'; // Red for start, blue for end + + return ( + + {/* Render all available points */} + {points.map((point, idx) => { + const isClosest = closestPointIndex === idx; + + return ( + + {/* Outer ring */} + + + {/* Inner dot - always visible with type color */} + + + ); + })} + + ); +} + +/** + * 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 */} + + + ); +} + +/** + * 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 4700d9add..cc07ca898 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, useSubtypeWaypoints } from "../../hooks"; import { useTranslation } from "react-i18next"; import { SideSheet } from "@douyinfe/semi-ui"; import RelationshipInfo from "../EditorSidePanel/RelationshipsTab/RelationshipInfo"; @@ -25,6 +25,13 @@ import { DefaultNotation, } from "./RelationshipFormat"; import { subDT, subDP, subOT, subOP } from "./subtypeFormats"; +import { getConnectionPoints } from "../../utils/perimeter"; +import { + calculateOrthogonalPath, + getFieldPerimeterPoints, + getAllTablePerimeterPoints, + findClosestPerimeterPoint, +} from "../../utils/perimeterPoints"; const labelFontSize = 16; @@ -34,7 +41,7 @@ export default function Relationship({ onContextMenu, }) { const { settings } = useSettings(); - const { tables } = useDiagram(); + const { tables, updateRelationshipWaypoints, updateSubtypeWaypoints, updateSubtypePerimeterPoints } = useDiagram(); const { layout } = useLayout(); const { selectedElement, setSelectedElement } = useSelect(); const { t } = useTranslation(); @@ -99,6 +106,7 @@ export default function Relationship({ if (!data.subtype || !data.endTableIds || data.endTableIds.length <= 1) { return null; } + console.log('📐 Recalculating subtypeGeometry for endTableIds:', data.endTableIds); const startTable = tables[data.startTableId]; if (!startTable) { console.error("Start table not found for multi-child subtype", { @@ -193,6 +201,261 @@ export default function Relationship({ }; }, [data.startTableId, data.endTableIds, data.subtype, tables]); // Dependencies for memoization + // Waypoint editor hook - enable for non-subtype and single-child subtype relationships + const isSingleChildSubtype = data.subtype && (!data.endTableIds || data.endTableIds.length <= 1); + const shouldUseWaypoints = (isSingleChildSubtype || !data.subtype) && startTable && endTable; + const waypointsData = useWaypointEditor( + shouldUseWaypoints ? data : null, + tables, + (updatedWaypoints) => { + if (shouldUseWaypoints) { + updateRelationshipWaypoints(data.id, updatedWaypoints); + } + } + ); + + const { + waypoints = [], + isDragging = false, + showWaypoints = false, + setShowWaypoints = () => {}, + 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]); + + // Subtype waypoints hook for multi-child relationships + const isMultiChildSubtype = data.subtype && data.endTableIds && data.endTableIds.length > 1; + const subtypeWaypointsData = useSubtypeWaypoints( + isMultiChildSubtype ? data : null, + tables, + (updatedWaypoints) => { + if (isMultiChildSubtype && updateSubtypeWaypoints) { + updateSubtypeWaypoints(data.id, updatedWaypoints); + } + } + ); + + const { + parentWaypoints = [], + childWaypoints = {}, + showWaypoints: showSubtypeWaypoints = false, + setShowWaypoints: setShowSubtypeWaypoints = () => {}, + isDragging: isSubtypeDragging = false, + handlers: subtypeHandlers = {}, + } = isMultiChildSubtype && subtypeWaypointsData ? subtypeWaypointsData : {}; + + // Initialize waypoints for multi-child subtype relationships + useEffect(() => { + if (!isMultiChildSubtype || !subtypeGeometry || !updateSubtypeWaypoints) return; + + const { childTables, parentCenter, subtypePoint } = subtypeGeometry; + + // If no waypoints exist at all, create everything from scratch + if (!data.subtypeWaypoints) { + const midX = (parentCenter.x + subtypePoint.x) / 2; + const midY = (parentCenter.y + subtypePoint.y) / 2; + + const initialChildWaypoints = {}; + childTables.forEach(child => { + const childCenter = { + x: child.x + child.width / 2, + y: child.y + child.height / 2, + }; + const childMidX = (subtypePoint.x + childCenter.x) / 2; + const childMidY = (subtypePoint.y + childCenter.y) / 2; + initialChildWaypoints[child.id] = [{ x: childMidX, y: childMidY }]; + }); + + updateSubtypeWaypoints(data.id, { + parentToSubtype: [{ x: midX, y: midY }], + subtypeToChildren: initialChildWaypoints, + }); + return; + } + + // If waypoints exist, check if there are new children without waypoints + const existingChildWaypoints = data.subtypeWaypoints.subtypeToChildren || {}; + const newChildren = childTables.filter(child => !existingChildWaypoints[child.id]); + + if (newChildren.length > 0) { + const updatedChildWaypoints = { ...existingChildWaypoints }; + + newChildren.forEach(child => { + const childCenter = { + x: child.x + child.width / 2, + y: child.y + child.height / 2, + }; + const childMidX = (subtypePoint.x + childCenter.x) / 2; + const childMidY = (subtypePoint.y + childCenter.y) / 2; + updatedChildWaypoints[child.id] = [{ x: childMidX, y: childMidY }]; + }); + + updateSubtypeWaypoints(data.id, { + parentToSubtype: data.subtypeWaypoints.parentToSubtype || [], + subtypeToChildren: updatedChildWaypoints, + }); + } + }, [isMultiChildSubtype, data.subtypeWaypoints, subtypeGeometry, updateSubtypeWaypoints, data.id, data.endTableIds]); + + // Initialize perimeter points for multi-child subtype relationships + useEffect(() => { + if (!isMultiChildSubtype || !subtypeGeometry || !updateSubtypePerimeterPoints) { + console.log( 'Perimeter points effect SKIPPED:', { isMultiChildSubtype, hasGeometry: !!subtypeGeometry, hasUpdater: !!updateSubtypePerimeterPoints }); + return; + } + + const { startTable, childTables } = subtypeGeometry; + const hasColorStrip = settings.notation === Notation.DEFAULT; + + console.log(' Perimeter points effect triggered'); + console.log('Current childTables:', childTables.map(c => ({ id: c.id, name: c.name }))); + console.log('Current subtypePerimeterPoints:', data.subtypePerimeterPoints); + console.log('data.endTableIds:', data.endTableIds); + + // If no perimeter points exist at all, create default ones + if (!data.subtypePerimeterPoints) { + console.log(' Creating initial perimeter points for all children'); + // Use the same method as rendering to get all parent perimeter points + const allParentPoints = getAllTablePerimeterPoints(startTable, hasColorStrip); + // Find the closest point to the subtype notation (or to children center) + const closestParentPoint = findClosestPerimeterPoint( + allParentPoints, + subtypeGeometry.subtypePoint.x, + subtypeGeometry.subtypePoint.y, + Infinity + ); + + // Create perimeter points for each child + const childPerimeterPoints = {}; + childTables.forEach(child => { + // Use the same method as rendering to get all perimeter points + const allChildPoints = getAllTablePerimeterPoints(child, hasColorStrip); + + // Find the closest point to the subtype notation + const closestPoint = findClosestPerimeterPoint( + allChildPoints, + subtypeGeometry.subtypePoint.x, + subtypeGeometry.subtypePoint.y, + Infinity + ); + + if (closestPoint) { + childPerimeterPoints[child.id] = { + side: closestPoint.side, + fieldIndex: closestPoint.fieldIndex + }; + } else { + console.warn('Could not find closest perimeter point for child:', child.name); + } + }); + + if (!closestParentPoint) { + console.error('Could not find closest perimeter point for parent table'); + return; + } + + updateSubtypePerimeterPoints(data.id, { + parentPoint: { side: closestParentPoint.side, fieldIndex: closestParentPoint.fieldIndex }, + childPoints: childPerimeterPoints + }); + return; + } + + // If perimeter points exist, check if there are new children without perimeter points + const existingChildPoints = data.subtypePerimeterPoints.childPoints || {}; + const newChildren = childTables.filter(child => !existingChildPoints[child.id]); + + console.log('Checking for new children without perimeter points'); + console.log('Existing child points:', existingChildPoints); + console.log('New children detected:', newChildren.map(c => ({ id: c.id, name: c.name }))); + + if (newChildren.length > 0) { + console.log('Adding perimeter points for new children'); + const updatedChildPoints = { ...existingChildPoints }; + + newChildren.forEach(child => { + // Use the same method as rendering to get all perimeter points + const allChildPoints = getAllTablePerimeterPoints(child, hasColorStrip); + + // Find the closest point to the subtype notation + const closestPoint = findClosestPerimeterPoint( + allChildPoints, + subtypeGeometry.subtypePoint.x, + subtypeGeometry.subtypePoint.y, + Infinity + ); + + if (closestPoint) { + updatedChildPoints[child.id] = { + side: closestPoint.side, + fieldIndex: closestPoint.fieldIndex + }; + } else { + console.warn('Could not find closest perimeter point for new child:', child.name); + } + }); + + console.log('Updated child points:', updatedChildPoints); + + updateSubtypePerimeterPoints(data.id, { + parentPoint: data.subtypePerimeterPoints.parentPoint, + childPoints: updatedChildPoints + }); + } else { + console.log('All children already have perimeter points'); + } + }, [isMultiChildSubtype, data.subtypePerimeterPoints, subtypeGeometry, updateSubtypePerimeterPoints, data.id, settings.notation, data.endTableIds]); + + // Show subtype waypoints when selected + useEffect(() => { + if (!isMultiChildSubtype) return; + + const isSelected = + selectedElement.element === ObjectType.RELATIONSHIP && + selectedElement.id === data.id; + + setShowSubtypeWaypoints(isSelected); + }, [selectedElement, data.id, isMultiChildSubtype, setShowSubtypeWaypoints]); + + // Add global mouse event listeners for subtype waypoint dragging + useEffect(() => { + if (!isMultiChildSubtype || !isSubtypeDragging) return; + + if (subtypeHandlers.onMouseMove && subtypeHandlers.onMouseUp) { + document.addEventListener("mousemove", subtypeHandlers.onMouseMove); + document.addEventListener("mouseup", subtypeHandlers.onMouseUp); + + return () => { + document.removeEventListener("mousemove", subtypeHandlers.onMouseMove); + document.removeEventListener("mouseup", subtypeHandlers.onMouseUp); + }; + } + }, [isSubtypeDragging, subtypeHandlers, isMultiChildSubtype]); + try { if (data.subtype && settings.notation === Notation.DEFAULT) { return null; @@ -207,32 +470,100 @@ export default function Relationship({ startTable, childTables, parentCenter, + childrenCenter, subtypePoint, isHorizontal, } = subtypeGeometry; + + // Calculate actual perimeter points from stored data + const hasColorStrip = settings.notation === Notation.DEFAULT; + let actualParentPoint = parentCenter; // Default to center + const actualChildPoints = {}; // Store actual child points + + if (data.subtypePerimeterPoints && data.subtypePerimeterPoints.parentPoint) { + // Adjust fieldIndex for top/bottom sides + let parentFieldIndex = data.subtypePerimeterPoints.parentPoint.fieldIndex; + if (data.subtypePerimeterPoints.parentPoint.side === 'top') { + parentFieldIndex = 0; + } else if (data.subtypePerimeterPoints.parentPoint.side === 'bottom') { + parentFieldIndex = startTable.fields.length - 1; + } + + const parentPerim = getFieldPerimeterPoints( + startTable, + parentFieldIndex, + startTable.fields.length, + hasColorStrip + ); + actualParentPoint = parentPerim[data.subtypePerimeterPoints.parentPoint.side] || parentCenter; + } + + // Calculate actual child perimeter points + childTables.forEach(childTable => { + const childCenter = { + x: childTable.x + childTable.width / 2, + y: childTable.y + childTable.height / 2, + }; + actualChildPoints[childTable.id] = childCenter; // Default to center + + if (data.subtypePerimeterPoints && data.subtypePerimeterPoints.childPoints && data.subtypePerimeterPoints.childPoints[childTable.id]) { + // Adjust fieldIndex for top/bottom sides + let childFieldIndex = data.subtypePerimeterPoints.childPoints[childTable.id].fieldIndex; + if (data.subtypePerimeterPoints.childPoints[childTable.id].side === 'top') { + childFieldIndex = 0; + } else if (data.subtypePerimeterPoints.childPoints[childTable.id].side === 'bottom') { + childFieldIndex = childTable.fields.length - 1; + } + + const childPerim = getFieldPerimeterPoints( + childTable, + childFieldIndex, + childTable.fields.length, + hasColorStrip + ); + actualChildPoints[childTable.id] = childPerim[data.subtypePerimeterPoints.childPoints[childTable.id].side] || childCenter; + } + }); + + // Calculate rotation angle in 90-degree increments based on direction to children + let notationAngle = 0; + if (isHorizontal) { + notationAngle = childrenCenter.x > subtypePoint.x ? 180 : 0; + } else { + // Vertical: check if children are below or above subtype point + notationAngle = childrenCenter.y > subtypePoint.y ? 90 : -90; + } + + // Calculate orthogonal path from parent to subtype point + const parentToSubtypePath = calculateOrthogonalPath( + actualParentPoint, + subtypePoint, + parentWaypoints // Empty array if no waypoints + ); + return ( - {/* Single line from parent to subtype point */} - - {/* Invisible line for larger hit area */} - { const childCenter = { x: childTable.x + childTable.width / 2, y: childTable.y + childTable.height / 2, }; - // Calculate the connection point based on relationship orientation - let connectionPointX, connectionPointY; - if (isHorizontal) { - // For horizontal relationships: connect from the right side of the notation - if ( - data.subtype_restriction === SubtypeRestriction.DISJOINT_TOTAL - ) { - connectionPointX = subtypePoint.x + (index % 2 === 0 ? 20 : 25); - } else { - connectionPointX = subtypePoint.x + 20; - } - connectionPointY = subtypePoint.y; - } else { - // For vertical relationships: connect from the bottom of the notation - connectionPointX = subtypePoint.x; - connectionPointY = subtypePoint.y + 20; - } + // Calculate the connection point: + const dx = childCenter.x - subtypePoint.x; + const dy = childCenter.y - subtypePoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Distance from notation symbol to connection point + const notationOffset = data.subtype_restriction === SubtypeRestriction.DISJOINT_TOTAL + ? (index % 2 === 0 ? 35 : 40) + : 35; + + const connectionPoint = { + x: subtypePoint.x + (dx / distance) * notationOffset, + y: subtypePoint.y + (dy / distance) * notationOffset + }; - // Calculate cardinality position for each child - const lineLength = Math.sqrt( - Math.pow(childCenter.x - connectionPointX, 2) + - Math.pow(childCenter.y - connectionPointY, 2) + // Get child ID and actual child perimeter point (already calculated above) + const childId = childTable.id; + const actualChildPoint = actualChildPoints[childId] || childCenter; + + // Calculate orthogonal path from subtype point to child perimeter point + const childSpecificWaypoints = childWaypoints[childId] || []; + const subtypeToChildPath = calculateOrthogonalPath( + connectionPoint, + actualChildPoint, + childSpecificWaypoints // Empty array if no waypoints ); + + // Create a temporary SVG path element to calculate cardinality position + const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + tempPath.setAttribute("d", subtypeToChildPath); + const pathLength = tempPath.getTotalLength(); const cardinalityOffset = 30; - const cardinalityRatio = Math.min(cardinalityOffset / lineLength, 0.8); - const cardinalityX = connectionPointX + (childCenter.x - connectionPointX) * (1 - cardinalityRatio); - const cardinalityY = connectionPointY + (childCenter.y - connectionPointY) * (1 - cardinalityRatio); + const cardinalityPoint = tempPath.getPointAtLength(Math.max(0, pathLength - cardinalityOffset)); return ( - - {/* Invisible line for larger hit area */} - )} + + {/* Render waypoints for this child if shown */} + {showSubtypeWaypoints && childSpecificWaypoints.map((waypoint, wpIndex) => ( + + subtypeHandlers.onWaypointMouseDown?.(e, 'child', wpIndex, childId)} + onDoubleClick={(e) => subtypeHandlers.onWaypointDoubleClick?.(e, 'child', wpIndex, childId)} + /> + + ))} ); })} + {/* Render waypoints for parent-to-subtype line if shown */} + {showSubtypeWaypoints && parentWaypoints.map((waypoint, wpIndex) => ( + + subtypeHandlers.onWaypointMouseDown?.(e, 'parent', wpIndex)} + onDoubleClick={(e) => subtypeHandlers.onWaypointDoubleClick?.(e, 'parent', wpIndex)} + /> + + ))} + {/* Label rendering for multi-child subtype relationships */} {settings.showRelationshipLabels && (data.showLabel !== undefined @@ -748,6 +1120,78 @@ export default function Relationship({ }; } + // Calculate path - use orthogonal routing with stored connection points + let pathString; + let actualStartPoint = null; + let actualEndPoint = null; + + // 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; + + // Adjust fieldIndex for top/bottom sides + let startFieldIndex = data.startPoint.fieldIndex; + if (data.startPoint.side === 'top') { + startFieldIndex = 0; + } else if (data.startPoint.side === 'bottom') { + startFieldIndex = startTable.fields.length - 1; + } + + // Recalculate start point based on stored side and adjusted fieldIndex + const startFieldPerimeter = getFieldPerimeterPoints( + startTable, + startFieldIndex, + startTable.fields.length, + hasColorStrip + ); + actualStartPoint = startFieldPerimeter[data.startPoint.side] || startFieldPerimeter.right; + + // Adjust fieldIndex for top/bottom sides + let endFieldIndex = data.endPoint.fieldIndex || 0; + if (data.endPoint.side === 'top') { + endFieldIndex = 0; + } else if (data.endPoint.side === 'bottom') { + endFieldIndex = endTable.fields.length - 1; + } + + // Recalculate end point based on stored side and adjusted fieldIndex + const endFieldPerimeter = getFieldPerimeterPoints( + endTable, + endFieldIndex, + endTable.fields.length, + hasColorStrip + ); + actualEndPoint = endFieldPerimeter[data.endPoint.side] || endFieldPerimeter.left; + + // 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 +1215,12 @@ export default function Relationship({ {/* Visible path */} {/* Show parent/child notations for all relationships */} {parentFormat && !data.subtype && @@ -793,6 +1233,7 @@ export default function Relationship({ )} {settings.notation === "default" && settings.showCardinality && + !data.subtype && childFormat && childFormat( pathRef, @@ -808,6 +1249,7 @@ export default function Relationship({ vectorInfo, // Pass vector information )} {settings.notation !== "default" && + !data.subtype && childFormat && childFormat( pathRef, @@ -937,6 +1379,7 @@ export default function Relationship({ )} + { + return tables.find((t) => t.id === data.startTableId); + }, [data.startTableId, tables]); + + const endTable = useMemo(() => { + if (data.endTableIds && data.endTableIds.length > 0) { + return tables.find((t) => t.id === data.endTableIds[0]); + } + return tables.find((t) => t.id === data.endTableId); + }, [data.endTableId, data.endTableIds, tables]); + + // 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 = [], + draggedWaypointIndex = -1, + hoveredWaypointIndex = -1, + hoveredVirtualBendIndex = -1, + showWaypoints = false, + virtualBends = [], + handlers = {}, + } = (shouldUseWaypoints && waypointsData) ? waypointsData : {}; + + // Check if this relationship is selected + const isSelected = selectedElement.element === ObjectType.RELATIONSHIP && selectedElement.id === data.id; + + // Calculate actual start and end points from stored side/fieldIndex + // IMPORTANT: All hooks must be called before any conditional returns + const actualStartPoint = useMemo(() => { + if (!data.startPoint || !startTable) return null; + let { side, fieldIndex } = data.startPoint; + if (side === undefined || fieldIndex === undefined) return data.startPoint; + + // Adjust fieldIndex for top/bottom sides to always point to first/last field + if (side === 'top') { + fieldIndex = 0; + } else if (side === 'bottom') { + fieldIndex = startTable.fields.length - 1; + } + + const perimeterPoints = getFieldPerimeterPoints(startTable, fieldIndex, startTable.fields.length); + return perimeterPoints[side] || data.startPoint; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.startPoint, startTable, startTable?.x, startTable?.y, startTable?.width, startTable?.height, startTable?.fields?.length]); + + const actualEndPoint = useMemo(() => { + if (!data.endPoint || !endTable) return null; + let { side, fieldIndex } = data.endPoint; + if (side === undefined || fieldIndex === undefined) return data.endPoint; + + // Adjust fieldIndex for top/bottom sides to always point to first/last field + if (side === 'top') { + fieldIndex = 0; + } else if (side === 'bottom') { + fieldIndex = endTable.fields.length - 1; + } + + const perimeterPoints = getFieldPerimeterPoints(endTable, fieldIndex, endTable.fields.length); + return perimeterPoints[side] || data.endPoint; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.endPoint, endTable, endTable?.x, endTable?.y, endTable?.width, endTable?.height, endTable?.fields?.length]); + + // Get all available perimeter points for start and end tables + const availableStartPoints = useMemo(() => { + if (!startTable) return []; + return getAllTablePerimeterPoints(startTable); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startTable, startTable?.x, startTable?.y, startTable?.width, startTable?.height, startTable?.fields?.length]); + + const availableEndPoints = useMemo(() => { + if (!endTable) return []; + return getAllTablePerimeterPoints(endTable); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [endTable, endTable?.x, endTable?.y, endTable?.width, endTable?.height, endTable?.fields?.length]); + + // Multi-child subtype perimeter points logic - MUST be before early return + const isMultiChildSubtype = data.subtype && data.endTableIds && data.endTableIds.length > 1; + const [isDraggingPerimeter, setIsDraggingPerimeter] = useState(false); + const [draggingPerimeterType, setDraggingPerimeterType] = useState(null); + const [draggingChildId, setDraggingChildId] = useState(null); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + + const childTables = useMemo(() => { + if (!isMultiChildSubtype) return []; + return data.endTableIds.map(id => tables.find(t => t.id === id)).filter(Boolean); + }, [isMultiChildSubtype, data.endTableIds, tables]); + + // Show waypoints when relationship is selected + useEffect(() => { + if (!shouldUseWaypoints) return; + if (isSelected && waypointsData?.setShowWaypoints) { + waypointsData.setShowWaypoints(true); + } + }, [isSelected, shouldUseWaypoints, waypointsData]); + + const handlePerimeterMouseDown = (e, type, childId = null) => { + e.stopPropagation(); + e.preventDefault(); + + // Get canvas element and coordinates using SVG transformation + const svg = e.currentTarget.ownerSVGElement || document.getElementById('diagram') || document.querySelector('svg.canvas'); + if (!svg) return; + + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const canvasPt = pt.matrixTransform(svg.getScreenCTM().inverse()); + + setMousePosition({ x: canvasPt.x, y: canvasPt.y }); + setIsDraggingPerimeter(true); + setDraggingPerimeterType(type); + setDraggingChildId(childId); + }; + + useEffect(() => { + if (!isDraggingPerimeter) return; + + const hasColorStrip = settings.notation === Notation.DEFAULT; + + const handleMove = (e) => { + // Get canvas element and coordinates + const svg = document.getElementById('diagram') || document.querySelector('svg.canvas') || document.querySelector('svg'); + if (!svg) return; + + // Use SVG point transformation for accurate coordinates + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const canvasPt = pt.matrixTransform(svg.getScreenCTM().inverse()); + + setMousePosition({ x: canvasPt.x, y: canvasPt.y }); + + // Update perimeter point IN REAL-TIME during drag (like ConnectionPointHandles) + if (draggingPerimeterType === 'parent') { + const parentPerimeterPoints = getAllTablePerimeterPoints(startTable, hasColorStrip); + const closestPoint = findClosestPerimeterPoint(parentPerimeterPoints, canvasPt.x, canvasPt.y, Infinity); + + if (closestPoint && updateSubtypePerimeterPoints) { + const currentChildPoints = data.subtypePerimeterPoints?.childPoints || {}; + updateSubtypePerimeterPoints(data.id, { + parentPoint: { + side: closestPoint.side, + fieldIndex: closestPoint.fieldIndex + }, + childPoints: currentChildPoints + }); + } + } else if (draggingPerimeterType === 'child' && draggingChildId) { + const childTable = childTables.find(t => t.id === draggingChildId); + if (childTable) { + const childPerimeterPoints = getAllTablePerimeterPoints(childTable, hasColorStrip); + const closestPoint = findClosestPerimeterPoint(childPerimeterPoints, canvasPt.x, canvasPt.y, Infinity); + + if (closestPoint && updateSubtypePerimeterPoints) { + const currentParentPoint = data.subtypePerimeterPoints?.parentPoint || {}; + const currentChildPoints = data.subtypePerimeterPoints?.childPoints || {}; + + updateSubtypePerimeterPoints(data.id, { + parentPoint: currentParentPoint, + childPoints: { + ...currentChildPoints, + [draggingChildId]: { + side: closestPoint.side, + fieldIndex: closestPoint.fieldIndex + } + } + }); + } + } + } + }; + + const handleUp = () => { + setIsDraggingPerimeter(false); + setDraggingPerimeterType(null); + setDraggingChildId(null); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + + return () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + }, [isDraggingPerimeter, draggingPerimeterType, draggingChildId, data.id, data.subtypePerimeterPoints, isMultiChildSubtype, settings.notation, updateSubtypePerimeterPoints, childTables, startTable]); + + // Don't render controls if relationship is not selected + if (!isSelected) { + return null; + } + + // Handlers for changing connection points + const handleStartPointChange = (newPoint) => { + if (!data.startPoint) return; + updateRelationship(data.id, { + startPoint: { + ...data.startPoint, + x: newPoint.x, + y: newPoint.y, + side: newPoint.side, + fieldIndex: newPoint.fieldIndex, + } + }); + }; + + const handleEndPointChange = (newPoint) => { + if (!data.endPoint) return; + updateRelationship(data.id, { + endPoint: { + ...data.endPoint, + x: newPoint.x, + y: newPoint.y, + side: newPoint.side, + fieldIndex: newPoint.fieldIndex, + } + }); + }; + + return ( + + {/* 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 && ( + + )} + + {/* Perimeter points for multi-child subtype relationships */} + {isMultiChildSubtype && isSelected && (() => { + const hasColorStrip = settings.notation === Notation.DEFAULT; + const parentPerimeterPoints = getAllTablePerimeterPoints(startTable, hasColorStrip); + + return ( + <> + {/* Parent table perimeter points */} + {parentPerimeterPoints.map((point, idx) => { + if (!point) return null; + + // Normalize fieldIndex for comparison with saved point + let savedParentFieldIndex = data.subtypePerimeterPoints?.parentPoint?.fieldIndex; + const savedParentSide = data.subtypePerimeterPoints?.parentPoint?.side; + + if (savedParentSide === 'top') { + savedParentFieldIndex = 0; + } else if (savedParentSide === 'bottom') { + savedParentFieldIndex = startTable.fields.length - 1; + } + + const isCurrentPoint = data.subtypePerimeterPoints && + data.subtypePerimeterPoints.parentPoint && + savedParentSide === point.side && + savedParentFieldIndex === point.fieldIndex; + + const closestPoint = isDraggingPerimeter && draggingPerimeterType === 'parent' + ? findClosestPerimeterPoint(parentPerimeterPoints, mousePosition.x, mousePosition.y, Infinity) + : null; + const isHovered = closestPoint && closestPoint.side === point.side && closestPoint.fieldIndex === point.fieldIndex; + + // only show: current point ALWAYS, or all points during drag of THIS parent + const shouldShow = isCurrentPoint || (isDraggingPerimeter && draggingPerimeterType === 'parent'); + if (!shouldShow) return null; + + const handleMouseDown = (e) => { + if (isCurrentPoint) { + handlePerimeterMouseDown(e, 'parent'); + } + }; + + return ( + + {/* Larger invisible hit area for easier clicking */} + + {/* Outer ring - unified style like ConnectionPointHandle */} + + {/* Inner dot - red for parent/start */} + + + ); + })} + + {/* Child tables perimeter points */} + {childTables.map((childTable) => { + const childId = childTable.id; + const childPerimeterPoints = getAllTablePerimeterPoints(childTable, hasColorStrip); + + const savedChildPoint = data.subtypePerimeterPoints?.childPoints?.[childId]; + + // Verificar si el punto guardado existe en los puntos generados + const savedPointExists = savedChildPoint && childPerimeterPoints.some( + p => p && p.side === savedChildPoint.side && p.fieldIndex === savedChildPoint.fieldIndex + ); + + if (savedChildPoint && !savedPointExists) { + console.warn(`Saved point for ${childTable.name} (${childId}) doesn't exist in generated points. Saved:`, savedChildPoint); + console.warn(`Available sides:`, [...new Set(childPerimeterPoints.filter(p => p).map(p => p.side))]); + } + + return childPerimeterPoints.map((point, idx) => { + if (!point) return null; + + const savedChildPoint = data.subtypePerimeterPoints?.childPoints?.[childId]; + + // Normalize fieldIndex for comparison with saved point + let savedChildFieldIndex = savedChildPoint?.fieldIndex; + const savedChildSide = savedChildPoint?.side; + + if (savedChildSide === 'top') { + savedChildFieldIndex = 0; + } else if (savedChildSide === 'bottom') { + savedChildFieldIndex = childTable.fields.length - 1; + } + + // Only show if saved point matches this point, or if dragging this child's perimeter + const isCurrentPoint = savedChildPoint && + point.side === savedChildSide && + point.fieldIndex === savedChildFieldIndex; + + const closestPoint = isDraggingPerimeter && draggingPerimeterType === 'child' && draggingChildId === childId + ? findClosestPerimeterPoint(childPerimeterPoints, mousePosition.x, mousePosition.y, Infinity) + : null; + const isHovered = closestPoint && closestPoint.side === point.side && closestPoint.fieldIndex === point.fieldIndex; + + // Only show: current point ALWAYS, or all points during drag of THIS child + const shouldShow = isCurrentPoint || (isDraggingPerimeter && draggingPerimeterType === 'child' && draggingChildId === childId); + if (!shouldShow) return null; + + const handleMouseDown = (e) => { + if (isCurrentPoint) { + handlePerimeterMouseDown(e, 'child', childId); + } + }; + + return ( + + {/* Larger invisible hit area for easier clicking */} + + {/* Outer ring - unified style like ConnectionPointHandle */} + + {/* Inner dot - blue for child/end */} + + + ); + }); + })} + + ); + })()} + + ); +} diff --git a/src/components/EditorCanvas/Table.jsx b/src/components/EditorCanvas/Table.jsx index e084dc9ff..1f90fe8e4 100644 --- a/src/components/EditorCanvas/Table.jsx +++ b/src/components/EditorCanvas/Table.jsx @@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next"; import { dbToTypes } from "../../data/datatypes"; import { isRtl } from "../../i18n/utils/rtl"; import i18n from "../../i18n/i18n"; +import { getAllTablePerimeterPoints } from "../../utils/perimeterPoints"; //Helper function to calculate text width const getTextWidth = (text, font) => { @@ -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(); @@ -219,22 +221,8 @@ export default function Table(props) { // width={settings.tableWidth} width={tableData.width || settings.tableWidth} height={height} - className="group drop-shadow-lg cursor-move" - onPointerDown={onPointerDown} - onPointerEnter={(e) => { - if (!e.isPrimary) return; - setHoveredTable({ - tableId: tableData.id, - field: -2, - }); - }} - onPointerLeave={(e) => { - if (!e.isPrimary) return; - setHoveredTable({ - tableId: -1, - field: -2, - }); - }} + className="group drop-shadow-lg" + style={{ pointerEvents: 'none' }} >
{tableData.name}
-
+
+ + {/* Perimeter connection points (shown when linking) */} + {isLinking && (() => { + const hasColorStrip = settings.notation === Notation.DEFAULT; + const perimeterPoints = getAllTablePerimeterPoints(tableData, hasColorStrip); + + return perimeterPoints.map((point, idx) => ( + + {/* Outer ring for visibility */} + + {/* Inner dot */} + + + )); + })()} + { if (!e.isPrimary) return; @@ -549,6 +580,8 @@ export default function Table(props) { setHoveredField(-1); }} onPointerDown={(e) => { + // Call the table's onPointerDown handler for dragging + onPointerDown(e); // Required for onPointerLeave to trigger when a touch pointer leaves // https://stackoverflow.com/a/70976017/1137077 e.target.releasePointerCapture(e.pointerId); diff --git a/src/components/EditorCanvas/WaypointHandle.jsx b/src/components/EditorCanvas/WaypointHandle.jsx new file mode 100644 index 000000000..7f175212e --- /dev/null +++ b/src/components/EditorCanvas/WaypointHandle.jsx @@ -0,0 +1,173 @@ +import { darkBgTheme } from "../../data/constants"; + +/** + * Waypoint component - renders a draggable waypoint on a relationship line + * Inspired by WaypointShape from drawio + */ +export default function WaypointHandle({ + x, + y, + index, + isSelected = false, + isHovered = false, + onMouseDown, + onMouseEnter, + onMouseLeave, + onDoubleClick, + onContextMenu, +}) { + const theme = localStorage.getItem("theme"); + const isDark = theme === darkBgTheme; + + const radius = 6; + const strokeWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5; + + const fillColor = isSelected + ? (isDark ? "#60a5fa" : "#3b82f6") + : isHovered + ? (isDark ? "#93c5fd" : "#60a5fa") + : (isDark ? "#e5e7eb" : "#fff"); + + const strokeColor = isDark ? "#374151" : "#1f2937"; + + return ( + onMouseDown && onMouseDown(e, index)} + onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, index)} + onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, index)} + onDoubleClick={(e) => onDoubleClick && onDoubleClick(e, index)} + onContextMenu={(e) => onContextMenu && onContextMenu(e, index)} + > + + {/* Larger invisible hit area for easier interaction */} + + + ); +} + +/** + * Virtual bend component - renders a semi-transparent point where a new waypoint can be added + * Appears at the midpoint of line segments + */ +export function VirtualBend({ + x, + y, + segmentIndex, + isHovered = false, + onMouseDown, + onMouseEnter, + onMouseLeave, +}) { + const theme = localStorage.getItem("theme"); + const isDark = theme === darkBgTheme; + + const radius = 5; + const opacity = isHovered ? 0.8 : 0.4; + + const fillColor = isDark ? "#60a5fa" : "#3b82f6"; + const strokeColor = isDark ? "#374151" : "#1f2937"; + + return ( + onMouseDown && onMouseDown(e, segmentIndex)} + onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, segmentIndex)} + onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, segmentIndex)} + > + + {/* Larger invisible hit area */} + + + ); +} + +/** + * Container component that renders all waypoints for a relationship + */ +export function WaypointContainer({ + waypoints = [], + relationshipId, + selectedWaypointIndex = null, + hoveredWaypointIndex = null, + onWaypointMouseDown, + onWaypointMouseEnter, + onWaypointMouseLeave, + onWaypointDoubleClick, + onWaypointContextMenu, + showVirtualBends = false, + virtualBends = [], + hoveredVirtualBendIndex = null, + onVirtualBendMouseDown, + onVirtualBendMouseEnter, + onVirtualBendMouseLeave, +}) { + return ( + + {/* Render virtual bends first (so they appear below waypoints) */} + {showVirtualBends && virtualBends.map((vb, index) => ( + + ))} + + {/* Render actual waypoints */} + {waypoints.map((wp, index) => ( + + ))} + + ); +} diff --git a/src/components/EditorCanvas/subtypeFormats.jsx b/src/components/EditorCanvas/subtypeFormats.jsx index e544ad10c..ae0e03c24 100644 --- a/src/components/EditorCanvas/subtypeFormats.jsx +++ b/src/components/EditorCanvas/subtypeFormats.jsx @@ -1,4 +1,5 @@ // Helper function to calculate angle for subtype notation based on parent-child relationship +// Returns angles in 90-degree increments (0, 90, 180, 270) for cleaner visual appearance function calculateSubtypeAngle( parentTable, childTable, @@ -19,14 +20,20 @@ function calculateSubtypeAngle( const dx = parentCenter.x - subtypePoint.x; const dy = parentCenter.y - subtypePoint.y; - // Calculate angle in degrees (Math.atan2 returns radians) - let angle = Math.atan2(dy, dx) * (180 / Math.PI); - // The notation is designed so that: - // - The lines/bars extend to the right (positive X direction) - // - The connection point for new subtypes is below (positive Y direction) - // We want the circle to face toward the parent, so we rotate to point the - // right-side elements (lines/bars) toward the parent - return angle; + // Determine if relationship is primarily horizontal or vertical + const isHorizontal = Math.abs(dx) > Math.abs(dy); + + // Return angle in 90-degree increments based on orientation + // The symbol should point AWAY from parent (towards children) + // Note: The symbol design has 0° pointing LEFT and 180° pointing RIGHT + if (isHorizontal) { + // Horizontal: if parent is to the left (dx < 0), children are to the right, so point right (180°) + // if parent is to the right (dx > 0), children are to the left, so point left (0°) + return dx < 0 ? 180 : 0; + } else { + // Vertical: if parent is above (dy > 0), point down (90°), if parent below, point up (-90°) + return dy > 0 ? 90 : -90; + } } export function subDT( diff --git a/src/context/DiagramContext.jsx b/src/context/DiagramContext.jsx index 3c8a3d533..dd84c84a2 100644 --- a/src/context/DiagramContext.jsx +++ b/src/context/DiagramContext.jsx @@ -5,7 +5,7 @@ import { Toast } from "@douyinfe/semi-ui"; import { useTranslation } from "react-i18next"; export const DiagramContext = createContext(null); - +// The undo/redo component must be updated with the waypoint logic. export default function DiagramContextProvider({ children }) { const { t } = useTranslation(); const [database, setDatabase] = useState(DB.GENERIC); @@ -560,7 +560,11 @@ export default function DiagramContextProvider({ children }) { const addRelationship = (relationshipData, autoGeneratedFkFields, childTableIdForFks, addToHistory = true) => { if (addToHistory) { const newRelationshipId = relationships.reduce((maxId, r) => Math.max(maxId, typeof r.id === 'number' ? r.id : -1), -1) + 1; - const newRelationshipWithId = { ...relationshipData, id: newRelationshipId }; + const newRelationshipWithId = { + ...relationshipData, + id: newRelationshipId, + waypoints: relationshipData.waypoints || [] // Initialize waypoints array + }; setRelationships((prev) => [...prev, newRelationshipWithId]); pushUndo({ @@ -574,7 +578,10 @@ export default function DiagramContextProvider({ children }) { message: t("add_relationship"), }); } else { - let relationshipToInsert = { ...relationshipData }; + let relationshipToInsert = { + ...relationshipData, + waypoints: relationshipData.waypoints || [] // Initialize waypoints array + }; if (Array.isArray(autoGeneratedFkFields) && autoGeneratedFkFields.length > 0 && typeof childTableIdForFks === 'number') { const childTable = tables.find((t) => t.id === childTableIdForFks); @@ -728,6 +735,10 @@ export default function DiagramContextProvider({ children }) { else if (updatedValues.subtype === false) { finalUpdatedValues.relationshipType = 'one_to_one'; } + // Ensure waypoints array exists if not present + if (!finalUpdatedValues.waypoints && !rel.waypoints) { + finalUpdatedValues.waypoints = []; + } return { ...rel, ...finalUpdatedValues }; } return rel; @@ -735,6 +746,79 @@ export default function DiagramContextProvider({ children }) { ); }; + const updateRelationshipWaypoints = (id, waypoints) => { + updateRelationship(id, { waypoints: waypoints || [] }); + }; + + // Update waypoints for subtype relationships (multi-child) + const updateSubtypeWaypoints = (id, subtypeWaypoints) => { + updateRelationship(id, { subtypeWaypoints: subtypeWaypoints || { parentToSubtype: [], subtypeToChildren: {} } }); + }; + + // Update perimeter points for subtype relationships (multi-child) + const updateSubtypePerimeterPoints = (id, perimeterPoints) => { + updateRelationship(id, { subtypePerimeterPoints: perimeterPoints }); + }; + + // Adjust waypoints when a table moves + const adjustWaypointsForTableMove = (tableId, deltaX, deltaY) => { + // Update all relationships in a single state update + setRelationships((prevRelationships) => { + return prevRelationships.map(rel => { + // Check if this relationship is connected to the moved table + const isConnected = + rel.startTableId === tableId || + rel.endTableId === tableId || + (rel.endTableIds && rel.endTableIds.includes(tableId)); + + if (!isConnected) return rel; + + const updatedRel = { ...rel }; + + // Adjust regular waypoints + if (rel.waypoints && rel.waypoints.length > 0) { + updatedRel.waypoints = rel.waypoints.map(waypoint => ({ + x: waypoint.x + deltaX, + y: waypoint.y + deltaY + })); + } + + // Adjust subtype waypoints for multi-child relationships + if (rel.subtypeWaypoints) { + const adjustedSubtypeWaypoints = { ...rel.subtypeWaypoints }; + + // Adjust parent-to-subtype waypoints if parent table moved + if (rel.startTableId === tableId && adjustedSubtypeWaypoints.parentToSubtype) { + adjustedSubtypeWaypoints.parentToSubtype = adjustedSubtypeWaypoints.parentToSubtype.map(waypoint => ({ + x: waypoint.x + deltaX, + y: waypoint.y + deltaY + })); + } + + // Adjust child waypoints if any child table moved + if (rel.endTableIds && rel.endTableIds.includes(tableId) && adjustedSubtypeWaypoints.subtypeToChildren) { + adjustedSubtypeWaypoints.subtypeToChildren = Object.fromEntries( + Object.entries(adjustedSubtypeWaypoints.subtypeToChildren).map(([childId, waypoints]) => { + // Only adjust waypoints for the moved child table + if (parseInt(childId) === tableId) { + return [childId, waypoints.map(waypoint => ({ + x: waypoint.x + deltaX, + y: waypoint.y + deltaY + }))]; + } + return [childId, waypoints]; + }) + ); + } + + updatedRel.subtypeWaypoints = adjustedSubtypeWaypoints; + } + + return updatedRel; + }); + }); + }; + // Subtype relationship management functions const addChildToSubtype = (relationshipId, childTableId, shouldAddToUndoStack = true) => { // Critical validation: Prevent infinite loops @@ -1023,6 +1107,10 @@ export default function DiagramContextProvider({ children }) { addRelationship, deleteRelationship, updateRelationship, + updateRelationshipWaypoints, + updateSubtypeWaypoints, + updateSubtypePerimeterPoints, + adjustWaypointsForTableMove, addChildToSubtype, removeChildFromSubtype, restoreFieldsToTable, diff --git a/src/hooks/index.js b/src/hooks/index.js index e21eee159..f56f8b98f 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -12,3 +12,5 @@ export { default as useTransform } from "./useTransform"; export { default as useTypes } from "./useTypes"; export { default as useUndoRedo } from "./useUndoRedo"; export { default as useEnums } from "./useEnums"; +export { useWaypointEditor, useConnectionPoints } from "./useWaypoints"; +export { useSubtypeWaypoints } from "./useSubtypeWaypoints"; diff --git a/src/hooks/useSubtypeWaypoints.js b/src/hooks/useSubtypeWaypoints.js new file mode 100644 index 000000000..4d0d115b1 --- /dev/null +++ b/src/hooks/useSubtypeWaypoints.js @@ -0,0 +1,211 @@ +import { useState, useCallback, useRef, useEffect } from "react"; + +/** + * Custom hook for managing waypoints in multi-child subtype relationships + * Handles separate waypoint arrays for: + * - Parent to subtype notation point + * - Each child line from subtype notation to child table + */ +export function useSubtypeWaypoints(relationship, tables, onUpdate) { + const [parentWaypoints, setParentWaypoints] = useState([]); + const [childWaypoints, setChildWaypoints] = useState({}); // { [childId]: [...waypoints] } + const [isDragging, setIsDragging] = useState(false); + const [draggedWaypoint, setDraggedWaypoint] = useState(null); // { type: 'parent'|'child', childId?, index } + const [showWaypoints, setShowWaypoints] = useState(false); + + const dragStartPos = useRef({ x: 0, y: 0 }); + const waypointStartPos = useRef({ x: 0, y: 0 }); + + // Refs to hold current waypoints state for dragging + const parentWaypointsRef = useRef(parentWaypoints); + const childWaypointsRef = useRef(childWaypoints); + + // Update refs when state changes + useEffect(() => { + parentWaypointsRef.current = parentWaypoints; + childWaypointsRef.current = childWaypoints; + }, [parentWaypoints, childWaypoints]); + + // Initialize waypoints from relationship data + useEffect(() => { + if (relationship && relationship.subtypeWaypoints) { + const { parentToSubtype = [], subtypeToChildren = {} } = relationship.subtypeWaypoints; + setParentWaypoints(parentToSubtype); + setChildWaypoints(subtypeToChildren); + } else { + setParentWaypoints([]); + setChildWaypoints({}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [relationship?.id, relationship?.subtypeWaypoints]); + + // Add waypoint to parent line + const addParentWaypoint = useCallback((x, y, insertIndex) => { + setParentWaypoints(prev => { + const newWaypoints = [...prev]; + newWaypoints.splice(insertIndex, 0, { x, y }); + return newWaypoints; + }); + }, []); + + // Add waypoint to child line + const addChildWaypoint = useCallback((childId, x, y, insertIndex) => { + setChildWaypoints(prev => { + const childWaypoints = prev[childId] || []; + const newChildWaypoints = [...childWaypoints]; + newChildWaypoints.splice(insertIndex, 0, { x, y }); + return { + ...prev, + [childId]: newChildWaypoints, + }; + }); + }, []); + + // Move waypoint + const moveWaypoint = useCallback((type, index, x, y, childId = null) => { + if (type === 'parent') { + setParentWaypoints(prev => { + const newWaypoints = [...prev]; + newWaypoints[index] = { x, y }; + return newWaypoints; + }); + } else if (type === 'child' && childId) { + setChildWaypoints(prev => { + const childWaypoints = prev[childId] || []; + const newChildWaypoints = [...childWaypoints]; + newChildWaypoints[index] = { x, y }; + return { + ...prev, + [childId]: newChildWaypoints, + }; + }); + } + }, []); + + // Remove waypoint + const removeWaypoint = useCallback((type, index, childId = null) => { + if (type === 'parent') { + setParentWaypoints(prev => prev.filter((_, i) => i !== index)); + } else if (type === 'child' && childId) { + setChildWaypoints(prev => { + const childWaypoints = prev[childId] || []; + return { + ...prev, + [childId]: childWaypoints.filter((_, i) => i !== index), + }; + }); + } + }, []); + + // Handle waypoint mouse down (start drag) + const handleWaypointMouseDown = useCallback((e, type, index, childId = null) => { + e.stopPropagation(); + e.preventDefault(); + + setIsDragging(true); + setDraggedWaypoint({ type, index, childId }); + + dragStartPos.current = { x: e.clientX, y: e.clientY }; + + // Get current waypoint position using refs + let waypoint; + if (type === 'parent') { + waypoint = parentWaypointsRef.current[index]; + } else if (type === 'child' && childId !== null) { + waypoint = (childWaypointsRef.current[childId] || [])[index]; + } + + if (waypoint) { + waypointStartPos.current = { x: waypoint.x, y: waypoint.y }; + } + }, []); + + // Handle mouse move during drag + const handleMouseMove = useCallback((e) => { + if (!isDragging || !draggedWaypoint) return; + + const dx = e.clientX - dragStartPos.current.x; + const dy = e.clientY - dragStartPos.current.y; + + const newX = waypointStartPos.current.x + dx; + const newY = waypointStartPos.current.y + dy; + + moveWaypoint(draggedWaypoint.type, draggedWaypoint.index, newX, newY, draggedWaypoint.childId); + }, [isDragging, draggedWaypoint, moveWaypoint]); + + // Handle mouse up (end drag) + const handleMouseUp = useCallback(() => { + if (isDragging && onUpdate) { + // Save the updated waypoints using refs to get most current values + onUpdate({ + parentToSubtype: parentWaypointsRef.current, + subtypeToChildren: childWaypointsRef.current, + }); + } + + setIsDragging(false); + setDraggedWaypoint(null); + }, [isDragging, onUpdate]); + + // Handle double-click to remove waypoint + const handleWaypointDoubleClick = useCallback((e, type, index, childId = null) => { + e.stopPropagation(); + e.preventDefault(); + + removeWaypoint(type, index, childId); + + if (onUpdate) { + // Use refs to get updated values after removal + setTimeout(() => { + onUpdate({ + parentToSubtype: parentWaypointsRef.current, + subtypeToChildren: childWaypointsRef.current, + }); + }, 0); + } + }, [removeWaypoint, onUpdate]); + + // Handle virtual bend click to add waypoint + const handleVirtualBendClick = useCallback((e, type, segmentIndex, childId = null) => { + e.stopPropagation(); + e.preventDefault(); + + const rect = e.currentTarget.ownerSVGElement.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (type === 'parent') { + addParentWaypoint(x, y, segmentIndex); + } else if (type === 'child' && childId) { + addChildWaypoint(childId, x, y, segmentIndex); + } + + if (onUpdate) { + const updatedParent = type === 'parent' ? [...parentWaypoints.slice(0, segmentIndex), { x, y }, ...parentWaypoints.slice(segmentIndex)] : parentWaypoints; + const updatedChildren = type === 'child' ? { + ...childWaypoints, + [childId]: [...(childWaypoints[childId] || []).slice(0, segmentIndex), { x, y }, ...(childWaypoints[childId] || []).slice(segmentIndex)], + } : childWaypoints; + + onUpdate({ + parentToSubtype: updatedParent, + subtypeToChildren: updatedChildren, + }); + } + }, [parentWaypoints, childWaypoints, addParentWaypoint, addChildWaypoint, onUpdate]); + + return { + parentWaypoints, + childWaypoints, + showWaypoints, + setShowWaypoints, + isDragging, + handlers: { + onWaypointMouseDown: handleWaypointMouseDown, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + onWaypointDoubleClick: handleWaypointDoubleClick, + onVirtualBendClick: handleVirtualBendClick, + }, + }; +} diff --git a/src/hooks/useWaypoints.js b/src/hooks/useWaypoints.js new file mode 100644 index 000000000..3e93a94c2 --- /dev/null +++ b/src/hooks/useWaypoints.js @@ -0,0 +1,199 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { EdgeHandler } from "../utils/edgeHandler"; +import { getConnectionPoints } from "../utils/perimeter"; + +/** + * Custom hook for managing waypoint editing on relationships + * Handles drag operations, virtual bends, and waypoint manipulation + */ +export function useWaypointEditor(relationship, tables, onUpdate) { + const [edgeHandler, setEdgeHandler] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [draggedWaypointIndex, setDraggedWaypointIndex] = useState(null); + const [hoveredWaypointIndex, setHoveredWaypointIndex] = useState(null); + const [hoveredVirtualBendIndex, setHoveredVirtualBendIndex] = useState(null); + const [showWaypoints, setShowWaypoints] = useState(false); + + const dragStartPos = useRef({ x: 0, y: 0 }); + const waypointStartPos = useRef({ x: 0, y: 0 }); + + // Initialize edge handler when relationship or tables change + useEffect(() => { + if (relationship && tables) { + const handler = new EdgeHandler(relationship, tables, { + snapToGrid: true, + gridSize: 10, + waypointRadius: 6, + virtualBendEnabled: true, + tolerance: 10, + }); + setEdgeHandler(handler); + } + }, [relationship?.id, relationship, tables]); + + // Handle waypoint mouse down (start drag) + const handleWaypointMouseDown = useCallback((e, index) => { + e.stopPropagation(); + e.preventDefault(); + + if (!edgeHandler) return; + + const waypoint = edgeHandler.waypoints[index]; + if (!waypoint) return; + + setIsDragging(true); + setDraggedWaypointIndex(index); + + dragStartPos.current = { x: e.clientX, y: e.clientY }; + waypointStartPos.current = { x: waypoint.x, y: waypoint.y }; + }, [edgeHandler]); + + // Handle mouse move during drag + const handleMouseMove = useCallback((e) => { + if (!isDragging || draggedWaypointIndex === null || !edgeHandler) return; + + const dx = e.clientX - dragStartPos.current.x; + const dy = e.clientY - dragStartPos.current.y; + + const newX = waypointStartPos.current.x + dx; + const newY = waypointStartPos.current.y + dy; + + edgeHandler.moveWaypoint(draggedWaypointIndex, newX, newY); + + // Trigger re-render by updating a state + setEdgeHandler({ ...edgeHandler }); + }, [isDragging, draggedWaypointIndex, edgeHandler]); + + // Handle mouse up (end drag) + const handleMouseUp = useCallback(() => { + if (isDragging && edgeHandler && onUpdate) { + // Save the updated waypoints + const waypoints = edgeHandler.getWaypointsData(); + onUpdate(waypoints); + } + + setIsDragging(false); + setDraggedWaypointIndex(null); + }, [isDragging, edgeHandler, onUpdate]); + + // Handle double-click to remove waypoint + const handleWaypointDoubleClick = useCallback((e, index) => { + e.stopPropagation(); + e.preventDefault(); + + if (!edgeHandler || !onUpdate) return; + + edgeHandler.removeWaypoint(index); + const waypoints = edgeHandler.getWaypointsData(); + onUpdate(waypoints); + + // Trigger re-render + setEdgeHandler({ ...edgeHandler }); + }, [edgeHandler, onUpdate]); + + // Handle virtual bend click to add waypoint + const handleVirtualBendMouseDown = useCallback((e, segmentIndex) => { + e.stopPropagation(); + e.preventDefault(); + + if (!edgeHandler || !onUpdate) return; + + const rect = e.currentTarget.ownerSVGElement.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Add waypoint at the virtual bend position + edgeHandler.addWaypoint(x, y, segmentIndex); + const waypoints = edgeHandler.getWaypointsData(); + onUpdate(waypoints); + + // Trigger re-render + setEdgeHandler({ ...edgeHandler }); + }, [edgeHandler, onUpdate]); + + // Hover handlers + const handleWaypointMouseEnter = useCallback((e, index) => { + setHoveredWaypointIndex(index); + }, []); + + const handleWaypointMouseLeave = useCallback(() => { + setHoveredWaypointIndex(null); + }, []); + + const handleVirtualBendMouseEnter = useCallback((e, index) => { + setHoveredVirtualBendIndex(index); + }, []); + + const handleVirtualBendMouseLeave = useCallback(() => { + setHoveredVirtualBendIndex(null); + }, []); + + // Get virtual bend positions (midpoints of segments) + const getVirtualBends = useCallback(() => { + if (!edgeHandler || !showWaypoints) return []; + + const segments = edgeHandler.getSegments(); + const virtualBends = []; + + // Skip first and last segment (connected to tables) + for (let i = 1; i < segments.length - 1; i++) { + const segment = segments[i]; + virtualBends.push({ + x: (segment.start.x + segment.end.x) / 2, + y: (segment.start.y + segment.end.y) / 2, + segmentIndex: i, + }); + } + + return virtualBends; + }, [edgeHandler, showWaypoints]); + + return { + edgeHandler, + waypoints: edgeHandler?.waypoints || [], + isDragging, + draggedWaypointIndex, + hoveredWaypointIndex, + hoveredVirtualBendIndex, + showWaypoints, + setShowWaypoints, + virtualBends: getVirtualBends(), + handlers: { + onWaypointMouseDown: handleWaypointMouseDown, + onWaypointMouseEnter: handleWaypointMouseEnter, + onWaypointMouseLeave: handleWaypointMouseLeave, + onWaypointDoubleClick: handleWaypointDoubleClick, + onVirtualBendMouseDown: handleVirtualBendMouseDown, + onVirtualBendMouseEnter: handleVirtualBendMouseEnter, + onVirtualBendMouseLeave: handleVirtualBendMouseLeave, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + }, + }; +} + +/** + * Hook for calculating connection points with waypoints + */ +export function useConnectionPoints(startTable, endTable, waypoints = []) { + return useCallback(() => { + if (!startTable || !endTable) { + return { startPoint: null, endPoint: null, points: [] }; + } + + const { startPoint, endPoint } = getConnectionPoints( + startTable, + endTable, + waypoints + ); + + // Build complete point array + const points = [startPoint]; + waypoints.forEach(wp => { + points.push({ x: wp.x, y: wp.y }); + }); + points.push(endPoint); + + return { startPoint, endPoint, points }; + }, [startTable, endTable, waypoints]); +} diff --git a/src/utils/edgeHandler.js b/src/utils/edgeHandler.js new file mode 100644 index 000000000..1f09b95d4 --- /dev/null +++ b/src/utils/edgeHandler.js @@ -0,0 +1,397 @@ +/** + * Waypoint and edge handler utilities inspired by mxEdgeHandler from drawio + * Handles creation, editing, and interaction with waypoints on relationships + */ + +import { Point, distance, isPointNearLine, snapToGrid } from './perimeter'; + +/** + * Waypoint class representing a breakpoint on a relationship line + */ +export class Waypoint { + constructor(x, y, id = null) { + this.x = x; + this.y = y; + this.id = id || `wp_${Date.now()}_${Math.random()}`; + } + + clone() { + return new Waypoint(this.x, this.y, this.id); + } + + toObject() { + return { x: this.x, y: this.y, id: this.id }; + } + + static fromObject(obj) { + return new Waypoint(obj.x, obj.y, obj.id); + } +} + +/** + * Edge handler for managing waypoint interaction + */ +export class EdgeHandler { + constructor(relationship, tables, options = {}) { + this.relationship = relationship; + this.tables = tables; + this.options = { + snapToGrid: true, + gridSize: 10, + waypointRadius: 6, + virtualBendEnabled: true, + tolerance: 10, + ...options, + }; + + this.waypoints = this.loadWaypoints(); + this.selectedWaypoint = null; + this.hoveredWaypoint = null; + this.hoveredVirtualBend = null; + } + + /** + * Load waypoints from relationship data + */ + loadWaypoints() { + if (!this.relationship.waypoints || !Array.isArray(this.relationship.waypoints)) { + return []; + } + return this.relationship.waypoints.map(wp => Waypoint.fromObject(wp)); + } + + /** + * Get absolute points for the edge (start, waypoints, end) + */ + getAbsolutePoints() { + const startTable = this.tables[this.relationship.startTableId]; + const endTable = this.tables[this.relationship.endTableId]; + + if (!startTable || !endTable) { + return []; + } + + const points = []; + + // Start point (table center for now, will be refined with perimeter calc) + points.push(new Point( + startTable.x + (startTable.width || 200) / 2, + startTable.y + (startTable.height || 100) / 2 + )); + + // Waypoints + this.waypoints.forEach(wp => { + points.push(new Point(wp.x, wp.y)); + }); + + // End point + points.push(new Point( + endTable.x + (endTable.width || 200) / 2, + endTable.y + (endTable.height || 100) / 2 + )); + + return points; + } + + /** + * Get all segments of the edge + * Returns array of { start, end } segment objects + */ + getSegments() { + const points = this.getAbsolutePoints(); + const segments = []; + + for (let i = 0; i < points.length - 1; i++) { + segments.push({ + start: points[i], + end: points[i + 1], + startIndex: i, + endIndex: i + 1, + }); + } + + return segments; + } + + /** + * Find waypoint at given coordinates + */ + findWaypointAt(x, y) { + const point = new Point(x, y); + const radius = this.options.waypointRadius; + + for (let i = 0; i < this.waypoints.length; i++) { + const wp = this.waypoints[i]; + if (distance(point, new Point(wp.x, wp.y)) <= radius) { + return { waypoint: wp, index: i }; + } + } + + return null; + } + + /** + * Find virtual bend location (midpoint of segment where new waypoint can be added) + */ + findVirtualBendAt(x, y) { + if (!this.options.virtualBendEnabled) { + return null; + } + + const point = new Point(x, y); + const segments = this.getSegments(); + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // Skip first and last segment (connected to tables) for now + // Can be enabled later if needed + if (i === 0 || i === segments.length - 1) { + continue; + } + + const midpoint = new Point( + (segment.start.x + segment.end.x) / 2, + (segment.start.y + segment.end.y) / 2 + ); + + if (distance(point, midpoint) <= this.options.waypointRadius) { + return { + midpoint, + segmentIndex: i, + waypointIndex: i, // Insert after this index + }; + } + } + + return null; + } + + /** + * Add a waypoint at the given position + * If insertIndex is provided, insert at that position, otherwise add to end + */ + addWaypoint(x, y, insertIndex = null) { + const point = this.options.snapToGrid + ? snapToGrid(new Point(x, y), this.options.gridSize) + : new Point(x, y); + + const waypoint = new Waypoint(point.x, point.y); + + if (insertIndex !== null && insertIndex >= 0 && insertIndex <= this.waypoints.length) { + this.waypoints.splice(insertIndex, 0, waypoint); + } else { + this.waypoints.push(waypoint); + } + + return waypoint; + } + + /** + * Remove waypoint at index + */ + removeWaypoint(index) { + if (index >= 0 && index < this.waypoints.length) { + const removed = this.waypoints.splice(index, 1); + return removed[0]; + } + return null; + } + + /** + * Move waypoint to new position + */ + moveWaypoint(index, x, y) { + if (index >= 0 && index < this.waypoints.length) { + const point = this.options.snapToGrid + ? snapToGrid(new Point(x, y), this.options.gridSize) + : new Point(x, y); + + this.waypoints[index].x = point.x; + this.waypoints[index].y = point.y; + + return this.waypoints[index]; + } + return null; + } + + /** + * Check if point is near any segment of the edge + */ + isPointNearEdge(x, y) { + const point = new Point(x, y); + const segments = this.getSegments(); + + for (const segment of segments) { + if (isPointNearLine(point, segment.start, segment.end, this.options.tolerance)) { + return true; + } + } + + return false; + } + + /** + * Get waypoints as plain objects for storage + */ + getWaypointsData() { + return this.waypoints.map(wp => wp.toObject()); + } + + /** + * Clear all waypoints + */ + clearWaypoints() { + this.waypoints = []; + } + + /** + * Detect if a click should add a waypoint on a virtual bend + */ + handleClick(x, y) { + // Check if clicking on existing waypoint + const existingWp = this.findWaypointAt(x, y); + if (existingWp) { + return { type: 'select-waypoint', ...existingWp }; + } + + // Check if clicking on virtual bend (to add new waypoint) + const virtualBend = this.findVirtualBendAt(x, y); + if (virtualBend) { + const newWp = this.addWaypoint(x, y, virtualBend.waypointIndex); + return { type: 'add-waypoint', waypoint: newWp, index: virtualBend.waypointIndex }; + } + + // Check if clicking near edge (for selection) + if (this.isPointNearEdge(x, y)) { + return { type: 'select-edge' }; + } + + return { type: 'none' }; + } + + /** + * Handle double-click to remove waypoint + */ + handleDoubleClick(x, y) { + const wp = this.findWaypointAt(x, y); + if (wp) { + this.removeWaypoint(wp.index); + return { type: 'remove-waypoint', ...wp }; + } + return { type: 'none' }; + } +} + +/** + * Connection handler for creating new relationships with waypoints + * Inspired by mxConnectionHandler from drawio + */ +export class ConnectionHandler { + constructor(options = {}) { + this.options = { + snapToGrid: true, + gridSize: 10, + waypointsEnabled: true, + ...options, + }; + + this.waypoints = []; + this.isConnecting = false; + this.sourceTable = null; + this.currentPoint = null; + } + + /** + * Start creating a connection from a table + */ + start(table, x, y) { + this.isConnecting = true; + this.sourceTable = table; + this.waypoints = []; + this.currentPoint = new Point(x, y); + } + + /** + * Add a waypoint during connection creation + */ + addWaypoint(x, y) { + if (!this.options.waypointsEnabled || !this.isConnecting) { + return null; + } + + const point = this.options.snapToGrid + ? snapToGrid(new Point(x, y), this.options.gridSize) + : new Point(x, y); + + const waypoint = new Waypoint(point.x, point.y); + this.waypoints.push(waypoint); + + return waypoint; + } + + /** + * Update current mouse position during connection creation + */ + updatePosition(x, y) { + this.currentPoint = new Point(x, y); + } + + /** + * Complete the connection + */ + complete() { + const waypoints = this.getWaypointsData(); + this.reset(); + return waypoints; + } + + /** + * Cancel connection creation + */ + cancel() { + this.reset(); + } + + /** + * Reset handler state + */ + reset() { + this.isConnecting = false; + this.sourceTable = null; + this.waypoints = []; + this.currentPoint = null; + } + + /** + * Get waypoints as plain objects + */ + getWaypointsData() { + return this.waypoints.map(wp => wp.toObject()); + } + + /** + * Get preview points for rendering + */ + getPreviewPoints(startTable, endX, endY) { + const points = []; + + // Start point + if (startTable) { + points.push(new Point( + startTable.x + (startTable.width || 200) / 2, + startTable.y + (startTable.height || 100) / 2 + )); + } + + // Waypoints + this.waypoints.forEach(wp => { + points.push(new Point(wp.x, wp.y)); + }); + + // Current end point + points.push(new Point(endX, endY)); + + return points; + } +} diff --git a/src/utils/perimeter.js b/src/utils/perimeter.js new file mode 100644 index 000000000..638f956da --- /dev/null +++ b/src/utils/perimeter.js @@ -0,0 +1,216 @@ +/** + * Perimeter calculation utilities inspired by mxGraph/drawio + * Used to calculate anchor points on table boundaries for relationship connections + */ + +/** + * Point class for coordinates + */ +export class Point { + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + + clone() { + return new Point(this.x, this.y); + } +} + +/** + * Rectangle bounds class + */ +export class Bounds { + constructor(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + getCenterX() { + return this.x + this.width / 2; + } + + getCenterY() { + return this.y + this.height / 2; + } + + getCenter() { + return new Point(this.getCenterX(), this.getCenterY()); + } + + contains(x, y) { + return x >= this.x && x <= this.x + this.width && + y >= this.y && y <= this.y + this.height; + } +} + +/** + * Rectangle perimeter calculation + * Calculates the intersection point on a rectangle's perimeter given: + * - bounds: Rectangle bounds (x, y, width, height) + * - next: The next point along the line (determines which edge to intersect) + * - orthogonal: Whether to use orthogonal routing + * + * Based on mxPerimeter.RectanglePerimeter from drawio + */ +export function rectanglePerimeter(bounds, next, orthogonal = false) { + const cx = bounds.getCenterX(); + const cy = bounds.getCenterY(); + const dx = next.x - cx; + const dy = next.y - cy; + + const alpha = Math.atan2(dy, dx); + const p = new Point(0, 0); + const pi = Math.PI; + const pi2 = Math.PI / 2; + const beta = pi2 - alpha; + const t = Math.atan2(bounds.height, bounds.width); + + // Determine which edge the line intersects + if (alpha < -pi + t || alpha > pi - t) { + // Left edge + p.x = bounds.x; + p.y = cy - (bounds.width * Math.tan(alpha)) / 2; + } else if (alpha < -t) { + // Top edge + p.y = bounds.y; + p.x = cx - (bounds.height * Math.tan(beta)) / 2; + } else if (alpha < t) { + // Right edge + p.x = bounds.x + bounds.width; + p.y = cy + (bounds.width * Math.tan(alpha)) / 2; + } else { + // Bottom edge + p.y = bounds.y + bounds.height; + p.x = cx + (bounds.height * Math.tan(beta)) / 2; + } + + // Apply orthogonal constraints + if (orthogonal) { + if (next.x >= bounds.x && next.x <= bounds.x + bounds.width) { + p.x = next.x; + } else if (next.y >= bounds.y && next.y <= bounds.y + bounds.height) { + p.y = next.y; + } + + if (next.x < bounds.x) { + p.x = bounds.x; + } else if (next.x > bounds.x + bounds.width) { + p.x = bounds.x + bounds.width; + } + + if (next.y < bounds.y) { + p.y = bounds.y; + } else if (next.y > bounds.y + bounds.height) { + p.y = bounds.y + bounds.height; + } + } + + return p; +} + +/** + * Get perimeter point for a table given a target point + * This is the main function to use for calculating where a relationship line + * should connect to a table's edge + */ +export function getTablePerimeterPoint(table, targetPoint, orthogonal = false) { + const bounds = new Bounds( + table.x, + table.y, + table.width || 200, + table.height || 100 + ); + + return rectanglePerimeter(bounds, targetPoint, orthogonal); +} + +/** + * Calculate connection points for a relationship between two tables + * Returns { startPoint, endPoint } representing where the line should connect + */ +export function getConnectionPoints(startTable, endTable, waypoints = []) { + // Get next point after start (first waypoint or end table center) + const endCenter = new Point( + endTable.x + (endTable.width || 200) / 2, + endTable.y + (endTable.height || 100) / 2 + ); + + const nextAfterStart = waypoints.length > 0 + ? new Point(waypoints[0].x, waypoints[0].y) + : endCenter; + + // Get previous point before end (last waypoint or start table center) + const startCenter = new Point( + startTable.x + (startTable.width || 200) / 2, + startTable.y + (startTable.height || 100) / 2 + ); + + const prevBeforeEnd = waypoints.length > 0 + ? new Point(waypoints[waypoints.length - 1].x, waypoints[waypoints.length - 1].y) + : startCenter; + + return { + startPoint: getTablePerimeterPoint(startTable, nextAfterStart), + endPoint: getTablePerimeterPoint(endTable, prevBeforeEnd), + }; +} + +/** + * Calculate distance between two points + */ +export function distance(p1, p2) { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** + * Check if a point is near a line segment + * Returns true if point is within tolerance distance of the line + */ +export function isPointNearLine(point, lineStart, lineEnd, tolerance = 10) { + const A = point.x - lineStart.x; + const B = point.y - lineStart.y; + const C = lineEnd.x - lineStart.x; + const D = lineEnd.y - lineStart.y; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) { + param = dot / lenSq; + } + + let xx, yy; + + if (param < 0) { + xx = lineStart.x; + yy = lineStart.y; + } else if (param > 1) { + xx = lineEnd.x; + yy = lineEnd.y; + } else { + xx = lineStart.x + param * C; + yy = lineStart.y + param * D; + } + + const dx = point.x - xx; + const dy = point.y - yy; + const dist = Math.sqrt(dx * dx + dy * dy); + + return dist <= tolerance; +} + +/** + * Snap point to grid + */ +export function snapToGrid(point, gridSize = 10) { + return new Point( + Math.round(point.x / gridSize) * gridSize, + Math.round(point.y / gridSize) * gridSize + ); +} diff --git a/src/utils/perimeterPoints.js b/src/utils/perimeterPoints.js new file mode 100644 index 000000000..a5a8bef13 --- /dev/null +++ b/src/utils/perimeterPoints.js @@ -0,0 +1,263 @@ +/** + * Perimeter Points System for Table Relationships + * + * This module calculates perimeter connection points for each field/row in a table. + * Each field gets 4 connection points (top, right, bottom, left) on the table's perimeter. + */ + +import { + tableHeaderHeight, + tableFieldHeight, + tableColorStripHeight +} from '../data/constants'; + +/** + * Calculate perimeter points for a specific field/row + * @param {Object} table - Table object with x, y, width, height + * @param {number} fieldIndex - Index of the field (0-based) + * @param {number} totalFields - Total number of fields in the table + * @param {boolean} hasColorStrip - Whether the table has a color strip (notation dependent) + * @returns {Object} Object with top, right, bottom, left points + */ +export function getFieldPerimeterPoints(table, fieldIndex, totalFields, hasColorStrip = false) { + const effectiveColorStripHeight = hasColorStrip ? tableColorStripHeight : 0; + const headerHeight = tableHeaderHeight + effectiveColorStripHeight; + + // Calculate the Y position of the field's center + const fieldCenterY = table.y + headerHeight + (fieldIndex * tableFieldHeight) + (tableFieldHeight / 2); + + // Table bounds + const tableLeft = table.x; + const tableRight = table.x + table.width; + const tableTop = table.y + headerHeight; // Top of first field (after header) + const tableBottom = table.y + headerHeight + (totalFields * tableFieldHeight); + const tableCenterX = table.x + (table.width / 2); + + return { + // Left side point (at field's vertical center) + left: { + x: tableLeft, + y: fieldCenterY, + side: 'left', + fieldIndex + }, + // Right side point (at field's vertical center) + right: { + x: tableRight, + y: fieldCenterY, + side: 'right', + fieldIndex + }, + // Top side point (at table's horizontal center, but only if this is the first field) + top: fieldIndex === 0 ? { + x: tableCenterX, + y: tableTop, + side: 'top', + fieldIndex + } : null, + // Bottom side point (at table's horizontal center, but only if this is the last field) + bottom: fieldIndex === totalFields - 1 ? { + x: tableCenterX, + y: tableBottom, + side: 'bottom', + fieldIndex + } : null + }; +} + +/** + * Get all perimeter points for a table + * @param {Object} table - Table object + * @param {boolean} hasColorStrip - Whether the table has a color strip + * @returns {Array} Array of all perimeter points + */ +export function getAllTablePerimeterPoints(table, hasColorStrip = false) { + if (!table || !table.fields || table.fields.length === 0) { + return []; + } + + const points = []; + const totalFields = table.fields.length; + + table.fields.forEach((field, index) => { + const fieldPoints = getFieldPerimeterPoints(table, index, totalFields, hasColorStrip); + + // Add left and right points (always present) + points.push(fieldPoints.left); + points.push(fieldPoints.right); + + // Add top point (only for first field) + if (fieldPoints.top) { + points.push(fieldPoints.top); + } + + // Add bottom point (only for last field) + if (fieldPoints.bottom) { + points.push(fieldPoints.bottom); + } + }); + + return points; +} + +/** + * Find the closest perimeter point to a given coordinate + * @param {Array} points - Array of perimeter points + * @param {number} x - Mouse X coordinate + * @param {number} y - Mouse Y coordinate + * @param {number} threshold - Maximum distance to consider (default: 30) + * @returns {Object|null} Closest point or null if none within threshold + */ +export function findClosestPerimeterPoint(points, x, y, threshold = 30) { + if (!points || points.length === 0) { + return null; + } + + let closestPoint = null; + let minDistance = threshold; + + points.forEach(point => { + const dx = point.x - x; + const dy = point.y - y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < minDistance) { + minDistance = distance; + closestPoint = { ...point, distance }; + } + }); + + return closestPoint; +} + +/** + * Calculate orthogonal path between two points + * Uses Manhattan routing (right angles only) and avoids crossing tables + * @param {Object} start - Start point {x, y, side} + * @param {Object} end - End point {x, y, side} + * @param {Array} waypoints - Optional intermediate waypoints + * @returns {string} SVG path string + */ +export function calculateOrthogonalPath(start, end, waypoints = []) { + if (!start || !end) { + return ''; + } + + // If there are waypoints, create path through them + if (waypoints && waypoints.length > 0) { + const points = [start, ...waypoints, end]; + let path = `M ${points[0].x} ${points[0].y}`; + + for (let i = 1; i < points.length; i++) { + path += ` L ${points[i].x} ${points[i].y}`; + } + + return path; + } + + // Simple orthogonal routing based on connection sides + const path = []; + path.push(`M ${start.x} ${start.y}`); + + // Determine routing based on sides + const startSide = start.side || 'right'; + const endSide = end.side || 'left'; + + // Calculate offset distance to clear the table edges + const offsetDistance = 30; + + // Route based on start and end sides + if (startSide === 'left') { + const exitX = start.x - offsetDistance; + path.push(`L ${exitX} ${start.y}`); + + if (endSide === 'right') { + const enterX = end.x + offsetDistance; + const midY = (start.y + end.y) / 2; + path.push(`L ${exitX} ${midY}`); + path.push(`L ${enterX} ${midY}`); + path.push(`L ${enterX} ${end.y}`); + } else if (endSide === 'left') { + const midY = (start.y + end.y) / 2; + const minX = Math.min(exitX, end.x - offsetDistance); + path.push(`L ${minX} ${start.y}`); + path.push(`L ${minX} ${midY}`); + path.push(`L ${end.x - offsetDistance} ${midY}`); + path.push(`L ${end.x - offsetDistance} ${end.y}`); + } else { + // top or bottom + path.push(`L ${exitX} ${end.y}`); + } + } else if (startSide === 'right') { + const exitX = start.x + offsetDistance; + path.push(`L ${exitX} ${start.y}`); + + if (endSide === 'left') { + const enterX = end.x - offsetDistance; + const midY = (start.y + end.y) / 2; + path.push(`L ${exitX} ${midY}`); + path.push(`L ${enterX} ${midY}`); + path.push(`L ${enterX} ${end.y}`); + } else if (endSide === 'right') { + const midY = (start.y + end.y) / 2; + const maxX = Math.max(exitX, end.x + offsetDistance); + path.push(`L ${maxX} ${start.y}`); + path.push(`L ${maxX} ${midY}`); + path.push(`L ${end.x + offsetDistance} ${midY}`); + path.push(`L ${end.x + offsetDistance} ${end.y}`); + } else { + // top or bottom + path.push(`L ${exitX} ${end.y}`); + } + } else if (startSide === 'top') { + const exitY = start.y - offsetDistance; + path.push(`L ${start.x} ${exitY}`); + + if (endSide === 'bottom') { + const enterY = end.y + offsetDistance; + const midX = (start.x + end.x) / 2; + path.push(`L ${midX} ${exitY}`); + path.push(`L ${midX} ${enterY}`); + path.push(`L ${end.x} ${enterY}`); + } else if (endSide === 'top') { + const midX = (start.x + end.x) / 2; + const minY = Math.min(exitY, end.y - offsetDistance); + path.push(`L ${start.x} ${minY}`); + path.push(`L ${midX} ${minY}`); + path.push(`L ${end.x} ${minY}`); + path.push(`L ${end.x} ${end.y - offsetDistance}`); + } else { + // left or right - need to route around + const midX = (start.x + end.x) / 2; + path.push(`L ${midX} ${exitY}`); + path.push(`L ${midX} ${end.y}`); + } + } else if (startSide === 'bottom') { + const exitY = start.y + offsetDistance; + path.push(`L ${start.x} ${exitY}`); + + if (endSide === 'top') { + const enterY = end.y - offsetDistance; + const midX = (start.x + end.x) / 2; + path.push(`L ${midX} ${exitY}`); + path.push(`L ${midX} ${enterY}`); + path.push(`L ${end.x} ${enterY}`); + } else if (endSide === 'bottom') { + const midX = (start.x + end.x) / 2; + const maxY = Math.max(exitY, end.y + offsetDistance); + path.push(`L ${start.x} ${maxY}`); + path.push(`L ${midX} ${maxY}`); + path.push(`L ${end.x} ${maxY}`); + path.push(`L ${end.x} ${end.y + offsetDistance}`); + } else { + // left or right - need to route around + const midX = (start.x + end.x) / 2; + path.push(`L ${midX} ${exitY}`); + path.push(`L ${midX} ${end.y}`); + } + } + + path.push(`L ${end.x} ${end.y}`); + + return path.join(' '); +}