diff --git a/src/components/flow/edges/ReflexiveAssociation.jsx b/src/components/flow/edges/ReflexiveAssociation.jsx index fff562e..be7a755 100644 --- a/src/components/flow/edges/ReflexiveAssociation.jsx +++ b/src/components/flow/edges/ReflexiveAssociation.jsx @@ -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({ @@ -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 @@ -89,25 +94,25 @@ export function ReflexiveAssociation({ {multiplicityA ? ( ) : null} {multiplicityB ? ( ) : null} {roleA ? ( ) : null} {roleB ? ( ) : null} diff --git a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js index fc31d5a..5540bb3 100644 --- a/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js +++ b/src/components/flow/utils/__tests__/reflexiveAssociationUtils.test.js @@ -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"); }); @@ -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"); @@ -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", () => { diff --git a/src/components/flow/utils/reflexiveAssociationUtils.js b/src/components/flow/utils/reflexiveAssociationUtils.js index af05aec..190c601 100644 --- a/src/components/flow/utils/reflexiveAssociationUtils.js +++ b/src/components/flow/utils/reflexiveAssociationUtils.js @@ -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 ( @@ -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) { @@ -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 @@ -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, @@ -184,10 +196,10 @@ export function getReflexiveAssociationLayout(sourceNode, data = {}) { minLoopWidth, minLoopHeight, outerX, - topY, - topSegmentCenter, + outerEdgeY, + outerEdgeSegmentCenter, outerSegmentCenter, - helperAnchor: topSegmentCenter, + helperAnchor: outerEdgeSegmentCenter, resizeHandles: [ { key: 'width', @@ -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, }, ], } } - diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 8cee42b..1110883 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -829,7 +829,7 @@ export function useModelState({ } return count + 1 }, 0) - return existingCount >= 2 + return existingCount >= 4 } return current.some((edge) => { @@ -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), ) @@ -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), ) diff --git a/src/model/__tests__/edgeUtils.test.js b/src/model/__tests__/edgeUtils.test.js index b27bcdd..236c65f 100644 --- a/src/model/__tests__/edgeUtils.test.js +++ b/src/model/__tests__/edgeUtils.test.js @@ -3,7 +3,6 @@ import { normalizeControlPoints, normalizePositiveNumber, normalizeReflexiveSide, - getOppositeReflexiveSide, normalizeEdges, } from "../edgeUtils.js"; import { @@ -70,6 +69,14 @@ describe("normalizeReflexiveSide", () => { expect(normalizeReflexiveSide("right")).toBe("right"); }); + it("returns 'lower-right' for 'lower-right'", () => { + expect(normalizeReflexiveSide("lower-right")).toBe("lower-right"); + }); + + it("returns 'lower-left' for 'lower-left'", () => { + expect(normalizeReflexiveSide("lower-left")).toBe("lower-left"); + }); + it("returns null for any other value", () => { expect(normalizeReflexiveSide("top")).toBeNull(); expect(normalizeReflexiveSide("")).toBeNull(); @@ -78,17 +85,6 @@ describe("normalizeReflexiveSide", () => { }); }); -describe("getOppositeReflexiveSide", () => { - it("returns 'left' for 'right'", () => { - expect(getOppositeReflexiveSide("right")).toBe("left"); - }); - - it("returns 'right' for 'left' (and any other value)", () => { - expect(getOppositeReflexiveSide("left")).toBe("right"); - expect(getOppositeReflexiveSide(null)).toBe("right"); - }); -}); - describe("normalizeEdges", () => { it("returns an empty array for empty input", () => { expect(normalizeEdges([])).toEqual([]); @@ -105,7 +101,21 @@ describe("normalizeEdges", () => { const result = normalizeEdges([edge]); expect(result[0].data.reflexiveIndex).toBe(0); expect(result[0].data.reflexiveCount).toBe(1); - expect(["left", "right"]).toContain(result[0].data.reflexiveSide); + expect(["left", "right", "lower-right", "lower-left"]).toContain(result[0].data.reflexiveSide); + }); + + it("assigns all four distinct sides when four reflexive edges share a node", () => { + const edges = ["e1", "e2", "e3", "e4"].map((id) => ({ + id, + type: REFLEXIVE_EDGE_TYPE, + source: "node1", + target: "node1", + data: {}, + })); + const result = normalizeEdges(edges); + const sides = result.map((e) => e.data.reflexiveSide); + expect(new Set(sides).size).toBe(4); + expect(new Set(sides)).toEqual(new Set(["left", "right", "lower-right", "lower-left"])); }); it("assigns parallelIndex and parallelCount to parallel association edges", () => { diff --git a/src/model/__tests__/javaModelizerImport.test.js b/src/model/__tests__/javaModelizerImport.test.js index 3cc7a91..cd6b96f 100644 --- a/src/model/__tests__/javaModelizerImport.test.js +++ b/src/model/__tests__/javaModelizerImport.test.js @@ -6,6 +6,7 @@ import { importJavaModelizer, } from "../javaModelizerImport.js"; import { ATTRIBUTE_TYPE_PARAMS_DEFAULT } from "../../attributes.js"; +import { REFLEXIVE_EDGE_TYPE } from "../constants.js"; // ─── parseDob ──────────────────────────────────────────────────────────────── @@ -210,15 +211,26 @@ describe("importJavaModelizer", () => { expect(result.edges[0].data.multiplicityB).toBe("n"); }); - it("enforces a maximum of 2 reflexive edges per class", () => { - const links = [1, 2, 3].map((i) => ({ + it("imports up to 4 reflexive edges per class", () => { + const links = [1, 2, 3, 4].map((i) => ({ name: `rel${i}`, endpoints: [{ dob: "A" }, { dob: "A" }], })); const mod = { tables: [{ name: "A", links }] }; const result = importJavaModelizer(JSON.stringify(mod)); - const reflexiveEdges = result.edges.filter((e) => e.type === "reflexive"); - expect(reflexiveEdges.length).toBeLessThanOrEqual(2); + const reflexiveEdges = result.edges.filter((e) => e.type === REFLEXIVE_EDGE_TYPE); + expect(reflexiveEdges.length).toBe(4); + }); + + it("drops a 5th reflexive edge on the same class", () => { + const links = [1, 2, 3, 4, 5].map((i) => ({ + name: `rel${i}`, + endpoints: [{ dob: "A" }, { dob: "A" }], + })); + const mod = { tables: [{ name: "A", links }] }; + const result = importJavaModelizer(JSON.stringify(mod)); + const reflexiveEdges = result.edges.filter((e) => e.type === REFLEXIVE_EDGE_TYPE); + expect(reflexiveEdges.length).toBe(4); }); it("uses deriveModelName to set the model name from fileName", () => { diff --git a/src/model/dialogCopy.js b/src/model/dialogCopy.js index e4a94c4..e86b14c 100644 --- a/src/model/dialogCopy.js +++ b/src/model/dialogCopy.js @@ -55,7 +55,7 @@ export const DIALOG_COPY = { reflexive: { title: 'Reflexive association limit', description: - 'Up to two reflexive associations on the same class are supported. Remove an existing reflexive association before creating another.', + 'Up to four reflexive associations on the same class are supported. Remove an existing reflexive association before creating another.', }, association: { title: 'Duplicate association', diff --git a/src/model/edgeUtils.js b/src/model/edgeUtils.js index 3b561e4..38a8bc7 100644 --- a/src/model/edgeUtils.js +++ b/src/model/edgeUtils.js @@ -10,6 +10,15 @@ import { const REFLEXIVE_SIDE_LEFT = 'left' const REFLEXIVE_SIDE_RIGHT = 'right' +const REFLEXIVE_SIDE_LOWER_RIGHT = 'lower-right' +const REFLEXIVE_SIDE_LOWER_LEFT = 'lower-left' + +const ALL_REFLEXIVE_SIDES = [ + REFLEXIVE_SIDE_LEFT, + REFLEXIVE_SIDE_RIGHT, + REFLEXIVE_SIDE_LOWER_RIGHT, + REFLEXIVE_SIDE_LOWER_LEFT, +] export function normalizeControlPoints(value) { if (!Array.isArray(value)) { @@ -36,22 +45,20 @@ export function normalizeReflexiveSide(value) { if (value === REFLEXIVE_SIDE_LEFT) { return REFLEXIVE_SIDE_LEFT } + if (value === REFLEXIVE_SIDE_LOWER_RIGHT) { + return REFLEXIVE_SIDE_LOWER_RIGHT + } + if (value === REFLEXIVE_SIDE_LOWER_LEFT) { + return REFLEXIVE_SIDE_LOWER_LEFT + } return null } -export function getOppositeReflexiveSide(side) { - return side === REFLEXIVE_SIDE_RIGHT - ? REFLEXIVE_SIDE_LEFT - : REFLEXIVE_SIDE_RIGHT -} - function getLegacyReflexiveSide(edge, fallbackIndex = 0) { const rawIndex = Number.isFinite(edge?.data?.reflexiveIndex) ? Number(edge.data.reflexiveIndex) : Number(fallbackIndex) - return Math.abs(rawIndex % 2) === 1 - ? REFLEXIVE_SIDE_RIGHT - : REFLEXIVE_SIDE_LEFT + return ALL_REFLEXIVE_SIDES[Math.abs(Math.trunc(rawIndex)) % 4] } function assignReflexiveSides(orderedEdges) { @@ -88,9 +95,8 @@ function assignReflexiveSides(orderedEdges) { } const preferredSide = getLegacyReflexiveSide(edge, index) - const oppositeSide = getOppositeReflexiveSide(preferredSide) - const resolvedSide = usedSides.has(preferredSide) && !usedSides.has(oppositeSide) - ? oppositeSide + const resolvedSide = usedSides.has(preferredSide) + ? (ALL_REFLEXIVE_SIDES.find((s) => !usedSides.has(s)) ?? preferredSide) : preferredSide sideById.set(edge.id, resolvedSide) diff --git a/src/model/javaModelizerImport.js b/src/model/javaModelizerImport.js index d080fd7..87bccdc 100644 --- a/src/model/javaModelizerImport.js +++ b/src/model/javaModelizerImport.js @@ -352,7 +352,7 @@ export function importJavaModelizer(text, fileName) { const isReflexive = sourceId === targetId if (isReflexive) { const existingCount = reflexiveCountByClass.get(sourceId) ?? 0 - if (existingCount >= 2) { + if (existingCount >= 4) { return } reflexiveCountByClass.set(sourceId, existingCount + 1)