Skip to content

Commit a20926b

Browse files
fix(library): separate required interface sockets
1 parent e77a977 commit a20926b

17 files changed

Lines changed: 568 additions & 51 deletions

.changeset/orthogonal-edge-routing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@tumaet/apollon": minor
33
---
44

5-
Edges now route themselves far more cleanly. They avoid unnecessary corners and crossings, run straight when two shapes line up, leave a node from a suitable side, and divide each available node side into balanced grid-aligned stretches — one edge centres itself, two edges make three even gaps, and larger groups spread the same way — instead of piling into a corner, stepping when they could be straight, or looping around another edge. Required-interface sockets stay concentric with their interface circles, including interfaces imported at an older size. Pin multiple endpoints to the same seat to form a stable shared junction; its common trunk is preserved intentionally, and opposing pinned branches use a balanced grid-aligned turn lane. Dragging an endpoint preserves its attachment while the route remains eligible for layout; dragging a bend preserves the route you authored. In both cases, neighbouring edges adapt without jumping on release, and node dragging remains substantially more responsive in edge-dense diagrams. Automatic results are deterministic, so every collaborator and a reloaded page see the same picture. Existing v3 diagrams and older v4 files with pre-routing edge data continue to open and are upgraded automatically.
5+
Edges now route themselves far more cleanly. They avoid unnecessary corners and crossings, run straight when two shapes line up, leave a node from a suitable side, and divide each available node side into balanced grid-aligned stretches — one edge centres itself, two edges make three even gaps, and larger groups spread the same way — instead of piling into a corner, stepping when they could be straight, or looping around another edge. Required-interface sockets stay concentric with their interface circles, including interfaces imported at an older size; relationship lines stop with visible breathing room before the socket, while its arc adapts to the sides the finished routes actually use. Pin multiple endpoints to the same seat to form a stable shared junction; its common trunk is preserved intentionally, and opposing pinned branches use a balanced grid-aligned turn lane. Dragging an endpoint preserves its attachment while the route remains eligible for layout; dragging a bend preserves the route you authored. In both cases, neighbouring edges adapt without jumping on release, and node dragging remains substantially more responsive in edge-dense diagrams. Automatic results are deterministic, so every collaborator and a reloaded page see the same picture. Existing v3 diagrams and older v4 files with pre-routing edge data continue to open and are upgraded automatically.

library/lib/constants.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,11 @@ export const MARKER_CONFIGS = Object.freeze({
291291
size: INTERFACE_SOCKET_SIZE,
292292
widthFactor: 1,
293293
heightFactor: 1,
294-
arcSpanDegrees: 180,
294+
// A slightly embracing socket reads more clearly around the ball than a
295+
// mathematically exact half-circle, especially when approached from a
296+
// cardinal side. Keep a generous opening so adjacent sockets remain
297+
// visually distinct.
298+
arcSpanDegrees: 210,
295299
},
296300
"required-interface-quarter": {
297301
type: "semicircle",

library/lib/edges/edgeTypes/ComponentDiagramEdge.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useToolbar } from "@/hooks"
77
import { FeedbackDropzone } from "@/components/wrapper/FeedbackDropzone"
88
import { AssessmentSelectableWrapper } from "@/components"
99
import { getCustomColorsFromDataForEdge } from "@/utils/layoutUtils"
10-
import { resolveRequiredInterfaceEdgeType } from "@/utils"
10+
import { useRequiredInterfaceEdgeType } from "@/hooks/useRequiredInterfaceEdgeType"
1111

1212
const COMPONENT_REQUIRED_INTERFACE_TYPES = [
1313
"ComponentRequiredInterface",
@@ -44,23 +44,17 @@ export const ComponentDiagramEdge = ({
4444
const allowMidpointDragging =
4545
"allowMidpointDragging" in config ? config.allowMidpointDragging : true
4646

47-
const { edges, assessments } = useDiagramStore(
48-
useShallow((state) => ({
49-
edges: state.edges,
50-
assessments: state.assessments,
51-
}))
52-
)
47+
const assessments = useDiagramStore(useShallow((state) => state.assessments))
5348

5449
const setPopOverElementId = usePopoverStore(
5550
useShallow((state) => state.setPopOverElementId)
5651
)
5752

58-
const dynamicEdgeType = resolveRequiredInterfaceEdgeType({
53+
const dynamicEdgeType = useRequiredInterfaceEdgeType({
5954
type,
6055
id,
6156
target,
6257
targetHandleId,
63-
edges,
6458
requiredTypes: COMPONENT_REQUIRED_INTERFACE_TYPES,
6559
defaultType: "ComponentRequiredInterface",
6660
reducedType: "ComponentRequiredQuarterInterface",

library/lib/edges/edgeTypes/DeploymentDiagramEdge.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useToolbar } from "@/hooks"
88
import { FeedbackDropzone } from "@/components/wrapper/FeedbackDropzone"
99
import { AssessmentSelectableWrapper } from "@/components"
1010
import { getCustomColorsFromDataForEdge } from "@/utils/layoutUtils"
11-
import { resolveRequiredInterfaceEdgeType } from "@/utils"
11+
import { useRequiredInterfaceEdgeType } from "@/hooks/useRequiredInterfaceEdgeType"
1212

1313
const DEPLOYMENT_REQUIRED_INTERFACE_TYPES = [
1414
"DeploymentRequiredInterface",
@@ -47,23 +47,17 @@ export const DeploymentDiagramEdge = ({
4747
const showRelationshipLabels =
4848
"showRelationshipLabels" in config ? config.showRelationshipLabels : false
4949

50-
const { edges, assessments } = useDiagramStore(
51-
useShallow((state) => ({
52-
edges: state.edges,
53-
assessments: state.assessments,
54-
}))
55-
)
50+
const assessments = useDiagramStore(useShallow((state) => state.assessments))
5651

5752
const setPopOverElementId = usePopoverStore(
5853
useShallow((state) => state.setPopOverElementId)
5954
)
6055

61-
const dynamicEdgeType = resolveRequiredInterfaceEdgeType({
56+
const dynamicEdgeType = useRequiredInterfaceEdgeType({
6257
type,
6358
id,
6459
target,
6560
targetHandleId,
66-
edges,
6761
requiredTypes: DEPLOYMENT_REQUIRED_INTERFACE_TYPES,
6862
defaultType: "DeploymentRequiredInterface",
6963
reducedType: "DeploymentRequiredQuarterInterface",

library/lib/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export * from "./useKeyboardShortcuts"
1313
export * from "./useDragOver"
1414
export * from "./useStepPathEdge"
1515
export * from "./useStraightPathEdge"
16+
export * from "./useRequiredInterfaceEdgeType"
1617
export * from "./useAssessmentSelection"
1718
export * from "./usePaneClicked"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useMemo } from "react"
2+
import { useShallow } from "zustand/shallow"
3+
import { useDiagramStore, useEdgeGeometryStore } from "@/store/context"
4+
import { getEndpointSideFromSegment } from "@/utils/edgeUtils"
5+
import { resolveRequiredInterfaceEdgeType } from "@/utils/requiredInterfaceUtils"
6+
7+
type RequiredInterfaceEdgeTypeOptions = {
8+
type: string
9+
id: string
10+
target: string
11+
targetHandleId?: string | null
12+
requiredTypes: readonly string[]
13+
defaultType: string
14+
reducedType: string
15+
}
16+
17+
/**
18+
* Choose a socket span from the actual holistic route sides. Stored handle ids
19+
* remain the pre-solve fallback, but once auto-layout moves a terminal segment
20+
* the marker follows the visible geometry. Only routes sharing this interface
21+
* are selected, so unrelated geometry updates do not re-render the edge.
22+
*/
23+
export function useRequiredInterfaceEdgeType({
24+
type,
25+
id,
26+
target,
27+
targetHandleId,
28+
requiredTypes,
29+
defaultType,
30+
reducedType,
31+
}: RequiredInterfaceEdgeTypeOptions): string {
32+
const edges = useDiagramStore(useShallow((state) => state.edges))
33+
const requiredTargetEdges = useMemo(
34+
() =>
35+
edges.filter(
36+
(edge) =>
37+
edge.target === target &&
38+
edge.type !== undefined &&
39+
requiredTypes.includes(edge.type)
40+
),
41+
[edges, requiredTypes, target]
42+
)
43+
const requiredTargetIds = useMemo(
44+
() => requiredTargetEdges.map((edge) => edge.id),
45+
[requiredTargetEdges]
46+
)
47+
const targetPositions = useEdgeGeometryStore(
48+
useShallow((state) =>
49+
requiredTargetIds.map((edgeId) => {
50+
const route = state.previewById[edgeId] ?? state.geometryById[edgeId]
51+
return route && route.length >= 2
52+
? getEndpointSideFromSegment(
53+
route[route.length - 1],
54+
route[route.length - 2]
55+
)
56+
: undefined
57+
})
58+
)
59+
)
60+
const targetPositionByEdgeId = useMemo(
61+
() =>
62+
new Map(
63+
requiredTargetIds.flatMap((edgeId, index) => {
64+
const position = targetPositions[index]
65+
return position ? [[edgeId, position] as const] : []
66+
})
67+
),
68+
[requiredTargetIds, targetPositions]
69+
)
70+
71+
return resolveRequiredInterfaceEdgeType({
72+
type,
73+
id,
74+
target,
75+
targetHandleId,
76+
edges,
77+
requiredTypes,
78+
defaultType,
79+
reducedType,
80+
targetPositionByEdgeId,
81+
})
82+
}

library/lib/hooks/useStepPathEdge.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
resolveOrthogonalEdgeReleasePoints,
3636
isInvalidOrthogonalEdgeRelease,
3737
getSideHandleIdForPosition,
38+
getEndpointSideFromSegment,
39+
getTargetConnectionPointPadding,
3840
isFreeformEdgeAnchor,
3941
roundAnchorPointOutward,
4042
type FreeformEdgeAnchor,
@@ -136,18 +138,6 @@ const arePointsEqual = (a: IPoint[], b: IPoint[]): boolean =>
136138
a.length === b.length &&
137139
a.every((point, index) => point.x === b[index].x && point.y === b[index].y)
138140

139-
/** The node side an endpoint sits on, read off the route: `from` is the endpoint,
140-
* `toward` the next point along. The first step leaves the source along its side,
141-
* so the direction from the source to its neighbour IS that side; at the target,
142-
* pass the points in reverse (endpoint, then inward neighbour). */
143-
const sideFromSegment = (from: IPoint, toward: IPoint): Position => {
144-
const dx = toward.x - from.x
145-
const dy = toward.y - from.y
146-
if (Math.abs(dx) >= Math.abs(dy))
147-
return dx >= 0 ? Position.Right : Position.Left
148-
return dy >= 0 ? Position.Bottom : Position.Top
149-
}
150-
151141
export const useStepPathEdge = ({
152142
id,
153143
type,
@@ -406,7 +396,10 @@ export const useStepPathEdge = ({
406396
const sourceConnectionPointPadding = resolvedSourceAnchor
407397
? 0
408398
: EDGES.SOURCE_CONNECTION_POINT_PADDING
409-
const targetConnectionPointPadding = resolvedTargetAnchor ? 0 : padding
399+
const targetConnectionPointPadding = getTargetConnectionPointPadding(
400+
padding,
401+
resolvedTargetAnchor !== null
402+
)
410403

411404
// Round coordinates to whole pixels for pixel-perfect rendering
412405
// React Flow may return fractional values when node dimensions are odd
@@ -445,10 +438,10 @@ export const useStepPathEdge = ({
445438
const routeEndpoints =
446439
centralRoute && centralRoute.length >= 2 ? centralRoute : null
447440
const sourcePosition = routeEndpoints
448-
? sideFromSegment(routeEndpoints[0], routeEndpoints[1])
441+
? getEndpointSideFromSegment(routeEndpoints[0], routeEndpoints[1])
449442
: baseSourcePosition
450443
const targetPosition = routeEndpoints
451-
? sideFromSegment(
444+
? getEndpointSideFromSegment(
452445
routeEndpoints[routeEndpoints.length - 1],
453446
routeEndpoints[routeEndpoints.length - 2]
454447
)

library/lib/hooks/useStraightPathEdge.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getEdgeMarkerStyles,
1515
adjustSourceCoordinates,
1616
adjustTargetCoordinates,
17+
getTargetConnectionPointPadding,
1718
getSideHandleIdForPosition,
1819
isFreeformEdgeAnchor,
1920
roundAnchorPointOutward,
@@ -262,7 +263,10 @@ export const useStraightPathEdge = ({
262263
const sourceConnectionPointPadding = resolvedSourceAnchor
263264
? 0
264265
: EDGES.SOURCE_CONNECTION_POINT_PADDING
265-
const targetConnectionPointPadding = resolvedTargetAnchor ? 0 : padding
266+
const targetConnectionPointPadding = getTargetConnectionPointPadding(
267+
padding,
268+
resolvedTargetAnchor !== null
269+
)
266270

267271
// Round coordinates to whole pixels for pixel-perfect rendering
268272
// React Flow may return fractional values when node dimensions are odd

library/lib/utils/edgeUtils.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ export const adjustSourceCoordinates = (
5252
return { sourceX, sourceY }
5353
}
5454

55+
/**
56+
* Convert marker padding from React Flow handle coordinates to a
57+
* shape-projected anchor.
58+
*
59+
* Unpinned endpoints start at an RF handle whose center is
60+
* `-EDGES.MARKER_PADDING` pixels outside the node. A projected/pinned anchor
61+
* already lies on the exact shape boundary, so only the marker-specific
62+
* remainder belongs there. Keeping this conversion shared prevents the
63+
* centralized router and the two rendering hooks from disagreeing.
64+
*/
65+
export const getTargetConnectionPointPadding = (
66+
markerPadding: number,
67+
hasResolvedAnchor: boolean
68+
): number =>
69+
hasResolvedAnchor ? markerPadding - EDGES.MARKER_PADDING : markerPadding
70+
71+
/**
72+
* Resolve the node side occupied by an endpoint from its terminal segment.
73+
* `from` is the endpoint and `toward` is its neighboring route point, so this
74+
* works for a source as-is and for a target when the route is read backwards.
75+
*/
76+
export const getEndpointSideFromSegment = (
77+
from: IPoint,
78+
toward: IPoint
79+
): Position => {
80+
const dx = toward.x - from.x
81+
const dy = toward.y - from.y
82+
if (Math.abs(dx) >= Math.abs(dy))
83+
return dx >= 0 ? Position.Right : Position.Left
84+
return dy >= 0 ? Position.Bottom : Position.Top
85+
}
86+
5587
/**
5688
* Round a shape-projected connection point without moving it back inside its
5789
* node. Fractional text measurements make this distinction observable at the
@@ -280,26 +312,36 @@ export function getEdgeMarkerStyles(edgeType: string): EdgeMarkerStyles {
280312
case "ComponentRequiredInterface":
281313
case "DeploymentRequiredInterface":
282314
return {
283-
// markerPadding = MARKER_PADDING + gap
315+
// markerPadding = MARKER_PADDING + ball/socket gap + line/socket gap
284316
// MARKER_PADDING (-3) compensates for React Flow handle offset
285-
// gap is the spacing between socket arc and ball circle
286-
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
317+
// SOCKET_GAP keeps the arc off the ball, while EDGE_SOCKET_GAP keeps
318+
// the relationship line from visually merging into the arc.
319+
markerPadding:
320+
EDGES.MARKER_PADDING +
321+
INTERFACE.SOCKET_GAP +
322+
INTERFACE.EDGE_SOCKET_GAP,
287323
markerEnd: "url(#required-interface)",
288324
strokeDashArray: "0",
289325
offset: 0,
290326
}
291327
case "ComponentRequiredQuarterInterface":
292328
case "DeploymentRequiredQuarterInterface":
293329
return {
294-
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
330+
markerPadding:
331+
EDGES.MARKER_PADDING +
332+
INTERFACE.SOCKET_GAP +
333+
INTERFACE.EDGE_SOCKET_GAP,
295334
markerEnd: "url(#required-interface-quarter)",
296335
strokeDashArray: "0",
297336
offset: 0,
298337
}
299338
case "ComponentRequiredThreeQuarterInterface":
300339
case "DeploymentRequiredThreeQuarterInterface":
301340
return {
302-
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
341+
markerPadding:
342+
EDGES.MARKER_PADDING +
343+
INTERFACE.SOCKET_GAP +
344+
INTERFACE.EDGE_SOCKET_GAP,
303345
markerEnd: "url(#required-interface-threequarter)",
304346
strokeDashArray: "0",
305347
offset: 0,

library/lib/utils/geometry/edgeGeometrySolver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
adjustSourceCoordinates,
1313
adjustTargetCoordinates,
1414
getEdgeMarkerStyles,
15+
getTargetConnectionPointPadding,
1516
preserveOrthogonalEdgePoints,
1617
isFreeformEdgeAnchor,
1718
roundAnchorPointOutward,
@@ -191,7 +192,10 @@ function resolveEdgeEndpoints(
191192
const sourceConnectionPointPadding = resolvedSourceAnchor
192193
? 0
193194
: EDGES.SOURCE_CONNECTION_POINT_PADDING
194-
const targetConnectionPointPadding = resolvedTargetAnchor ? 0 : padding
195+
const targetConnectionPointPadding = getTargetConnectionPointPadding(
196+
padding,
197+
resolvedTargetAnchor !== null
198+
)
195199

196200
const roundedSource = resolvedSourceAnchor
197201
? roundAnchorPointOutward(resolvedSourceAnchor.point, sourcePosition)

0 commit comments

Comments
 (0)