Skip to content

Commit e311a3e

Browse files
feat(library): route and lay out straight-edge diagrams
1 parent a731b12 commit e311a3e

26 files changed

Lines changed: 4070 additions & 108 deletions

.changeset/curly-cats-rescue.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
Keep straight connections clear automatically. They now choose facing sides, spread
6+
sibling links across shared borders, and route around nodes and nearby connections
7+
without adding unnecessary bends; clear shots remain straight and hand-placed
8+
waypoints stay authoritative. Syntax trees also gain a one-click tidy layout that
9+
arranges valid parent-child forests while leaving malformed portions untouched.

library/lib/apollon-editor.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,23 @@ export class ApollonEditor {
405405
requestAnimationFrame(attempt)
406406
}
407407

408+
/**
409+
* Arrange a syntax-tree diagram into a tidy hierarchical layout, so straight
410+
* parent→child links no longer overlap sibling nodes (issue #282). Derives the
411+
* hierarchy from `SyntaxTreeLink` edges; malformed graphs (forests, cycles,
412+
* multi-parent) are laid out where clean and left untouched elsewhere. It is a
413+
* single undo step and a no-op on non-syntax-tree diagrams.
414+
*/
415+
public layoutSyntaxTree(): void {
416+
if (
417+
this.metadataStore.getState().diagramType !== UMLDiagramType.SyntaxTree
418+
) {
419+
return
420+
}
421+
this.diagramStore.getState().layoutSyntaxTree()
422+
this.fitView()
423+
}
424+
408425
// ---- Canvas overlay / control API -------------------------------------
409426
// A library-owned overlay engine: host chrome (header, rails, banners) and the
410427
// editor's own overlays share one measured, inset-aware layout. Controls

library/lib/chrome/builtins/ZoomControls.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useReactFlow, useStore } from "@xyflow/react"
22
import { useShallow } from "zustand/shallow"
33
import {
4+
ListTree,
45
Maximize,
56
Redo2,
67
SquareMousePointer,
@@ -13,6 +14,8 @@ import {
1314
useMetadataStore,
1415
useOverlayStore,
1516
} from "@/store/context"
17+
import { useDiagramModifiable } from "@/hooks/useDiagramModifiable"
18+
import { UMLDiagramType } from "@/types"
1619
import { insetAwareFitView } from "@/overlay/fitView"
1720
import { ariaKeyshortcuts } from "@/keyboard"
1821
import { Tooltip } from "@/components/ui"
@@ -48,12 +51,17 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
4851
}))
4952
)
5053

51-
const { multiSelectionMode, setMultiSelectionMode } = useMetadataStore(
52-
useShallow((state) => ({
53-
multiSelectionMode: state.multiSelectionMode,
54-
setMultiSelectionMode: state.setMultiSelectionMode,
55-
}))
56-
)
54+
const { multiSelectionMode, setMultiSelectionMode, isSyntaxTree } =
55+
useMetadataStore(
56+
useShallow((state) => ({
57+
multiSelectionMode: state.multiSelectionMode,
58+
setMultiSelectionMode: state.setMultiSelectionMode,
59+
isSyntaxTree: state.diagramType === UMLDiagramType.SyntaxTree,
60+
}))
61+
)
62+
const layoutSyntaxTree = useDiagramStore((state) => state.layoutSyntaxTree)
63+
const isModifiable = useDiagramModifiable()
64+
const showTidyLayout = isSyntaxTree && isModifiable
5765

5866
const { ref: toolbarRef, onKeyDown: onToolbarKeyDown } =
5967
useRovingToolbar<HTMLDivElement>()
@@ -124,6 +132,18 @@ export function ZoomControls({ history = true }: ZoomControlsProps) {
124132
<SquareMousePointer width={18} height={18} aria-hidden="true" />
125133
</button>
126134
</Tooltip>
135+
{showTidyLayout && (
136+
<Tooltip title={t.tidyLayoutHint}>
137+
<button
138+
type="button"
139+
className="apollon-chrome-iconbtn"
140+
onClick={() => layoutSyntaxTree()}
141+
aria-label={t.tidyLayout}
142+
>
143+
<ListTree width={18} height={18} aria-hidden="true" />
144+
</button>
145+
</Tooltip>
146+
)}
127147
</div>
128148

129149
{history && undoManagerExist && (

library/lib/components/ConnectionPreviewLine.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ export const ConnectionPreviewLine = ({
124124
nativeTargetId !== undefined &&
125125
nativeTargetId !== fromNodeId &&
126126
nativeTargetPosition !== undefined
127+
// A valid native handle is an exact attachment point, not merely a side hint.
128+
// Resolve that point into the pending edge too, so the central solver cannot
129+
// replace the native ghost endpoint with an automatic facing-side anchor.
130+
const nativeDropTarget = hasNativeTarget
131+
? resolveDropTarget({ x: toX, y: toY }, fromNodeId)
132+
: null
133+
const nativeAnchor =
134+
nativeDropTarget && nativeDropTarget.id === nativeTargetId
135+
? getEdgeAnchorFromPoint(
136+
nativeDropTarget.type,
137+
{ x: toX, y: toY },
138+
nativeDropTarget.rect
139+
)
140+
: null
127141
const target = hasNativeTarget
128142
? null
129143
: resolveDropTarget(pointer, fromNodeId)
@@ -159,7 +173,8 @@ export const ConnectionPreviewLine = ({
159173
: freeformTarget
160174
? getSideHandleIdForPosition(freeformTarget.position)
161175
: undefined,
162-
targetAnchor: hit && dropAnchorIsAimed(hit.type) ? anchor : null,
176+
targetAnchor:
177+
nativeAnchor ?? (hit && dropAnchorIsAimed(hit.type) ? anchor : null),
163178
snapPoint: freeformTarget?.showSnapCircle ? freeformTarget.point : null,
164179
visible: draggedFar || hasNativeTarget || freeformTarget !== null,
165180
}

library/lib/edges/GenericEdge.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -528,8 +528,10 @@ export const EdgeBendHandle = ({
528528
* handle on the point itself, not the step edge's elongated segment pill, and it
529529
* carries a `move` cursor because it travels in two dimensions rather than one.
530530
*
531-
* Every authored interior vertex gets one. Segment midpoints carry the same opaque
532-
* handle as step-edge bendable segments; dragging one materialises a waypoint.
531+
* Every interior vertex of the rendered route gets one, whether the user placed it
532+
* or the router did: dragging an automatic bend is how you take ownership of a route
533+
* the solver chose. Segment midpoints carry the same opaque handle as step-edge
534+
* bendable segments; dragging one materialises a waypoint.
533535
*/
534536
export const EdgeWaypointHandles = ({
535537
route,
@@ -542,7 +544,7 @@ export const EdgeWaypointHandles = ({
542544
}: {
543545
/** Full route `[source, ...interior, target]`. */
544546
route: IPoint[]
545-
/** The editable authored interior vertices. */
547+
/** The editable interior vertices — every bend on the rendered route. */
546548
interior: IPoint[]
547549
selectedWaypointIndex: number | null
548550
onWaypointPointerDown: (

library/lib/hooks/useConnect.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,40 @@ export const useConnect = () => {
219219
const onConnectEnd: OnConnectEnd = useCallback(
220220
(event, connectionState) => {
221221
try {
222-
if (!connectionState.isValid) {
222+
if (connectionState.isValid) {
223+
// A valid native handle is an exact attachment point, not merely a side
224+
// hint. Persist the preview's resolved anchor on commit; otherwise the
225+
// central solver can immediately move the endpoint to an automatic seat.
226+
const edgeId = pendingConnectionId.current
227+
const dropPosition = getDropPosition(event)
228+
const nodeOnTop = resolveDropTarget(
229+
dropPosition,
230+
connectionState.fromNode?.id
231+
)
232+
if (edgeId && nodeOnTop) {
233+
const anchor = getEdgeAnchorFromPoint(
234+
nodeOnTop.type,
235+
dropPosition,
236+
nodeOnTop.rect
237+
)
238+
if (anchor) {
239+
const endpoint =
240+
connectionStartParams.current?.handleType === "target"
241+
? "source"
242+
: "target"
243+
setEdges((eds) =>
244+
eds.map((edge) =>
245+
edge.id === edgeId
246+
? {
247+
...edge,
248+
data: withEndpointAnchor(edge.data, endpoint, anchor),
249+
}
250+
: edge
251+
)
252+
)
253+
}
254+
}
255+
} else {
223256
const dropPosition = getDropPosition(event)
224257
const nodeOnTop = resolveDropTarget(
225258
dropPosition,

library/lib/hooks/useStraightPathEdge.ts

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -386,41 +386,23 @@ export const useStraightPathEdge = ({
386386
}),
387387
[adjustedTargetCoordinates.targetX, adjustedTargetCoordinates.targetY]
388388
)
389-
// The authored route is source → interior waypoints → target. Automatic
390-
// straight-edge routing is deliberately a separate concern: this branch
391-
// renders only user intent and the endpoint preview already present on main.
389+
// The synchronous truth: source → interior waypoints → target. Used as the
390+
// fallback before the solver's route lands and whenever the solver route is
391+
// stale (its endpoints no longer match, e.g. mid node-move) so the edge never
392+
// detaches from its nodes.
392393
const basePoints = useMemo<IPoint[]>(
393394
() => [sourceEndpoint, ...interiorPoints, targetEndpoint],
394395
[sourceEndpoint, interiorPoints, targetEndpoint]
395396
)
396-
const centralPreviewMatchesCommit =
397-
dragPreviewPoints !== null &&
398-
endpointPreviewCommit !== null &&
399-
centralRoute !== undefined &&
400-
centralRoute.length >= 2 &&
401-
(endpointPreviewCommit.endpoint === "source"
402-
? centralRoute[0].x === endpointPreviewCommit.sourceEndpoint.x &&
403-
centralRoute[0].y === endpointPreviewCommit.sourceEndpoint.y
404-
: centralRoute[centralRoute.length - 1].x ===
405-
endpointPreviewCommit.targetEndpoint.x &&
406-
centralRoute[centralRoute.length - 1].y ===
407-
endpointPreviewCommit.targetEndpoint.y)
397+
// The solver's committed route is the source of truth: its endpoints are the
398+
// facing-side attachment sites the port assignment chose (not the drawn handle),
399+
// and it carries any automatic obstacle-avoidance bends. Fall back to the analytic
400+
// base polyline only before the first solve lands (never painted after that).
408401
const renderPoints = useMemo<IPoint[]>(
409402
() =>
410-
centralPreviewMatchesCommit
411-
? [
412-
centralRoute[0],
413-
...interiorPoints,
414-
centralRoute[centralRoute.length - 1],
415-
]
416-
: (dragPreviewPoints ?? basePoints),
417-
[
418-
basePoints,
419-
centralPreviewMatchesCommit,
420-
centralRoute,
421-
dragPreviewPoints,
422-
interiorPoints,
423-
]
403+
dragPreviewPoints ??
404+
(centralRoute && centralRoute.length >= 2 ? centralRoute : basePoints),
405+
[basePoints, centralRoute, dragPreviewPoints]
424406
)
425407
const renderSourcePosition =
426408
dragPreviewPositions?.sourcePosition ?? resolvedSourcePosition
@@ -505,8 +487,10 @@ export const useStraightPathEdge = ({
505487

506488
const sourcePoint = renderPoints[0]
507489
const targetPoint = renderPoints[renderPoints.length - 1]
508-
// Every authored bend is editable. Automatic detours are introduced by the
509-
// separate auto-layout/routing change and can later reuse the same controls.
490+
// Every bend on the RENDERED route is editable, not only the authored ones. An
491+
// automatic detour is a perfectly good starting point for a hand-placed route:
492+
// dragging one of its bends is how the user takes ownership of it, exactly as
493+
// dragging a computed step edge freezes its path into manual points.
510494
const editableWaypoints = useMemo<IPoint[]>(
511495
() => (dragHandleRoute ?? renderPoints).slice(1, -1),
512496
[dragHandleRoute, renderPoints]
@@ -777,9 +761,11 @@ export const useStraightPathEdge = ({
777761
]
778762
)
779763

780-
// Persist the interior waypoints and pin their visible endpoints when the first
781-
// bend is authored. This mirrors the step-edge bend-commit behaviour and keeps
782-
// later geometry updates from moving a hand-shaped route at its ends.
764+
// Persist the interior waypoints. A bend customises the whole visible route,
765+
// including the facing-side attachment sites the port assignment chose, so on the
766+
// first bend the endpoints are pinned to `pinSource`/`pinTarget` (when still
767+
// automatic) — otherwise a newly-bent edge would snap its endpoints back to the
768+
// drawn handle. Mirrors the step-edge bend-commit behaviour.
783769
const commitWaypoints = useCallback(
784770
(nextInterior: IPoint[], pinSource: IPoint, pinTarget: IPoint) => {
785771
setEdges((edges) =>
@@ -825,7 +811,8 @@ export const useStraightPathEdge = ({
825811

826812
// Shared drag routine for both an existing waypoint and a freshly materialised
827813
// one. `startInterior` is the interior array the drag operates on; `index` is the
828-
// waypoint being moved. Only pointer-up commits `data.points`.
814+
// waypoint being moved. The live route is published so neighbouring step edges
815+
// reflow around the dragged diagonal, and only pointer-up commits `data.points`.
829816
const beginWaypointDrag = useCallback(
830817
(
831818
pointerId: number,
@@ -839,8 +826,10 @@ export const useStraightPathEdge = ({
839826
dragInteriorRef.current = startInterior
840827
dragMovedRef.current = false
841828
dragCollapseRef.current = false
842-
// Capture the endpoints at gesture start so the preview and eventual commit
843-
// pivot around stable attachment sites.
829+
// The endpoints the drag pivots around are the CURRENTLY RENDERED attachment
830+
// sites (the solver's facing-side ports), captured at gesture start — not the
831+
// drawn handle — so the route and the pinned commit keep the edge attached
832+
// exactly where it is on screen.
844833
const routeSource = sourcePoint
845834
const routeTarget = targetPoint
846835
const collapseTolerance =
@@ -851,7 +840,8 @@ export const useStraightPathEdge = ({
851840
const angleReference = routeAtStart[index + 2] ?? routeAtStart[index]
852841

853842
// Drive the preview through state; the existing layout effect republishes it
854-
// to shared edge geometry and clears it when the drag ends.
843+
// as an authoritative live override so neighbouring step edges reflow around
844+
// the dragged diagonal, and clears it when the drag ends.
855845
const publish = (
856846
pathInterior: IPoint[],
857847
handleInterior: IPoint[] = pathInterior

library/lib/i18n/labels.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface ApollonLabels {
2121
redoHint: string
2222
multiSelection: string
2323
multiSelectionHint: string
24+
/** Syntax-tree tidy-layout button (accessible name). */
25+
tidyLayout: string
26+
/** Syntax-tree tidy-layout tooltip. */
27+
tidyLayoutHint: string
2428
/** Accessible name for a straight-edge segment midpoint create handle. */
2529
addEdgeWaypoint: string
2630
/** Accessible name for an authored straight-edge waypoint handle. */
@@ -311,6 +315,8 @@ export const DEFAULT_LABELS: ApollonLabels = Object.freeze<ApollonLabels>({
311315
redoHint: "Redo (Ctrl+Y or Ctrl+Shift+Z)",
312316
multiSelection: "Select multiple elements",
313317
multiSelectionHint: "Select multiple: click elements to add or remove",
318+
tidyLayout: "Tidy tree layout",
319+
tidyLayoutHint: "Arrange the syntax tree so links no longer overlap nodes",
314320
addEdgeWaypoint: "Drag to add a waypoint",
315321
moveEdgeWaypoint:
316322
"Waypoint: drag to move, double-click or press Delete to remove",

library/lib/store/diagramStore.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
STORE_ORIGIN,
2020
} from "@/sync/ydoc"
2121
import { recordStoreNodeWrite } from "@/sync/perfCounters"
22+
import { computeTidyLayout } from "@/utils/tidyTree"
2223
import { deepEqual } from "@/utils/storeUtils"
2324
import { Assessment, DraggingNode, InteractiveElements } from "@/typings"
2425
import {
@@ -153,6 +154,9 @@ export type DiagramStore = {
153154
*/
154155
endTransientNodeBroadcast: () => void
155156
setNodes: (payload: Node[] | ((nodes: Node[]) => Node[])) => void
157+
/** Reposition syntax-tree nodes into a tidy hierarchical layout (issue #282).
158+
* A single undo step; a no-op on non-syntax-tree or already-tidy diagrams. */
159+
layoutSyntaxTree: () => void
156160
setEdges: (payload: Edge[] | ((edges: Edge[]) => Edge[])) => void
157161
setNodesAndEdges: (nodes: Node[], edges: Edge[]) => void
158162
addEdge: (edge: Edge) => void
@@ -503,6 +507,13 @@ export const createDiagramStore = (
503507
)
504508
},
505509

510+
layoutSyntaxTree: () => {
511+
// Tidy the syntax tree by repositioning nodes (issue #282). Routed
512+
// through `setNodes`, so it is a single Yjs transaction / one undo step
513+
// and the deep-equal guard makes an already-tidy layout a no-op.
514+
get().setNodes(computeTidyLayout(get().nodes, get().edges))
515+
},
516+
506517
setEdges: (payload) => {
507518
const edges =
508519
typeof payload === "function" ? payload(get().edges) : payload

0 commit comments

Comments
 (0)