Skip to content

Commit 2d995f0

Browse files
feat(library): step-route crow's-foot ER edges like class diagrams
Switch the crow's-foot relationship edge from straight to the class diagram's step (orthogonal) routing by reusing useStepPathEdge and StepEdgeBody — bringing bend points, midpoint dragging and reconnection for free, and removing the bespoke BaseEdge/overlay/endpoint-marker duplication. deriveErCfEdgeRender now orients each crow's-foot marker along the path's end segment (via getPathStartInfo/getPathEndInfo) instead of the source→target vector, so the markers stay attached and correctly angled when the edge bends. Falls back to the straight line before first layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe7e998 commit 2d995f0

6 files changed

Lines changed: 112 additions & 76 deletions

File tree

.changeset/entity-relationship-diagram.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
Add two first-class **Entity-Relationship diagram** types, covering both notations taught and used in practice:
77

88
- **Entity-Relationship (Chen):** strong and weak entities (double border), regular and identifying relationships (diamond / double diamond), and attributes as ellipses with key (underline), partial-key (dashed underline), multivalued (double ellipse) and derived (dashed ellipse) decorations. Entity↔relationship connectors carry a free-text cardinality label with quick presets for both Chen ratio (`1`/`N`/`M`) and `(min,max)`, plus a participation toggle (total participation draws a double line). The editor only lets you draw structurally valid connections and picks the right connector vs. link edge automatically, including on reconnection.
9-
- **Entity-Relationship (Crow's Foot):** the Mermaid/IE-style notation — entity tables whose columns are listed as rows, related by straight lines with crow's-foot cardinality markers at each end (zero/one/many) and solid (identifying) or dashed (non-identifying) lines.
9+
- **Entity-Relationship (Crow's Foot):** the Mermaid/IE-style notation — entity tables whose columns are listed as rows, related by step-routed lines (the same orthogonal routing, bend points and midpoint dragging as class edges) with crow's-foot cardinality markers at each end (zero/one/many) and solid (identifying) or dashed (non-identifying) lines.

library/lib/edges/edgeTypes/ErCfRelationshipEdge.tsx

Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
import { BaseEdge } from "@xyflow/react"
2-
import {
3-
BaseEdgeProps,
4-
CommonEdgeElements,
5-
EdgeEndpointMarkers,
6-
} from "../GenericEdge"
1+
import { BaseEdgeProps, StepEdgeBody, CommonEdgeElements } from "../GenericEdge"
72
import { EdgeMiddleLabels } from "../labelTypes/EdgeMiddleLabels"
8-
import { useStraightPathEdge } from "@/hooks/useStraightPathEdge"
3+
import { useEdgeConfig } from "@/hooks/useEdgeConfig"
4+
import { useStepPathEdge } from "@/hooks/useStepPathEdge"
95
import { useDiagramStore, usePopoverStore } from "@/store/context"
106
import { useShallow } from "zustand/shallow"
117
import { useToolbar } from "@/hooks"
12-
import { EDGES } from "@/constants"
138
import {
149
AssessmentSelectableWrapper,
1510
FeedbackDropzone,
@@ -18,8 +13,10 @@ import { getCustomColorsFromDataForEdge } from "@/utils/layoutUtils"
1813
import { ErCrowsFootMarker } from "@/components/svgs/edges/ErCrowsFootMarker"
1914
import { deriveErCfEdgeRender } from "@/utils/erCfUtils"
2015

21-
// Crow's-foot (Mermaid-style) relationship: a straight line directly between two
22-
// entity tables, with a crow's-foot cardinality marker at each end. Solid when
16+
// Crow's-foot (Mermaid-style) relationship between two entity tables. Reuses the
17+
// class diagram's step (orthogonal) routing — bend points, midpoint dragging,
18+
// reconnection — and overlays a crow's-foot cardinality marker at each end,
19+
// oriented along the path's final segment so it tracks bends. Solid line when
2320
// identifying, dashed when not.
2421
export const ErCfRelationshipEdge = ({
2522
id,
@@ -37,11 +34,17 @@ export const ErCfRelationshipEdge = ({
3734
data,
3835
}: BaseEdgeProps) => {
3936
const { handleDelete } = useToolbar({ id })
37+
const config = useEdgeConfig(type as "ErCfRelationship")
38+
const allowMidpointDragging =
39+
"allowMidpointDragging" in config ? config.allowMidpointDragging : true
40+
const enableStraightPath =
41+
"enableStraightPath" in config
42+
? (config.enableStraightPath as boolean)
43+
: true
4044

4145
const { assessments } = useDiagramStore(
4246
useShallow((state) => ({ assessments: state.assessments }))
4347
)
44-
4548
const setPopOverElementId = usePopoverStore(
4649
useShallow((state) => state.setPopOverElementId)
4750
)
@@ -51,12 +54,17 @@ export const ErCfRelationshipEdge = ({
5154
edgeData,
5255
currentPath,
5356
overlayPath,
57+
bendHandles,
58+
isBendDragging,
59+
draggingHandleSegmentIndex,
60+
hasInitialCalculation,
61+
isReconnecting,
62+
handlePointerDown,
5463
sourcePoint,
5564
targetPoint,
5665
isDiagramModifiable,
57-
isReconnecting,
5866
canEditEndpoint,
59-
} = useStraightPathEdge({
67+
} = useStepPathEdge({
6068
id,
6169
type,
6270
source,
@@ -69,67 +77,62 @@ export const ErCfRelationshipEdge = ({
6977
targetPosition,
7078
sourceHandleId,
7179
targetHandleId,
80+
data,
81+
allowMidpointDragging,
82+
enableStraightPath,
7283
})
7384

7485
const { strokeColor, textColor } = getCustomColorsFromDataForEdge(data)
7586
const {
7687
dashed,
7788
source: sourceMarker,
7889
target: targetMarker,
79-
} = deriveErCfEdgeRender(data, sourcePoint, targetPoint)
90+
} = deriveErCfEdgeRender(data, currentPath, sourcePoint, targetPoint)
8091

8192
return (
8293
<AssessmentSelectableWrapper elementId={id} asElement="g">
8394
<FeedbackDropzone elementId={id} asElement="path" elementType={type}>
84-
<g className="edge-container">
85-
<BaseEdge
86-
id={id}
87-
path={currentPath}
88-
pointerEvents="none"
89-
style={{
90-
stroke: strokeColor,
91-
strokeDasharray: dashed ? "8 5" : undefined,
92-
}}
93-
/>
94-
95+
<StepEdgeBody
96+
id={id}
97+
markerKey={`${id}-ercf`}
98+
currentPath={currentPath}
99+
overlayPath={overlayPath}
100+
pathRef={pathRef}
101+
strokeColor={strokeColor}
102+
strokeDashArray={dashed ? "8 5" : undefined}
103+
hasInitialCalculation={hasInitialCalculation}
104+
isReconnecting={isReconnecting}
105+
isBendDragging={isBendDragging}
106+
draggingHandleSegmentIndex={draggingHandleSegmentIndex}
107+
markerStart={undefined}
108+
markerEnd={undefined}
109+
sourcePoint={sourcePoint}
110+
targetPoint={targetPoint}
111+
sourcePosition={sourcePosition}
112+
targetPosition={targetPosition}
113+
isDiagramModifiable={isDiagramModifiable}
114+
canEditEndpoint={canEditEndpoint}
115+
allowMidpointDragging={allowMidpointDragging}
116+
bendHandles={bendHandles}
117+
handlePointerDown={handlePointerDown}
118+
>
95119
{!isReconnecting && (
96120
<>
97121
<ErCrowsFootMarker
98-
point={sourcePoint}
122+
point={sourceMarker.point}
99123
direction={sourceMarker.direction}
100124
cardinality={sourceMarker.cardinality}
101125
strokeColor={strokeColor}
102126
/>
103127
<ErCrowsFootMarker
104-
point={targetPoint}
128+
point={targetMarker.point}
105129
direction={targetMarker.direction}
106130
cardinality={targetMarker.cardinality}
107131
strokeColor={strokeColor}
108132
/>
109133
</>
110134
)}
111-
112-
<path
113-
ref={pathRef}
114-
className="edge-overlay"
115-
d={overlayPath}
116-
fill="none"
117-
strokeWidth={EDGES.EDGE_HIGHLIGHT_STROKE_WIDTH}
118-
pointerEvents="stroke"
119-
style={{ opacity: isReconnecting ? 0 : 0.4 }}
120-
/>
121-
122-
{!isReconnecting && (
123-
<EdgeEndpointMarkers
124-
sourcePoint={sourcePoint}
125-
targetPoint={targetPoint}
126-
sourcePosition={sourcePosition}
127-
targetPosition={targetPosition}
128-
isDiagramModifiable={isDiagramModifiable}
129-
canEditEndpoint={canEditEndpoint}
130-
/>
131-
)}
132-
</g>
135+
</StepEdgeBody>
133136

134137
{!isReconnecting && (
135138
<>

library/lib/edges/types.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ export const edgeConfig = {
161161

162162
ErConnector: { allowMidpointDragging: true },
163163
ErLink: { allowMidpointDragging: true },
164-
// Straight only: the crow's-foot end markers are oriented from the
165-
// source→target vector, so a bend point would detach them from the line.
166-
ErCfRelationship: { allowMidpointDragging: false },
164+
// Step-routed like class edges; the crow's-foot markers orient off the path's
165+
// end segment so they stay attached at bends.
166+
ErCfRelationship: { allowMidpointDragging: true },
167167
} as const
168168

169169
export type DiagramEdgeType = keyof typeof diagramEdgeTypes

library/lib/utils/edgeUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2429,8 +2429,9 @@ export function getConnectionLineType(
24292429
case "SyntaxTree":
24302430
case "PetriNet":
24312431
case "EntityRelationship":
2432-
case "EntityRelationshipCrowsFoot":
24332432
return ConnectionLineType.Straight
2433+
// EntityRelationshipCrowsFoot falls through to the default Step routing, like
2434+
// the class diagram — its markers orient off the path's end segment.
24342435

24352436
default:
24362437
return ConnectionLineType.Step

library/lib/utils/erCfUtils.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CustomEdgeProps } from "@/edges/EdgeProps"
22
import { IPoint } from "@/edges/Connection"
3+
import { getPathStartInfo, getPathEndInfo } from "./pathParsing"
34
import {
45
DEFAULT_ER_CF_SOURCE_CARDINALITY,
56
DEFAULT_ER_CF_TARGET_CARDINALITY,
@@ -10,6 +11,7 @@ import {
1011
// of the React components so it can be unit-tested without a React Flow context.
1112

1213
export interface ErCfMarkerSpec {
14+
point: IPoint
1315
cardinality: ErCfCardinality
1416
// Angle (radians) pointing INTO the entity at this end.
1517
direction: number
@@ -23,28 +25,39 @@ export interface ErCfEdgeRender {
2325

2426
/**
2527
* Derive what a crow's-foot edge draws: a dashed line when non-identifying, and
26-
* a cardinality + into-the-entity direction for each end (the target marker
27-
* follows the source→target line; the source marker is the reverse). Cardinality
28-
* falls back to the shared defaults when the edge omits it.
28+
* a per-end cardinality marker placed at the entity boundary, oriented along the
29+
* path's final segment (so the foot stays attached and correctly angled even when
30+
* the step-routed edge bends). The target marker follows the segment entering the
31+
* target; the source marker is the reverse of the segment leaving the source. Both
32+
* fall back to the straight source→target line before the path is laid out, and
33+
* cardinality falls back to the shared defaults.
2934
*/
3035
export function deriveErCfEdgeRender(
3136
data: CustomEdgeProps | undefined,
37+
currentPath: string,
3238
sourcePoint: IPoint,
3339
targetPoint: IPoint
3440
): ErCfEdgeRender {
35-
const intoTarget = Math.atan2(
41+
const straight = Math.atan2(
3642
targetPoint.y - sourcePoint.y,
3743
targetPoint.x - sourcePoint.x
3844
)
45+
const start = getPathStartInfo(currentPath)
46+
const end = getPathEndInfo(currentPath)
3947
return {
4048
dashed: data?.identifying === false,
4149
source: {
50+
point: sourcePoint,
4251
cardinality: data?.sourceCardinality ?? DEFAULT_ER_CF_SOURCE_CARDINALITY,
43-
direction: intoTarget + Math.PI,
52+
// getPathStartInfo already points INTO the source; the straight fallback
53+
// points OUT (toward the target), so reverse only that case.
54+
direction: start ? start.direction : straight + Math.PI,
4455
},
4556
target: {
57+
point: targetPoint,
4658
cardinality: data?.targetCardinality ?? DEFAULT_ER_CF_TARGET_CARDINALITY,
47-
direction: intoTarget,
59+
// end.direction already enters the target.
60+
direction: end ? end.direction : straight,
4861
},
4962
}
5063
}

library/tests/unit/erCrowsFoot.test.tsx

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -102,30 +102,48 @@ describe("crow's-foot diagram wiring", () => {
102102
describe("deriveErCfEdgeRender", () => {
103103
const left = { x: 0, y: 0 }
104104
const right = { x: 10, y: 0 }
105+
const straight = "M 0 0 L 10 0" // horizontal source→target
106+
// L-shaped step path: leaves the source going DOWN, enters the target going RIGHT.
107+
const bent = "M 0 0 L 0 10 L 10 10"
105108

106109
it("is dashed only when explicitly non-identifying", () => {
107-
expect(
110+
const d = (id?: boolean) =>
108111
deriveErCfEdgeRender(
109-
{ identifying: false } as CustomEdgeProps,
112+
id === undefined ? undefined : ({ identifying: id } as CustomEdgeProps),
113+
straight,
110114
left,
111115
right
112116
).dashed
113-
).toBe(true)
114-
expect(
115-
deriveErCfEdgeRender(
116-
{ identifying: true } as CustomEdgeProps,
117-
left,
118-
right
119-
).dashed
120-
).toBe(false)
117+
expect(d(false)).toBe(true)
118+
expect(d(true)).toBe(false)
121119
// Undefined defaults to identifying (solid), matching the popover toggle.
122-
expect(deriveErCfEdgeRender(undefined, left, right).dashed).toBe(false)
120+
expect(d(undefined)).toBe(false)
121+
})
122+
123+
it("orients each marker along the path's end segment (straight)", () => {
124+
const { source, target } = deriveErCfEdgeRender(
125+
undefined,
126+
straight,
127+
left,
128+
right
129+
)
130+
expect(target.direction).toBeCloseTo(0) // segment enters target going +x
131+
expect(source.direction).toBeCloseTo(Math.PI) // reverse of leaving the source
132+
expect(source.point).toEqual(left)
133+
expect(target.point).toEqual(right)
123134
})
124135

125-
it("points each end's marker into its entity (source = target + π)", () => {
126-
const { source, target } = deriveErCfEdgeRender(undefined, left, right)
127-
expect(target.direction).toBeCloseTo(0) // along source→target (+x)
128-
expect(source.direction).toBeCloseTo(Math.PI) // the reverse
136+
it("follows the path round a bend (markers track the step route)", () => {
137+
const { source, target } = deriveErCfEdgeRender(
138+
undefined,
139+
bent,
140+
left,
141+
right
142+
)
143+
// Last segment enters the target going +x; first segment leaves the source
144+
// going +y (down), so the marker points back up (−y).
145+
expect(target.direction).toBeCloseTo(0)
146+
expect(source.direction).toBeCloseTo(-Math.PI / 2)
129147
})
130148

131149
it("maps source/target cardinality to the matching end, with defaults", () => {
@@ -134,13 +152,14 @@ describe("deriveErCfEdgeRender", () => {
134152
sourceCardinality: "OneOrMany",
135153
targetCardinality: "ZeroOrOne",
136154
} as CustomEdgeProps,
155+
straight,
137156
left,
138157
right
139158
)
140159
expect(withData.source.cardinality).toBe("OneOrMany")
141160
expect(withData.target.cardinality).toBe("ZeroOrOne")
142161

143-
const defaults = deriveErCfEdgeRender(undefined, left, right)
162+
const defaults = deriveErCfEdgeRender(undefined, straight, left, right)
144163
expect(defaults.source.cardinality).toBe("ExactlyOne")
145164
expect(defaults.target.cardinality).toBe("ZeroOrMany")
146165
})

0 commit comments

Comments
 (0)