diff --git a/packages/vyuh_node_flow/CHANGELOG.md b/packages/vyuh_node_flow/CHANGELOG.md index 9d7fc0a..2923e78 100644 --- a/packages/vyuh_node_flow/CHANGELOG.md +++ b/packages/vyuh_node_flow/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.28.1 + + - **FEAT**: remove viewport sync policy and optimize grid rendering logic. + +## 0.28.0 + + - **FEAT**: improve panning and connection performance. + ## 0.27.2 - **FEAT**(mixins): export `GroupableMixin` and `ResizableMixin`, update footer/trailing null checks. diff --git a/packages/vyuh_node_flow/lib/src/connections/connection_painter.dart b/packages/vyuh_node_flow/lib/src/connections/connection_painter.dart index 18c4a9a..a43df2c 100644 --- a/packages/vyuh_node_flow/lib/src/connections/connection_painter.dart +++ b/packages/vyuh_node_flow/lib/src/connections/connection_painter.dart @@ -63,6 +63,7 @@ class ConnectionPainter { double? animationValue, bool skipEndpoints = false, ConnectionStyle? overrideStyle, + bool useDrawOnlyCache = false, }) { // Get effective path style: // 1. Use overrideStyle from builder (if provided) @@ -71,13 +72,21 @@ class ConnectionPainter { overrideStyle ?? connection.getEffectiveStyle(theme.connectionTheme.style); - // Get or create path using the cache with connection style - final path = _pathCache.getOrCreatePath( - connection: connection, - sourceNode: sourceNode, - targetNode: targetNode, - connectionStyle: effectiveStyle, - ); + // Get or create path using either full cache (with hit data) or + // draw-only cache (without hit data) for interaction-heavy frames. + final path = useDrawOnlyCache + ? _pathCache.getOrCreateDrawPath( + connection: connection, + sourceNode: sourceNode, + targetNode: targetNode, + connectionStyle: effectiveStyle, + ) + : _pathCache.getOrCreatePath( + connection: connection, + sourceNode: sourceNode, + targetNode: targetNode, + connectionStyle: effectiveStyle, + ); if (path == null) { return; // Failed to create path @@ -251,6 +260,7 @@ class ConnectionPainter { Port? targetPort, Rect? sourceNodeBounds, Rect? targetNodeBounds, + ConnectionStyle? overrideStyle, double? animationValue, }) { final connectionTheme = theme.temporaryConnectionTheme; @@ -308,6 +318,7 @@ class ConnectionPainter { isSelected: false, isTemporary: true, drawTargetEndpoint: targetPort != null, + overrideStyle: overrideStyle, animationValue: animationValue, ); } @@ -358,13 +369,17 @@ class ConnectionPainter { bool isSelected = false, bool isTemporary = false, bool drawTargetEndpoint = true, + ConnectionStyle? overrideStyle, double? animationValue, }) { // Get theme components based on connection type final connectionTheme = isTemporary ? theme.temporaryConnectionTheme : theme.connectionTheme; - final connectionStyle = connectionTheme.style; + // Temporary edges should reflect the same geometry as final edges. + // Keep temporary visuals (color/dash/endpoints), but route with final theme params. + final routingTheme = isTemporary ? theme.connectionTheme : connectionTheme; + final connectionStyle = overrideStyle ?? routingTheme.style; // Create connection path parameters and generate path from segments // For temporary connections, sourceOffset/targetOffset computed properties @@ -373,12 +388,12 @@ class ConnectionPainter { final pathParams = ConnectionPathParameters( start: source.linePos, end: target.linePos, - curvature: connectionTheme.bezierCurvature, + curvature: routingTheme.bezierCurvature, sourcePort: sourcePort, targetPort: targetPort, - cornerRadius: connectionTheme.cornerRadius, - offset: connectionTheme.portExtension, - backEdgeGap: connectionTheme.backEdgeGap, + cornerRadius: routingTheme.cornerRadius, + offset: routingTheme.portExtension, + backEdgeGap: routingTheme.backEdgeGap, sourceNodeBounds: sourceNodeBounds, targetNodeBounds: targetNodeBounds, ); diff --git a/packages/vyuh_node_flow/lib/src/connections/connection_path_cache.dart b/packages/vyuh_node_flow/lib/src/connections/connection_path_cache.dart index 691fe60..bc20a66 100644 --- a/packages/vyuh_node_flow/lib/src/connections/connection_path_cache.dart +++ b/packages/vyuh_node_flow/lib/src/connections/connection_path_cache.dart @@ -14,6 +14,8 @@ class _CachedConnectionPath { required this.originalPath, required this.hitTestPath, required this.segmentBounds, + required this.styleHash, + required this.hasHitTestData, required this.sourcePosition, required this.targetPosition, required this.startGap, @@ -33,6 +35,12 @@ class _CachedConnectionPath { /// Rectangle bounds for each path segment, used for spatial indexing final List segmentBounds; + /// Hash of the [ConnectionStyle] used to generate this cache entry. + final int styleHash; + + /// Whether hit-test geometry (segment bounds + hit test path) was generated. + final bool hasHitTestData; + /// Cached node positions for invalidation final Offset sourcePosition; final Offset targetPosition; @@ -121,11 +129,17 @@ class ConnectionPathCache { final hitTolerance = tolerance ?? defaultHitTolerance; final connectionStyle = theme.connectionTheme.style; + final styleHash = connectionStyle.hashCode; + final connectionTheme = theme.connectionTheme; + final currentStartGap = connection.startGap ?? connectionTheme.startGap; + final currentEndGap = connection.endGap ?? connectionTheme.endGap; // Get cached path final cachedPath = _getCachedPath(connection.id); final currentSourcePos = sourceNode.position.value; final currentTargetPos = targetNode.position.value; + final currentSourceSize = sourceNode.size.value; + final currentTargetSize = targetNode.size.value; // Get current port offsets for cache invalidation final sourcePort = sourceNode.findPort(connection.sourcePortId); @@ -133,15 +147,23 @@ class ConnectionPathCache { final currentSourcePortOffset = sourcePort?.offset ?? Offset.zero; final currentTargetPortOffset = targetPort?.offset ?? Offset.zero; - // Check if cache is valid (positions and port offsets match) - final cacheValid = - cachedPath != null && - cachedPath.sourcePosition == currentSourcePos && - cachedPath.targetPosition == currentTargetPos && - cachedPath.sourcePortOffset == currentSourcePortOffset && - cachedPath.targetPortOffset == currentTargetPortOffset; + // Check if cache is valid (positions, sizes, style and port offsets match). + final cacheValid = _isCacheEntryValid( + existing: cachedPath, + sourcePosition: currentSourcePos, + targetPosition: currentTargetPos, + sourceNodeSize: currentSourceSize, + targetNodeSize: currentTargetSize, + startGap: currentStartGap, + endGap: currentEndGap, + sourcePortOffset: currentSourcePortOffset, + targetPortOffset: currentTargetPortOffset, + styleHash: styleHash, + requiresHitTestData: true, + ); if (cacheValid) { + final cached = cachedPath!; // Use the pre-computed hit test path (already expanded for tolerance) // Only recompute if tolerance differs significantly from default if ((hitTolerance - defaultHitTolerance).abs() > 1.0) { @@ -149,11 +171,11 @@ class ConnectionPathCache { // Note: We'd need the segments to do this properly, but for now // we can use the cached path bounds with adjusted tolerance // This is a simplification - for full accuracy we'd cache segments too - return cachedPath.hitTestPath.contains(testPoint); + return cached.hitTestPath.contains(testPoint); } // Use cached hit test path (most common case) - return cachedPath.hitTestPath.contains(testPoint); + return cached.hitTestPath.contains(testPoint); } // Cache is stale or missing - compute path on-demand for hit testing @@ -165,8 +187,9 @@ class ConnectionPathCache { sourcePosition: currentSourcePos, targetPosition: currentTargetPos, connectionStyle: connectionStyle, - startGap: connection.startGap ?? theme.connectionTheme.startGap, - endGap: connection.endGap ?? theme.connectionTheme.endGap, + startGap: currentStartGap, + endGap: currentEndGap, + includeHitTestData: true, ); if (newCachedPath == null) { @@ -178,8 +201,10 @@ class ConnectionPathCache { return newCachedPath.hitTestPath.contains(testPoint); } - /// Get or create cached path during painting operations - /// This is the only place where paths should be created + /// Get or create cached path with full hit-test geometry. + /// + /// This preserves existing behavior for callers that also depend on + /// segment bounds and hit-test paths after path creation. Path? getOrCreatePath({ required Connection connection, required Node sourceNode, @@ -198,6 +223,7 @@ class ConnectionPathCache { final connectionTheme = theme.connectionTheme; final currentStartGap = connection.startGap ?? connectionTheme.startGap; final currentEndGap = connection.endGap ?? connectionTheme.endGap; + final styleHash = connectionStyle.hashCode; // Get current port offsets for cache invalidation final sourcePort = sourceNode.findPort(connection.sourcePortId); @@ -205,18 +231,21 @@ class ConnectionPathCache { final currentSourcePortOffset = sourcePort?.offset ?? Offset.zero; final currentTargetPortOffset = targetPort?.offset ?? Offset.zero; - // Check if cache needs updating (including node sizes and port offsets) final existing = _getCachedPath(connection.id); - if (existing != null && - existing.sourcePosition == currentSourcePos && - existing.targetPosition == currentTargetPos && - existing.startGap == currentStartGap && - existing.endGap == currentEndGap && - existing.sourceNodeSize == currentSourceSize && - existing.targetNodeSize == currentTargetSize && - existing.sourcePortOffset == currentSourcePortOffset && - existing.targetPortOffset == currentTargetPortOffset) { - return existing.originalPath; // Cache hit + if (_isCacheEntryValid( + existing: existing, + sourcePosition: currentSourcePos, + targetPosition: currentTargetPos, + sourceNodeSize: currentSourceSize, + targetNodeSize: currentTargetSize, + startGap: currentStartGap, + endGap: currentEndGap, + sourcePortOffset: currentSourcePortOffset, + targetPortOffset: currentTargetPortOffset, + styleHash: styleHash, + requiresHitTestData: true, + )) { + return existing!.originalPath; // Cache hit } // Create new path and cache it @@ -229,6 +258,68 @@ class ConnectionPathCache { connectionStyle: connectionStyle, startGap: currentStartGap, endGap: currentEndGap, + includeHitTestData: true, + ); + + return newPath?.originalPath; + } + + /// Get or create cached path optimized for drawing only. + /// + /// This avoids generating hit-test geometry on invalidation-heavy frames. + Path? getOrCreateDrawPath({ + required Connection connection, + required Node sourceNode, + required Node targetNode, + required ConnectionStyle connectionStyle, + }) { + // Skip path creation for hidden connections + if (!sourceNode.isVisible || !targetNode.isVisible) { + return null; + } + + final currentSourcePos = sourceNode.position.value; + final currentTargetPos = targetNode.position.value; + final currentSourceSize = sourceNode.size.value; + final currentTargetSize = targetNode.size.value; + final connectionTheme = theme.connectionTheme; + final currentStartGap = connection.startGap ?? connectionTheme.startGap; + final currentEndGap = connection.endGap ?? connectionTheme.endGap; + final styleHash = connectionStyle.hashCode; + + // Get current port offsets for cache invalidation + final sourcePort = sourceNode.findPort(connection.sourcePortId); + final targetPort = targetNode.findPort(connection.targetPortId); + final currentSourcePortOffset = sourcePort?.offset ?? Offset.zero; + final currentTargetPortOffset = targetPort?.offset ?? Offset.zero; + + final existing = _getCachedPath(connection.id); + if (_isCacheEntryValid( + existing: existing, + sourcePosition: currentSourcePos, + targetPosition: currentTargetPos, + sourceNodeSize: currentSourceSize, + targetNodeSize: currentTargetSize, + startGap: currentStartGap, + endGap: currentEndGap, + sourcePortOffset: currentSourcePortOffset, + targetPortOffset: currentTargetPortOffset, + styleHash: styleHash, + requiresHitTestData: false, + )) { + return existing!.originalPath; + } + + final newPath = _createAndCachePath( + connection: connection, + sourceNode: sourceNode, + targetNode: targetNode, + sourcePosition: currentSourcePos, + targetPosition: currentTargetPos, + connectionStyle: connectionStyle, + startGap: currentStartGap, + endGap: currentEndGap, + includeHitTestData: false, ); return newPath?.originalPath; @@ -244,6 +335,7 @@ class ConnectionPathCache { required ConnectionStyle connectionStyle, required double startGap, required double endGap, + required bool includeHitTestData, }) { // Get connection and port themes final connectionTheme = theme.connectionTheme; @@ -338,25 +430,29 @@ class ConnectionPathCache { // Create segments ONCE - this is the canonical source final segmentResult = connectionStyle.createSegments(pathParams); - // Derive path and hit test from segments using style's build methods + // Derive drawing path from segments. final originalPath = connectionStyle.buildPath( segmentResult.start, segmentResult.segments, ); - - final segmentBounds = connectionStyle.buildHitTestRects( - segmentResult.start, - segmentResult.segments, - defaultHitTolerance, - ); - - final hitTestPath = connectionStyle.buildHitTestPath(segmentBounds); + final segmentBounds = includeHitTestData + ? connectionStyle.buildHitTestRects( + segmentResult.start, + segmentResult.segments, + defaultHitTolerance, + ) + : const []; + final hitTestPath = includeHitTestData + ? connectionStyle.buildHitTestPath(segmentBounds) + : Path(); // Cache paths and segment bounds for invalidation final cachedPath = _CachedConnectionPath( originalPath: originalPath, hitTestPath: hitTestPath, segmentBounds: segmentBounds, + styleHash: connectionStyle.hashCode, + hasHitTestData: includeHitTestData, sourcePosition: sourcePosition, targetPosition: targetPosition, startGap: startGap, @@ -376,6 +472,37 @@ class ConnectionPathCache { return _pathCache[connectionId]; } + bool _isCacheEntryValid({ + required _CachedConnectionPath? existing, + required Offset sourcePosition, + required Offset targetPosition, + required Size sourceNodeSize, + required Size targetNodeSize, + required double startGap, + required double endGap, + required Offset sourcePortOffset, + required Offset targetPortOffset, + required int styleHash, + required bool requiresHitTestData, + }) { + if (existing == null) return false; + if (existing.sourcePosition != sourcePosition || + existing.targetPosition != targetPosition || + existing.startGap != startGap || + existing.endGap != endGap || + existing.sourceNodeSize != sourceNodeSize || + existing.targetNodeSize != targetNodeSize || + existing.sourcePortOffset != sourcePortOffset || + existing.targetPortOffset != targetPortOffset || + existing.styleHash != styleHash) { + return false; + } + if (requiresHitTestData && !existing.hasHitTestData) { + return false; + } + return true; + } + /// Remove cached path when connection is deleted void removeConnection(String connectionId) { _pathCache.remove(connectionId); @@ -394,7 +521,9 @@ class ConnectionPathCache { /// Get the cached hit test path for debugging purposes Path? getHitTestPath(String connectionId) { - return _getCachedPath(connectionId)?.hitTestPath; + final cachedPath = _getCachedPath(connectionId); + if (cachedPath == null || !cachedPath.hasHitTestData) return null; + return cachedPath.hitTestPath; } /// Get the cached original path for debugging purposes @@ -405,7 +534,9 @@ class ConnectionPathCache { /// Get the cached segment bounds for spatial indexing. /// Returns null if there's no cached path for this connection. List? getSegmentBounds(String connectionId) { - return _getCachedPath(connectionId)?.segmentBounds; + final cachedPath = _getCachedPath(connectionId); + if (cachedPath == null || !cachedPath.hasHitTestData) return null; + return cachedPath.segmentBounds; } /// Get or compute segment bounds for a connection. @@ -428,6 +559,7 @@ class ConnectionPathCache { final connectionTheme = theme.connectionTheme; final currentStartGap = connection.startGap ?? connectionTheme.startGap; final currentEndGap = connection.endGap ?? connectionTheme.endGap; + final styleHash = connectionStyle.hashCode; // Get current port offsets for cache invalidation final sourcePort = sourceNode.findPort(connection.sourcePortId); @@ -435,18 +567,21 @@ class ConnectionPathCache { final currentSourcePortOffset = sourcePort?.offset ?? Offset.zero; final currentTargetPortOffset = targetPort?.offset ?? Offset.zero; - // Check if cache is valid (including node sizes and port offsets) final existing = _getCachedPath(connection.id); - if (existing != null && - existing.sourcePosition == currentSourcePos && - existing.targetPosition == currentTargetPos && - existing.startGap == currentStartGap && - existing.endGap == currentEndGap && - existing.sourceNodeSize == currentSourceSize && - existing.targetNodeSize == currentTargetSize && - existing.sourcePortOffset == currentSourcePortOffset && - existing.targetPortOffset == currentTargetPortOffset) { - return existing.segmentBounds; + if (_isCacheEntryValid( + existing: existing, + sourcePosition: currentSourcePos, + targetPosition: currentTargetPos, + sourceNodeSize: currentSourceSize, + targetNodeSize: currentTargetSize, + startGap: currentStartGap, + endGap: currentEndGap, + sourcePortOffset: currentSourcePortOffset, + targetPortOffset: currentTargetPortOffset, + styleHash: styleHash, + requiresHitTestData: true, + )) { + return existing!.segmentBounds; } // Create new path and get segments @@ -459,6 +594,7 @@ class ConnectionPathCache { connectionStyle: connectionStyle, startGap: currentStartGap, endGap: currentEndGap, + includeHitTestData: true, ); return newCachedPath?.segmentBounds ?? []; diff --git a/packages/vyuh_node_flow/lib/src/connections/connections_canvas.dart b/packages/vyuh_node_flow/lib/src/connections/connections_canvas.dart index 86d7c75..2e0a233 100644 --- a/packages/vyuh_node_flow/lib/src/connections/connections_canvas.dart +++ b/packages/vyuh_node_flow/lib/src/connections/connections_canvas.dart @@ -57,6 +57,7 @@ class ConnectionsCanvas extends CustomPainter { this.selectedIds, this.animation, this.connectionStyleBuilder, + this.useDrawOnlyPathCache = false, }) : _fingerprint = _computeFingerprint(store, connections, selectedIds), super(repaint: animation); @@ -91,6 +92,11 @@ class ConnectionsCanvas extends CustomPainter { /// which [ConnectionStyle] (path renderer) to use. final ConnectionStyleBuilder? connectionStyleBuilder; + /// When true, uses draw-only path caching (no hit-test geometry generation). + /// + /// Intended for high-frequency interaction frames. + final bool useDrawOnlyPathCache; + /// Cached fingerprint for efficient shouldRepaint comparison. final int _fingerprint; @@ -153,6 +159,7 @@ class ConnectionsCanvas extends CustomPainter { // Check LOD state for endpoint visibility // If LOD extension is not configured, default to showing endpoints final skipEndpoints = !(store.lod?.showConnectionEndpoints ?? true); + final selectedConnectionIds = selectedIds ?? store.selectedConnectionIds; // Paint only connection lines and endpoints (no labels) // Labels are now rendered in a separate layer for better performance @@ -168,7 +175,7 @@ class ConnectionsCanvas extends CustomPainter { // Skip connections where either node is hidden if (!sourceNode.isVisible || !targetNode.isVisible) continue; - final isSelected = store.selectedConnectionIds.contains(connection.id); + final isSelected = selectedConnectionIds.contains(connection.id); // Call builder to get dynamic style override (if provided) final overrideStyle = connectionStyleBuilder?.call( @@ -187,6 +194,7 @@ class ConnectionsCanvas extends CustomPainter { animationValue: animationValue, skipEndpoints: skipEndpoints, overrideStyle: overrideStyle, + useDrawOnlyCache: useDrawOnlyPathCache, ); } } @@ -197,6 +205,7 @@ class ConnectionsCanvas extends CustomPainter { if (_fingerprint != oldDelegate._fingerprint) return true; // Check theme reference (theme changes are rare) if (theme != oldDelegate.theme) return true; + if (useDrawOnlyPathCache != oldDelegate.useDrawOnlyPathCache) return true; return false; } diff --git a/packages/vyuh_node_flow/lib/src/connections/styles/bezier_connection_style.dart b/packages/vyuh_node_flow/lib/src/connections/styles/bezier_connection_style.dart index 53071d2..37f0c7a 100644 --- a/packages/vyuh_node_flow/lib/src/connections/styles/bezier_connection_style.dart +++ b/packages/vyuh_node_flow/lib/src/connections/styles/bezier_connection_style.dart @@ -77,7 +77,6 @@ class BezierConnectionStyle extends ConnectionStyle { if (params.sourceNodeBounds != null) { cp1 = _adjustControlPointForNodeAvoidance( controlPoint: cp1, - anchorPoint: params.start, position: sourcePosition, nodeBounds: params.sourceNodeBounds!, clearance: params.offset, @@ -87,7 +86,6 @@ class BezierConnectionStyle extends ConnectionStyle { if (params.targetNodeBounds != null) { cp2 = _adjustControlPointForNodeAvoidance( controlPoint: cp2, - anchorPoint: params.end, position: targetPosition, nodeBounds: params.targetNodeBounds!, clearance: params.offset, @@ -120,7 +118,6 @@ class BezierConnectionStyle extends ConnectionStyle { /// [clearance] specifies the minimum distance from node edges (defaults to port extension). Offset _adjustControlPointForNodeAvoidance({ required Offset controlPoint, - required Offset anchorPoint, required PortPosition position, required Rect nodeBounds, double clearance = 10.0, diff --git a/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder.dart b/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder.dart index 1d7d26e..9438574 100644 --- a/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder.dart +++ b/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import '../../ports/port.dart'; import 'connection_style_base.dart'; import 'path_segments.dart'; +import 'waypoint_builder_segment_ops.dart'; // Re-export segment types for convenience export 'path_segments.dart'; @@ -249,7 +250,6 @@ class WaypointBuilder { startExtended: startExtended, endExtended: endExtended, sourcePosition: sourcePosition, - targetPosition: targetPosition, ); } @@ -387,7 +387,6 @@ class WaypointBuilder { startExtended, endExtended, sourcePosition, - targetPosition, ); // Check if the L-shape path would cross any node bounds @@ -410,7 +409,6 @@ class WaypointBuilder { startExtended: startExtended, endExtended: endExtended, sourcePosition: sourcePosition, - targetPosition: targetPosition, ); } @@ -419,7 +417,6 @@ class WaypointBuilder { required Offset startExtended, required Offset endExtended, required PortPosition sourcePosition, - required PortPosition targetPosition, }) { // Check clearance based on source port position final hasHorizontalClearance = switch (sourcePosition) { @@ -462,7 +459,6 @@ class WaypointBuilder { Offset startExtended, Offset endExtended, PortPosition sourcePosition, - PortPosition targetPosition, ) { // For horizontal source ports, corner is at (startExtended.dx, endExtended.dy) // For vertical source ports, corner is at (endExtended.dx, startExtended.dy) @@ -481,13 +477,11 @@ class WaypointBuilder { required Offset startExtended, required Offset endExtended, required PortPosition sourcePosition, - required PortPosition targetPosition, }) { final cornerPoint = _getLShapeCorner( startExtended, endExtended, sourcePosition, - targetPosition, ); return [start, startExtended, cornerPoint, endExtended, end]; @@ -747,12 +741,17 @@ class WaypointBuilder { ); if (zCurveIntersects) { - // Z-curve would intersect - route around (above or below) - // Use union bounds center to determine direction - takes shorter path - final routeAbove = start.dy < unionBounds.center.dy; - final routeY = routeAbove - ? unionBounds.top - backEdgeGap - : unionBounds.bottom + backEdgeGap; + // Z-curve would intersect - route around (above or below). + // Choose the cleaner route by minimizing orthogonal travel distance. + final aboveY = unionBounds.top - backEdgeGap; + final belowY = unionBounds.bottom + backEdgeGap; + final aboveCost = + (startExtended.dy - aboveY).abs() + + (endExtended.dy - aboveY).abs(); + final belowCost = + (startExtended.dy - belowY).abs() + + (endExtended.dy - belowY).abs(); + final routeY = aboveCost <= belowCost ? aboveY : belowY; return [ start, startExtended, @@ -829,12 +828,16 @@ class WaypointBuilder { ); if (zCurveIntersects) { - // Z-curve would intersect - route around (left or right) - // Use union bounds center to determine direction - takes shorter path - final routeLeft = start.dx < unionBounds.center.dx; - final routeX = routeLeft - ? unionBounds.left - backEdgeGap - : unionBounds.right + backEdgeGap; + // Z-curve would intersect - route around (left or right). + // Choose the cleaner route by minimizing orthogonal travel distance. + final leftX = unionBounds.left - backEdgeGap; + final rightX = unionBounds.right + backEdgeGap; + final leftCost = + (startExtended.dx - leftX).abs() + (endExtended.dx - leftX).abs(); + final rightCost = + (startExtended.dx - rightX).abs() + + (endExtended.dx - rightX).abs(); + final routeX = leftCost <= rightCost ? leftX : rightX; return [ start, startExtended, @@ -961,19 +964,12 @@ class WaypointBuilder { startExtended: startExtended, endExtended: endExtended, sourcePosition: sourcePosition, - targetPosition: targetPosition, ); } // Determine the best direction to route around the union final direction = _determineLoopbackDirection( - start: start, - end: end, - startExtended: startExtended, - endExtended: endExtended, - sourcePosition: sourcePosition, targetPosition: targetPosition, - unionBounds: unionBounds, ); return _routeAroundBounds( @@ -997,13 +993,7 @@ class WaypointBuilder { /// /// When both source and target allow flexibility, we choose based on position. static LoopbackDirection _determineLoopbackDirection({ - required Offset start, - required Offset end, - required Offset startExtended, - required Offset endExtended, - required PortPosition sourcePosition, required PortPosition targetPosition, - required Rect unionBounds, }) { // TARGET port direction is a hard constraint - the connection must approach // from the direction the port faces. This ensures connections never cross @@ -1085,7 +1075,6 @@ class WaypointBuilder { required Offset startExtended, required Offset endExtended, required PortPosition sourcePosition, - required PortPosition targetPosition, }) { // Use midpoint-based routing as fallback final isHorizontalSource = @@ -1204,19 +1193,30 @@ class WaypointBuilder { /// Optimizes waypoints by removing collinear intermediate points. static List optimizeWaypoints(List waypoints) { - if (waypoints.length <= 2) return waypoints; + final length = waypoints.length; + if (length <= 2) return waypoints; - final optimized = [waypoints.first]; + List? optimized; + var previousKept = waypoints.first; - for (int i = 1; i < waypoints.length - 1; i++) { - final prev = optimized.last; + for (int i = 1; i < length - 1; i++) { final current = waypoints[i]; final next = waypoints[i + 1]; + final isRedundant = _isCollinear(previousKept, current, next); + + if (isRedundant) { + optimized ??= waypoints.sublist(0, i); + continue; + } - // Check if current point is NOT collinear with prev and next - if (!_isCollinear(prev, current, next)) { + if (optimized != null) { optimized.add(current); } + previousKept = current; + } + + if (optimized == null) { + return waypoints; } optimized.add(waypoints.last); @@ -1241,391 +1241,64 @@ class WaypointBuilder { } // ============================================================ - // Path Generation from Waypoints + // Segment/Path Delegates // ============================================================ - /// Generates a Path from waypoints with optional rounded corners. - /// - /// This is the central path generation method that all connection styles - /// can use for waypoint-based paths (loopback, step, etc.). - /// - /// Parameters: - /// - [waypoints]: The list of points the path passes through - /// - [cornerRadius]: Radius for rounding corners (0 for sharp corners) + /// Generates a [Path] from waypoints with optional rounded corners. static Path generatePathFromWaypoints( List waypoints, { double cornerRadius = 0, }) { - if (waypoints.length < 2) { - return Path(); - } - - final path = Path(); - path.moveTo(waypoints.first.dx, waypoints.first.dy); - - if (waypoints.length == 2) { - path.lineTo(waypoints.last.dx, waypoints.last.dy); - return path; - } - - // If corner radius is 0, just draw straight lines - if (cornerRadius == 0) { - for (int i = 1; i < waypoints.length; i++) { - path.lineTo(waypoints[i].dx, waypoints[i].dy); - } - return path; - } - - // Draw path with rounded corners at waypoints - for (int i = 1; i < waypoints.length - 1; i++) { - final prev = waypoints[i - 1]; - final current = waypoints[i]; - final next = waypoints[i + 1]; - - // Calculate vectors - final incomingVector = current - prev; - final outgoingVector = next - current; - - // Skip if vectors are zero (duplicate points) - if (incomingVector.distance < 0.01 || outgoingVector.distance < 0.01) { - path.lineTo(current.dx, current.dy); - continue; - } - - // Check if this is a corner (perpendicular segments) - final incomingHorizontal = incomingVector.dy.abs() < 0.01; - final incomingVertical = incomingVector.dx.abs() < 0.01; - final outgoingHorizontal = outgoingVector.dy.abs() < 0.01; - final outgoingVertical = outgoingVector.dx.abs() < 0.01; - - // Only round corners between perpendicular segments - if ((incomingHorizontal && outgoingVertical) || - (incomingVertical && outgoingHorizontal)) { - final incomingDistance = incomingVector.distance; - final outgoingDistance = outgoingVector.distance; - - // Adapt corner radius to available space - final maxRadius = math.min(incomingDistance / 2, outgoingDistance / 2); - final actualRadius = math.min(cornerRadius, maxRadius); - - if (actualRadius < 1.0) { - path.lineTo(current.dx, current.dy); - continue; - } - - // Calculate unit vectors - final incomingDirection = incomingVector / incomingDistance; - final outgoingDirection = outgoingVector / outgoingDistance; - - // Calculate corner start and end points - final cornerStart = current - (incomingDirection * actualRadius); - final cornerEnd = current + (outgoingDirection * actualRadius); - - // Draw line to corner start - path.lineTo(cornerStart.dx, cornerStart.dy); - - // Draw quadratic bezier curve for the corner - path.quadraticBezierTo( - current.dx, - current.dy, - cornerEnd.dx, - cornerEnd.dy, - ); - } else { - // Not a perpendicular corner, just draw straight line - path.lineTo(current.dx, current.dy); - } - } - - // Draw line to the last point - path.lineTo(waypoints.last.dx, waypoints.last.dy); - - return path; + return WaypointSegmentOps.generatePathFromWaypoints( + waypoints, + cornerRadius: cornerRadius, + ); } - // ============================================================ - // Hit Test Segment Generation - // ============================================================ - - /// Generates hit test segments from waypoints. - /// - /// For waypoint-based paths (step, loopback), the hit test segments - /// are simple axis-aligned rectangles around each straight segment. - /// This is much more efficient than curve sampling. - /// - /// Parameters: - /// - [waypoints]: The list of waypoints - /// - [tolerance]: The hit test tolerance (rectangle will extend this far) + /// Generates hit test rectangles from waypoint polylines. static List generateHitTestSegments( List waypoints, double tolerance, ) { - if (waypoints.length < 2) return []; - - // Merge collinear segments to minimize rectangle count - final mergedSegments = _mergeCollinearSegments(waypoints); - - return mergedSegments.map((segment) { - return Rect.fromLTRB( - math.min(segment.start.dx, segment.end.dx) - tolerance, - math.min(segment.start.dy, segment.end.dy) - tolerance, - math.max(segment.start.dx, segment.end.dx) + tolerance, - math.max(segment.start.dy, segment.end.dy) + tolerance, - ); - }).toList(); + return WaypointSegmentOps.generateHitTestSegments(waypoints, tolerance); } - /// Merges consecutive collinear segments to reduce rectangle count. - /// - /// For example: [A→B→C] where A, B, C are on same line becomes [A→C] - static List<({Offset start, Offset end})> _mergeCollinearSegments( - List waypoints, - ) { - if (waypoints.length < 2) return []; - - final segments = <({Offset start, Offset end})>[]; - Offset segmentStart = waypoints[0]; - - for (int i = 1; i < waypoints.length; i++) { - final current = waypoints[i]; - bool shouldEndSegment = (i == waypoints.length - 1); - - if (!shouldEndSegment && i < waypoints.length - 1) { - final next = waypoints[i + 1]; - - final currentVector = current - segmentStart; - final nextVector = next - current; - - final currentIsHorizontal = currentVector.dy.abs() < 0.5; - final currentIsVertical = currentVector.dx.abs() < 0.5; - final nextIsHorizontal = nextVector.dy.abs() < 0.5; - final nextIsVertical = nextVector.dx.abs() < 0.5; - - // If direction changes, end this segment - shouldEndSegment = - (currentIsHorizontal != nextIsHorizontal) || - (currentIsVertical != nextIsVertical); - } - - if (shouldEndSegment) { - segments.add((start: segmentStart, end: current)); - segmentStart = current; - } - } - - return segments; - } - - // ============================================================ - // Segment-Based Path Generation - // ============================================================ - /// Generates a [Path] from a list of [PathSegment]s. - /// - /// This is the core method for building paths from segment primitives. - /// Each segment type is handled according to its definition: - /// - [StraightSegment]: `lineTo` - /// - [QuadraticSegment]: `quadraticBezierTo` - /// - [CubicSegment]: `cubicTo` - /// - /// ## Example - /// ```dart - /// final path = WaypointBuilder.generatePathFromSegments( - /// start: Offset(0, 0), - /// segments: [ - /// StraightSegment(end: Offset(50, 0)), - /// QuadraticSegment(controlPoint: Offset(60, 0), end: Offset(60, 10)), - /// StraightSegment(end: Offset(60, 100)), - /// ], - /// ); - /// ``` static Path generatePathFromSegments({ required Offset start, required List segments, }) { - final path = Path(); - path.moveTo(start.dx, start.dy); - - for (final segment in segments) { - switch (segment) { - case StraightSegment(): - path.lineTo(segment.end.dx, segment.end.dy); - case QuadraticSegment(): - path.quadraticBezierTo( - segment.controlPoint.dx, - segment.controlPoint.dy, - segment.end.dx, - segment.end.dy, - ); - case CubicSegment(): - path.cubicTo( - segment.controlPoint1.dx, - segment.controlPoint1.dy, - segment.controlPoint2.dx, - segment.controlPoint2.dy, - segment.end.dx, - segment.end.dy, - ); - } - } - - return path; + return WaypointSegmentOps.generatePathFromSegments( + start: start, + segments: segments, + ); } - // ============================================================ - // Segment-Based Hit Test Generation - // ============================================================ - - /// Generates hit test rectangles from a list of [PathSegment]s. - /// - /// Different segment types have different hit test strategies: - /// - [StraightSegment]: Single rectangle around the line - /// - [QuadraticSegment]: Sample points along the curve - /// - [CubicSegment]: Sample points along the curve - /// - /// This method ensures tight hit test areas that follow the actual - /// path geometry, minimizing false positives from oversized rectangles. - /// - /// Parameters: - /// - [start]: The starting point of the path - /// - [segments]: The list of path segments - /// - [tolerance]: The hit test tolerance (rectangles extend this far from path) + /// Generates hit test rectangles from [PathSegment]s. static List generateHitTestFromSegments({ required Offset start, required List segments, required double tolerance, }) { - if (segments.isEmpty) return []; - - final hitRects = []; - Offset currentPoint = start; - - for (final segment in segments) { - // Each segment knows how to generate its own hit test rectangles - hitRects.addAll(segment.getHitTestRects(currentPoint, tolerance)); - currentPoint = segment.end; - } - - return hitRects; + return WaypointSegmentOps.generateHitTestFromSegments( + start: start, + segments: segments, + tolerance: tolerance, + ); } - // ============================================================ - // Segment Builders - Convert Waypoints to Segments - // ============================================================ - - /// Converts waypoints to path segments with optional rounded corners. - /// - /// This method transforms a list of waypoints into [PathSegment]s, - /// inserting [QuadraticSegment]s at corners when [cornerRadius] > 0. - /// - /// This is useful for step/smoothstep styles that use waypoint-based - /// routing but want the flexibility of the segment-based API. - /// - /// Parameters: - /// - [waypoints]: The list of points to convert - /// - [cornerRadius]: Radius for rounded corners (0 for sharp corners) + /// Converts waypoints to drawable path segments. static List waypointsToSegments( List waypoints, { double cornerRadius = 0, }) { - if (waypoints.length < 2) return []; - - final segments = []; - - if (waypoints.length == 2) { - segments.add(StraightSegment(end: waypoints[1])); - return segments; - } - - // No corner radius - all straight segments - if (cornerRadius <= 0) { - for (int i = 1; i < waypoints.length; i++) { - segments.add(StraightSegment(end: waypoints[i])); - } - return segments; - } - - // Build segments with rounded corners - for (int i = 1; i < waypoints.length - 1; i++) { - final prev = i == 1 ? waypoints[0] : segments.last.end; - final current = waypoints[i]; - final next = waypoints[i + 1]; - - // Calculate vectors - final incomingVector = current - prev; - final outgoingVector = next - current; - - // Skip if vectors are too short - if (incomingVector.distance < 0.01 || outgoingVector.distance < 0.01) { - segments.add(StraightSegment(end: current)); - continue; - } - - // Check if this is a perpendicular corner - final incomingHorizontal = incomingVector.dy.abs() < 0.01; - final incomingVertical = incomingVector.dx.abs() < 0.01; - final outgoingHorizontal = outgoingVector.dy.abs() < 0.01; - final outgoingVertical = outgoingVector.dx.abs() < 0.01; - - if ((incomingHorizontal && outgoingVertical) || - (incomingVertical && outgoingHorizontal)) { - // Calculate corner radius that fits available space - final maxRadius = math.min( - incomingVector.distance / 2, - outgoingVector.distance / 2, - ); - final actualRadius = math.min(cornerRadius, maxRadius); - - if (actualRadius < 1.0) { - segments.add(StraightSegment(end: current)); - continue; - } - - // Calculate corner start and end - final inDir = incomingVector / incomingVector.distance; - final outDir = outgoingVector / outgoingVector.distance; - final cornerStart = current - (inDir * actualRadius); - final cornerEnd = current + (outDir * actualRadius); - - // Add straight segment to corner start - segments.add(StraightSegment(end: cornerStart)); - - // Add quadratic curve for the corner - // Skip hit test rects - corner is already covered by adjacent straight segments - segments.add( - QuadraticSegment( - controlPoint: current, - end: cornerEnd, - generateHitTestRects: false, - ), - ); - } else { - // Not a perpendicular corner - straight line - segments.add(StraightSegment(end: current)); - } - } - - // Add final segment - segments.add(StraightSegment(end: waypoints.last)); - - return segments; + return WaypointSegmentOps.waypointsToSegments( + waypoints, + cornerRadius: cornerRadius, + ); } - /// Creates a single cubic bezier segment for a forward connection. - /// - /// This is a convenience method for styles that use cubic bezier curves - /// for forward (non-loopback) connections. - /// - /// Parameters: - /// - [start]: The start point - /// - [end]: The end point - /// - [sourcePosition]: The position of the source port - /// - [targetPosition]: The position of the target port - /// - [curvature]: How curved the connection should be (0-1) - /// - [portExtension]: The default minimum extension from the port - /// - [sourceExtension]: Extension for source control point (null = use portExtension) - /// - [targetExtension]: Extension for target control point (null = use portExtension). - /// Set to 0 for temporary connections where target is mouse position. + /// Creates a cubic bezier segment for forward (non-loopback) connections. static CubicSegment createBezierSegment({ required Offset start, required Offset end, @@ -1636,79 +1309,15 @@ class WaypointBuilder { double? sourceExtension, double? targetExtension, }) { - // Use specific extensions if provided, otherwise use default - final effectiveSourceExtension = sourceExtension ?? portExtension; - final effectiveTargetExtension = targetExtension ?? portExtension; - - // Calculate control points based on port positions - final cp1 = _calculateBezierControlPoint( - anchor: start, - target: end, - position: sourcePosition, - curvature: curvature, - portExtension: effectiveSourceExtension, - ); - - final cp2 = _calculateBezierControlPoint( - anchor: end, - target: start, - position: targetPosition, - curvature: curvature, - portExtension: effectiveTargetExtension, - ); - - return CubicSegment( - controlPoint1: cp1, - controlPoint2: cp2, + return WaypointSegmentOps.createBezierSegment( + start: start, end: end, + sourcePosition: sourcePosition, + targetPosition: targetPosition, curvature: curvature, + portExtension: portExtension, + sourceExtension: sourceExtension, + targetExtension: targetExtension, ); } - - /// Calculates a bezier control point for a port. - /// - /// Matches React Flow's bezier calculation: - /// offset = |distance| * curvature - /// - /// For horizontal ports, offset is based on horizontal distance. - /// For vertical ports, offset is based on vertical distance. - static Offset _calculateBezierControlPoint({ - required Offset anchor, - required Offset target, - required PortPosition position, - required double curvature, - required double portExtension, - }) { - // React Flow style: offset = |distance| * curvature - // portExtension ensures minimum offset for very close nodes - switch (position) { - case PortPosition.right: - final offset = math.max( - portExtension, - (target.dx - anchor.dx).abs() * curvature, - ); - return Offset(anchor.dx + offset, anchor.dy); - - case PortPosition.left: - final offset = math.max( - portExtension, - (target.dx - anchor.dx).abs() * curvature, - ); - return Offset(anchor.dx - offset, anchor.dy); - - case PortPosition.bottom: - final offset = math.max( - portExtension, - (target.dy - anchor.dy).abs() * curvature, - ); - return Offset(anchor.dx, anchor.dy + offset); - - case PortPosition.top: - final offset = math.max( - portExtension, - (target.dy - anchor.dy).abs() * curvature, - ); - return Offset(anchor.dx, anchor.dy - offset); - } - } } diff --git a/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder_segment_ops.dart b/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder_segment_ops.dart new file mode 100644 index 0000000..a890e1e --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/connections/styles/waypoint_builder_segment_ops.dart @@ -0,0 +1,376 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import '../../ports/port.dart'; +import 'path_segments.dart'; + +/// Internal segment/path operations extracted from [WaypointBuilder]. +/// +/// Keeps segment conversion and path math isolated from routing decisions. +final class WaypointSegmentOps { + const WaypointSegmentOps._(); + + static Path generatePathFromWaypoints( + List waypoints, { + double cornerRadius = 0, + }) { + if (waypoints.length < 2) { + return Path(); + } + + final path = Path(); + path.moveTo(waypoints.first.dx, waypoints.first.dy); + + if (waypoints.length == 2) { + path.lineTo(waypoints.last.dx, waypoints.last.dy); + return path; + } + + // If corner radius is 0, just draw straight lines + if (cornerRadius == 0) { + for (int i = 1; i < waypoints.length; i++) { + path.lineTo(waypoints[i].dx, waypoints[i].dy); + } + return path; + } + + // Draw path with rounded corners at waypoints + for (int i = 1; i < waypoints.length - 1; i++) { + final prev = waypoints[i - 1]; + final current = waypoints[i]; + final next = waypoints[i + 1]; + + // Calculate vectors + final incomingVector = current - prev; + final outgoingVector = next - current; + + // Skip if vectors are zero (duplicate points) + if (incomingVector.distance < 0.01 || outgoingVector.distance < 0.01) { + path.lineTo(current.dx, current.dy); + continue; + } + + // Check if this is a corner (perpendicular segments) + final incomingHorizontal = incomingVector.dy.abs() < 0.01; + final incomingVertical = incomingVector.dx.abs() < 0.01; + final outgoingHorizontal = outgoingVector.dy.abs() < 0.01; + final outgoingVertical = outgoingVector.dx.abs() < 0.01; + + // Only round corners between perpendicular segments + if ((incomingHorizontal && outgoingVertical) || + (incomingVertical && outgoingHorizontal)) { + final incomingDistance = incomingVector.distance; + final outgoingDistance = outgoingVector.distance; + + // Adapt corner radius to available space + final maxRadius = math.min(incomingDistance / 2, outgoingDistance / 2); + final actualRadius = math.min(cornerRadius, maxRadius); + + if (actualRadius < 1.0) { + path.lineTo(current.dx, current.dy); + continue; + } + + // Calculate unit vectors + final incomingDirection = incomingVector / incomingDistance; + final outgoingDirection = outgoingVector / outgoingDistance; + + // Calculate corner start and end points + final cornerStart = current - (incomingDirection * actualRadius); + final cornerEnd = current + (outgoingDirection * actualRadius); + + // Draw line to corner start + path.lineTo(cornerStart.dx, cornerStart.dy); + + // Draw quadratic bezier curve for the corner + path.quadraticBezierTo( + current.dx, + current.dy, + cornerEnd.dx, + cornerEnd.dy, + ); + } else { + // Not a perpendicular corner, just draw straight line + path.lineTo(current.dx, current.dy); + } + } + + // Draw line to the last point + path.lineTo(waypoints.last.dx, waypoints.last.dy); + + return path; + } + + static List generateHitTestSegments( + List waypoints, + double tolerance, + ) { + if (waypoints.length < 2) return []; + + // Merge collinear segments to minimize rectangle count + final mergedSegments = _mergeCollinearSegments(waypoints); + + return mergedSegments.map((segment) { + return Rect.fromLTRB( + math.min(segment.start.dx, segment.end.dx) - tolerance, + math.min(segment.start.dy, segment.end.dy) - tolerance, + math.max(segment.start.dx, segment.end.dx) + tolerance, + math.max(segment.start.dy, segment.end.dy) + tolerance, + ); + }).toList(); + } + + static List<({Offset start, Offset end})> _mergeCollinearSegments( + List waypoints, + ) { + if (waypoints.length < 2) return []; + + final segments = <({Offset start, Offset end})>[]; + Offset segmentStart = waypoints[0]; + + for (int i = 1; i < waypoints.length; i++) { + final current = waypoints[i]; + bool shouldEndSegment = (i == waypoints.length - 1); + + if (!shouldEndSegment && i < waypoints.length - 1) { + final next = waypoints[i + 1]; + + final currentVector = current - segmentStart; + final nextVector = next - current; + + final currentIsHorizontal = currentVector.dy.abs() < 0.5; + final currentIsVertical = currentVector.dx.abs() < 0.5; + final nextIsHorizontal = nextVector.dy.abs() < 0.5; + final nextIsVertical = nextVector.dx.abs() < 0.5; + + // If direction changes, end this segment + shouldEndSegment = + (currentIsHorizontal != nextIsHorizontal) || + (currentIsVertical != nextIsVertical); + } + + if (shouldEndSegment) { + segments.add((start: segmentStart, end: current)); + segmentStart = current; + } + } + + return segments; + } + + static Path generatePathFromSegments({ + required Offset start, + required List segments, + }) { + final path = Path(); + path.moveTo(start.dx, start.dy); + + for (final segment in segments) { + switch (segment) { + case StraightSegment(): + path.lineTo(segment.end.dx, segment.end.dy); + case QuadraticSegment(): + path.quadraticBezierTo( + segment.controlPoint.dx, + segment.controlPoint.dy, + segment.end.dx, + segment.end.dy, + ); + case CubicSegment(): + path.cubicTo( + segment.controlPoint1.dx, + segment.controlPoint1.dy, + segment.controlPoint2.dx, + segment.controlPoint2.dy, + segment.end.dx, + segment.end.dy, + ); + } + } + + return path; + } + + static List generateHitTestFromSegments({ + required Offset start, + required List segments, + required double tolerance, + }) { + if (segments.isEmpty) return []; + + final hitRects = []; + Offset currentPoint = start; + + for (final segment in segments) { + // Each segment knows how to generate its own hit test rectangles + hitRects.addAll(segment.getHitTestRects(currentPoint, tolerance)); + currentPoint = segment.end; + } + + return hitRects; + } + + static List waypointsToSegments( + List waypoints, { + double cornerRadius = 0, + }) { + if (waypoints.length < 2) return []; + + final segments = []; + + if (waypoints.length == 2) { + segments.add(StraightSegment(end: waypoints[1])); + return segments; + } + + // No corner radius - all straight segments + if (cornerRadius <= 0) { + for (int i = 1; i < waypoints.length; i++) { + segments.add(StraightSegment(end: waypoints[i])); + } + return segments; + } + + // Build segments with rounded corners + for (int i = 1; i < waypoints.length - 1; i++) { + final prev = i == 1 ? waypoints[0] : segments.last.end; + final current = waypoints[i]; + final next = waypoints[i + 1]; + + // Calculate vectors + final incomingVector = current - prev; + final outgoingVector = next - current; + + // Skip if vectors are too short + if (incomingVector.distance < 0.01 || outgoingVector.distance < 0.01) { + segments.add(StraightSegment(end: current)); + continue; + } + + // Check if this is a perpendicular corner + final incomingHorizontal = incomingVector.dy.abs() < 0.01; + final incomingVertical = incomingVector.dx.abs() < 0.01; + final outgoingHorizontal = outgoingVector.dy.abs() < 0.01; + final outgoingVertical = outgoingVector.dx.abs() < 0.01; + + if ((incomingHorizontal && outgoingVertical) || + (incomingVertical && outgoingHorizontal)) { + // Calculate corner radius that fits available space + final maxRadius = math.min( + incomingVector.distance / 2, + outgoingVector.distance / 2, + ); + final actualRadius = math.min(cornerRadius, maxRadius); + + if (actualRadius < 1.0) { + segments.add(StraightSegment(end: current)); + continue; + } + + // Calculate corner start and end + final inDir = incomingVector / incomingVector.distance; + final outDir = outgoingVector / outgoingVector.distance; + final cornerStart = current - (inDir * actualRadius); + final cornerEnd = current + (outDir * actualRadius); + + // Add straight segment to corner start + segments.add(StraightSegment(end: cornerStart)); + + // Add quadratic curve for the corner + // Skip hit test rects - corner is already covered by adjacent straight segments + segments.add( + QuadraticSegment( + controlPoint: current, + end: cornerEnd, + generateHitTestRects: false, + ), + ); + } else { + // Not a perpendicular corner - straight line + segments.add(StraightSegment(end: current)); + } + } + + // Add final segment + segments.add(StraightSegment(end: waypoints.last)); + + return segments; + } + + static CubicSegment createBezierSegment({ + required Offset start, + required Offset end, + required PortPosition sourcePosition, + required PortPosition targetPosition, + required double curvature, + required double portExtension, + double? sourceExtension, + double? targetExtension, + }) { + // Use specific extensions if provided, otherwise use default + final effectiveSourceExtension = sourceExtension ?? portExtension; + final effectiveTargetExtension = targetExtension ?? portExtension; + + // Calculate control points based on port positions + final cp1 = _calculateBezierControlPoint( + anchor: start, + target: end, + position: sourcePosition, + curvature: curvature, + portExtension: effectiveSourceExtension, + ); + + final cp2 = _calculateBezierControlPoint( + anchor: end, + target: start, + position: targetPosition, + curvature: curvature, + portExtension: effectiveTargetExtension, + ); + + return CubicSegment( + controlPoint1: cp1, + controlPoint2: cp2, + end: end, + curvature: curvature, + ); + } + + static Offset _calculateBezierControlPoint({ + required Offset anchor, + required Offset target, + required PortPosition position, + required double curvature, + required double portExtension, + }) { + switch (position) { + case PortPosition.right: + final offset = math.max( + portExtension, + (target.dx - anchor.dx).abs() * curvature, + ); + return Offset(anchor.dx + offset, anchor.dy); + + case PortPosition.left: + final offset = math.max( + portExtension, + (target.dx - anchor.dx).abs() * curvature, + ); + return Offset(anchor.dx - offset, anchor.dy); + + case PortPosition.bottom: + final offset = math.max( + portExtension, + (target.dy - anchor.dy).abs() * curvature, + ); + return Offset(anchor.dx, anchor.dy + offset); + + case PortPosition.top: + final offset = math.max( + portExtension, + (target.dy - anchor.dy).abs() * curvature, + ); + return Offset(anchor.dx, anchor.dy - offset); + } + } +} diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/connection_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/connection_api.dart index c6ac81c..bb7dbf9 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/connection_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/connection_api.dart @@ -141,13 +141,7 @@ extension ConnectionApi on NodeFlowController { void addConnection(Connection connection) { runInAction(() { _connections.add(connection); - // Update connection index for O(1) lookup - _connectionsByNodeId - .putIfAbsent(connection.sourceNodeId, () => {}) - .add(connection.id); - _connectionsByNodeId - .putIfAbsent(connection.targetNodeId, () => {}) - .add(connection.id); + _indexConnection(connection); }); // Fire event after successful addition events.connection?.onCreated?.call(connection); @@ -218,14 +212,7 @@ extension ConnectionApi on NodeFlowController { runInAction(() { _connections.removeWhere((c) => c.id == connectionId); _selectedConnectionIds.remove(connectionId); - - // Update connection index for O(1) lookup - _connectionsByNodeId[connectionToDelete.sourceNodeId]?.remove( - connectionId, - ); - _connectionsByNodeId[connectionToDelete.targetNodeId]?.remove( - connectionId, - ); + _deindexConnection(connectionToDelete); // Remove from spatial index _spatialIndex.removeConnection(connectionId); @@ -292,9 +279,7 @@ extension ConnectionApi on NodeFlowController { _spatialIndex.removeConnection(conn.id); _connectionPainter?.removeConnectionFromCache(conn.id); - // Update connection index for O(1) lookup - _connectionsByNodeId[conn.sourceNodeId]?.remove(conn.id); - _connectionsByNodeId[conn.targetNodeId]?.remove(conn.id); + _deindexConnection(conn); // Remove from connections list _connections.remove(conn); @@ -634,20 +619,14 @@ extension ConnectionApi on NodeFlowController { ); } - // Get existing connections for this port - final existingConnections = _connections - .where( - (conn) => isOutput - ? (conn.sourceNodeId == nodeId && conn.sourcePortId == portId) - : (conn.targetNodeId == nodeId && conn.targetPortId == portId), - ) - .map((c) => c.id) - .toList(); + final existingConnectionCount = isOutput + ? (_sourceConnectionCountByPortKey[_portKey(nodeId, portId)] ?? 0) + : (_targetConnectionCountByPortKey[_portKey(nodeId, portId)] ?? 0); // Check max connections for source port (output side) if (isOutput && port.maxConnections != null) { if (port.multiConnections && - existingConnections.length >= port.maxConnections!) { + existingConnectionCount >= port.maxConnections!) { return const ConnectionValidationResult.deny( reason: 'Maximum connections reached', ); @@ -657,6 +636,14 @@ extension ConnectionApi on NodeFlowController { // Call custom validation callback if provided final onBeforeStart = events.connection?.onBeforeStart; if (onBeforeStart != null) { + final existingConnections = _connections + .where( + (conn) => isOutput + ? (conn.sourceNodeId == nodeId && conn.sourcePortId == portId) + : (conn.targetNodeId == nodeId && conn.targetPortId == portId), + ) + .map((c) => c.id) + .toList(); final context = ConnectionStartContext( sourceNode: node, sourcePort: port, @@ -878,32 +865,20 @@ extension ConnectionApi on NodeFlowController { actualTargetPort = sourcePort; } - // Get existing connections for both ports - final existingSourceConnections = _connections - .where( - (conn) => - conn.sourceNodeId == actualSourceNode.id && - conn.sourcePortId == actualSourcePort.id, - ) - .map((c) => c.id) - .toList(); - final existingTargetConnections = _connections - .where( - (conn) => - conn.targetNodeId == actualTargetNode.id && - conn.targetPortId == actualTargetPort.id, - ) - .map((c) => c.id) - .toList(); + final targetPortKey = _portKey(actualTargetNode.id, actualTargetPort.id); + final existingTargetCount = + _targetConnectionCountByPortKey[targetPortKey] ?? 0; // No duplicate connections - final duplicateExists = _connections.any( - (conn) => - conn.sourceNodeId == actualSourceNode.id && - conn.sourcePortId == actualSourcePort.id && - conn.targetNodeId == actualTargetNode.id && - conn.targetPortId == actualTargetPort.id, - ); + final duplicateExists = + (_connectionPairCountByPorts[_connectionPairKey( + sourceNodeId: actualSourceNode.id, + sourcePortId: actualSourcePort.id, + targetNodeId: actualTargetNode.id, + targetPortId: actualTargetPort.id, + )] ?? + 0) > + 0; if (duplicateExists) { return const ConnectionValidationResult.deny( reason: 'Connection already exists', @@ -912,8 +887,7 @@ extension ConnectionApi on NodeFlowController { // Max connections limit if (actualTargetPort.maxConnections != null) { - if (existingTargetConnections.length >= - actualTargetPort.maxConnections!) { + if (existingTargetCount >= actualTargetPort.maxConnections!) { return const ConnectionValidationResult.deny( reason: 'Target port has maximum connections', ); @@ -924,6 +898,22 @@ extension ConnectionApi on NodeFlowController { if (!skipCustomValidation) { final onBeforeComplete = events.connection?.onBeforeComplete; if (onBeforeComplete != null) { + final existingSourceConnections = _connections + .where( + (conn) => + conn.sourceNodeId == actualSourceNode.id && + conn.sourcePortId == actualSourcePort.id, + ) + .map((c) => c.id) + .toList(); + final existingTargetConnections = _connections + .where( + (conn) => + conn.targetNodeId == actualTargetNode.id && + conn.targetPortId == actualTargetPort.id, + ) + .map((c) => c.id) + .toList(); final context = ConnectionCompleteContext( sourceNode: actualSourceNode, sourcePort: actualSourcePort, diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/connection_index_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/connection_index_api.dart new file mode 100644 index 0000000..bb2962e --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/editor/controller/connection_index_api.dart @@ -0,0 +1,126 @@ +part of 'node_flow_controller.dart'; + +/// Connection indexing utilities for O(1) connectivity queries. +/// +/// This extension owns index maintenance and lookup helpers used by +/// rendering and connection validation paths. +extension ConnectionIndexApi on NodeFlowController { + /// Checks if a port has outgoing connections. + /// + /// Uses an internal O(1) index instead of scanning all connections. + bool hasOutgoingConnectionsFromPort(String nodeId, String portId) { + return (_sourceConnectionCountByPortKey[_portKey(nodeId, portId)] ?? 0) > 0; + } + + /// Checks if a port has incoming connections. + /// + /// Uses an internal O(1) index instead of scanning all connections. + bool hasIncomingConnectionsToPort(String nodeId, String portId) { + return (_targetConnectionCountByPortKey[_portKey(nodeId, portId)] ?? 0) > 0; + } + + /// Checks if a port participates in any connection (incoming or outgoing). + bool hasConnectionsForPort(String nodeId, String portId) { + final key = _portKey(nodeId, portId); + return (_sourceConnectionCountByPortKey[key] ?? 0) > 0 || + (_targetConnectionCountByPortKey[key] ?? 0) > 0; + } + + /// Rebuilds all connection indexes from [_connections]. + /// + /// Call this after bulk graph loading operations. + void _rebuildConnectionIndexes() { + _connectionsByNodeId.clear(); + _sourceConnectionCountByPortKey.clear(); + _targetConnectionCountByPortKey.clear(); + _connectionPairCountByPorts.clear(); + + for (final connection in _connections) { + _indexConnection(connection); + } + } + + String _portKey(String nodeId, String portId) => '$nodeId::$portId'; + + String _connectionPairKey({ + required String sourceNodeId, + required String sourcePortId, + required String targetNodeId, + required String targetPortId, + }) { + return '$sourceNodeId::$sourcePortId->$targetNodeId::$targetPortId'; + } + + void _incrementIndex(Map index, String key) { + index.update(key, (value) => value + 1, ifAbsent: () => 1); + } + + void _decrementIndex(Map index, String key) { + final current = index[key]; + if (current == null) return; + if (current <= 1) { + index.remove(key); + return; + } + index[key] = current - 1; + } + + void _indexConnection(Connection connection) { + _connectionsByNodeId + .putIfAbsent(connection.sourceNodeId, () => {}) + .add(connection.id); + _connectionsByNodeId + .putIfAbsent(connection.targetNodeId, () => {}) + .add(connection.id); + + _incrementIndex( + _sourceConnectionCountByPortKey, + _portKey(connection.sourceNodeId, connection.sourcePortId), + ); + _incrementIndex( + _targetConnectionCountByPortKey, + _portKey(connection.targetNodeId, connection.targetPortId), + ); + _incrementIndex( + _connectionPairCountByPorts, + _connectionPairKey( + sourceNodeId: connection.sourceNodeId, + sourcePortId: connection.sourcePortId, + targetNodeId: connection.targetNodeId, + targetPortId: connection.targetPortId, + ), + ); + } + + void _deindexConnection(Connection connection) { + final sourceSet = _connectionsByNodeId[connection.sourceNodeId]; + sourceSet?.remove(connection.id); + if (sourceSet != null && sourceSet.isEmpty) { + _connectionsByNodeId.remove(connection.sourceNodeId); + } + + final targetSet = _connectionsByNodeId[connection.targetNodeId]; + targetSet?.remove(connection.id); + if (targetSet != null && targetSet.isEmpty) { + _connectionsByNodeId.remove(connection.targetNodeId); + } + + _decrementIndex( + _sourceConnectionCountByPortKey, + _portKey(connection.sourceNodeId, connection.sourcePortId), + ); + _decrementIndex( + _targetConnectionCountByPortKey, + _portKey(connection.targetNodeId, connection.targetPortId), + ); + _decrementIndex( + _connectionPairCountByPorts, + _connectionPairKey( + sourceNodeId: connection.sourceNodeId, + sourcePortId: connection.sourcePortId, + targetNodeId: connection.targetNodeId, + targetPortId: connection.targetPortId, + ), + ); + } +} diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/dirty_tracking_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/dirty_tracking_api.dart index c09320d..cde4263 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/dirty_tracking_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/dirty_tracking_api.dart @@ -181,19 +181,6 @@ extension DirtyTrackingExtension on NodeFlowController { } } - /// Rebuilds the connection-by-node index for O(1) lookup. - void _rebuildConnectionsByNodeIndex() { - _connectionsByNodeId.clear(); - for (final connection in _connections) { - _connectionsByNodeId - .putIfAbsent(connection.sourceNodeId, () => {}) - .add(connection.id); - _connectionsByNodeId - .putIfAbsent(connection.targetNodeId, () => {}) - .add(connection.id); - } - } - /// Updates spatial index bounds for a single node's connections using proper segment bounds. void _updateConnectionBoundsForNode(String nodeId) { // Use the API method that calculates proper segment bounds from path cache diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/editor_init_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/editor_init_api.dart index 8683d7e..5f41ab3 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/editor_init_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/editor_init_api.dart @@ -300,7 +300,7 @@ extension EditorInitApi on NodeFlowController { Set connectionIds, ) { // Rebuild connection-by-node index - _rebuildConnectionsByNodeIndex(); + _rebuildConnectionIndexes(); // Rebuild connection spatial index with proper segments rebuildAllConnectionSegments(); }, fireImmediately: false); diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/graph_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/graph_api.dart index 6246c4f..4daa478 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/graph_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/graph_api.dart @@ -51,6 +51,7 @@ extension GraphApi on NodeFlowController { _nodes[node.id] = node; } _connections.addAll(graph.connections); + _rebuildConnectionIndexes(); // Set viewport _viewport.value = graph.viewport; @@ -118,6 +119,10 @@ extension GraphApi on NodeFlowController { runInAction(() { _nodes.clear(); _connections.clear(); + _connectionsByNodeId.clear(); + _sourceConnectionCountByPortKey.clear(); + _targetConnectionCountByPortKey.clear(); + _connectionPairCountByPorts.clear(); _selectedNodeIds.clear(); _selectedConnectionIds.clear(); }); diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/node_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/node_api.dart index 0425593..6f5cb11 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/node_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/node_api.dart @@ -204,6 +204,8 @@ extension NodeApi on NodeFlowController { _spatialIndex.removeConnection(connection.id); // Also remove from path cache to prevent stale rendering _connectionPainter?.removeConnectionFromCache(connection.id); + _selectedConnectionIds.remove(connection.id); + _deindexConnection(connection); } // Then remove from connections list @@ -362,6 +364,8 @@ extension NodeApi on NodeFlowController { if (node == null) return; node.addPort(port); + // Keep spatial index in sync so newly added ports are immediately hit-testable. + markNodeDirty(nodeId); } /// Removes a port from a node and all connections involving that port. @@ -389,6 +393,8 @@ extension NodeApi on NodeFlowController { for (final connection in connectionsToRemove) { _spatialIndex.removeConnection(connection.id); _connectionPainter?.removeConnectionFromCache(connection.id); + _selectedConnectionIds.remove(connection.id); + _deindexConnection(connection); } // Remove from connections list @@ -401,6 +407,9 @@ extension NodeApi on NodeFlowController { // Remove the port using the node's dynamic method node.removePort(portId); }); + + // Refresh spatial node/port data immediately after port removal. + markNodeDirty(nodeId); } /// Sets the ports of a node. @@ -423,6 +432,9 @@ extension NodeApi on NodeFlowController { node.ports.clear(); node.ports.addAll(ports); }); + + // Reindex ports so hit testing reflects the new port set immediately. + markNodeDirty(nodeId); } // ============================================================================ diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart b/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart index 10c88da..abb93c0 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/node_flow_controller.dart @@ -34,8 +34,10 @@ import '../resizer_widget.dart'; import '../snap_delegate.dart'; import '../themes/node_flow_theme.dart'; import '../viewport_animation_mixin.dart'; +import 'viewport_culling_policy.dart'; part 'connection_api.dart'; +part 'connection_index_api.dart'; part 'dirty_tracking_api.dart'; part 'editor_init_api.dart'; part 'graph_api.dart'; @@ -154,6 +156,7 @@ class NodeFlowController { _nodes[node.id] = node; } _connections.addAll(connections); + _rebuildConnectionIndexes(); }); // Note: Full infrastructure setup happens when initController is called @@ -430,6 +433,15 @@ class NodeFlowController { // Connection index for O(1) lookup by node ID final Map> _connectionsByNodeId = {}; + // Connection indexes for O(1) port-level queries. + // Keys use "nodeId::portId" format. + final Map _sourceConnectionCountByPortKey = {}; + final Map _targetConnectionCountByPortKey = {}; + + // Duplicate-connection detection index keyed by: + // "sourceNode::sourcePort->targetNode::targetPort". + final Map _connectionPairCountByPorts = {}; + // Editor initialization tracking - set to true after initializeForEditor() is called bool _editorInitialized = false; @@ -441,10 +453,12 @@ class NodeFlowController { Rect? _cachedNodeQueryRect; List> _cachedVisibleNodesList = []; int _lastNodeIndexVersion = -1; + Rect? _lastNodeViewportRect; Rect? _cachedConnectionQueryRect; List> _cachedVisibleConnectionsList = []; int _lastConnectionIndexVersion = -1; + Rect? _lastConnectionViewportRect; // Computed values - stored as late final fields for proper caching late final Computed _hasSelection = Computed( @@ -499,6 +513,11 @@ class NodeFlowController { // Depend on viewport and screen size final v = _viewport.value; final s = _screenSize.value; + final isViewportInteracting = interaction.isViewportDragging; + + if (!v.x.isFinite || !v.y.isFinite || !v.zoom.isFinite || v.zoom <= 0) { + return _nodes.values.toList(); + } if (s.isEmpty) return _nodes.values.toList(); @@ -513,26 +532,25 @@ class NodeFlowController { // Check if spatial index changed final currentIndexVersion = _spatialIndex.version.value; final indexChanged = currentIndexVersion != _lastNodeIndexVersion; + final previousViewportRect = _lastNodeViewportRect; - // Check if viewport is safely within cached query rect (Hysteresis) - // We use a margin of 200px. If we are within 200px of the edge of the - // cached area, we trigger a re-query. - final cacheValid = - !indexChanged && - _cachedNodeQueryRect != null && - _cachedNodeQueryRect!.contains( - currentViewportRect.topLeft - const Offset(200, 200), - ) && - _cachedNodeQueryRect!.contains( - currentViewportRect.bottomRight + const Offset(200, 200), - ); + // Reuse cache while the viewport remains safely inside the prefetched area. + final cacheValid = ViewportCullingPolicy.isCacheValid( + cachedQueryRect: _cachedNodeQueryRect, + viewportRect: currentViewportRect, + indexChanged: indexChanged, + ); if (cacheValid) { + _lastNodeViewportRect = currentViewportRect; return _cachedVisibleNodesList; } - // Re-query: Expand viewport by 1000px (chunk size) - final queryRect = currentViewportRect.inflate(1000); + final queryRect = ViewportCullingPolicy.buildQueryRect( + viewportRect: currentViewportRect, + previousViewportRect: previousViewportRect, + isViewportInteracting: isViewportInteracting, + ); final nodes = _spatialIndex.nodesIn(queryRect); // Ensure currently interacting nodes are always included @@ -554,6 +572,7 @@ class NodeFlowController { _cachedNodeQueryRect = queryRect; _cachedVisibleNodesList = nodes; _lastNodeIndexVersion = currentIndexVersion; + _lastNodeViewportRect = currentViewportRect; return nodes; }); @@ -563,6 +582,11 @@ class NodeFlowController { // Depend on viewport and screen size final v = _viewport.value; final s = _screenSize.value; + final isViewportInteracting = interaction.isViewportDragging; + + if (!v.x.isFinite || !v.y.isFinite || !v.zoom.isFinite || v.zoom <= 0) { + return _connections; + } if (s.isEmpty) return _connections; @@ -575,27 +599,30 @@ class NodeFlowController { final currentIndexVersion = _spatialIndex.version.value; final indexChanged = currentIndexVersion != _lastConnectionIndexVersion; + final previousViewportRect = _lastConnectionViewportRect; - final cacheValid = - !indexChanged && - _cachedConnectionQueryRect != null && - _cachedConnectionQueryRect!.contains( - currentViewportRect.topLeft - const Offset(200, 200), - ) && - _cachedConnectionQueryRect!.contains( - currentViewportRect.bottomRight + const Offset(200, 200), - ); + final cacheValid = ViewportCullingPolicy.isCacheValid( + cachedQueryRect: _cachedConnectionQueryRect, + viewportRect: currentViewportRect, + indexChanged: indexChanged, + ); if (cacheValid) { + _lastConnectionViewportRect = currentViewportRect; return _cachedVisibleConnectionsList; } - final queryRect = currentViewportRect.inflate(1000); + final queryRect = ViewportCullingPolicy.buildQueryRect( + viewportRect: currentViewportRect, + previousViewportRect: previousViewportRect, + isViewportInteracting: isViewportInteracting, + ); final connections = _spatialIndex.connectionsIn(queryRect); _cachedConnectionQueryRect = queryRect; _cachedVisibleConnectionsList = connections; _lastConnectionIndexVersion = currentIndexVersion; + _lastConnectionViewportRect = currentViewportRect; return connections; }); diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/viewport_api.dart b/packages/vyuh_node_flow/lib/src/editor/controller/viewport_api.dart index 9092170..0b2dedb 100644 --- a/packages/vyuh_node_flow/lib/src/editor/controller/viewport_api.dart +++ b/packages/vyuh_node_flow/lib/src/editor/controller/viewport_api.dart @@ -39,13 +39,28 @@ extension ViewportApi on NodeFlowController { /// controller.setViewport(GraphViewport(x: 100, y: 50, zoom: 1.5)); /// ``` void setViewport(GraphViewport viewport) { + final minZoom = _config.minZoom.value; + final maxZoom = _config.maxZoom.value; + + final safeX = viewport.x.isFinite ? viewport.x : _viewport.value.x; + final safeY = viewport.y.isFinite ? viewport.y : _viewport.value.y; + final safeZoom = viewport.zoom.isFinite && viewport.zoom > 0 + ? viewport.zoom.clamp(minZoom, maxZoom).toDouble() + : _viewport.value.zoom.clamp(minZoom, maxZoom).toDouble(); + + final sanitizedViewport = GraphViewport( + x: safeX, + y: safeY, + zoom: safeZoom, + ); + final previousViewport = _viewport.value; // Immediate viewport updates for real-time panning responsiveness runInAction(() { - _viewport.value = viewport; + _viewport.value = sanitizedViewport; }); // Emit extension event - _emitEvent(ViewportChanged(viewport, previousViewport)); + _emitEvent(ViewportChanged(sanitizedViewport, previousViewport)); } /// Sets the screen size used for viewport calculations. diff --git a/packages/vyuh_node_flow/lib/src/editor/controller/viewport_culling_policy.dart b/packages/vyuh_node_flow/lib/src/editor/controller/viewport_culling_policy.dart new file mode 100644 index 0000000..ea7d308 --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/editor/controller/viewport_culling_policy.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +/// Viewport-aware culling policy for visible node/connection queries. +/// +/// This policy keeps the same total prefetch budget as the legacy implementation +/// but, during active viewport panning, biases the query window in the pan +/// direction. This reduces expensive cache boundary re-queries that can cause +/// hitching near culling edges. +class ViewportCullingPolicy { + const ViewportCullingPolicy._(); + + static const double _prefetchPadding = 1000.0; + static const double _safetyMargin = 200.0; + static const double _directionalBias = 600.0; + static const double _minAxisPadding = 250.0; + + /// Returns whether the cached query rect still safely contains the viewport. + static bool isCacheValid({ + required Rect? cachedQueryRect, + required Rect viewportRect, + required bool indexChanged, + }) { + if (indexChanged || cachedQueryRect == null) { + return false; + } + + return cachedQueryRect.contains( + viewportRect.topLeft - const Offset(_safetyMargin, _safetyMargin), + ) && + cachedQueryRect.contains( + viewportRect.bottomRight + const Offset(_safetyMargin, _safetyMargin), + ); + } + + /// Builds a query rect for visible-element culling. + /// + /// While idle, this matches the original symmetric `inflate(1000)` behavior. + /// While panning, it biases prefetch toward movement direction. + static Rect buildQueryRect({ + required Rect viewportRect, + required Rect? previousViewportRect, + required bool isViewportInteracting, + }) { + if (!isViewportInteracting || previousViewportRect == null) { + return viewportRect.inflate(_prefetchPadding); + } + + final dx = viewportRect.center.dx - previousViewportRect.center.dx; + final dy = viewportRect.center.dy - previousViewportRect.center.dy; + + final xPads = _axisPads(dx); + final yPads = _axisPads(dy); + + return Rect.fromLTRB( + viewportRect.left - xPads.before, + viewportRect.top - yPads.before, + viewportRect.right + xPads.after, + viewportRect.bottom + yPads.after, + ); + } + + static ({double before, double after}) _axisPads(double delta) { + if (delta.abs() < 0.01) { + return (before: _prefetchPadding, after: _prefetchPadding); + } + + final direction = delta.sign; + final before = (_prefetchPadding - _directionalBias * direction) + .clamp(_minAxisPadding, double.infinity) + .toDouble(); + final after = (_prefetchPadding + _directionalBias * direction) + .clamp(_minAxisPadding, double.infinity) + .toDouble(); + + return (before: before, after: after); + } +} diff --git a/packages/vyuh_node_flow/lib/src/editor/element_scope.dart b/packages/vyuh_node_flow/lib/src/editor/element_scope.dart index b412b71..a1a3834 100644 --- a/packages/vyuh_node_flow/lib/src/editor/element_scope.dart +++ b/packages/vyuh_node_flow/lib/src/editor/element_scope.dart @@ -114,6 +114,9 @@ class ElementScope extends StatefulWidget { this.onDragCancel, this.createSession, this.isDraggable = true, + this.canStartDrag, + this.shouldCaptureTouch, + this.allowTouchPanGesture = false, this.dragStartBehavior = DragStartBehavior.start, this.onTap, this.onDoubleTap, @@ -140,6 +143,18 @@ class ElementScope extends StatefulWidget { /// drag events to pass through to underlying elements. final bool isDraggable; + /// Optional guard to prevent starting a drag based on external state. + /// Return false to block drag start (e.g., when a connection drag is active). + final bool Function()? canStartDrag; + + /// Optional guard to decide whether to capture touch pointer for dragging. + /// Return false to let child widgets (e.g., ports) handle the touch. + final bool Function(Offset localPosition)? shouldCaptureTouch; + + /// When true, allow touch pointers to be handled by the pan gesture recognizer. + /// This is useful for widgets like ports that need pointer capture outside bounds. + final bool allowTouchPanGesture; + /// Determines when a drag gesture formally starts. /// /// - [DragStartBehavior.start] (default): Drag starts after the pointer has @@ -309,6 +324,20 @@ class _ElementScopeState extends State with AutoPanMixin { /// Created via [ElementScope.createSession] at drag start, manages canvas /// locking/unlocking automatically. DragSession? _session; + bool _preDragLock = false; + + // Touch drag tracking (pointer-based) to avoid gesture arena conflicts on mobile. + int? _touchPointerId; + Offset? _touchStartLocal; + Offset? _touchStartGlobal; + Offset? _lastTouchLocal; + bool _touchDragStarted = false; + + bool _isTouchLike(PointerEvent event) { + return event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus; + } // --------------------------------------------------------------------------- // AutoPanMixin Implementation @@ -341,6 +370,10 @@ class _ElementScopeState extends State with AutoPanMixin { /// which automatically locks the canvas. void _startDrag(DragStartDetails details) { if (_isDragging) return; + if (!widget.isDraggable) return; + if (widget.canStartDrag != null && !widget.canStartDrag!()) { + return; + } // If shift is pressed, canvas-level shift-drag selection takes priority. // Don't start node drag - let the selection drag handle this pointer. @@ -351,9 +384,10 @@ class _ElementScopeState extends State with AutoPanMixin { resetAutoPanState(); // Reset drift at drag start (from mixin) // Create and start session if factory provided (locks canvas automatically) - if (widget.createSession != null) { + if (widget.createSession != null && _session == null) { _session = widget.createSession!(); _session!.start(); + _preDragLock = false; } widget.onDragStart(details); @@ -409,6 +443,7 @@ class _ElementScopeState extends State with AutoPanMixin { // End session if active (unlocks canvas automatically) _session?.end(); _session = null; + _preDragLock = false; widget.onDragEnd(details); } @@ -428,6 +463,7 @@ class _ElementScopeState extends State with AutoPanMixin { // Cancel session if active (unlocks canvas automatically) _session?.cancel(); _session = null; + _preDragLock = false; // Use dedicated cancel callback if provided, otherwise fall back to onDragEnd if (widget.onDragCancel != null) { @@ -450,6 +486,31 @@ class _ElementScopeState extends State with AutoPanMixin { /// Note: This is treated as a cancel because we don't have proper DragEndDetails /// from a pointer up event outside the gesture recognizer flow. void _handlePointerUp(PointerUpEvent event) { + // If we locked the canvas on touch down but never started a drag, + // release the lock now. + if (_preDragLock && !_isDragging) { + _session?.end(); + _session = null; + _preDragLock = false; + } + // Touch pointer-based drag end (bypasses gesture arena). + if (_touchPointerId != null && event.pointer == _touchPointerId) { + if (_touchDragStarted) { + _endDrag( + DragEndDetails( + velocity: Velocity(pixelsPerSecond: event.delta), + primaryVelocity: + event.delta.distance == 0 ? null : event.delta.distance, + ), + ); + } + _touchPointerId = null; + _touchStartLocal = null; + _touchStartGlobal = null; + _lastTouchLocal = null; + _touchDragStarted = false; + } + // Only consider canceling if this is the EXACT pointer that started the drag if (_isDragging && event.pointer == _dragPointerId) { // Schedule for next microtask to give gesture recognizer's onEnd priority. @@ -490,6 +551,93 @@ class _ElementScopeState extends State with AutoPanMixin { _pendingPointerId = event.pointer; widget.onTap?.call(); + + // Touch drag tracking (pointer-based) to avoid gesture arena conflicts. + if (_isTouchLike(event) && + widget.isDraggable && + !widget.allowTouchPanGesture) { + if (widget.shouldCaptureTouch != null && + !widget.shouldCaptureTouch!(event.localPosition)) { + return; + } + // Pre-lock the canvas on touch down so the canvas doesn't pan + // while we decide whether this is a tap or a drag. + if (widget.createSession != null && _session == null) { + _session = widget.createSession!(); + _session!.start(); + _preDragLock = true; + } + _touchPointerId = event.pointer; + _touchStartLocal = event.localPosition; + _touchStartGlobal = event.position; + _lastTouchLocal = event.localPosition; + _touchDragStarted = false; + + // If configured to start immediately, start the drag on touch down. + if (widget.dragStartBehavior == DragStartBehavior.down) { + _touchDragStarted = true; + _startDrag( + DragStartDetails( + globalPosition: event.position, + localPosition: event.localPosition, + sourceTimeStamp: event.timeStamp, + ), + ); + } + } + }, + onPointerMove: (event) { + if (!_isTouchLike(event)) return; + if (_touchPointerId == null || event.pointer != _touchPointerId) { + return; + } + + final startLocal = _touchStartLocal; + final lastLocal = _lastTouchLocal; + if (startLocal == null || lastLocal == null) return; + + if (!_touchDragStarted) { + final slop = + widget.dragStartBehavior == DragStartBehavior.down + ? 0.0 + : kTouchSlop; + if ((event.localPosition - startLocal).distance < slop) { + return; + } + _touchDragStarted = true; + _startDrag( + DragStartDetails( + globalPosition: _touchStartGlobal ?? event.position, + localPosition: startLocal, + sourceTimeStamp: event.timeStamp, + ), + ); + } + + final delta = event.localPosition - lastLocal; + _lastTouchLocal = event.localPosition; + + _updateDrag( + DragUpdateDetails( + globalPosition: event.position, + localPosition: event.localPosition, + delta: delta, + primaryDelta: null, + sourceTimeStamp: event.timeStamp, + ), + ); + }, + onPointerCancel: (event) { + if (_touchPointerId != null && event.pointer == _touchPointerId) { + _touchPointerId = null; + _touchStartLocal = null; + _touchStartGlobal = null; + _lastTouchLocal = null; + _touchDragStarted = false; + if (_isDragging) { + _cancelDrag(); + } + } }, // Track pointer up to ensure drag ends when the correct pointer releases onPointerUp: _handlePointerUp, @@ -503,7 +651,11 @@ class _ElementScopeState extends State with AutoPanMixin { NonTrackpadPanGestureRecognizer: GestureRecognizerFactoryWithHandlers< NonTrackpadPanGestureRecognizer - >(() => NonTrackpadPanGestureRecognizer(), (recognizer) { + >( + () => NonTrackpadPanGestureRecognizer( + allowTouch: widget.allowTouchPanGesture, + ), + (recognizer) { // Configure drag start behavior (immediate for ports, threshold for elements) recognizer.dragStartBehavior = widget.dragStartBehavior; // Use local methods that track drag state and pass full details diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/connections_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/connections_layer.dart index 82940aa..a2d5afa 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/connections_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/connections_layer.dart @@ -138,6 +138,7 @@ class ConnectionsLayer extends StatelessWidget { selectedIds: controller.selectedConnectionIds, animation: animation, connectionStyleBuilder: connectionStyleBuilder, + useDrawOnlyPathCache: true, ), size: Size.infinite, ); diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/grid_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/grid_layer.dart index 33a8e24..67a8a66 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/grid_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/grid_layer.dart @@ -1,17 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import '../../graph/viewport.dart'; import '../../grid/grid_painter.dart'; +import '../../grid/grid_render_policy.dart'; +import '../../plugins/lod/lod_plugin.dart'; +import '../controller/node_flow_controller.dart'; import '../themes/node_flow_theme.dart'; -import '../../graph/viewport.dart'; /// Grid background layer widget that renders the grid pattern class GridLayer extends StatelessWidget { const GridLayer({ super.key, + required this.controller, required this.theme, required this.transformationController, }); + final NodeFlowController controller; final NodeFlowTheme theme; final TransformationController transformationController; @@ -19,20 +25,39 @@ class GridLayer extends StatelessWidget { Widget build(BuildContext context) { return Positioned.fill( child: RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: transformationController, - builder: (context, transform, child) { - final translation = transform.getTranslation(); - final scale = transform.getMaxScaleOnAxis(); - final viewport = GraphViewport( - x: translation.x, - y: translation.y, - zoom: scale, + child: Observer( + builder: (context) { + final isViewportInteracting = + controller.interaction.isViewportInteracting.value; + final lod = controller.lod; + + final effectiveTheme = GridRenderPolicy.resolve( + baseTheme: theme, + isViewportInteracting: isViewportInteracting, + adaptiveInteractionActive: + lod?.isAdaptiveInteractionActive ?? false, + useThumbnailMode: lod?.useThumbnailMode ?? false, ); - return CustomPaint( - painter: GridPainter(theme: theme, viewport: viewport), - size: Size.infinite, + return ValueListenableBuilder( + valueListenable: transformationController, + builder: (context, transform, child) { + final translation = transform.getTranslation(); + final scale = transform.getMaxScaleOnAxis(); + final viewport = GraphViewport( + x: translation.x, + y: translation.y, + zoom: scale, + ); + + return CustomPaint( + painter: GridPainter( + theme: effectiveTheme, + viewport: viewport, + ), + size: Size.infinite, + ); + }, ); }, ), diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/interaction_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/interaction_layer.dart index c5c9baf..da1d116 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/interaction_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/interaction_layer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import '../../connections/styles/connection_style_base.dart'; import '../../connections/temporary_connection.dart'; import '../../graph/coordinates.dart'; import '../../nodes/node.dart'; @@ -8,6 +9,15 @@ import '../../ports/port.dart'; import '../controller/node_flow_controller.dart'; import '../themes/node_flow_theme.dart'; +typedef TemporaryConnectionStyleResolver = + ConnectionStyle? Function( + TemporaryConnection temporary, + Node startNode, + Port startPort, + Node? hoveredNode, + Port? hoveredPort, + ); + /// Interaction layer widget that renders temporary connections and selection rectangles. /// /// This layer is positioned outside the InteractiveViewer (as a peer to the MinimapOverlay) @@ -23,6 +33,7 @@ class InteractionLayer extends StatelessWidget { required this.controller, required this.transformationController, this.animation, + this.temporaryStyleResolver, }); final NodeFlowController controller; @@ -39,6 +50,12 @@ class InteractionLayer extends StatelessWidget { /// for rendering animation effects (if configured in the theme). final Animation? animation; + /// Optional resolver for temporary connection routing style. + /// + /// Use this to align temporary routing with final connection style selection + /// (for example, when using dynamic `connectionStyleBuilder`). + final TemporaryConnectionStyleResolver? temporaryStyleResolver; + @override Widget build(BuildContext context) { return IgnorePointer( @@ -60,6 +77,11 @@ class InteractionLayer extends StatelessWidget { final previewConnections = controller.interaction.previewConnections .toList(); + final hasInteractionVisuals = + selectionRect != null || + tempConnection != null || + previewConnections.isNotEmpty; + // Get theme from context - this ensures automatic rebuilds when theme changes final theme = Theme.of(builderContext).extension() ?? @@ -74,6 +96,8 @@ class InteractionLayer extends StatelessWidget { previewConnections: previewConnections, transformationController: transformationController, animation: animation, + temporaryStyleResolver: temporaryStyleResolver, + listenToTransform: hasInteractionVisuals, ), size: Size.infinite, ); @@ -90,7 +114,8 @@ class InteractionLayer extends StatelessWidget { /// to screen coordinates, enabling rendering of elements that extend beyond /// the visible viewport bounds. /// -/// Listens to both [transformationController] and [animation] for repaints. +/// Listens to [transformationController] (and optionally [animation]) only when +/// interaction visuals are active. class InteractionLayerPainter extends CustomPainter { InteractionLayerPainter({ required this.controller, @@ -100,10 +125,14 @@ class InteractionLayerPainter extends CustomPainter { this.previewConnections = const [], required this.transformationController, this.animation, + this.temporaryStyleResolver, + this.listenToTransform = true, }) : super( - repaint: animation != null - ? Listenable.merge([transformationController, animation]) - : transformationController, + repaint: listenToTransform + ? (animation != null + ? Listenable.merge([transformationController, animation]) + : transformationController) + : null, ); final NodeFlowController controller; @@ -126,6 +155,15 @@ class InteractionLayerPainter extends CustomPainter { /// Optional animation for animated temporary connections. final Animation? animation; + /// Optional resolver for temporary connection routing style. + final TemporaryConnectionStyleResolver? temporaryStyleResolver; + + /// Whether this painter should listen to viewport transform updates. + /// + /// Disabled when there are no interaction visuals, avoiding redundant + /// repaint scheduling while panning an otherwise idle canvas. + final bool listenToTransform; + @override void paint(Canvas canvas, Size size) { // Apply the canvas transform to convert graph coordinates to screen coordinates @@ -218,6 +256,17 @@ class InteractionLayerPainter extends CustomPainter { targetNodeBounds = temp.startNodeBounds; } + ConnectionStyle? overrideStyle; + if (startNode != null && startPort != null) { + overrideStyle = temporaryStyleResolver?.call( + temp, + startNode, + startPort, + hoveredNode, + hoveredPort, + ); + } + controller.connectionPainter.paintTemporaryConnection( canvas, sourcePoint, @@ -226,6 +275,7 @@ class InteractionLayerPainter extends CustomPainter { targetPort: targetPort, sourceNodeBounds: sourceNodeBounds, targetNodeBounds: targetNodeBounds, + overrideStyle: overrideStyle, animationValue: animation?.value, ); } @@ -259,6 +309,7 @@ class InteractionLayerPainter extends CustomPainter { return true; } - return selectionRect != oldDelegate.selectionRect; + return selectionRect != oldDelegate.selectionRect || + listenToTransform != oldDelegate.listenToTransform; } } diff --git a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart index 0f52a4d..81dd09f 100644 --- a/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/layers/nodes_layer.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import '../../connections/connection.dart'; import '../../graph/coordinates.dart'; import '../../nodes/node.dart'; import '../../nodes/node_container.dart'; @@ -28,16 +27,15 @@ import 'nodes_thumbnail_layer.dart'; /// /// Use the factory constructors to create filtered layers: /// ```dart -/// NodesLayer.background(controller, nodeBuilder, connections) // Groups -/// NodesLayer.middle(controller, nodeBuilder, connections) // Regular nodes -/// NodesLayer.foreground(controller, nodeBuilder, connections) // Stickies, markers +/// NodesLayer.background(controller, nodeBuilder) // Groups +/// NodesLayer.middle(controller, nodeBuilder) // Regular nodes +/// NodesLayer.foreground(controller, nodeBuilder) // Stickies, markers /// ``` class NodesLayer extends StatelessWidget { const NodesLayer({ super.key, required this.controller, required this.nodeBuilder, - required this.connections, this.portBuilder, this.layerFilter, this.onNodeTap, @@ -56,8 +54,7 @@ class NodesLayer extends StatelessWidget { /// typically used for group containers. static NodesLayer background( NodeFlowController controller, - Widget Function(BuildContext context, Node node) nodeBuilder, - List connections, { + Widget Function(BuildContext context, Node node) nodeBuilder, { PortBuilder? portBuilder, ThumbnailBuilder? thumbnailBuilder, void Function(Node node)? onNodeTap, @@ -73,7 +70,6 @@ class NodesLayer extends StatelessWidget { return NodesLayer( controller: controller, nodeBuilder: nodeBuilder, - connections: connections, portBuilder: portBuilder, thumbnailBuilder: thumbnailBuilder, layerFilter: NodeRenderLayer.background, @@ -93,8 +89,7 @@ class NodesLayer extends StatelessWidget { /// used for regular nodes. static NodesLayer middle( NodeFlowController controller, - Widget Function(BuildContext context, Node node) nodeBuilder, - List connections, { + Widget Function(BuildContext context, Node node) nodeBuilder, { PortBuilder? portBuilder, ThumbnailBuilder? thumbnailBuilder, void Function(Node node)? onNodeTap, @@ -110,7 +105,6 @@ class NodesLayer extends StatelessWidget { return NodesLayer( controller: controller, nodeBuilder: nodeBuilder, - connections: connections, portBuilder: portBuilder, thumbnailBuilder: thumbnailBuilder, layerFilter: NodeRenderLayer.middle, @@ -130,8 +124,7 @@ class NodesLayer extends StatelessWidget { /// typically used for sticky notes and markers. static NodesLayer foreground( NodeFlowController controller, - Widget Function(BuildContext context, Node node) nodeBuilder, - List connections, { + Widget Function(BuildContext context, Node node) nodeBuilder, { PortBuilder? portBuilder, ThumbnailBuilder? thumbnailBuilder, void Function(Node node)? onNodeTap, @@ -147,7 +140,6 @@ class NodesLayer extends StatelessWidget { return NodesLayer( controller: controller, nodeBuilder: nodeBuilder, - connections: connections, portBuilder: portBuilder, thumbnailBuilder: thumbnailBuilder, layerFilter: NodeRenderLayer.foreground, @@ -177,8 +169,6 @@ class NodesLayer extends StatelessWidget { /// - [NodesLayer.foreground] for foreground layer nodes final NodeRenderLayer? layerFilter; - final List connections; - /// Callback invoked when a node is tapped. final void Function(Node node)? onNodeTap; @@ -286,7 +276,6 @@ class NodesLayer extends StatelessWidget { node: node, controller: controller, shape: shape, - connections: connections, portBuilder: portBuilder, // Event callbacks onTap: onNodeTap != null ? () => onNodeTap!(node) : null, diff --git a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart index 82319e5..95a8f62 100644 --- a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart +++ b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor.dart @@ -7,6 +7,7 @@ import 'package:vector_math/vector_math_64.dart' hide Colors; import '../connections/connection.dart'; import '../connections/styles/connection_style_base.dart'; +import '../connections/temporary_connection.dart'; import '../graph/coordinates.dart'; import '../graph/viewport.dart'; import '../nodes/node.dart'; @@ -306,6 +307,7 @@ class _NodeFlowEditorState extends State> with TickerProviderStateMixin, ViewportAnimationMixin { late final TransformationController _transformationController; final List _disposers = []; + bool _isSyncingViewportFromTransform = false; // Animation controller for animated connections AnimationController? _connectionAnimationController; @@ -337,6 +339,17 @@ class _NodeFlowEditorState extends State> // ups from prematurely ending mouse drags. int? _dragPointerId; + // Touch-driven connection drag state (for mobile). + bool _isTouchConnecting = false; + int? _touchConnectionPointerId; + Offset _touchConnectionPointerOffset = Offset.zero; + + bool _isTouchLike(PointerEvent event) { + return event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus; + } + @override void initState() { super.initState(); @@ -546,6 +559,7 @@ class _NodeFlowEditorState extends State> onPointerDown: _handlePointerDown, onPointerMove: _handlePointerMove, onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, onPointerHover: _handleMouseHover, child: Observer.withBuiltChild( builder: (context, child) { @@ -609,6 +623,7 @@ class _NodeFlowEditorState extends State> // Background grid GridLayer( + controller: widget.controller, theme: theme, transformationController: _transformationController, @@ -630,7 +645,6 @@ class _NodeFlowEditorState extends State> NodesLayer.background( widget.controller, widget.nodeBuilder, - widget.controller.connections, portBuilder: widget.portBuilder, thumbnailBuilder: widget.thumbnailBuilder, onNodeTap: _handleNodeTap, @@ -700,7 +714,6 @@ class _NodeFlowEditorState extends State> NodesLayer.middle( widget.controller, widget.nodeBuilder, - widget.controller.connections, portBuilder: widget.portBuilder, thumbnailBuilder: widget.thumbnailBuilder, onNodeTap: _handleNodeTap, @@ -732,7 +745,6 @@ class _NodeFlowEditorState extends State> NodesLayer.foreground( widget.controller, widget.nodeBuilder, - widget.controller.connections, portBuilder: widget.portBuilder, thumbnailBuilder: widget.thumbnailBuilder, onNodeTap: _handleNodeTap, @@ -776,6 +788,10 @@ class _NodeFlowEditorState extends State> controller: widget.controller, transformationController: _transformationController, animation: _connectionAnimationController, + temporaryStyleResolver: + widget.connectionStyleBuilder == null + ? null + : _resolveTemporaryConnectionStyle, ), ), @@ -822,6 +838,29 @@ class _NodeFlowEditorState extends State> _disposers.add( reaction((_) => widget.controller.viewport, (GraphViewport viewport) { if (mounted) { + if (!viewport.x.isFinite || + !viewport.y.isFinite || + !viewport.zoom.isFinite || + viewport.zoom <= 0) { + return; + } + + // Skip feedback updates when viewport was just synced from transform. + if (_isSyncingViewportFromTransform) { + return; + } + + // Avoid redundant matrix writes (which trigger listeners/repaints) + // when InteractiveViewer already has the same transform. + final currentTransform = _transformationController.value; + final currentTranslation = currentTransform.getTranslation(); + final currentZoom = currentTransform.getMaxScaleOnAxis(); + if ((currentTranslation.x - viewport.x).abs() < 0.01 && + (currentTranslation.y - viewport.y).abs() < 0.01 && + (currentZoom - viewport.zoom).abs() < 0.0001) { + return; + } + final matrix = Matrix4.identity() ..translateByVector3(Vector3(viewport.x, viewport.y, 0)) ..scaleByDouble(viewport.zoom, viewport.zoom, viewport.zoom, 1.0); @@ -888,6 +927,48 @@ class _NodeFlowEditorState extends State> } } + ConnectionStyle? _resolveTemporaryConnectionStyle( + TemporaryConnection temporary, + Node startNode, + Port startPort, + Node? hoveredNode, + Port? hoveredPort, + ) { + final styleBuilder = widget.connectionStyleBuilder; + if (styleBuilder == null || hoveredNode == null || hoveredPort == null) { + return null; + } + + final Node sourceNode; + final Port sourcePort; + final Node targetNode; + final Port targetPort; + + if (temporary.isStartFromOutput) { + sourceNode = startNode; + sourcePort = startPort; + targetNode = hoveredNode; + targetPort = hoveredPort; + } else { + sourceNode = hoveredNode; + sourcePort = hoveredPort; + targetNode = startNode; + targetPort = startPort; + } + + final temporaryEdge = Connection( + id: + '__temp_${sourceNode.id}_${sourcePort.id}_' + '${targetNode.id}_${targetPort.id}', + sourceNodeId: sourceNode.id, + sourcePortId: sourcePort.id, + targetNodeId: targetNode.id, + targetPortId: targetPort.id, + ); + + return styleBuilder(temporaryEdge, sourceNode, targetNode); + } + @override void dispose() { // Remove keyboard handler @@ -925,8 +1006,9 @@ class _NodeFlowEditorState extends State> /// they don't work reliably in all cases. This listener is the safety net. /// /// IMPORTANT: This sync is skipped during viewport animation to prevent - /// the animation from being interrupted. The viewport is synced once - /// when the animation completes via the onAnimationComplete callback. + /// the animation from being interrupted. During active pan/zoom gestures, + /// this still syncs every transform tick so hit testing and culling remain + /// in lock-step with what the user sees. void _syncViewportFromTransform() { // Skip sync during animation - final sync happens via onAnimationComplete if (isViewportAnimating) { @@ -936,6 +1018,12 @@ class _NodeFlowEditorState extends State> final transform = _transformationController.value; final translation = transform.getTranslation(); final currentZoom = transform.getMaxScaleOnAxis(); + if (!translation.x.isFinite || + !translation.y.isFinite || + !currentZoom.isFinite || + currentZoom <= 0) { + return; + } final viewport = GraphViewport( x: translation.x, @@ -943,13 +1031,12 @@ class _NodeFlowEditorState extends State> zoom: currentZoom, ); - // Only update if viewport actually changed to avoid unnecessary reactions final currentViewport = widget.controller.viewport; - if (currentViewport.x != viewport.x || - currentViewport.y != viewport.y || - currentViewport.zoom != viewport.zoom) { - widget.controller.setViewport(viewport); + if (_isSameViewport(currentViewport, viewport)) { + return; } + + _syncViewportToController(viewport); } void _onInteractionStart(ScaleStartDetails details) { @@ -973,7 +1060,9 @@ class _NodeFlowEditorState extends State> // We don't call setViewport here - the listener is the authoritative source. // Fire viewport move event with current viewport state - widget.controller.events.viewport?.onMove?.call(widget.controller.viewport); + widget.controller.events.viewport?.onMove?.call( + widget.controller.viewport, + ); } void _onInteractionEnd(ScaleEndDetails details) { @@ -992,6 +1081,21 @@ class _NodeFlowEditorState extends State> ); } + void _syncViewportToController(GraphViewport viewport) { + _isSyncingViewportFromTransform = true; + try { + widget.controller.setViewport(viewport); + } finally { + _isSyncingViewportFromTransform = false; + } + } + + bool _isSameViewport(GraphViewport a, GraphViewport b) { + return (a.x - b.x).abs() < 0.01 && + (a.y - b.y).abs() < 0.01 && + (a.zoom - b.zoom).abs() < 0.0001; + } + void _handlePointerDown(PointerDownEvent event) { // Handle secondary button (right-click) for context menu if (_handleContextMenu(event)) { @@ -1047,6 +1151,49 @@ class _NodeFlowEditorState extends State> // Note: Connection handling is done via GestureDetector in PortWidget. // PortWidget uses pan gestures to handle connection drag. Pan is disabled // above when pointer is on a port, preventing InteractiveViewer from competing. + // + // On touch devices, start connection drag here to ensure we keep receiving + // pointer updates even after leaving the port bounds. + if (hitResult.isPort && + _isTouchLike(event) && + widget.behavior.canCreate && + !_isTouchConnecting) { + final node = widget.controller.getNode(hitResult.nodeId!); + if (node != null) { + final port = _findPort(node, hitResult.portId!); + if (port != null) { + final theme = widget.controller.theme ?? NodeFlowTheme.light; + final portTheme = theme.portTheme; + final effectivePortSize = port.size ?? portTheme.size; + final shape = widget.controller.nodeShapeBuilder?.call(node); + + final startPoint = node.getConnectionPoint( + port.id, + portSize: effectivePortSize, + shape: shape, + ); + + final result = widget.controller.startConnectionDrag( + nodeId: node.id, + portId: port.id, + isOutput: hitResult.isOutput ?? false, + startPoint: startPoint, + nodeBounds: node.getBounds(), + initialScreenPosition: event.position, + ); + + if (result.allowed) { + _isTouchConnecting = true; + _touchConnectionPointerId = event.pointer; + + final pointerGraphPos = widget.controller + .screenToGraph(ScreenPosition(event.position)) + .offset; + _touchConnectionPointerOffset = startPoint - pointerGraphPos; + } + } + } + } switch (hitResult.hitType) { // Node selection is handled by widget-level handlers: @@ -1089,11 +1236,38 @@ class _NodeFlowEditorState extends State> } void _handlePointerMove(PointerMoveEvent event) { - // Always update mouse position for debug visualization (before any early returns) - final worldPosition = widget.controller.viewport.toGraph( - ScreenPosition(event.localPosition), - ); - widget.controller.setMousePositionWorld(worldPosition); + // Mouse world tracking is only needed for spatial-index debug overlays. + if (_shouldTrackMouseWorldPosition) { + final worldPosition = widget.controller.viewport.toGraph( + ScreenPosition(event.localPosition), + ); + widget.controller.setMousePositionWorld(worldPosition); + } + + // Touch-driven connection drag updates + if (_isTouchConnecting && + _touchConnectionPointerId != null && + event.pointer == _touchConnectionPointerId) { + final pointerGraphPos = widget.controller + .screenToGraph(ScreenPosition(event.position)) + .offset; + final newEndPoint = pointerGraphPos + _touchConnectionPointerOffset; + + final hitResult = widget.controller.hitTestPort(newEndPoint); + Rect? targetNodeBounds; + if (hitResult != null) { + final targetNode = widget.controller.getNode(hitResult.nodeId); + targetNodeBounds = targetNode?.getBounds(); + } + + widget.controller.updateConnectionDrag( + graphPosition: newEndPoint, + targetNodeId: hitResult?.nodeId, + targetPortId: hitResult?.portId, + targetNodeBounds: targetNodeBounds, + ); + return; + } // Reset tap tracking if user moves significantly (they're dragging, not tapping) const dragThreshold = 5.0; // pixels @@ -1138,7 +1312,30 @@ class _NodeFlowEditorState extends State> widget.controller._setPointerPosition(ScreenPosition(event.localPosition)); } + bool get _shouldTrackMouseWorldPosition => + widget.controller.debug?.showSpatialIndex ?? false; + void _handlePointerUp(PointerUpEvent event) { + // Complete touch-driven connection drag + if (_isTouchConnecting && + _touchConnectionPointerId != null && + event.pointer == _touchConnectionPointerId) { + final temp = widget.controller.temporaryConnection; + if (temp != null && + temp.targetNodeId != null && + temp.targetPortId != null) { + widget.controller.completeConnectionDrag( + targetNodeId: temp.targetNodeId!, + targetPortId: temp.targetPortId!, + ); + } else { + widget.controller.cancelConnectionDrag(); + } + _isTouchConnecting = false; + _touchConnectionPointerId = null; + _touchConnectionPointerOffset = Offset.zero; + } + // Check if this was a tap (minimal movement from initial position) const tapThreshold = 5.0; // pixels final wasTap = @@ -1217,6 +1414,32 @@ class _NodeFlowEditorState extends State> // Cursor is derived from state via Observer - no update needed } + void _handlePointerCancel(PointerCancelEvent event) { + final controller = widget.controller; + + if (controller.isDrawingSelection) { + controller._finishSelectionDrag(); + } + + if (controller.isConnecting) { + controller.cancelConnectionDrag(); + } + + if (!controller.isConnecting && + controller.draggedNodeId == null && + !controller.isResizing && + !controller.isDrawingSelection) { + controller._updateInteractionState(canvasLocked: false); + } + + if (_dragPointerId == event.pointer) { + _dragPointerId = null; + } + + _initialPointerPosition = null; + _shouldClearSelectionOnTap = false; + } + // Helper methods void _startSelectionDrag(Offset startPosition) { diff --git a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart index 6260e67..62bbcf7 100644 --- a/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart +++ b/packages/vyuh_node_flow/lib/src/editor/node_flow_editor_hit_testing.dart @@ -36,6 +36,18 @@ extension _HitTestingExtension on _NodeFlowEditorState { /// Handles mouse hover events, updating cursor and firing enter/leave events. void _handleMouseHover(PointerHoverEvent event) { + // During viewport pan/zoom, skip hover hit testing entirely. + // This avoids expensive spatial hit tests while the scene is moving. + if (widget.controller.interaction.isViewportDragging) { + if (_lastHoverHitType != null) { + _fireMouseLeaveEvent(); + _lastHoverHitType = null; + _lastHoveredEntityId = null; + } + widget.controller.interaction.setHoveringConnection(false); + return; + } + // Check shift key state for selection mode cursor final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; if (isShiftPressed != _isShiftPressed) { @@ -43,11 +55,13 @@ extension _HitTestingExtension on _NodeFlowEditorState { widget.controller.interaction.setSelectionStarted(_isShiftPressed); } - // Update mouse position in world coordinates for debug visualization - final worldPosition = widget.controller.viewport.toGraph( - ScreenPosition(event.localPosition), - ); - widget.controller.setMousePositionWorld(worldPosition); + // Update mouse position in world coordinates only for spatial-index debug. + if (_shouldTrackMouseWorldPosition) { + final worldPosition = widget.controller.viewport.toGraph( + ScreenPosition(event.localPosition), + ); + widget.controller.setMousePositionWorld(worldPosition); + } final hitResult = _performHitTest(event.localPosition); diff --git a/packages/vyuh_node_flow/lib/src/editor/non_trackpad_pan_gesture_recognizer.dart b/packages/vyuh_node_flow/lib/src/editor/non_trackpad_pan_gesture_recognizer.dart index 556339e..79fdc32 100644 --- a/packages/vyuh_node_flow/lib/src/editor/non_trackpad_pan_gesture_recognizer.dart +++ b/packages/vyuh_node_flow/lib/src/editor/non_trackpad_pan_gesture_recognizer.dart @@ -42,14 +42,55 @@ import 'package:flutter/gestures.dart'; /// we allow the gesture to bubble up to the parent's gesture recognizers. class NonTrackpadPanGestureRecognizer extends PanGestureRecognizer { /// Creates a pan gesture recognizer that ignores trackpad gestures. - NonTrackpadPanGestureRecognizer({super.debugOwner}); + NonTrackpadPanGestureRecognizer({super.debugOwner, this.allowTouch = false}); + + /// Whether touch/stylus pointers are allowed by this recognizer. + /// Defaults to false to avoid competing with touch-specific handlers. + final bool allowTouch; + + /// Stores the default drag start behavior configured by the caller. + /// This lets us override behavior per pointer type without losing the + /// caller's intent (e.g., ports use DragStartBehavior.down). + DragStartBehavior _defaultDragStartBehavior = DragStartBehavior.start; + + @override + set dragStartBehavior(DragStartBehavior value) { + _defaultDragStartBehavior = value; + super.dragStartBehavior = value; + } + + void _applyDragStartBehaviorForPointer(PointerDownEvent event) { + final isTouchLike = + event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus; + + // On touch devices, starting immediately helps win the gesture arena + // against InteractiveViewer panning/zooming. + if (isTouchLike && _defaultDragStartBehavior == DragStartBehavior.start) { + super.dragStartBehavior = DragStartBehavior.down; + return; + } + + super.dragStartBehavior = _defaultDragStartBehavior; + } + + @override + void addPointer(PointerDownEvent event) { + _applyDragStartBehaviorForPointer(event); + super.addPointer(event); + } @override void handleEvent(PointerEvent event) { // Reject trackpad events - let them bubble to InteractiveViewer for canvas panning. // On macOS, two-finger trackpad gestures generate PointerPanZoomEvent which bypasses // addPointer/isPointerAllowed and goes directly to handleEvent. - if (event.kind == PointerDeviceKind.trackpad) { + if (event.kind == PointerDeviceKind.trackpad || + (!allowTouch && + (event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus))) { return; } super.handleEvent(event); @@ -58,7 +99,11 @@ class NonTrackpadPanGestureRecognizer extends PanGestureRecognizer { @override bool isPointerAllowed(PointerEvent event) { // Reject trackpad pointers - let them bubble to InteractiveViewer - if (event.kind == PointerDeviceKind.trackpad) { + if (event.kind == PointerDeviceKind.trackpad || + (!allowTouch && + (event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus))) { return false; } return super.isPointerAllowed(event); @@ -72,7 +117,11 @@ class NonTrackpadPanGestureRecognizer extends PanGestureRecognizer { // ongoing drag gesture. By returning early for trackpad pointers, // we ensure that a trackpad tap during a mouse drag doesn't cancel // the mouse drag. - if (event.kind == PointerDeviceKind.trackpad) { + if (event.kind == PointerDeviceKind.trackpad || + (!allowTouch && + (event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus))) { return; } super.handleNonAllowedPointer(event); diff --git a/packages/vyuh_node_flow/lib/src/grid/grid_render_policy.dart b/packages/vyuh_node_flow/lib/src/grid/grid_render_policy.dart new file mode 100644 index 0000000..c27268a --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/grid/grid_render_policy.dart @@ -0,0 +1,47 @@ +import '../editor/themes/node_flow_theme.dart'; +import 'grid_styles.dart'; + +/// Runtime rendering policy for grid fidelity. +/// +/// During active viewport interaction, this policy can temporarily reduce +/// grid cost to keep panning smooth. Full fidelity is restored immediately +/// when interaction ends. +class GridRenderPolicy { + const GridRenderPolicy._(); + + /// Returns the effective theme to use for grid rendering in the current state. + static NodeFlowTheme resolve({ + required NodeFlowTheme baseTheme, + required bool isViewportInteracting, + required bool adaptiveInteractionActive, + required bool useThumbnailMode, + }) { + if (!isViewportInteracting) { + return baseTheme; + } + + // At extreme adaptive fidelity reductions, skip grid rendering entirely. + // This avoids expensive point/path generation while the scene is in + // thumbnail mode for interaction responsiveness. + if (useThumbnailMode) { + return baseTheme.copyWith( + gridTheme: baseTheme.gridTheme.copyWith(style: GridStyles.none), + ); + } + + // Keep default visuals when adaptive mode is not active. + if (!adaptiveInteractionActive) { + return baseTheme; + } + + final gridTheme = baseTheme.gridTheme; + final coarseSize = (gridTheme.size * 2.0).clamp(8.0, 256.0); + final coarseColor = gridTheme.color.withValues( + alpha: (gridTheme.color.a * 0.7).clamp(0.0, 1.0), + ); + + return baseTheme.copyWith( + gridTheme: gridTheme.copyWith(size: coarseSize, color: coarseColor), + ); + } +} diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/cross_grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/cross_grid_style.dart index 0ecd933..6e8fac1 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/cross_grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/cross_grid_style.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; +import 'grid_sampling_policy.dart'; import 'grid_style.dart'; /// Grid style that renders small crosses at grid intersections. @@ -26,39 +27,43 @@ class CrossGridStyle extends GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ) { final gridTheme = theme.gridTheme; - final gridSize = gridTheme.size; + final sampling = GridSamplingPolicy.resolve( + area: gridArea, + baseSpacing: gridTheme.size, + maxColumns: 180, + maxRows: 180, + ); + if (sampling == null) return; // Create paint for the crosses final paint = createGridPaint(theme) - ..strokeWidth = gridTheme.thickness.clamp(0.5, 1.5); + ..strokeWidth = gridTheme.thickness.clamp(0.5, 1.5).toDouble(); // Calculate arm length - final armLength = crossSize ?? (gridTheme.thickness * 3).clamp(2.0, 6.0); - - // Calculate grid-aligned start positions - final startX = (gridArea.left / gridSize).floor() * gridSize; - final startY = (gridArea.top / gridSize).floor() * gridSize; + final armLength = + crossSize ?? (gridTheme.thickness * 3).clamp(2.0, 6.0).toDouble(); + final path = Path(); // Draw crosses at each grid intersection - for (double x = startX; x <= gridArea.right; x += gridSize) { - for (double y = startY; y <= gridArea.bottom; y += gridSize) { - // Draw horizontal arm - canvas.drawLine( - Offset(x - armLength, y), - Offset(x + armLength, y), - paint, - ); + for (var col = 0; col < sampling.columns; col++) { + final x = sampling.startX + col * sampling.spacing; + for (var row = 0; row < sampling.rows; row++) { + final y = sampling.startY + row * sampling.spacing; + // Horizontal arm + path + ..moveTo(x - armLength, y) + ..lineTo(x + armLength, y); - // Draw vertical arm - canvas.drawLine( - Offset(x, y - armLength), - Offset(x, y + armLength), - paint, - ); + // Vertical arm + path + ..moveTo(x, y - armLength) + ..lineTo(x, y + armLength); } } + + canvas.drawPath(path, paint); } } diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/dots_grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/dots_grid_style.dart index c96f197..e252cf7 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/dots_grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/dots_grid_style.dart @@ -1,6 +1,10 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui show PointMode; + import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; +import 'grid_sampling_policy.dart'; import 'grid_style.dart'; /// Grid style that renders dots at grid intersections. @@ -26,26 +30,41 @@ class DotsGridStyle extends GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ) { final gridTheme = theme.gridTheme; - final gridSize = gridTheme.size; + final sampling = GridSamplingPolicy.resolve( + area: gridArea, + baseSpacing: gridTheme.size, + maxColumns: 260, + maxRows: 260, + ); + if (sampling == null) return; - // Calculate dot radius and create paint - final radius = dotSize ?? gridTheme.thickness.clamp(0.5, 2.0); + // Calculate dot radius and create paint. + // Draw points in a single batch with round stroke caps for lower draw-call cost. + final radius = dotSize ?? gridTheme.thickness.clamp(0.5, 2.0).toDouble(); final paint = Paint() ..color = gridTheme.color - ..style = PaintingStyle.fill; + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = radius * 2; + + final columnCount = sampling.columns; + final rowCount = sampling.rows; - // Calculate grid-aligned start positions - final startX = (gridArea.left / gridSize).floor() * gridSize; - final startY = (gridArea.top / gridSize).floor() * gridSize; + final rawPoints = Float32List(columnCount * rowCount * 2); + var i = 0; - // Draw dots at each grid intersection - for (double x = startX; x <= gridArea.right; x += gridSize) { - for (double y = startY; y <= gridArea.bottom; y += gridSize) { - canvas.drawCircle(Offset(x, y), radius, paint); + // Build points in a flat float array for drawRawPoints batching. + for (var col = 0; col < columnCount; col++) { + final x = sampling.startX + col * sampling.spacing; + for (var row = 0; row < rowCount; row++) { + rawPoints[i++] = x; + rawPoints[i++] = sampling.startY + row * sampling.spacing; } } + + canvas.drawRawPoints(ui.PointMode.points, rawPoints, paint); } } diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/grid_sampling_policy.dart b/packages/vyuh_node_flow/lib/src/grid/styles/grid_sampling_policy.dart new file mode 100644 index 0000000..f97dd2f --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/grid/styles/grid_sampling_policy.dart @@ -0,0 +1,80 @@ +import 'dart:math' as math; + +typedef GridArea = ({double left, double top, double right, double bottom}); + +typedef GridSampling = ({ + double startX, + double startY, + int columns, + int rows, + double spacing, +}); + +/// Safety policy for grid iteration counts. +/// +/// This keeps grid rendering bounded at extreme zoom-out and very large pan +/// offsets by coarsening the spacing before styles generate paths/points. +final class GridSamplingPolicy { + const GridSamplingPolicy._(); + + static const int defaultMaxColumns = 320; + static const int defaultMaxRows = 320; + + static GridSampling? resolve({ + required GridArea area, + required double baseSpacing, + int maxColumns = defaultMaxColumns, + int maxRows = defaultMaxRows, + }) { + if (maxColumns <= 0 || maxRows <= 0) return null; + if (!baseSpacing.isFinite || baseSpacing <= 0) return null; + if (!_isFiniteArea(area)) return null; + + final width = area.right - area.left; + final height = area.bottom - area.top; + if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { + return null; + } + + final rawColumns = _count(width, baseSpacing); + final rawRows = _count(height, baseSpacing); + if (rawColumns <= 0 || rawRows <= 0) return null; + + final columnFactor = (rawColumns / maxColumns).ceil(); + final rowFactor = (rawRows / maxRows).ceil(); + final factor = math.max(1, math.max(columnFactor, rowFactor)); + + final spacing = baseSpacing * factor; + if (!spacing.isFinite || spacing <= 0) return null; + + final columns = _count(width, spacing); + final rows = _count(height, spacing); + if (columns <= 0 || rows <= 0) return null; + + final startX = (area.left / spacing).floor() * spacing; + final startY = (area.top / spacing).floor() * spacing; + if (!startX.isFinite || !startY.isFinite) return null; + + return ( + startX: startX, + startY: startY, + columns: columns, + rows: rows, + spacing: spacing, + ); + } + + static int _count(double extent, double spacing) { + if (!extent.isFinite || !spacing.isFinite || spacing <= 0) { + return 0; + } + return (extent / spacing).floor() + 1; + } + + static bool _isFiniteArea(GridArea area) { + return area.left.isFinite && + area.top.isFinite && + area.right.isFinite && + area.bottom.isFinite; + } +} diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/grid_style.dart index 3e37b00..9bb0582 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/grid_style.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; import '../../graph/viewport.dart'; +import 'grid_sampling_policy.dart'; /// Abstract base class for all grid styles. /// @@ -22,7 +23,7 @@ import '../../graph/viewport.dart'; /// void paintGrid( /// Canvas canvas, /// NodeFlowTheme theme, -/// ({double left, double top, double right, double bottom}) gridArea, +/// GridArea gridArea, /// ) { /// // Calculate style-specific positions and render /// final gridSize = theme.gridTheme.size; @@ -34,6 +35,8 @@ import '../../graph/viewport.dart'; abstract class GridStyle { const GridStyle(); + static const double _minZoomEpsilon = 1e-6; + /// Paints the grid pattern on the canvas. /// /// Calculates common parameters (visible area, grid area) once, then calls @@ -51,10 +54,11 @@ abstract class GridStyle { GraphViewport viewport, ) { final gridSize = theme.gridTheme.size; - if (gridSize <= 0) return; + if (!gridSize.isFinite || gridSize <= 0) return; // Calculate common parameters once final visibleArea = _calculateVisibleArea(viewport, size); + if (visibleArea == null) return; final gridArea = _calculateGridArea(visibleArea, gridSize); // Delegate to style-specific implementation @@ -74,20 +78,30 @@ abstract class GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ); /// Calculates the visible area in world coordinates. /// /// Transforms the screen viewport bounds to graph coordinates. - ({double left, double top, double right, double bottom}) - _calculateVisibleArea(GraphViewport viewport, Size canvasSize) { + GridArea? _calculateVisibleArea(GraphViewport viewport, Size canvasSize) { final zoom = viewport.zoom; + if (!zoom.isFinite || zoom.abs() < _minZoomEpsilon) { + return null; + } + final viewportLeft = -viewport.x / zoom; final viewportTop = -viewport.y / zoom; final viewportRight = viewportLeft + (canvasSize.width / zoom); final viewportBottom = viewportTop + (canvasSize.height / zoom); + if (!viewportLeft.isFinite || + !viewportTop.isFinite || + !viewportRight.isFinite || + !viewportBottom.isFinite) { + return null; + } + return ( left: viewportLeft, top: viewportTop, @@ -100,8 +114,8 @@ abstract class GridStyle { /// /// Extends the visible area by 2 grid cells to prevent grid elements from /// popping in/out during panning. - ({double left, double top, double right, double bottom}) _calculateGridArea( - ({double left, double top, double right, double bottom}) visibleArea, + GridArea _calculateGridArea( + GridArea visibleArea, double gridSize, ) { final padding = gridSize * 2.0; diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/hierarchical_grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/hierarchical_grid_style.dart index 15e89ca..8056781 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/hierarchical_grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/hierarchical_grid_style.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; +import 'grid_sampling_policy.dart'; import 'grid_style.dart'; /// Grid style that renders a two-level hierarchical grid. @@ -24,11 +25,25 @@ class HierarchicalGridStyle extends GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ) { final gridTheme = theme.gridTheme; - final gridSize = gridTheme.size; - final majorGridSize = gridSize * majorGridMultiplier; + final minorSampling = GridSamplingPolicy.resolve( + area: gridArea, + baseSpacing: gridTheme.size, + maxColumns: 240, + maxRows: 240, + ); + if (minorSampling == null) return; + + final majorBaseSpacing = minorSampling.spacing * majorGridMultiplier; + final majorSampling = GridSamplingPolicy.resolve( + area: gridArea, + baseSpacing: majorBaseSpacing, + maxColumns: 120, + maxRows: 120, + ); + if (majorSampling == null) return; // Create paint objects for minor and major grids final minorPaint = Paint() @@ -40,47 +55,40 @@ class HierarchicalGridStyle extends GridStyle { ..color = gridTheme.color ..strokeWidth = gridTheme.thickness * 2 ..style = PaintingStyle.stroke; - - // Calculate grid-aligned start positions for minor grid - final minorStartX = (gridArea.left / gridSize).floor() * gridSize; - final minorStartY = (gridArea.top / gridSize).floor() * gridSize; - - // Calculate grid-aligned start positions for major grid - final majorStartX = (gridArea.left / majorGridSize).floor() * majorGridSize; - final majorStartY = (gridArea.top / majorGridSize).floor() * majorGridSize; + final minorPath = Path(); + final majorPath = Path(); // Draw minor grid lines - for (double x = minorStartX; x <= gridArea.right; x += gridSize) { - canvas.drawLine( - Offset(x, gridArea.top), - Offset(x, gridArea.bottom), - minorPaint, - ); + for (var col = 0; col < minorSampling.columns; col++) { + final x = minorSampling.startX + col * minorSampling.spacing; + minorPath + ..moveTo(x, gridArea.top) + ..lineTo(x, gridArea.bottom); } - for (double y = minorStartY; y <= gridArea.bottom; y += gridSize) { - canvas.drawLine( - Offset(gridArea.left, y), - Offset(gridArea.right, y), - minorPaint, - ); + for (var row = 0; row < minorSampling.rows; row++) { + final y = minorSampling.startY + row * minorSampling.spacing; + minorPath + ..moveTo(gridArea.left, y) + ..lineTo(gridArea.right, y); } // Draw major grid lines (on top of minor lines) - for (double x = majorStartX; x <= gridArea.right; x += majorGridSize) { - canvas.drawLine( - Offset(x, gridArea.top), - Offset(x, gridArea.bottom), - majorPaint, - ); + for (var col = 0; col < majorSampling.columns; col++) { + final x = majorSampling.startX + col * majorSampling.spacing; + majorPath + ..moveTo(x, gridArea.top) + ..lineTo(x, gridArea.bottom); } - for (double y = majorStartY; y <= gridArea.bottom; y += majorGridSize) { - canvas.drawLine( - Offset(gridArea.left, y), - Offset(gridArea.right, y), - majorPaint, - ); + for (var row = 0; row < majorSampling.rows; row++) { + final y = majorSampling.startY + row * majorSampling.spacing; + majorPath + ..moveTo(gridArea.left, y) + ..lineTo(gridArea.right, y); } + + canvas.drawPath(minorPath, minorPaint); + canvas.drawPath(majorPath, majorPaint); } } diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/lines_grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/lines_grid_style.dart index c400007..18b5973 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/lines_grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/lines_grid_style.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; +import 'grid_sampling_policy.dart'; import 'grid_style.dart'; /// Grid style that renders evenly spaced vertical and horizontal lines. @@ -18,31 +19,35 @@ class LinesGridStyle extends GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ) { - final paint = createGridPaint(theme); - final gridSize = theme.gridTheme.size; + final sampling = GridSamplingPolicy.resolve( + area: gridArea, + baseSpacing: theme.gridTheme.size, + maxColumns: 480, + maxRows: 480, + ); + if (sampling == null) return; - // Calculate grid-aligned start positions - final startX = (gridArea.left / gridSize).floor() * gridSize; - final startY = (gridArea.top / gridSize).floor() * gridSize; + final paint = createGridPaint(theme); + final path = Path(); // Draw vertical lines - for (double x = startX; x <= gridArea.right; x += gridSize) { - canvas.drawLine( - Offset(x, gridArea.top), - Offset(x, gridArea.bottom), - paint, - ); + for (var col = 0; col < sampling.columns; col++) { + final x = sampling.startX + col * sampling.spacing; + path + ..moveTo(x, gridArea.top) + ..lineTo(x, gridArea.bottom); } // Draw horizontal lines - for (double y = startY; y <= gridArea.bottom; y += gridSize) { - canvas.drawLine( - Offset(gridArea.left, y), - Offset(gridArea.right, y), - paint, - ); + for (var row = 0; row < sampling.rows; row++) { + final y = sampling.startY + row * sampling.spacing; + path + ..moveTo(gridArea.left, y) + ..lineTo(gridArea.right, y); } + + canvas.drawPath(path, paint); } } diff --git a/packages/vyuh_node_flow/lib/src/grid/styles/none_grid_style.dart b/packages/vyuh_node_flow/lib/src/grid/styles/none_grid_style.dart index a1c666a..fed5b08 100644 --- a/packages/vyuh_node_flow/lib/src/grid/styles/none_grid_style.dart +++ b/packages/vyuh_node_flow/lib/src/grid/styles/none_grid_style.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../editor/themes/node_flow_theme.dart'; +import 'grid_sampling_policy.dart'; import 'grid_style.dart'; /// Grid style that renders nothing. @@ -16,7 +17,7 @@ class NoneGridStyle extends GridStyle { void paintGrid( Canvas canvas, NodeFlowTheme theme, - ({double left, double top, double right, double bottom}) gridArea, + GridArea gridArea, ) { // Intentionally empty - render nothing } diff --git a/packages/vyuh_node_flow/lib/src/nodes/node_container.dart b/packages/vyuh_node_flow/lib/src/nodes/node_container.dart index ee91b80..64d2a69 100644 --- a/packages/vyuh_node_flow/lib/src/nodes/node_container.dart +++ b/packages/vyuh_node_flow/lib/src/nodes/node_container.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import '../connections/connection.dart'; import '../editor/controller/node_flow_controller.dart'; import '../editor/drag_session.dart'; import '../editor/element_scope.dart'; @@ -41,7 +40,6 @@ class NodeContainer extends StatelessWidget { required this.controller, required this.child, this.shape, - this.connections = const [], this.portBuilder, this.onTap, this.onDoubleTap, @@ -66,9 +64,6 @@ class NodeContainer extends StatelessWidget { /// Optional shape for the node (used for port positioning). final NodeShape? shape; - /// List of connections for determining which ports are connected. - final List connections; - /// Optional builder for customizing individual port widgets. final PortBuilder? portBuilder; @@ -157,6 +152,19 @@ class NodeContainer extends StatelessWidget { // Drag lifecycle - unified for all node types // Check both node lock state AND behavior mode isDraggable: !node.locked && controller.behavior.canDrag, + // Don't start node drag while a connection drag is active + canStartDrag: () => !controller.isConnecting, + // On touch, don't capture if the pointer is over a port + shouldCaptureTouch: (localPos) { + if (!lodVisibility.showPorts) return true; + return !_isLocalPositionOverPort( + localPos, + node, + shape, + portSnapDistance, + theme, + ); + }, onDragStart: (_) => controller.startNodeDrag(node.id), onDragUpdate: (details) => controller.moveNodeDrag(details.delta), @@ -243,8 +251,8 @@ class NodeContainer extends StatelessWidget { // Calculate node bounds for port positioning final nodeBounds = Rect.fromLTWH( - node.position.value.dx, - node.position.value.dy, + node.visualPosition.value.dx, + node.visualPosition.value.dy, node.size.value.width, node.size.value.height, ); @@ -284,27 +292,53 @@ class NodeContainer extends StatelessWidget { ); } - /// Checks if a port is connected by examining the connections list. + /// Checks if a port is connected using controller-maintained connection indexes. /// /// For [PortType.both] ports, checks both source and target connections /// since these ports can act as either input or output. bool _isPortConnected(Port port) { - return connections.any((connection) { - // For bidirectional ports (PortType.both), check both directions - if (port.isInput && port.isOutput) { - return (connection.sourceNodeId == node.id && - connection.sourcePortId == port.id) || - (connection.targetNodeId == node.id && - connection.targetPortId == port.id); - } - // For output-only ports, check as source - if (port.isOutput) { - return connection.sourceNodeId == node.id && - connection.sourcePortId == port.id; + // For bidirectional ports (PortType.both), check both directions. + if (port.isInput && port.isOutput) { + return controller.hasConnectionsForPort(node.id, port.id); + } + // For output-only ports, check outgoing connections. + if (port.isOutput) { + return controller.hasOutgoingConnectionsFromPort(node.id, port.id); + } + // For input-only ports, check incoming connections. + return controller.hasIncomingConnectionsToPort(node.id, port.id); + } + + bool _isLocalPositionOverPort( + Offset localPosition, + Node node, + NodeShape? shape, + double snapDistance, + NodeFlowTheme theme, + ) { + final portTheme = theme.portTheme; + final allPorts = [...node.inputPorts, ...node.outputPorts]; + + for (final port in allPorts) { + final effectivePortSize = port.size ?? portTheme.size; + final visualPosition = node.getVisualPortOrigin( + port.id, + portSize: effectivePortSize, + shape: shape, + ); + + final rect = Rect.fromLTWH( + visualPosition.dx - snapDistance, + visualPosition.dy - snapDistance, + effectivePortSize.width + snapDistance * 2, + effectivePortSize.height + snapDistance * 2, + ); + + if (rect.contains(localPosition)) { + return true; } - // For input-only ports, check as target - return connection.targetNodeId == node.id && - connection.targetPortId == port.id; - }); + } + + return false; } } diff --git a/packages/vyuh_node_flow/lib/src/plugins/lod/lod_adaptive_policy.dart b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_adaptive_policy.dart new file mode 100644 index 0000000..dc0958b --- /dev/null +++ b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_adaptive_policy.dart @@ -0,0 +1,74 @@ +import 'detail_visibility.dart'; + +/// Adaptive visibility policy used during interaction-heavy phases. +/// +/// This policy keeps full fidelity while idle, then progressively reduces +/// expensive visual details as graph complexity grows during active interaction. +class LodAdaptivePolicy { + const LodAdaptivePolicy({ + required this.complexityNodeThreshold, + required this.complexityConnectionThreshold, + required this.extremeNodeThreshold, + required this.extremeConnectionThreshold, + }); + + /// Node count threshold above which interaction mode reduces more detail. + final int complexityNodeThreshold; + + /// Connection count threshold above which interaction mode reduces more detail. + final int complexityConnectionThreshold; + + /// Node count threshold for aggressive interaction-mode degradation. + final int extremeNodeThreshold; + + /// Connection count threshold for aggressive interaction-mode degradation. + final int extremeConnectionThreshold; + + bool isComplex({required int nodeCount, required int connectionCount}) { + return nodeCount >= complexityNodeThreshold || + connectionCount >= complexityConnectionThreshold; + } + + bool isExtreme({required int nodeCount, required int connectionCount}) { + return nodeCount >= extremeNodeThreshold || + connectionCount >= extremeConnectionThreshold; + } + + /// Applies adaptive visibility reduction to [baseVisibility]. + /// + /// Returns [baseVisibility] unchanged when not interacting. + DetailVisibility apply({ + required DetailVisibility baseVisibility, + required bool isInteracting, + required int nodeCount, + required int connectionCount, + }) { + if (!isInteracting) return baseVisibility; + + if (isExtreme(nodeCount: nodeCount, connectionCount: connectionCount)) { + // Extreme graphs need aggressive degradation to stay interactive. + return baseVisibility.copyWith( + showNodeContent: false, + showPorts: false, + showPortLabels: false, + showConnectionLabels: false, + showConnectionEndpoints: false, + showResizeHandles: false, + ); + } + + if (isComplex(nodeCount: nodeCount, connectionCount: connectionCount)) { + // For complex graphs, avoid toggling port labels because mounting/unmounting + // thousands of label widgets can cause a one-frame hitch at drag start. + return baseVisibility.copyWith( + showConnectionLabels: false, + showConnectionEndpoints: false, + showResizeHandles: false, + ); + } + + // For normal graph sizes, keep full visual fidelity during interaction to + // avoid global tree churn from visibility flips. + return baseVisibility; + } +} diff --git a/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart index 1b1b4b0..9c35f5e 100644 --- a/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart +++ b/packages/vyuh_node_flow/lib/src/plugins/lod/lod_plugin.dart @@ -4,6 +4,7 @@ import '../../editor/controller/node_flow_controller.dart'; import '../events/events.dart'; import '../node_flow_plugin.dart'; import 'detail_visibility.dart'; +import 'lod_adaptive_policy.dart'; /// Level of Detail (LOD) plugin that provides reactive visibility /// settings based on the viewport zoom level. @@ -59,12 +60,24 @@ class LodPlugin extends NodeFlowPlugin { DetailVisibility minVisibility = DetailVisibility.minimal, DetailVisibility midVisibility = DetailVisibility.standard, DetailVisibility maxVisibility = DetailVisibility.full, + bool adaptiveEnabled = true, + int adaptiveNodeThreshold = 300, + int adaptiveConnectionThreshold = 500, + int adaptiveExtremeNodeThreshold = 1000, + int adaptiveExtremeConnectionThreshold = 2000, }) : _enabled = Observable(enabled), _minThreshold = Observable(minThreshold), _midThreshold = Observable(midThreshold), _minVisibility = Observable(minVisibility), _midVisibility = Observable(midVisibility), - _maxVisibility = Observable(maxVisibility); + _maxVisibility = Observable(maxVisibility), + _adaptiveEnabled = Observable(adaptiveEnabled), + _adaptiveNodeThreshold = Observable(adaptiveNodeThreshold), + _adaptiveConnectionThreshold = Observable(adaptiveConnectionThreshold), + _adaptiveExtremeNodeThreshold = Observable(adaptiveExtremeNodeThreshold), + _adaptiveExtremeConnectionThreshold = Observable( + adaptiveExtremeConnectionThreshold, + ); // ═══════════════════════════════════════════════════════════════════════════ // Observable State @@ -76,11 +89,21 @@ class LodPlugin extends NodeFlowPlugin { final Observable _minVisibility; final Observable _midVisibility; final Observable _maxVisibility; + final Observable _adaptiveEnabled; + final Observable _adaptiveNodeThreshold; + final Observable _adaptiveConnectionThreshold; + final Observable _adaptiveExtremeNodeThreshold; + final Observable _adaptiveExtremeConnectionThreshold; NodeFlowController? _controller; late Computed _normalizedZoom; + late Computed _baseVisibility; late Computed _currentVisibility; + late Computed _isInteractionActive; + late Computed _isViewportPanning; + late Computed _isAdaptiveComplexity; + late Computed _isAdaptiveExtremeComplexity; @override String get id => 'lod'; @@ -212,6 +235,55 @@ class LodPlugin extends NodeFlowPlugin { void setMaxVisibility(DetailVisibility visibility) => runInAction(() => _maxVisibility.value = visibility); + // ═══════════════════════════════════════════════════════════════════════════ + // Adaptive Fidelity + // ═══════════════════════════════════════════════════════════════════════════ + + /// Whether adaptive interaction fidelity is enabled. + bool get adaptiveEnabled => _adaptiveEnabled.value; + + /// Enables adaptive interaction fidelity. + void enableAdaptive() => runInAction(() => _adaptiveEnabled.value = true); + + /// Disables adaptive interaction fidelity. + void disableAdaptive() => runInAction(() => _adaptiveEnabled.value = false); + + /// Sets adaptive interaction fidelity enabled state. + void setAdaptiveEnabled(bool enabled) => + runInAction(() => _adaptiveEnabled.value = enabled); + + int get adaptiveNodeThreshold => _adaptiveNodeThreshold.value; + + int get adaptiveConnectionThreshold => _adaptiveConnectionThreshold.value; + + int get adaptiveExtremeNodeThreshold => _adaptiveExtremeNodeThreshold.value; + + int get adaptiveExtremeConnectionThreshold => + _adaptiveExtremeConnectionThreshold.value; + + /// Sets adaptive complexity thresholds. + void setAdaptiveThresholds({ + int? nodeThreshold, + int? connectionThreshold, + int? extremeNodeThreshold, + int? extremeConnectionThreshold, + }) { + runInAction(() { + if (nodeThreshold != null) { + _adaptiveNodeThreshold.value = nodeThreshold; + } + if (connectionThreshold != null) { + _adaptiveConnectionThreshold.value = connectionThreshold; + } + if (extremeNodeThreshold != null) { + _adaptiveExtremeNodeThreshold.value = extremeNodeThreshold; + } + if (extremeConnectionThreshold != null) { + _adaptiveExtremeConnectionThreshold.value = extremeConnectionThreshold; + } + }); + } + // ═══════════════════════════════════════════════════════════════════════════ // Computed State // ═══════════════════════════════════════════════════════════════════════════ @@ -232,17 +304,35 @@ class LodPlugin extends NodeFlowPlugin { /// - [maxVisibility] when normalizedZoom >= midThreshold DetailVisibility get currentVisibility => _currentVisibility.value; + /// Current zoom-only visibility before adaptive interaction adjustments. + DetailVisibility get baseVisibility => _baseVisibility.value; + + /// Whether interaction-adaptive mode is currently active. + bool get isAdaptiveInteractionActive => + _adaptiveEnabled.value && _isInteractionActive.value; + /// Whether to use thumbnail (paint) mode instead of widget mode. /// /// Returns `true` when: - /// 1. LOD is enabled - /// 2. Zoom is below minThreshold (very zoomed out) + /// 1. LOD is enabled and zoom is below minThreshold (very zoomed out), OR + /// 2. Adaptive mode is enabled and interaction complexity crosses thresholds /// /// When true, NodesLayer should switch to NodesThumbnailLayer /// for maximum performance. bool get useThumbnailMode { - if (!_enabled.value) return false; - return _normalizedZoom.value < _minThreshold.value; + final zoomThumbnailMode = + _enabled.value && _normalizedZoom.value < _minThreshold.value; + + // Adaptive thumbnail mode is intentionally independent from LOD enabled state. + // This keeps full-fidelity while idle but allows aggressive downgrades + // during heavy interaction even when zoom-based LOD is turned off. + final adaptiveThumbnailMode = + _adaptiveEnabled.value && + _isInteractionActive.value && + (_isAdaptiveExtremeComplexity.value || + (_isViewportPanning.value && _isAdaptiveComplexity.value)); + + return zoomThumbnailMode || adaptiveThumbnailMode; } // ═══════════════════════════════════════════════════════════════════════════ @@ -291,7 +381,34 @@ class LodPlugin extends NodeFlowPlugin { return ((zoom - minZoom) / range).clamp(0.0, 1.0); }); - _currentVisibility = Computed(() { + _isInteractionActive = Computed(() { + return controller.draggedNodeId != null || + controller.isResizing || + controller.isConnecting || + controller.isDrawingSelection || + controller.canvasLocked || + controller.interaction.isViewportDragging; + }); + + _isViewportPanning = Computed( + () => controller.interaction.isViewportDragging, + ); + + _isAdaptiveComplexity = Computed(() { + return _createAdaptivePolicy().isComplex( + nodeCount: controller.nodeCount, + connectionCount: controller.connectionCount, + ); + }); + + _isAdaptiveExtremeComplexity = Computed(() { + return _createAdaptivePolicy().isExtreme( + nodeCount: controller.nodeCount, + connectionCount: controller.connectionCount, + ); + }); + + _baseVisibility = Computed(() { // When disabled, always return max visibility if (!_enabled.value) { return _maxVisibility.value; @@ -309,6 +426,27 @@ class LodPlugin extends NodeFlowPlugin { return _maxVisibility.value; } }); + + _currentVisibility = Computed(() { + final base = _baseVisibility.value; + if (!_adaptiveEnabled.value) return base; + + return _createAdaptivePolicy().apply( + baseVisibility: base, + isInteracting: _isInteractionActive.value, + nodeCount: controller.nodeCount, + connectionCount: controller.connectionCount, + ); + }); + } + + LodAdaptivePolicy _createAdaptivePolicy() { + return LodAdaptivePolicy( + complexityNodeThreshold: _adaptiveNodeThreshold.value, + complexityConnectionThreshold: _adaptiveConnectionThreshold.value, + extremeNodeThreshold: _adaptiveExtremeNodeThreshold.value, + extremeConnectionThreshold: _adaptiveExtremeConnectionThreshold.value, + ); } } diff --git a/packages/vyuh_node_flow/pubspec.yaml b/packages/vyuh_node_flow/pubspec.yaml index 43ceead..8c78e6f 100644 --- a/packages/vyuh_node_flow/pubspec.yaml +++ b/packages/vyuh_node_flow/pubspec.yaml @@ -1,6 +1,6 @@ name: vyuh_node_flow description: A flexible, high-performance node-based flow editor for Flutter. Build visual programming interfaces, workflow editors, diagrams, and data pipelines. -version: 0.27.2 +version: 0.28.1 homepage: https://flow.vyuh.tech repository: https://github.com/vyuh-tech/vyuh_node_flow diff --git a/packages/vyuh_node_flow/test/unit/controller/node_api_test.dart b/packages/vyuh_node_flow/test/unit/controller/node_api_test.dart index 96983de..0bad3f9 100644 --- a/packages/vyuh_node_flow/test/unit/controller/node_api_test.dart +++ b/packages/vyuh_node_flow/test/unit/controller/node_api_test.dart @@ -23,6 +23,14 @@ void main() { resetTestCounters(); }); + void initializeEditor(NodeFlowController controller) { + controller.initController( + theme: NodeFlowTheme.light, + portSizeResolver: (port) => + NodeFlowTheme.light.portTheme.resolveSize(port), + ); + } + // =========================================================================== // Model APIs - Lookup // =========================================================================== @@ -348,6 +356,28 @@ void main() { expect(controller.getPort('node-1', 'new-output'), isNotNull); }); + test('addPort updates spatial index for immediate port hit testing', () { + final node = createTestNode( + id: 'node-1', + position: const Offset(100, 100), + ); + final controller = createTestController(nodes: [node]); + initializeEditor(controller); + + final newPort = createInputPort(id: 'new-input'); + controller.addPort('node-1', newPort); + + final portCenter = node.getPortCenter( + newPort.id, + portSize: NodeFlowTheme.light.portTheme.resolveSize(newPort), + ); + final hit = controller.hitTestPort(portCenter); + + expect(hit, isNotNull); + expect(hit!.nodeId, equals('node-1')); + expect(hit.portId, equals('new-input')); + }); + test('removePort removes port from node', () { final port = createInputPort(id: 'port-to-remove'); final node = createTestNode(id: 'node-1', inputPorts: [port]); @@ -379,6 +409,27 @@ void main() { expect(controller.connections, isEmpty); }); + test('removePort updates spatial index for immediate port hit testing', () { + final port = createInputPort(id: 'remove-me'); + final node = createTestNode( + id: 'node-1', + position: const Offset(100, 100), + inputPorts: [port], + ); + final controller = createTestController(nodes: [node]); + initializeEditor(controller); + + final portCenter = node.getPortCenter( + port.id, + portSize: NodeFlowTheme.light.portTheme.resolveSize(port), + ); + expect(controller.hitTestPort(portCenter)?.portId, equals('remove-me')); + + controller.removePort('node-1', 'remove-me'); + + expect(controller.hitTestPort(portCenter), isNull); + }); + test('setNodePorts replaces all ports', () { final node = createTestNode( id: 'node-1', @@ -409,6 +460,30 @@ void main() { expect(controller.getOutputPorts('node-1'), hasLength(1)); expect(controller.getPort('node-1', 'new-out'), isNotNull); }); + + test('setNodePorts updates spatial index for group node ports', () { + final loopNode = createTestGroupNode( + id: 'loop-node', + position: const Offset(200, 200), + size: const Size(320, 220), + data: 'loop-data', + ); + final controller = createTestController(nodes: [loopNode]); + initializeEditor(controller); + + final loopIn = createInputPort(id: 'loop-in'); + controller.setNodePorts('loop-node', [loopIn]); + + final portCenter = loopNode.getPortCenter( + loopIn.id, + portSize: NodeFlowTheme.light.portTheme.resolveSize(loopIn), + ); + final hit = controller.hitTestPort(portCenter); + + expect(hit, isNotNull); + expect(hit!.nodeId, equals('loop-node')); + expect(hit.portId, equals('loop-in')); + }); }); // =========================================================================== diff --git a/packages/vyuh_node_flow/test/unit/controller/viewport_culling_policy_test.dart b/packages/vyuh_node_flow/test/unit/controller/viewport_culling_policy_test.dart new file mode 100644 index 0000000..2c835c4 --- /dev/null +++ b/packages/vyuh_node_flow/test/unit/controller/viewport_culling_policy_test.dart @@ -0,0 +1,107 @@ +@Tags(['unit']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vyuh_node_flow/src/editor/controller/viewport_culling_policy.dart'; + +void main() { + group('ViewportCullingPolicy.isCacheValid', () { + test('returns false when index changed', () { + final viewport = Rect.fromLTWH(0, 0, 100, 100); + final cached = viewport.inflate(1000); + + final result = ViewportCullingPolicy.isCacheValid( + cachedQueryRect: cached, + viewportRect: viewport, + indexChanged: true, + ); + + expect(result, isFalse); + }); + + test('returns true when viewport stays within safety margin', () { + final viewport = Rect.fromLTWH(100, 100, 200, 200); + final cached = viewport.inflate(1000); + + final result = ViewportCullingPolicy.isCacheValid( + cachedQueryRect: cached, + viewportRect: viewport, + indexChanged: false, + ); + + expect(result, isTrue); + }); + + test('returns false when viewport approaches cached edge', () { + final viewport = Rect.fromLTWH(980, 100, 200, 200); + final cached = Rect.fromLTWH(0, 0, 1200, 1200); + + final result = ViewportCullingPolicy.isCacheValid( + cachedQueryRect: cached, + viewportRect: viewport, + indexChanged: false, + ); + + expect(result, isFalse); + }); + }); + + group('ViewportCullingPolicy.buildQueryRect', () { + test('uses symmetric padding when not interacting', () { + final viewport = Rect.fromLTWH(50, 75, 300, 200); + final queryRect = ViewportCullingPolicy.buildQueryRect( + viewportRect: viewport, + previousViewportRect: null, + isViewportInteracting: false, + ); + + expect(queryRect.left, equals(-950)); + expect(queryRect.top, equals(-925)); + expect(queryRect.right, equals(1350)); + expect(queryRect.bottom, equals(1275)); + }); + + test('biases padding in pan direction while interacting', () { + final previous = Rect.fromLTWH(0, 0, 300, 200); + final current = Rect.fromLTWH(100, 60, 300, 200); + + final queryRect = ViewportCullingPolicy.buildQueryRect( + viewportRect: current, + previousViewportRect: previous, + isViewportInteracting: true, + ); + + final leftPad = current.left - queryRect.left; + final rightPad = queryRect.right - current.right; + final topPad = current.top - queryRect.top; + final bottomPad = queryRect.bottom - current.bottom; + + expect(rightPad, greaterThan(leftPad)); + expect(bottomPad, greaterThan(topPad)); + expect(leftPad + rightPad, equals(2000)); + expect(topPad + bottomPad, equals(2000)); + }); + + test('biases toward left/up for negative pan deltas', () { + final previous = Rect.fromLTWH(100, 60, 300, 200); + final current = Rect.fromLTWH(0, 0, 300, 200); + + final queryRect = ViewportCullingPolicy.buildQueryRect( + viewportRect: current, + previousViewportRect: previous, + isViewportInteracting: true, + ); + + final leftPad = current.left - queryRect.left; + final rightPad = queryRect.right - current.right; + final topPad = current.top - queryRect.top; + final bottomPad = queryRect.bottom - current.bottom; + + expect(leftPad, greaterThan(rightPad)); + expect(topPad, greaterThan(bottomPad)); + expect(leftPad + rightPad, equals(2000)); + expect(topPad + bottomPad, equals(2000)); + }); + }); +} diff --git a/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart b/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart index 7be9fa4..e5af0d8 100644 --- a/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart +++ b/packages/vyuh_node_flow/test/unit/lod/lod_plugin_test.dart @@ -371,6 +371,179 @@ void main() { }); }); + // =========================================================================== + // LodPlugin - Adaptive Interaction + // =========================================================================== + + group('LodPlugin - Adaptive Interaction', () { + test('viewport interaction keeps full visibility for non-complex graphs', () { + final controller = NodeFlowController( + config: NodeFlowConfig( + minZoom: 0.0, + maxZoom: 1.0, + plugins: [ + LodPlugin(enabled: false, adaptiveEnabled: true), + ...NodeFlowConfig.defaultPlugins().where((e) => e is! LodPlugin), + ], + ), + initialViewport: const GraphViewport(zoom: 0.8), + ); + + final lod = controller.lod!; + + // Idle: with LOD disabled, base visibility is full. + expect(lod.isAdaptiveInteractionActive, isFalse); + expect(lod.currentVisibility.showPortLabels, isTrue); + expect(lod.currentVisibility.showConnectionLabels, isTrue); + expect(lod.currentVisibility.showResizeHandles, isTrue); + + // During viewport panning, adaptive policy should reduce expensive detail. + controller.interaction.setViewportInteracting(true); + + expect(lod.isAdaptiveInteractionActive, isTrue); + expect(lod.currentVisibility.showPortLabels, isTrue); + expect(lod.currentVisibility.showConnectionLabels, isTrue); + expect(lod.currentVisibility.showResizeHandles, isTrue); + + controller.interaction.setViewportInteracting(false); + expect(lod.isAdaptiveInteractionActive, isFalse); + expect(lod.currentVisibility.showPortLabels, isTrue); + expect(lod.currentVisibility.showConnectionLabels, isTrue); + expect(lod.currentVisibility.showResizeHandles, isTrue); + + controller.dispose(); + }); + + test('complex viewport interaction hides connection labels only', () { + final nodes = List.generate( + 4, + (i) => Node( + id: 'node-$i', + type: 'test', + position: Offset(i * 100.0, 0), + data: 'data-$i', + ), + ); + + final controller = NodeFlowController( + nodes: nodes, + config: NodeFlowConfig( + minZoom: 0.0, + maxZoom: 1.0, + plugins: [ + LodPlugin( + enabled: false, + adaptiveEnabled: true, + adaptiveNodeThreshold: 3, + adaptiveConnectionThreshold: 1000, + adaptiveExtremeNodeThreshold: 100, + adaptiveExtremeConnectionThreshold: 1000, + ), + ...NodeFlowConfig.defaultPlugins().where((e) => e is! LodPlugin), + ], + ), + initialViewport: const GraphViewport(zoom: 0.8), + ); + + final lod = controller.lod!; + + controller.interaction.setViewportInteracting(true); + + expect(lod.currentVisibility.showPortLabels, isTrue); + expect(lod.currentVisibility.showConnectionLabels, isFalse); + expect(lod.currentVisibility.showConnectionEndpoints, isFalse); + expect(lod.currentVisibility.showResizeHandles, isFalse); + + controller.dispose(); + }); + + test( + 'useThumbnailMode activates during viewport pan for complex graphs', + () { + final nodes = List.generate( + 3, + (i) => Node( + id: 'node-$i', + type: 'test', + position: Offset(i * 100.0, 0), + data: 'data-$i', + ), + ); + + final controller = NodeFlowController( + nodes: nodes, + config: NodeFlowConfig( + plugins: [ + LodPlugin( + enabled: false, + adaptiveEnabled: true, + adaptiveNodeThreshold: 2, + adaptiveConnectionThreshold: 1000, + adaptiveExtremeNodeThreshold: 100, + adaptiveExtremeConnectionThreshold: 1000, + ), + ...NodeFlowConfig.defaultPlugins().where((e) => e is! LodPlugin), + ], + ), + ); + + final lod = controller.lod!; + + // Idle state should keep widget mode. + expect(lod.useThumbnailMode, isFalse); + + // Complex graph + active viewport pan should use thumbnail mode. + controller.interaction.setViewportInteracting(true); + expect(lod.useThumbnailMode, isTrue); + + // Revert to widget mode when panning ends. + controller.interaction.setViewportInteracting(false); + expect(lod.useThumbnailMode, isFalse); + + controller.dispose(); + }, + ); + + test( + 'useThumbnailMode stays disabled during viewport pan for small graphs', + () { + final nodes = List.generate( + 2, + (i) => Node( + id: 'node-$i', + type: 'test', + position: Offset(i * 100.0, 0), + data: 'data-$i', + ), + ); + + final controller = NodeFlowController( + nodes: nodes, + config: NodeFlowConfig( + plugins: [ + LodPlugin( + enabled: false, + adaptiveEnabled: true, + adaptiveNodeThreshold: 20, + adaptiveConnectionThreshold: 20, + adaptiveExtremeNodeThreshold: 100, + adaptiveExtremeConnectionThreshold: 100, + ), + ...NodeFlowConfig.defaultPlugins().where((e) => e is! LodPlugin), + ], + ), + ); + + final lod = controller.lod!; + controller.interaction.setViewportInteracting(true); + + expect(lod.useThumbnailMode, isFalse); + + controller.dispose(); + }, + ); + }); + // =========================================================================== // LodPlugin - Plugin ID // =========================================================================== diff --git a/packages/vyuh_node_flow/test/unit/nodes/node_container_test.dart b/packages/vyuh_node_flow/test/unit/nodes/node_container_test.dart index 9f86bda..85b3618 100644 --- a/packages/vyuh_node_flow/test/unit/nodes/node_container_test.dart +++ b/packages/vyuh_node_flow/test/unit/nodes/node_container_test.dart @@ -73,39 +73,6 @@ void main() { expect(container.portSnapDistance, equals(12.0)); }); - test('creates container with empty connections list by default', () { - final node = createTestNode(); - final controller = createTestController(); - - final container = NodeContainer( - node: node, - controller: controller, - child: const SizedBox(), - ); - - expect(container.connections, isEmpty); - }); - - test('creates container with connections list', () { - final node = createTestNodeWithPorts(id: 'node-a'); - final nodeB = createTestNodeWithPorts(id: 'node-b'); - final controller = createTestController(nodes: [node, nodeB]); - final connection = createTestConnection( - sourceNodeId: 'node-a', - targetNodeId: 'node-b', - ); - - final container = NodeContainer( - node: node, - controller: controller, - connections: [connection], - child: const SizedBox(), - ); - - expect(container.connections.length, equals(1)); - expect(container.connections.first.sourceNodeId, equals('node-a')); - }); - test('creates container with optional shape', () { final node = createTestNode(); final controller = createTestController(); @@ -303,10 +270,10 @@ void main() { }); // ========================================================================== - // Port Connection Checking Tests + // Port Connection Index Tests // ========================================================================== - group('Port Connection Detection', () { - test('_isPortConnected returns true for connected output port', () { + group('Port Connection Index', () { + test('controller indexes connected output port', () { final node = createTestNodeWithOutputPort(id: 'node-a', portId: 'out-1'); final nodeB = createTestNodeWithInputPort(id: 'node-b', portId: 'in-1'); final controller = createTestController(nodes: [node, nodeB]); @@ -317,25 +284,16 @@ void main() { targetPortId: 'in-1', ); - final container = NodeContainer( - node: node, - controller: controller, - connections: [connection], - child: const SizedBox(), - ); + controller.addConnection(connection); - // Use reflection or testing helper to access private method - // Since _isPortConnected is private, we test indirectly through behavior - expect(container.connections.length, equals(1)); expect( - container.connections.any( - (c) => c.sourceNodeId == 'node-a' && c.sourcePortId == 'out-1', - ), + controller.hasOutgoingConnectionsFromPort('node-a', 'out-1'), isTrue, ); + expect(controller.hasConnectionsForPort('node-a', 'out-1'), isTrue); }); - test('_isPortConnected returns true for connected input port', () { + test('controller indexes connected input port', () { final nodeA = createTestNodeWithOutputPort(id: 'node-a', portId: 'out-1'); final node = createTestNodeWithInputPort(id: 'node-b', portId: 'in-1'); final controller = createTestController(nodes: [nodeA, node]); @@ -346,22 +304,13 @@ void main() { targetPortId: 'in-1', ); - final container = NodeContainer( - node: node, - controller: controller, - connections: [connection], - child: const SizedBox(), - ); + controller.addConnection(connection); - expect( - container.connections.any( - (c) => c.targetNodeId == 'node-b' && c.targetPortId == 'in-1', - ), - isTrue, - ); + expect(controller.hasIncomingConnectionsToPort('node-b', 'in-1'), isTrue); + expect(controller.hasConnectionsForPort('node-b', 'in-1'), isTrue); }); - test('multiple connections can be tracked', () { + test('controller tracks multiple connected ports', () { final node = Node( id: 'node-a', type: 'test', @@ -391,14 +340,18 @@ void main() { ), ]; - final container = NodeContainer( - node: node, - controller: controller, - connections: connections, - child: const SizedBox(), - ); + for (final connection in connections) { + controller.addConnection(connection); + } - expect(container.connections.length, equals(2)); + expect( + controller.hasOutgoingConnectionsFromPort('node-a', 'out-1'), + isTrue, + ); + expect( + controller.hasOutgoingConnectionsFromPort('node-a', 'out-2'), + isTrue, + ); }); });