Skip to content

Commit 7cb7f8a

Browse files
committed
feat: refactor dirty tracking and group APIs for better modularity and maintainability
1 parent d1c1fb8 commit 7cb7f8a

File tree

6 files changed

+594
-512
lines changed

6 files changed

+594
-512
lines changed
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
part of 'node_flow_controller.dart';
2+
3+
/// Dirty tracking and spatial index management for [NodeFlowController].
4+
///
5+
/// This extension groups all the dirty tracking logic for efficient spatial
6+
/// index updates. The pattern defers spatial index updates during drag
7+
/// operations and batches them when the drag ends.
8+
///
9+
/// ## Key Concepts
10+
///
11+
/// - **Dirty tracking**: Nodes/connections are marked "dirty" during drag
12+
/// - **Deferred updates**: Spatial index updates are deferred to pending sets
13+
/// - **Batch flush**: All pending updates are flushed when drag ends
14+
/// - **Connection index**: O(1) lookup of connections by node ID
15+
///
16+
/// ## Performance
17+
///
18+
/// During drag operations, spatial index updates are expensive and can cause
19+
/// jank. This extension defers updates until the drag ends, then batches all
20+
/// updates together for better performance.
21+
extension DirtyTrackingExtension<T> on NodeFlowController<T> {
22+
// ============================================================================
23+
// State Queries
24+
// ============================================================================
25+
26+
/// Checks if any drag operation is in progress.
27+
bool get _isAnyDragInProgress => interaction.draggedNodeId.value != null;
28+
29+
/// Checks if spatial index updates should be deferred.
30+
/// Updates are deferred during drag UNLESS debug mode is on (for live visualization).
31+
bool get _shouldDeferSpatialUpdates =>
32+
_isAnyDragInProgress && !(_theme?.debugMode.isEnabled ?? false);
33+
34+
// ============================================================================
35+
// Internal API (library-private)
36+
// ============================================================================
37+
38+
/// Marks a node as needing spatial index update.
39+
///
40+
/// If no drag is in progress (or debug mode is on), updates immediately.
41+
/// Otherwise, defers until drag ends.
42+
void _markNodeDirty(String nodeId) {
43+
if (_shouldDeferSpatialUpdates) {
44+
_pendingNodeUpdates.add(nodeId);
45+
// Also mark connected connections as dirty
46+
final connectedIds = _connectionsByNodeId[nodeId];
47+
if (connectedIds != null) {
48+
_pendingConnectionUpdates.addAll(connectedIds);
49+
}
50+
} else {
51+
// Immediate update
52+
final node = _nodes[nodeId];
53+
if (node != null) {
54+
_spatialIndex.update(node);
55+
_updateConnectionBoundsForNode(nodeId);
56+
}
57+
}
58+
}
59+
60+
/// Marks multiple nodes as needing spatial index update.
61+
void _markNodesDirty(Iterable<String> nodeIds) {
62+
if (_shouldDeferSpatialUpdates) {
63+
_pendingNodeUpdates.addAll(nodeIds);
64+
// Also mark connected connections as dirty
65+
for (final nodeId in nodeIds) {
66+
final connectedIds = _connectionsByNodeId[nodeId];
67+
if (connectedIds != null) {
68+
_pendingConnectionUpdates.addAll(connectedIds);
69+
}
70+
}
71+
} else {
72+
// Immediate update
73+
_spatialIndex.batch(() {
74+
for (final nodeId in nodeIds) {
75+
final node = _nodes[nodeId];
76+
if (node != null) {
77+
_spatialIndex.update(node);
78+
}
79+
}
80+
});
81+
_updateConnectionBoundsForNodeIds(nodeIds);
82+
}
83+
}
84+
85+
/// Flushes all pending spatial index updates synchronously.
86+
///
87+
/// This method should be called after drag operations end to ensure the
88+
/// spatial index is up-to-date before performing hit tests. Normally the
89+
/// flush happens via a MobX reaction, but that's asynchronous. This method
90+
/// allows synchronous flushing when immediate hit testing is needed.
91+
void flushPendingSpatialUpdates() {
92+
_flushPendingSpatialUpdates();
93+
}
94+
95+
// ============================================================================
96+
// Internal Implementation
97+
// ============================================================================
98+
99+
/// Flushes all pending spatial index updates.
100+
/// Called when drag operations end.
101+
void _flushPendingSpatialUpdates() {
102+
bool hadUpdates = false;
103+
104+
// Flush node updates
105+
if (_pendingNodeUpdates.isNotEmpty) {
106+
hadUpdates = true;
107+
_spatialIndex.batch(() {
108+
for (final nodeId in _pendingNodeUpdates) {
109+
final node = _nodes[nodeId];
110+
if (node != null) {
111+
_spatialIndex.update(node);
112+
}
113+
}
114+
});
115+
_pendingNodeUpdates.clear();
116+
}
117+
118+
// Flush connection updates using proper segment bounds
119+
if (_pendingConnectionUpdates.isNotEmpty) {
120+
hadUpdates = true;
121+
_flushPendingConnectionUpdates();
122+
_pendingConnectionUpdates.clear();
123+
}
124+
125+
// Always notify at the end of flush to ensure debug layer updates
126+
// even if all pending updates were handled via batch (which also notifies)
127+
if (hadUpdates) {
128+
// Force a final notification to ensure observers are updated
129+
_spatialIndex.notifyChanged();
130+
}
131+
}
132+
133+
/// Returns a signature of all path-affecting theme properties.
134+
/// Used by the reaction to detect when spatial index needs rebuilding.
135+
Object _getPathAffectingSignature() {
136+
final theme = _themeObservable.value;
137+
if (theme == null) return const Object();
138+
139+
final conn = theme.connectionTheme;
140+
// Return a tuple of all properties that affect connection path geometry
141+
return (
142+
conn.style.id,
143+
conn.bezierCurvature,
144+
conn.cornerRadius,
145+
conn.portExtension,
146+
conn.backEdgeGap,
147+
conn.startGap,
148+
conn.endGap,
149+
conn.hitTolerance,
150+
theme.portTheme.size,
151+
);
152+
}
153+
154+
/// Flushes pending connection updates using proper segment bounds from path cache.
155+
void _flushPendingConnectionUpdates() {
156+
if (!isConnectionPainterInitialized || _theme == null) return;
157+
158+
final pathCache = _connectionPainter!.pathCache;
159+
final connectionStyle = _theme!.connectionTheme.style;
160+
161+
for (final connectionId in _pendingConnectionUpdates) {
162+
final connection = _connections.firstWhere(
163+
(c) => c.id == connectionId,
164+
orElse: () => throw StateError('Connection not found: $connectionId'),
165+
);
166+
167+
final sourceNode = _nodes[connection.sourceNodeId];
168+
final targetNode = _nodes[connection.targetNodeId];
169+
if (sourceNode == null || targetNode == null) continue;
170+
171+
final segments = pathCache.getOrCreateSegmentBounds(
172+
connection: connection,
173+
sourceNode: sourceNode,
174+
targetNode: targetNode,
175+
connectionStyle: connectionStyle,
176+
);
177+
_spatialIndex.updateConnection(connection, segments);
178+
}
179+
}
180+
181+
/// Rebuilds the connection-by-node index for O(1) lookup.
182+
void _rebuildConnectionsByNodeIndex() {
183+
_connectionsByNodeId.clear();
184+
for (final connection in _connections) {
185+
_connectionsByNodeId
186+
.putIfAbsent(connection.sourceNodeId, () => {})
187+
.add(connection.id);
188+
_connectionsByNodeId
189+
.putIfAbsent(connection.targetNodeId, () => {})
190+
.add(connection.id);
191+
}
192+
}
193+
194+
/// Updates spatial index bounds for a single node's connections using proper segment bounds.
195+
void _updateConnectionBoundsForNode(String nodeId) {
196+
// Use the API method that calculates proper segment bounds from path cache
197+
rebuildConnectionSegmentsForNodes([nodeId]);
198+
}
199+
200+
/// Updates spatial index bounds for connections attached to the given nodes.
201+
void _updateConnectionBoundsForNodeIds(Iterable<String> nodeIds) {
202+
// Use the API method that calculates proper segment bounds from path cache
203+
rebuildConnectionSegmentsForNodes(nodeIds.toList());
204+
}
205+
}

0 commit comments

Comments
 (0)