Skip to content

Commit 2115fe3

Browse files
tamang29FelixTJDietrichclaude
authored
fix: prevent communication diagram message labels from overlapping the edge (#645)
Co-authored-by: FelixTJDietrich <felix_dietrich@gmx.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d5f806f commit 2115fe3

8 files changed

Lines changed: 269 additions & 278 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/apollon": patch
3+
---
4+
5+
Communication diagram message labels no longer overlap the edge line, and on vertical edges the two message directions now appear on the correct sides.

library/eslint.config.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export default [
3636
"lib/components/collaboration/**/*.tsx",
3737
"lib/components/popovers/**/*EditPopover.tsx",
3838
"lib/edges/GenericEdge.tsx",
39-
"lib/hooks/useMessagePositioning.ts",
4039
"lib/hooks/useRemoteDraggingNodes.ts",
4140
"lib/hooks/useStraightPathEdge.ts",
4241
],

library/lib/edges/edgeTypes/CommunicationDiagramEdge.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ export const CommunicationDiagramEdge = ({
117117
sourcePosition={{ x: sourceX, y: sourceY }}
118118
targetPosition={{ x: targetX, y: targetY }}
119119
textColor={textColor}
120-
edgePoints={edgeData.activePoints}
121120
isHorizontalEdge={edgeData.isMiddlePathHorizontal}
122121
/>
123122

Lines changed: 51 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { useMessagePositioning } from "../../hooks"
21
import { IPoint } from "../Connection"
32
import { MessageData } from "../EdgeProps"
3+
import {
4+
ARROW_SIZE,
5+
computeMessageLayout,
6+
MessageGroupLayout,
7+
} from "./messageLayout"
48

59
interface EdgeMultipleLabelsProps {
610
messages?: MessageData[]
@@ -9,165 +13,78 @@ interface EdgeMultipleLabelsProps {
913
isReconnecting?: boolean
1014
sourcePosition: IPoint
1115
targetPosition: IPoint
12-
edgePoints?: IPoint[]
13-
isHorizontalEdge?: boolean
16+
isHorizontalEdge: boolean
1417
textColor: string
1518
}
19+
1620
export const EdgeMultipleLabels = ({
1721
messages,
1822
pathMiddlePosition,
1923
showRelationshipLabels,
2024
isReconnecting,
2125
sourcePosition,
2226
targetPosition,
23-
edgePoints,
2427
isHorizontalEdge,
2528
textColor,
2629
}: EdgeMultipleLabelsProps) => {
27-
const displayMessages: MessageData[] = messages || []
30+
const displayMessages = messages ?? []
31+
32+
if (displayMessages.length === 0 || !showRelationshipLabels || isReconnecting)
33+
return null
2834

29-
const {
30-
forwardMessages,
31-
backwardMessages,
32-
forwardArrowRotation,
33-
backwardArrowRotation,
34-
forwardLabelBoxPosition,
35-
backwardLabelBoxPosition,
36-
isPositioned,
37-
} = useMessagePositioning(
35+
const { forward, backward } = computeMessageLayout(
3836
displayMessages,
3937
sourcePosition,
4038
targetPosition,
41-
pathMiddlePosition,
42-
edgePoints,
4339
isHorizontalEdge
4440
)
4541

46-
if (
47-
!displayMessages ||
48-
displayMessages.length === 0 ||
49-
!showRelationshipLabels ||
50-
!isPositioned
51-
)
52-
return null
53-
54-
if (isReconnecting) return null
55-
56-
const getMessageOffset = (index: number, isForward: boolean) => {
57-
const spacing = 25
58-
59-
if (isHorizontalEdge) {
60-
if (isForward) {
61-
return { y: -index * spacing, x: 0 }
62-
} else {
63-
return { y: index * spacing, x: 0 }
64-
}
65-
} else {
66-
// For vertical edges, separate forward and backward messages vertically
67-
if (isForward) {
68-
return { y: -index * spacing, x: 0 }
69-
} else {
70-
return { y: index * spacing, x: 0 }
71-
}
72-
}
73-
}
74-
75-
const renderMessages = (
76-
messageList: MessageData[],
77-
labelBoxPosition: { x: number; y: number },
78-
arrowRotation: number,
79-
keyPrefix: string,
80-
isForward: boolean
81-
) => {
82-
return messageList.map((message, index) => {
83-
const offset = getMessageOffset(index, isForward)
84-
85-
const x = pathMiddlePosition.x + labelBoxPosition.x + offset.x
86-
const y = pathMiddlePosition.y + labelBoxPosition.y + offset.y
87-
88-
// Arrow dimensions — match the original 16x16 visual size
89-
// (old code used <svg width="16" height="16" viewBox="0 0 24 24">)
90-
const arrowWidth = 16
91-
const arrowHeight = 16
92-
const arrowTextSpacing = 4
93-
94-
// Defensive: handle missing text property
95-
const messageText = message.text || ""
96-
const estimatedTextWidth = messageText.length * 8
97-
const totalWidth = arrowWidth + arrowTextSpacing + estimatedTextWidth
98-
99-
// Center the entire label (arrow + text) around the x position
100-
const labelStartX = x - totalWidth / 2
101-
102-
return (
103-
<g key={`${keyPrefix}-${index}`}>
104-
{/* Arrow (only for first message in group) */}
105-
{index === 0 && (
42+
const renderGroup = (group: MessageGroupLayout, keyPrefix: string) =>
43+
group.messages.map((message, index) => (
44+
<g key={`${keyPrefix}-${message.id}`}>
45+
{/* One direction arrow per group, drawn with the first message. */}
46+
{index === 0 && (
47+
<g
48+
transform={`translate(${pathMiddlePosition.x + group.arrowOrigin.x}, ${
49+
pathMiddlePosition.y + group.arrowOrigin.y
50+
}) rotate(${group.arrowRotation}, ${ARROW_SIZE / 2}, ${ARROW_SIZE / 2})`}
51+
>
52+
{/* Lucide "move-right" glyph (24×24, stroked) scaled to the arrow box. */}
10653
<g
107-
transform={`translate(${labelStartX}, ${y - arrowHeight / 2}) rotate(${arrowRotation}, ${arrowWidth / 2}, ${arrowHeight / 2})`}
54+
transform={`scale(${ARROW_SIZE / 24})`}
55+
stroke={textColor}
56+
strokeWidth="2.5"
57+
strokeLinecap="round"
58+
strokeLinejoin="round"
59+
fill="none"
10860
>
109-
{/* Scale 24x24 icon paths down to fit 16x16 box */}
110-
<g transform={`scale(${16 / 24})`}>
111-
<path
112-
d="M2 12h20"
113-
stroke={textColor}
114-
strokeWidth="2.5"
115-
strokeLinecap="round"
116-
strokeLinejoin="round"
117-
fill="none"
118-
/>
119-
<path
120-
d="m17 5 5 7-5 7"
121-
stroke={textColor}
122-
strokeWidth="2.5"
123-
strokeLinecap="round"
124-
strokeLinejoin="round"
125-
fill="none"
126-
/>
127-
</g>
61+
<path d="M2 12h20" />
62+
<path d="m17 5 5 7-5 7" />
12863
</g>
129-
)}
64+
</g>
65+
)}
13066

131-
{/* Label Text */}
132-
<text
133-
x={labelStartX + arrowWidth + arrowTextSpacing}
134-
y={y}
135-
textAnchor="start"
136-
dominantBaseline="middle"
137-
style={{
138-
fontSize: "14px",
139-
fontWeight: 400,
140-
fill: textColor,
141-
userSelect: "none",
142-
pointerEvents: "none",
143-
}}
144-
>
145-
{messageText}
146-
</text>
147-
</g>
148-
)
149-
})
150-
}
67+
<text
68+
x={pathMiddlePosition.x + group.textOrigin.x + index * group.stackStep.x}
69+
y={pathMiddlePosition.y + group.textOrigin.y + index * group.stackStep.y}
70+
textAnchor={group.textAnchor}
71+
dominantBaseline="middle"
72+
style={{
73+
fontSize: "14px",
74+
fill: textColor,
75+
userSelect: "none",
76+
pointerEvents: "none",
77+
}}
78+
>
79+
{message.text}
80+
</text>
81+
</g>
82+
))
15183

15284
return (
15385
<g className="edge-labels">
154-
{/* Forward Messages (pointing to target) */}
155-
{renderMessages(
156-
forwardMessages,
157-
forwardLabelBoxPosition,
158-
forwardArrowRotation,
159-
"forward",
160-
true // isForward = true
161-
)}
162-
163-
{/* Backward Messages (pointing to source) */}
164-
{renderMessages(
165-
backwardMessages,
166-
backwardLabelBoxPosition,
167-
backwardArrowRotation,
168-
"backward",
169-
false // isForward = false
170-
)}
86+
{renderGroup(forward, "forward")}
87+
{renderGroup(backward, "backward")}
17188
</g>
17289
)
17390
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { MessageData } from "../EdgeProps"
2+
import { IPoint } from "../Connection"
3+
4+
/**
5+
* Geometry for communication-edge message labels, relative to the middle-segment
6+
* midpoint (position-independent, hence unit-testable on its own).
7+
*
8+
* Invariant: a group's SIDE is fixed by the segment orientation; flow direction
9+
* is encoded ONLY by the arrow rotation, never by the side. Gaps are constant by
10+
* construction — nothing measures text width.
11+
*/
12+
13+
type ArrowDirection = "Up" | "Down" | "Left" | "Right"
14+
export type LabelTextAnchor = "start" | "middle" | "end"
15+
16+
export const ARROW_SIZE = 16
17+
export const ARROW_TEXT_GAP = 4
18+
export const LINE_GAP = 8
19+
export const STACK_SPACING = 22
20+
21+
export interface MessageGroupLayout {
22+
messages: MessageData[]
23+
arrowRotation: number
24+
textAnchor: LabelTextAnchor
25+
/** Top-left of the arrow icon for the first message, relative to the segment midpoint. */
26+
arrowOrigin: IPoint
27+
/** Text anchor point for the first message, relative to the segment midpoint. */
28+
textOrigin: IPoint
29+
/** Offset added per stacked message (multiplied by the message index). */
30+
stackStep: IPoint
31+
}
32+
33+
export interface MessageLayout {
34+
forward: MessageGroupLayout
35+
backward: MessageGroupLayout
36+
}
37+
38+
const calculateRotation = (direction: ArrowDirection): number => {
39+
switch (direction) {
40+
case "Right":
41+
return 0
42+
case "Left":
43+
return 180
44+
case "Down":
45+
return 90
46+
case "Up":
47+
return -90
48+
}
49+
}
50+
51+
export const computeMessageLayout = (
52+
messages: MessageData[],
53+
sourcePosition: IPoint,
54+
targetPosition: IPoint,
55+
isHorizontalEdge: boolean
56+
): MessageLayout => {
57+
const forwardMessages = messages.filter((msg) => msg.direction === "target")
58+
const backwardMessages = messages.filter((msg) => msg.direction === "source")
59+
60+
let sourceArrowDirection: ArrowDirection
61+
let targetArrowDirection: ArrowDirection
62+
if (isHorizontalEdge) {
63+
const sourceIsLeft = sourcePosition.x < targetPosition.x
64+
sourceArrowDirection = sourceIsLeft ? "Left" : "Right"
65+
targetArrowDirection = sourceIsLeft ? "Right" : "Left"
66+
} else {
67+
const sourceIsAbove = sourcePosition.y < targetPosition.y
68+
sourceArrowDirection = sourceIsAbove ? "Up" : "Down"
69+
targetArrowDirection = sourceIsAbove ? "Down" : "Up"
70+
}
71+
72+
const forwardRotation = calculateRotation(targetArrowDirection)
73+
const backwardRotation = calculateRotation(sourceArrowDirection)
74+
75+
const half = ARROW_SIZE / 2
76+
const textInset = LINE_GAP + ARROW_SIZE + ARROW_TEXT_GAP
77+
78+
if (isHorizontalEdge) {
79+
// Centred on the midpoint (text-anchor "middle") with the arrow stacked
80+
// between text and line, so a label never drifts sideways into a neighbour.
81+
const textGap = LINE_GAP + ARROW_SIZE + half + ARROW_TEXT_GAP
82+
return {
83+
forward: {
84+
messages: forwardMessages,
85+
arrowRotation: forwardRotation,
86+
textAnchor: "middle",
87+
arrowOrigin: { x: -half, y: -(LINE_GAP + ARROW_SIZE) },
88+
textOrigin: { x: 0, y: -textGap },
89+
stackStep: { x: 0, y: -STACK_SPACING },
90+
},
91+
backward: {
92+
messages: backwardMessages,
93+
arrowRotation: backwardRotation,
94+
textAnchor: "middle",
95+
arrowOrigin: { x: -half, y: LINE_GAP },
96+
textOrigin: { x: 0, y: textGap },
97+
stackStep: { x: 0, y: STACK_SPACING },
98+
},
99+
}
100+
}
101+
102+
// Vertical: the arrow sits innermost (nearest the line) so the text can anchor
103+
// outward and grow away from the line without ever measuring its width.
104+
return {
105+
forward: {
106+
messages: forwardMessages,
107+
arrowRotation: forwardRotation,
108+
textAnchor: "start",
109+
arrowOrigin: { x: LINE_GAP, y: -half },
110+
textOrigin: { x: textInset, y: 0 },
111+
stackStep: { x: 0, y: STACK_SPACING },
112+
},
113+
backward: {
114+
messages: backwardMessages,
115+
arrowRotation: backwardRotation,
116+
textAnchor: "end",
117+
arrowOrigin: { x: -(LINE_GAP + ARROW_SIZE), y: -half },
118+
textOrigin: { x: -textInset, y: 0 },
119+
stackStep: { x: 0, y: STACK_SPACING },
120+
},
121+
}
122+
}

library/lib/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,5 @@ export * from "./useDragOver"
1414
export * from "./useHandleFinder"
1515
export * from "./useStepPathEdge"
1616
export * from "./useStraightPathEdge"
17-
export * from "./useMessagePositioning"
1817
export * from "./useAssessmentSelection"
1918
export * from "./usePaneClicked"

0 commit comments

Comments
 (0)