Skip to content

Commit 53f3e8d

Browse files
tamang29FelixTJDietrichclaude
authored
feat: add line-jump on edge collision (#706)
Co-authored-by: Felix T.J. Dietrich <felix_dietrich@gmx.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9265b47 commit 53f3e8d

15 files changed

Lines changed: 1533 additions & 36 deletions

library/lib/apollon-editor.tsx

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ import {
2121
createAlignmentGuidesStore,
2222
AlignmentGuidesStore,
2323
} from "@/store/alignmentGuidesStore"
24+
import {
25+
createEdgeGeometryStore,
26+
EdgeGeometryStore,
27+
} from "@/store/edgeGeometryStore"
2428
import {
2529
DiagramStoreContext,
2630
MetadataStoreContext,
2731
PopoverStoreContext,
2832
AssessmentSelectionStoreContext,
2933
AlignmentGuidesStoreContext,
34+
EdgeGeometryStoreContext,
3035
} from "./store/context"
3136
import { MessageType, SendBroadcastMessage, YjsSync } from "./sync/yjsSync"
3237
import * as Y from "yjs"
@@ -76,6 +81,7 @@ export class ApollonEditor {
7681
private readonly popoverStore: StoreApi<PopoverStore>
7782
private readonly assessmentSelectionStore: StoreApi<AssessmentSelectionStore>
7883
private readonly alignmentGuidesStore: StoreApi<AlignmentGuidesStore>
84+
private readonly edgeGeometryStore: StoreApi<EdgeGeometryStore>
7985
private subscribers: Apollon.Subscribers = {}
8086
constructor(element: HTMLElement, options?: Apollon.ApollonOptions) {
8187
if (!(element instanceof HTMLElement)) {
@@ -91,6 +97,7 @@ export class ApollonEditor {
9197
this.popoverStore = createPopoverStore()
9298
this.assessmentSelectionStore = createAssessmentSelectionStore()
9399
this.alignmentGuidesStore = createAlignmentGuidesStore()
100+
this.edgeGeometryStore = createEdgeGeometryStore()
94101
this.syncManager = new YjsSync(
95102
this.ydoc,
96103
this.diagramStore,
@@ -179,22 +186,26 @@ export class ApollonEditor {
179186
<AlignmentGuidesStoreContext.Provider
180187
value={this.alignmentGuidesStore}
181188
>
182-
<AppWithProvider
183-
onReactFlowInit={this.setReactFlowInstance.bind(this)}
184-
collaboration={collaboration}
185-
awareness={{
186-
setLocalAwarenessCursor:
187-
this.syncManager.setLocalAwarenessCursor,
188-
setLocalAwarenessSelectedElement:
189-
this.syncManager.setLocalAwarenessSelectedElement,
190-
subscribeToAwarenessChanges:
191-
this.syncManager.subscribeToAwarenessChanges,
192-
subscribeToCollaboratorChanges:
193-
this.syncManager.subscribeToCollaboratorChanges,
194-
getLocalAwarenessClientId:
195-
this.syncManager.getLocalAwarenessClientId,
196-
}}
197-
/>
189+
<EdgeGeometryStoreContext.Provider
190+
value={this.edgeGeometryStore}
191+
>
192+
<AppWithProvider
193+
onReactFlowInit={this.setReactFlowInstance.bind(this)}
194+
collaboration={collaboration}
195+
awareness={{
196+
setLocalAwarenessCursor:
197+
this.syncManager.setLocalAwarenessCursor,
198+
setLocalAwarenessSelectedElement:
199+
this.syncManager.setLocalAwarenessSelectedElement,
200+
subscribeToAwarenessChanges:
201+
this.syncManager.subscribeToAwarenessChanges,
202+
subscribeToCollaboratorChanges:
203+
this.syncManager.subscribeToCollaboratorChanges,
204+
getLocalAwarenessClientId:
205+
this.syncManager.getLocalAwarenessClientId,
206+
}}
207+
/>
208+
</EdgeGeometryStoreContext.Provider>
198209
</AlignmentGuidesStoreContext.Provider>
199210
</AssessmentSelectionStoreContext.Provider>
200211
</PopoverStoreContext.Provider>
@@ -320,6 +331,7 @@ export class ApollonEditor {
320331
const popoverStore = createPopoverStore()
321332
const assessmentSelectionStore = createAssessmentSelectionStore()
322333
const alignmentGuidesStore = createAlignmentGuidesStore()
334+
const edgeGeometryStore = createEdgeGeometryStore()
323335
const diagramId = Math.random().toString(36).substring(2, 15)
324336

325337
let setReactFlowInstance: (instance: ReactFlowInstance) => void = () => {}
@@ -347,11 +359,13 @@ export class ApollonEditor {
347359
<AlignmentGuidesStoreContext.Provider
348360
value={alignmentGuidesStore}
349361
>
350-
<AppWithProvider
351-
onReactFlowInit={setReactFlowInstance}
352-
collaboration={disabledCollaboration}
353-
awareness={noopCollaborationAwareness}
354-
/>
362+
<EdgeGeometryStoreContext.Provider value={edgeGeometryStore}>
363+
<AppWithProvider
364+
onReactFlowInit={setReactFlowInstance}
365+
collaboration={disabledCollaboration}
366+
awareness={noopCollaborationAwareness}
367+
/>
368+
</EdgeGeometryStoreContext.Provider>
355369
</AlignmentGuidesStoreContext.Provider>
356370
</AssessmentSelectionStoreContext.Provider>
357371
</PopoverStoreContext.Provider>

library/lib/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export const EDGES = Object.freeze({
166166
STEP_BORDER_RADIUS: 0,
167167
/** Width of the invisible stroke used for edge selection/highlighting */
168168
EDGE_HIGHLIGHT_STROKE_WIDTH: 15,
169+
/** Height of the line-jump bridge used when edges cross */
170+
EDGE_LINE_JUMP_HEIGHT: 10,
171+
/** Length of the line-jump bridge along the crossed segment */
172+
EDGE_LINE_JUMP_WIDTH: 16,
169173
/** Stub length locked to node, matches getSmoothStepPath offset */
170174
STUB_LENGTH: 30,
171175
/** Minimum total edge length (screen px) for the endpoint reconnect handles
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useEffect, useMemo } from "react"
2+
import { EDGES } from "@/constants"
3+
import { useEdgeGeometryStore } from "@/store/context"
4+
import { IPoint, pointsToSvgPath } from "../edges/Connection"
5+
import {
6+
LineJumpHit,
7+
buildPathWithLineJumps,
8+
computeLineJumpsForEdge,
9+
} from "@/utils/edgeUtils"
10+
11+
const pointsKey = (points: IPoint[]): string =>
12+
points.map((point) => `${point.x.toFixed(3)},${point.y.toFixed(3)}`).join("|")
13+
14+
/**
15+
* Publishes this edge's actual rendered (jump-free) polyline to the shared
16+
* geometry registry so other edges can bridge over it at its real position,
17+
* and removes it on unmount. `points` should be the edge's live render
18+
* geometry (`renderPoints` for step edges, source/target for straight ones).
19+
*/
20+
export function usePublishEdgeGeometry(
21+
id: string | undefined,
22+
points: IPoint[]
23+
): void {
24+
const publish = useEdgeGeometryStore((state) => state.publishEdgeGeometry)
25+
const remove = useEdgeGeometryStore((state) => state.removeEdgeGeometry)
26+
const key = pointsKey(points)
27+
28+
useEffect(() => {
29+
if (id) publish(id, points)
30+
// points is captured via the stable `key`; publishing is idempotent.
31+
// eslint-disable-next-line react-hooks/exhaustive-deps
32+
}, [id, key, publish])
33+
34+
useEffect(() => {
35+
return () => {
36+
if (id) remove(id)
37+
}
38+
}, [id, remove])
39+
}
40+
41+
/**
42+
* Returns where this edge should bridge over the edges it crosses, using the
43+
* stable horizontal-hops-vertical convention (see `computeLineJumpsForEdge`).
44+
* Other edges' geometry is read from the shared registry (their actual rendered
45+
* points), so a bridge always centers on the crossing the user sees. Pass
46+
* `enabled: false` to skip the scan (e.g. while reconnecting).
47+
*
48+
* Shared by both `useStepPathEdge` and `useStraightPathEdge`; the only
49+
* difference between them is the `basePoints` they feed in.
50+
*/
51+
export function useEdgeLineJumps(
52+
id: string | undefined,
53+
basePoints: IPoint[],
54+
enabled: boolean
55+
): LineJumpHit[] {
56+
const geometryById = useEdgeGeometryStore((state) => state.geometryById)
57+
const baseKey = pointsKey(basePoints)
58+
59+
return useMemo(() => {
60+
if (!enabled || !id) return []
61+
const geometryMap = new Map<string, IPoint[]>(Object.entries(geometryById))
62+
const edges = Object.keys(geometryById).map((edgeId) => ({ id: edgeId }))
63+
return computeLineJumpsForEdge(id, basePoints, edges, geometryMap)
64+
// basePoints is captured via the stable baseKey string.
65+
// eslint-disable-next-line react-hooks/exhaustive-deps
66+
}, [enabled, id, baseKey, geometryById])
67+
}
68+
69+
/**
70+
* Builds the SVG path for an edge, drawing bridge arcs at the given crossings.
71+
* Falls back to a plain polyline when there are no jumps.
72+
*/
73+
export function buildEdgePath(
74+
points: IPoint[],
75+
lineJumps: LineJumpHit[]
76+
): string {
77+
if (lineJumps.length === 0) return pointsToSvgPath(points)
78+
79+
// Different "other" edges can report the same crossing; collapse duplicates
80+
// (by segment + whole-pixel point) so only one arc is drawn per crossing.
81+
const seen = new Set<string>()
82+
const uniqueJumps = lineJumps.filter((jump) => {
83+
const key = `${jump.segmentIndex}:${Math.round(jump.point.x)}:${Math.round(
84+
jump.point.y
85+
)}`
86+
if (seen.has(key)) return false
87+
seen.add(key)
88+
return true
89+
})
90+
91+
return buildPathWithLineJumps(
92+
points,
93+
uniqueJumps,
94+
EDGES.EDGE_LINE_JUMP_HEIGHT,
95+
EDGES.EDGE_LINE_JUMP_WIDTH
96+
)
97+
}

library/lib/hooks/useStepPathEdge.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ import {
4040
} from "@/utils/geometry/bendHandles"
4141
import { useEdgeState } from "../edges/GenericEdge"
4242
import { useDiagramModifiable } from "./useDiagramModifiable"
43+
import {
44+
useEdgeLineJumps,
45+
usePublishEdgeGeometry,
46+
buildEdgePath,
47+
} from "./useEdgeLineJumps"
4348

4449
interface UseStepPathEdgeProps {
4550
id: string
@@ -344,9 +349,19 @@ export const useStepPathEdge = ({
344349
// so a live preview never leaks into the store mid-drag.
345350
const renderPoints = dragPreviewPoints ?? activePoints
346351

347-
const currentPath = useMemo(() => {
348-
return pointsToSvgPath(renderPoints)
349-
}, [renderPoints])
352+
// Bridge over edges this one crosses. Computed from `renderPoints` so the
353+
// arcs follow a live bend drag frame-by-frame (during a bend only THIS edge's
354+
// points change, so only its own scan re-runs — cheap). Suppressed only while
355+
// reconnecting, where the preview is drawn separately by ReconnectConnectionLine.
356+
// Publish this edge's real geometry so other edges bridge over it accurately;
357+
// read others' geometry to bridge over them.
358+
usePublishEdgeGeometry(id, renderPoints)
359+
const lineJumps = useEdgeLineJumps(id, renderPoints, !isReconnecting)
360+
361+
const currentPath = useMemo(
362+
() => buildEdgePath(renderPoints, lineJumps),
363+
[renderPoints, lineJumps]
364+
)
350365

351366
const markerSegmentPath = useMemo(
352367
() => getMarkerSegmentPath(renderPoints, offset, targetPosition),

library/lib/hooks/useStraightPathEdge.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import {
1717
isLengthEditableAtZoom,
1818
} from "@/utils/geometry/bendHandles"
1919
import { useMetadataStore } from "@/store/context"
20+
import {
21+
useEdgeLineJumps,
22+
usePublishEdgeGeometry,
23+
buildEdgePath,
24+
} from "./useEdgeLineJumps"
2025

2126
export interface StraightPathEdgeData {
2227
pathMiddlePosition: IPoint
@@ -69,6 +74,36 @@ export const useStraightPathEdge = ({
6974
EDGES.SOURCE_CONNECTION_POINT_PADDING
7075
)
7176

77+
const basePoints = useMemo<IPoint[]>(
78+
() => [
79+
{
80+
x: adjustedSourceCoordinates.sourceX,
81+
y: adjustedSourceCoordinates.sourceY,
82+
},
83+
{
84+
x: adjustedTargetCoordinates.targetX,
85+
y: adjustedTargetCoordinates.targetY,
86+
},
87+
],
88+
[
89+
adjustedSourceCoordinates.sourceX,
90+
adjustedSourceCoordinates.sourceY,
91+
adjustedTargetCoordinates.targetX,
92+
adjustedTargetCoordinates.targetY,
93+
]
94+
)
95+
96+
// Publish this edge's real geometry so other edges bridge over it accurately.
97+
usePublishEdgeGeometry(id, basePoints)
98+
99+
// UseCase include/extend edges are dashed connectors that never read as
100+
// crossings to disambiguate, so they opt out of bridging.
101+
const lineJumps = useEdgeLineJumps(
102+
id,
103+
basePoints,
104+
!isReconnecting && type !== "UseCaseInclude" && type !== "UseCaseExtend"
105+
)
106+
72107
const [pathMiddlePosition, setPathMiddlePosition] = useState<IPoint>(() => ({
73108
x:
74109
(adjustedSourceCoordinates.sourceX + adjustedTargetCoordinates.targetX) /
@@ -90,6 +125,7 @@ export const useStraightPathEdge = ({
90125
)
91126

92127
const currentPath = useMemo(() => {
128+
if (lineJumps.length > 0) return buildEdgePath(basePoints, lineJumps)
93129
return calculateStraightPath(
94130
adjustedSourceCoordinates.sourceX,
95131
adjustedSourceCoordinates.sourceY,
@@ -103,10 +139,14 @@ export const useStraightPathEdge = ({
103139
adjustedTargetCoordinates.targetX,
104140
adjustedTargetCoordinates.targetY,
105141
type,
106-
targetPosition,
142+
basePoints,
143+
lineJumps,
107144
])
108145

109146
const overlayPath = useMemo(() => {
147+
// When bridging, the arc'd path is the hit target too, so the selectable
148+
// stroke matches exactly what's drawn.
149+
if (lineJumps.length > 0) return currentPath
110150
return calculateOverlayPath(
111151
adjustedSourceCoordinates.sourceX,
112152
adjustedSourceCoordinates.sourceY,
@@ -120,7 +160,8 @@ export const useStraightPathEdge = ({
120160
adjustedTargetCoordinates.targetX,
121161
adjustedTargetCoordinates.targetY,
122162
type,
123-
targetPosition,
163+
currentPath,
164+
lineJumps,
124165
])
125166

126167
useEffect(() => {
@@ -205,14 +246,7 @@ export const useStraightPathEdge = ({
205246
adjustedTargetCoordinates.targetY,
206247
])
207248

208-
const sourcePoint = {
209-
x: adjustedSourceCoordinates.sourceX,
210-
y: adjustedSourceCoordinates.sourceY,
211-
}
212-
const targetPoint = {
213-
x: adjustedTargetCoordinates.targetX,
214-
y: adjustedTargetCoordinates.targetY,
215-
}
249+
const [sourcePoint, targetPoint] = basePoints
216250
const canvasLength = Math.hypot(
217251
targetPoint.x - sourcePoint.x,
218252
targetPoint.y - sourcePoint.y

library/lib/store/context.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MetadataStore } from "./metadataStore"
55
import { PopoverStore } from "./popoverStore"
66
import { AssessmentSelectionStore } from "./assessmentSelectionStore"
77
import { AlignmentGuidesStore } from "./alignmentGuidesStore"
8+
import { EdgeGeometryStore } from "./edgeGeometryStore"
89

910
export const DiagramStoreContext = createContext<StoreApi<DiagramStore> | null>(
1011
null
@@ -23,6 +24,9 @@ export const AssessmentSelectionStoreContext =
2324
export const AlignmentGuidesStoreContext =
2425
createContext<StoreApi<AlignmentGuidesStore> | null>(null)
2526

27+
export const EdgeGeometryStoreContext =
28+
createContext<StoreApi<EdgeGeometryStore> | null>(null)
29+
2630
// Custom hooks for components
2731
export const useDiagramStore = <T>(selector: (state: DiagramStore) => T): T => {
2832
const store = useContext(DiagramStoreContext)
@@ -59,3 +63,11 @@ export const useAlignmentGuidesStore = <T>(
5963
if (!store) throw new Error("AlignmentGuidesStoreContext not provided")
6064
return useStore(store, selector)
6165
}
66+
67+
export const useEdgeGeometryStore = <T>(
68+
selector: (state: EdgeGeometryStore) => T
69+
): T => {
70+
const store = useContext(EdgeGeometryStoreContext)
71+
if (!store) throw new Error("EdgeGeometryStoreContext not provided")
72+
return useStore(store, selector)
73+
}

0 commit comments

Comments
 (0)