Skip to content

Commit 0cf3be3

Browse files
fix(library): refine required interface seams
1 parent 3a58786 commit 0cf3be3

8 files changed

Lines changed: 160 additions & 51 deletions

File tree

.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; 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.
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 join the socket cleanly, while its arc adapts to the sides the finished routes actually use and remains visually distinct from neighbouring sockets and provided edges. 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: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,27 +291,28 @@ export const MARKER_CONFIGS = Object.freeze({
291291
size: INTERFACE_SOCKET_SIZE,
292292
widthFactor: 1,
293293
heightFactor: 1,
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,
294+
// Two opposing sockets leave an 8° seam at both joins instead of merging
295+
// into a visually indistinguishable 360° ring. At the canonical 18px
296+
// socket radius, 8° is the smallest grid-friendly seam that also clears a
297+
// provided-interface line passing through it at the current stroke width.
298+
arcSpanDegrees: 172,
299299
},
300300
"required-interface-quarter": {
301301
type: "semicircle",
302302
filled: false,
303303
size: INTERFACE_SOCKET_SIZE,
304304
widthFactor: 1,
305305
heightFactor: 1,
306-
arcSpanDegrees: 90,
306+
// Adjacent cardinal sockets retain the same 5° seam.
307+
arcSpanDegrees: 85,
307308
},
308309
"required-interface-threequarter": {
309310
type: "semicircle",
310311
filled: false,
311312
size: INTERFACE_SOCKET_SIZE,
312313
widthFactor: 1,
313314
heightFactor: 1,
314-
arcSpanDegrees: 270,
315+
arcSpanDegrees: 265,
315316
},
316317
// BPMN markers - compact style
317318
"bpmn-white-triangle": {

library/lib/utils/edgeUtils.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -312,36 +312,27 @@ export function getEdgeMarkerStyles(edgeType: string): EdgeMarkerStyles {
312312
case "ComponentRequiredInterface":
313313
case "DeploymentRequiredInterface":
314314
return {
315-
// markerPadding = MARKER_PADDING + ball/socket gap + line/socket gap
315+
// markerPadding = MARKER_PADDING + ball/socket gap
316316
// MARKER_PADDING (-3) compensates for React Flow handle offset
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,
317+
// SOCKET_GAP moves the line endpoint onto the socket's outermost point:
318+
// the relationship joins its arc, while the arc stays off the ball.
319+
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
323320
markerEnd: "url(#required-interface)",
324321
strokeDashArray: "0",
325322
offset: 0,
326323
}
327324
case "ComponentRequiredQuarterInterface":
328325
case "DeploymentRequiredQuarterInterface":
329326
return {
330-
markerPadding:
331-
EDGES.MARKER_PADDING +
332-
INTERFACE.SOCKET_GAP +
333-
INTERFACE.EDGE_SOCKET_GAP,
327+
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
334328
markerEnd: "url(#required-interface-quarter)",
335329
strokeDashArray: "0",
336330
offset: 0,
337331
}
338332
case "ComponentRequiredThreeQuarterInterface":
339333
case "DeploymentRequiredThreeQuarterInterface":
340334
return {
341-
markerPadding:
342-
EDGES.MARKER_PADDING +
343-
INTERFACE.SOCKET_GAP +
344-
INTERFACE.EDGE_SOCKET_GAP,
335+
markerPadding: EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP,
345336
markerEnd: "url(#required-interface-threequarter)",
346337
strokeDashArray: "0",
347338
offset: 0,

library/lib/utils/geometry/routingConstants.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,15 @@ export const CANVAS = Object.freeze({
1818
const INTERFACE_SIZE = 30
1919
const INTERFACE_RADIUS = INTERFACE_SIZE / 2
2020
const INTERFACE_STROKE_WIDTH = 2
21-
const INTERFACE_SOCKET_GAP = 4
22-
// Keep the relationship line visibly separate from the required-interface
23-
// socket. React Flow places its handle center 3px outside the node; routing
24-
// padding accounts for that independently, so this is the actual canvas-space
25-
// air gap between the line endpoint and the socket stroke centerline.
26-
const INTERFACE_EDGE_SOCKET_GAP = 3
21+
// The socket is concentric with the provided-interface ball. With two 2px
22+
// strokes, a 3px centerline difference leaves a crisp 1px visible gap.
23+
const INTERFACE_SOCKET_GAP = 3
2724

2825
export const INTERFACE = Object.freeze({
2926
SIZE: INTERFACE_SIZE,
3027
RADIUS: INTERFACE_RADIUS,
3128
STROKE_WIDTH: INTERFACE_STROKE_WIDTH,
3229
SOCKET_GAP: INTERFACE_SOCKET_GAP,
33-
EDGE_SOCKET_GAP: INTERFACE_EDGE_SOCKET_GAP,
3430
} as const)
3531

3632
export const EDGES = Object.freeze({

library/tests/unit/edgeGeometrySolver.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe("computeAllEdgeGeometry", () => {
9696
"ComponentRequiredInterface",
9797
"ComponentRequiredQuarterInterface",
9898
"ComponentRequiredThreeQuarterInterface",
99-
])("keeps %s lines separated from a pinned interface socket", (type) => {
99+
])("joins %s lines to a pinned interface socket", (type) => {
100100
const source = makeNode("source", 0, 0)
101101
const target = makeNode(
102102
"interface",
@@ -135,10 +135,7 @@ describe("computeAllEdgeGeometry", () => {
135135
const route = routeById.required
136136
const targetPoint = route[route.length - 1]
137137
expect(targetPoint).toEqual({
138-
x:
139-
target.node.position.x -
140-
INTERFACE.SOCKET_GAP -
141-
INTERFACE.EDGE_SOCKET_GAP,
138+
x: target.node.position.x - INTERFACE.SOCKET_GAP,
142139
y: target.node.position.y + INTERFACE.RADIUS,
143140
})
144141
})

library/tests/unit/edgeUtils.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ describe("getEdgeMarkerStyles", () => {
274274
{
275275
type: "ComponentRequiredInterface",
276276
end: "url(#required-interface)",
277-
pad: MP + INTERFACE.SOCKET_GAP + INTERFACE.EDGE_SOCKET_GAP,
277+
pad: MP + INTERFACE.SOCKET_GAP,
278278
},
279279
{
280280
type: "ComponentRequiredQuarterInterface",
@@ -312,11 +312,10 @@ describe("getTargetConnectionPointPadding", () => {
312312
expect(getTargetConnectionPointPadding(EDGES.MARKER_PADDING, true)).toBe(0)
313313
})
314314

315-
it("preserves the full ball-to-socket and line-to-socket gaps on an exact interface anchor", () => {
316-
const requiredPadding =
317-
EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP + INTERFACE.EDGE_SOCKET_GAP
315+
it("preserves the ball-to-socket gap on an exact interface anchor", () => {
316+
const requiredPadding = EDGES.MARKER_PADDING + INTERFACE.SOCKET_GAP
318317
expect(getTargetConnectionPointPadding(requiredPadding, true)).toBe(
319-
INTERFACE.SOCKET_GAP + INTERFACE.EDGE_SOCKET_GAP
318+
INTERFACE.SOCKET_GAP
320319
)
321320
})
322321
})

library/tests/unit/markerGeometry.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,16 @@ describe("class diagram marker geometry", () => {
6060
})
6161

6262
describe("required interface marker geometry", () => {
63-
it("wraps the standard socket beyond a half-circle without closing it", () => {
63+
it("leaves a stroke-safe seam between opposing standard sockets", () => {
6464
const span = MARKER_CONFIGS["required-interface"].arcSpanDegrees
65-
expect(span).toBeGreaterThan(180)
66-
expect(span).toBeLessThan(270)
65+
expect(span).toBe(172)
66+
expect(180 - span).toBeGreaterThanOrEqual(5)
67+
})
68+
69+
it("leaves a five-degree seam between adjacent reduced sockets", () => {
70+
const span = MARKER_CONFIGS["required-interface-quarter"].arcSpanDegrees
71+
expect(span).toBe(85)
72+
expect(90 - span).toBe(5)
6773
})
6874

6975
it.each([
@@ -98,8 +104,8 @@ describe("required interface marker geometry", () => {
98104
)
99105

100106
it("scales its bounds with imported interface sizes", () => {
101-
expect(getMarkerHalfHeight("required-interface", 10)).toBe(14)
102-
expect(getMarkerHalfHeight("required-interface", 15)).toBe(19)
107+
expect(getMarkerHalfHeight("required-interface", 10)).toBeCloseTo(13, 1)
108+
expect(getMarkerHalfHeight("required-interface", 15)).toBeCloseTo(18, 1)
103109
})
104110

105111
it.each([
@@ -108,7 +114,7 @@ describe("required interface marker geometry", () => {
108114
{ position: Position.Right, direction: Math.PI },
109115
{ position: Position.Bottom, direction: -Math.PI / 2 },
110116
])(
111-
"keeps a visible line-to-socket gap when entering from $position",
117+
"joins the line to the socket when entering from $position",
112118
({ position, direction }) => {
113119
const center = { x: 100, y: 100 }
114120
const towardTarget = {
@@ -146,7 +152,7 @@ describe("required interface marker geometry", () => {
146152

147153
expect(
148154
Math.hypot(edgeEnd.x - socketPoint.x, edgeEnd.y - socketPoint.y)
149-
).toBe(INTERFACE.EDGE_SOCKET_GAP)
155+
).toBeCloseTo(0, 10)
150156
}
151157
)
152158
})

standalone/webapp/tests/visual/diagrams.visual.spec.ts

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ const REQUIRED_INTERFACE_TYPES = new Set([
7070
"ComponentRequiredThreeQuarterInterface",
7171
"DeploymentRequiredThreeQuarterInterface",
7272
])
73-
const STANDARD_REQUIRED_INTERFACE_ARC_RADIANS = (210 * Math.PI) / 180
73+
const STANDARD_REQUIRED_INTERFACE_ARC_RADIANS = (172 * Math.PI) / 180
7474

7575
/**
7676
* Whole-editor screenshots intentionally tolerate a small amount of
7777
* anti-aliasing noise. A required-interface socket occupies too few pixels for
7878
* that threshold to protect its topology, so assert its SVG geometry exactly:
79-
* the relationship must stop before the arc, and each socket variant must keep
79+
* the relationship must join the arc, and each socket variant must keep
8080
* its intended angular span.
8181
*/
8282
async function expectRequiredInterfaceGeometry(
@@ -125,7 +125,7 @@ async function expectRequiredInterfaceGeometry(
125125
}
126126
})
127127

128-
expect(geometry.minimumGap).toBeCloseTo(3, 1)
128+
expect(geometry.minimumGap).toBeCloseTo(0, 1)
129129
const explicitExpectedArc = expectedArcByEdgeId?.[edge.id]
130130
if (explicitExpectedArc === undefined) {
131131
// Every interface in the canonical fixtures has one required edge. Its
@@ -142,6 +142,62 @@ async function expectRequiredInterfaceGeometry(
142142
}
143143
}
144144

145+
async function expectProvidedInterfaceClearsRequiredSockets(
146+
page: Page,
147+
providedEdgeId: string,
148+
requiredEdgeIds: readonly string[]
149+
) {
150+
const clearance = await page.evaluate(
151+
({ providedEdgeId, requiredEdgeIds }) => {
152+
const edgePath = (edgeId: string) =>
153+
document.querySelector<SVGPathElement>(
154+
`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`
155+
)
156+
const provided = edgePath(providedEdgeId)
157+
const sockets = requiredEdgeIds.map((edgeId) =>
158+
document.querySelector<SVGPathElement>(
159+
`.react-flow__edge[data-id="${edgeId}"] path[data-inline-marker="true"]`
160+
)
161+
)
162+
if (!provided || sockets.some((socket) => socket === null))
163+
throw new Error("provided/required interface paths are absent")
164+
165+
const sample = (path: SVGPathElement, count: number) =>
166+
Array.from({ length: count + 1 }, (_, index) =>
167+
path.getPointAtLength((path.getTotalLength() * index) / count)
168+
)
169+
const providedPoints = sample(provided, 360)
170+
let minimumDistance = Number.POSITIVE_INFINITY
171+
for (const socket of sockets as SVGPathElement[]) {
172+
for (const edgePoint of providedPoints) {
173+
for (const socketPoint of sample(socket, 720)) {
174+
minimumDistance = Math.min(
175+
minimumDistance,
176+
Math.hypot(
177+
edgePoint.x - socketPoint.x,
178+
edgePoint.y - socketPoint.y
179+
)
180+
)
181+
}
182+
}
183+
}
184+
185+
return {
186+
minimumDistance,
187+
providedHalfStroke:
188+
Number.parseFloat(getComputedStyle(provided).strokeWidth) / 2,
189+
}
190+
},
191+
{ providedEdgeId, requiredEdgeIds }
192+
)
193+
194+
// The required arcs use butt caps, so clearing the provided line's
195+
// half-stroke is the exact non-intersection condition at their seam.
196+
expect(clearance.minimumDistance).toBeGreaterThan(
197+
clearance.providedHalfStroke
198+
)
199+
}
200+
145201
// All 13 diagram fixtures with human-readable name + kebab-case file slug.
146202
// `fitView: true` triggers the ReactFlow fit-view button so the full diagram
147203
// is visible — needed for diagrams that overflow the viewport at zoom 1.0.
@@ -306,6 +362,47 @@ const requiredInterfaceCombinationFixture = (
306362
assessments: {},
307363
})
308364

365+
const requiredInterfaceWithProvidedFixture = (() => {
366+
const fixture = requiredInterfaceCombinationFixture("socket-provided", {
367+
x: 485,
368+
y: 545,
369+
})
370+
return {
371+
...fixture,
372+
nodes: [
373+
...fixture.nodes,
374+
{
375+
id: "socket-provided-subsystem",
376+
width: 180,
377+
height: 120,
378+
type: "componentSubsystem",
379+
position: { x: 725, y: 365 },
380+
data: { name: "Subsystem", isComponentSubsystemHeaderShown: true },
381+
measured: { width: 180, height: 120 },
382+
},
383+
],
384+
edges: [
385+
...fixture.edges,
386+
{
387+
id: "socket-provided-line",
388+
source: "socket-provided-subsystem",
389+
target: "socket-provided-interface",
390+
type: "ComponentProvidedInterface",
391+
sourceHandle: "left",
392+
targetHandle: "right",
393+
data: {
394+
points: [
395+
{ x: 725, y: 435 },
396+
{ x: 610, y: 435 },
397+
],
398+
sourceAnchor: { side: "left", ratio: 7 / 12 },
399+
targetAnchor: { side: "right", ratio: 0.5 },
400+
},
401+
},
402+
],
403+
}
404+
})()
405+
309406
test.describe("Required interface socket combinations", () => {
310407
const cases = [
311408
{
@@ -322,7 +419,7 @@ test.describe("Required interface socket combinations", () => {
322419
x: 320,
323420
y: 390,
324421
}),
325-
expectedArc: Math.PI / 2,
422+
expectedArc: (85 * Math.PI) / 180,
326423
},
327424
]
328425

@@ -337,6 +434,28 @@ test.describe("Required interface socket combinations", () => {
337434
})
338435
})
339436
}
437+
438+
test("a provided edge passes cleanly through the seam between required sockets", async ({
439+
page,
440+
}) => {
441+
const fixture = requiredInterfaceWithProvidedFixture
442+
await injectFixtureIntoLocalStorage(page, fixture)
443+
await page.goto(resolveLocalDiagramRoute(fixture))
444+
await waitForCanvasReady(page)
445+
const requiredEdgeIds = [
446+
"socket-provided-side",
447+
"socket-provided-top",
448+
] as const
449+
await expectRequiredInterfaceGeometry(page, fixture, {
450+
[requiredEdgeIds[0]]: STANDARD_REQUIRED_INTERFACE_ARC_RADIANS,
451+
[requiredEdgeIds[1]]: STANDARD_REQUIRED_INTERFACE_ARC_RADIANS,
452+
})
453+
await expectProvidedInterfaceClearsRequiredSockets(
454+
page,
455+
"socket-provided-line",
456+
requiredEdgeIds
457+
)
458+
})
340459
})
341460

342461
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)