Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 28 additions & 23 deletions src/components/flow/edges/ReflexiveAssociation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,31 @@ import { RoleLabel } from '../labels/RoleLabel.jsx'
import { getReflexiveAssociationLayout } from '../utils/reflexiveAssociationUtils.js'
import { ReflexiveResizeHandles } from './ReflexiveResizeHandles.jsx'

function getStartMultiplicityTransform(side, x, y) {
if (side === 'right') {
return `translate(0%, -100%) translate(${x}px, ${y - 1}px)`
}
return `translate(-100%, -100%) translate(${x}px, ${y - 1}px)`
function getStartMultiplicityTransform(isRight, x, y, isLower) {
const xTranslate = isRight ? '0%' : '-100%'
const yTranslate = isLower ? '0%' : '-100%'
const yOffset = isLower ? y + 1 : y - 1
return `translate(${xTranslate}, ${yTranslate}) translate(${x}px, ${yOffset}px)`
}

function getEndMultiplicityTransform(side, x, y) {
if (side === 'right') {
return `translate(0%, -100%) translate(${x + 1}px, ${y}px)`
}
return `translate(-100%, -100%) translate(${x - 1}px, ${y}px)`
function getEndMultiplicityTransform(isRight, x, y, isLower) {
const xTranslate = isRight ? '0%' : '-100%'
const yTranslate = isLower ? '0%' : '-100%'
const xOffset = isRight ? x + 1 : x - 1
return `translate(${xTranslate}, ${yTranslate}) translate(${xOffset}px, ${y}px)`
}

function getStartRoleTransform(side, x, y) {
if (side === 'right') {
return `translate(0%, 0%) translate(${x}px, ${y + 1}px)`
}
return `translate(-100%, 0%) translate(${x}px, ${y + 1}px)`
function getStartRoleTransform(isRight, x, y, isLower) {
const xTranslate = isRight ? '0%' : '-100%'
const yTranslate = isLower ? '-100%' : '0%'
const yOffset = isLower ? y - 1 : y + 1
return `translate(${xTranslate}, ${yTranslate}) translate(${x}px, ${yOffset}px)`
}

function getEndRoleTransform(x, y, isLower) {
const yTranslate = isLower ? '0%' : '-100%'
const yOffset = isLower ? y + 2 : y - 2
return `translate(-50%, ${yTranslate}) translate(${x}px, ${yOffset}px)`
}

export function ReflexiveAssociation({
Expand All @@ -40,14 +46,13 @@ export function ReflexiveAssociation({
return null
}

const side = layout.side
const isRight = side === 'right'
const { isRight, isLower } = layout
const startX = layout.startAnchor.x
const startY = layout.startAnchor.y
const endX = layout.endAnchor.x
const endY = layout.endAnchor.y
const roleBX = layout.topSegmentCenter.x
const roleBY = layout.topSegmentCenter.y
const roleBX = layout.outerEdgeSegmentCenter.x
const roleBY = layout.outerEdgeSegmentCenter.y
const associationNameX = isRight
? layout.outerSegmentCenter.x + 4
: layout.outerSegmentCenter.x - 4
Expand Down Expand Up @@ -89,25 +94,25 @@ export function ReflexiveAssociation({
<EdgeLabelRenderer>
{multiplicityA ? (
<MultiplicityLabel
transform={getStartMultiplicityTransform(side, startX, startY)}
transform={getStartMultiplicityTransform(isRight, startX, startY, isLower)}
label={multiplicityA}
/>
) : null}
{multiplicityB ? (
<MultiplicityLabel
transform={getEndMultiplicityTransform(side, endX, endY)}
transform={getEndMultiplicityTransform(isRight, endX, endY, isLower)}
label={multiplicityB}
/>
) : null}
{roleA ? (
<RoleLabel
transform={getStartRoleTransform(side, startX, startY)}
transform={getStartRoleTransform(isRight, startX, startY, isLower)}
label={roleA}
/>
) : null}
{roleB ? (
<RoleLabel
transform={`translate(-50%, -100%) translate(${roleBX}px, ${roleBY - 2}px)`}
transform={getEndRoleTransform(roleBX, roleBY, isLower)}
label={roleB}
/>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ describe("getReflexiveSide", () => {
expect(getReflexiveSide({ reflexiveSide: "left" })).toBe("left");
});

it("returns 'lower-right' when reflexiveSide is 'lower-right'", () => {
expect(getReflexiveSide({ reflexiveSide: "lower-right" })).toBe("lower-right");
});

it("returns 'lower-left' when reflexiveSide is 'lower-left'", () => {
expect(getReflexiveSide({ reflexiveSide: "lower-left" })).toBe("lower-left");
});

it("returns 'left' for reflexiveIndex 0", () => {
expect(getReflexiveSide({ reflexiveIndex: 0 })).toBe("left");
});
Expand All @@ -70,6 +78,14 @@ describe("getReflexiveSide", () => {
expect(getReflexiveSide({ reflexiveIndex: 1 })).toBe("right");
});

it("returns 'lower-right' for reflexiveIndex 2", () => {
expect(getReflexiveSide({ reflexiveIndex: 2 })).toBe("lower-right");
});

it("returns 'lower-left' for reflexiveIndex 3", () => {
expect(getReflexiveSide({ reflexiveIndex: 3 })).toBe("lower-left");
});

it("defaults to 'left' for missing/null data", () => {
expect(getReflexiveSide(null)).toBe("left");
expect(getReflexiveSide(undefined)).toBe("left");
Expand Down Expand Up @@ -129,17 +145,66 @@ describe("getReflexiveAssociationLayout", () => {
expect(layout).toHaveProperty("loopWidth");
expect(layout).toHaveProperty("loopHeight");
expect(layout).toHaveProperty("side");
expect(layout).toHaveProperty("isRight");
expect(layout).toHaveProperty("isLower");
expect(layout).toHaveProperty("outerEdgeSegmentCenter");
expect(layout).toHaveProperty("resizeHandles");
});

it("produces a 'left' side layout by default", () => {
const layout = getReflexiveAssociationLayout(makeNode(0, 0, 200, 100), {});
expect(layout.side).toBe("left");
expect(layout.isLower).toBe(false);
});

it("produces a 'right' side layout when reflexiveSide is right", () => {
const layout = getReflexiveAssociationLayout(makeNode(0, 0, 200, 100), { reflexiveSide: "right" });
expect(layout.side).toBe("right");
expect(layout.isLower).toBe(false);
});

it("produces a 'lower-right' layout with isLower=true", () => {
const node = makeNode(0, 0, 200, 100);
const layout = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-right" });
expect(layout.side).toBe("lower-right");
expect(layout.isLower).toBe(true);
// startAnchor should be near the bottom of the node
expect(layout.startAnchor.y).toBeGreaterThan(node.internals.positionAbsolute.y + node.measured.height / 2);
// loop extends downward: outerEdgeY > startAnchor.y
expect(layout.outerEdgeY).toBeGreaterThan(layout.startAnchor.y);
});

it("produces a 'lower-left' layout with isLower=true", () => {
const node = makeNode(0, 0, 200, 100);
const layout = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-left" });
expect(layout.side).toBe("lower-left");
expect(layout.isLower).toBe(true);
expect(layout.startAnchor.y).toBeGreaterThan(node.internals.positionAbsolute.y + node.measured.height / 2);
expect(layout.outerEdgeY).toBeGreaterThan(layout.startAnchor.y);
});

it("right-side layouts extend outerX to the right of startAnchor", () => {
const node = makeNode(0, 0, 200, 100);
const right = getReflexiveAssociationLayout(node, { reflexiveSide: "right" });
const lowerRight = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-right" });
expect(right.outerX).toBeGreaterThan(right.startAnchor.x);
expect(lowerRight.outerX).toBeGreaterThan(lowerRight.startAnchor.x);
});

it("left-side layouts extend outerX to the left of startAnchor", () => {
const node = makeNode(0, 0, 200, 100);
const left = getReflexiveAssociationLayout(node, { reflexiveSide: "left" });
const lowerLeft = getReflexiveAssociationLayout(node, { reflexiveSide: "lower-left" });
expect(left.outerX).toBeLessThan(left.startAnchor.x);
expect(lowerLeft.outerX).toBeLessThan(lowerLeft.startAnchor.x);
});

it("upper loops extend upward (outerEdgeY < startAnchor.y)", () => {
const node = makeNode(0, 0, 200, 100);
const left = getReflexiveAssociationLayout(node, { reflexiveSide: "left" });
const right = getReflexiveAssociationLayout(node, { reflexiveSide: "right" });
expect(left.outerEdgeY).toBeLessThan(left.startAnchor.y);
expect(right.outerEdgeY).toBeLessThan(right.startAnchor.y);
});

it("clamps loopWidth and loopHeight to their minimums for invalid data values", () => {
Expand Down
59 changes: 35 additions & 24 deletions src/components/flow/utils/reflexiveAssociationUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const REFLEXIVE_CORNER_RADIUS = 5
const REFLEXIVE_MIN_SEGMENT = 12
const REFLEXIVE_SIDE_LEFT = 'left'
const REFLEXIVE_SIDE_RIGHT = 'right'
const REFLEXIVE_SIDE_LOWER_RIGHT = 'lower-right'
const REFLEXIVE_SIDE_LOWER_LEFT = 'lower-left'

function getNodePosition(node) {
return (
Expand Down Expand Up @@ -88,11 +90,20 @@ export function getReflexiveSide(data) {
if (data?.reflexiveSide === REFLEXIVE_SIDE_LEFT) {
return REFLEXIVE_SIDE_LEFT
}
if (data?.reflexiveSide === REFLEXIVE_SIDE_LOWER_RIGHT) {
return REFLEXIVE_SIDE_LOWER_RIGHT
}
if (data?.reflexiveSide === REFLEXIVE_SIDE_LOWER_LEFT) {
return REFLEXIVE_SIDE_LOWER_LEFT
}

const reflexiveIndex = Number.isFinite(data?.reflexiveIndex)
? Number(data.reflexiveIndex)
: 0
return reflexiveIndex === 1 ? REFLEXIVE_SIDE_RIGHT : REFLEXIVE_SIDE_LEFT
if (reflexiveIndex === 1) return REFLEXIVE_SIDE_RIGHT
if (reflexiveIndex === 2) return REFLEXIVE_SIDE_LOWER_RIGHT
if (reflexiveIndex === 3) return REFLEXIVE_SIDE_LOWER_LEFT
return REFLEXIVE_SIDE_LEFT
}

export function getReflexiveNodeRect(node) {
Expand Down Expand Up @@ -121,18 +132,19 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) {
}

const side = getReflexiveSide(data)
const isRight = side === REFLEXIVE_SIDE_RIGHT || side === REFLEXIVE_SIDE_LOWER_RIGHT
const isLower = side === REFLEXIVE_SIDE_LOWER_RIGHT || side === REFLEXIVE_SIDE_LOWER_LEFT

const horizontalInset = Math.min(REFLEXIVE_INSET_LIMIT, rect.width / 4)
const verticalInset = Math.min(REFLEXIVE_INSET_LIMIT, rect.height / 4)

const startAnchor = {
x: side === REFLEXIVE_SIDE_RIGHT ? rect.x + rect.width : rect.x,
y: rect.y + verticalInset,
x: isRight ? rect.x + rect.width : rect.x,
y: isLower ? rect.y + rect.height - verticalInset : rect.y + verticalInset,
}
const endAnchor = {
x:
side === REFLEXIVE_SIDE_RIGHT
? startAnchor.x - horizontalInset
: startAnchor.x + horizontalInset,
y: rect.y,
x: isRight ? startAnchor.x - horizontalInset : startAnchor.x + horizontalInset,
y: isLower ? rect.y + rect.height : rect.y,
}

const minLoopWidth = REFLEXIVE_MIN_SEGMENT
Expand All @@ -151,29 +163,29 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) {
normalizePositive(data?.loopHeight) ?? defaultLoopHeight,
)

const outerX =
side === REFLEXIVE_SIDE_RIGHT
? startAnchor.x + loopWidth
: startAnchor.x - loopWidth
const topY = startAnchor.y - loopHeight
const outerX = isRight ? startAnchor.x + loopWidth : startAnchor.x - loopWidth
const outerEdgeY = isLower ? startAnchor.y + loopHeight : startAnchor.y - loopHeight

const pathPoints = [
startAnchor,
{ x: outerX, y: startAnchor.y },
{ x: outerX, y: topY },
{ x: endAnchor.x, y: topY },
{ x: outerX, y: outerEdgeY },
{ x: endAnchor.x, y: outerEdgeY },
endAnchor,
]
const topSegmentCenter = {
const outerEdgeSegmentCenter = {
x: (outerX + endAnchor.x) / 2,
y: topY,
y: outerEdgeY,
}
const outerSegmentCenter = {
x: outerX,
y: (startAnchor.y + topY) / 2,
y: (startAnchor.y + outerEdgeY) / 2,
}

return {
side,
isRight,
isLower,
rect,
edgePath: getRoundedPolylinePath(pathPoints, REFLEXIVE_CORNER_RADIUS),
pathPoints,
Expand All @@ -184,10 +196,10 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) {
minLoopWidth,
minLoopHeight,
outerX,
topY,
topSegmentCenter,
outerEdgeY,
outerEdgeSegmentCenter,
outerSegmentCenter,
helperAnchor: topSegmentCenter,
helperAnchor: outerEdgeSegmentCenter,
resizeHandles: [
{
key: 'width',
Expand All @@ -198,10 +210,9 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) {
{
key: 'height',
axis: 'y',
x: topSegmentCenter.x,
y: topSegmentCenter.y,
x: outerEdgeSegmentCenter.x,
y: outerEdgeSegmentCenter.y,
},
],
}
}

13 changes: 7 additions & 6 deletions src/hooks/useModelState.js
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ export function useModelState({
}
return count + 1
}, 0)
return existingCount >= 2
return existingCount >= 4
}

return current.some((edge) => {
Expand Down Expand Up @@ -1891,10 +1891,9 @@ export function useModelState({
}

if (handleKey === 'width') {
const rawWidth =
layout.side === 'right'
? nextPoint.x - layout.startAnchor.x
: layout.startAnchor.x - nextPoint.x
const rawWidth = layout.isRight
? nextPoint.x - layout.startAnchor.x
: layout.startAnchor.x - nextPoint.x
const nextLoopWidth = roundLoopMetric(
Math.max(layout.minLoopWidth, rawWidth),
)
Expand All @@ -1911,7 +1910,9 @@ export function useModelState({
}
}

const rawHeight = layout.startAnchor.y - nextPoint.y
const rawHeight = layout.isLower
? nextPoint.y - layout.startAnchor.y
: layout.startAnchor.y - nextPoint.y
const nextLoopHeight = roundLoopMetric(
Math.max(layout.minLoopHeight, rawHeight),
)
Expand Down
Loading