import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../models/network/network_device.dart'; import '../../../models/network/topology_graph.dart'; import '../layout/sugiyama_layout.dart'; import 'device_node.dart'; /// Pan + zoom topology canvas with Sugiyama-layered layout, M3-styled edges, /// line-crossing jumps, port labels, multi-layer Bézier routing, and animated /// transitions between view modes. /// /// Layout pipeline: /// 1. On graph/viewMode change, [computeSugiyamaLayout] produces node /// positions and edge waypoints. Positions are saved as a "target." /// 2. An [AnimationController] interpolates from the previous positions to /// the target over 600ms with [Curves.easeInOutCubicEmphasized]. /// 3. Each frame during the transition, edge geometries and line-jump /// crossing points are recomputed from the interpolated positions. /// O(E²) per frame is acceptable for ≤500 edges. /// /// Rendering: /// * Edges with no waypoints (single-layer span) draw as straight lines. /// * Edges with waypoints (multi-layer span) draw as sequential quadratic /// Bézier curves passing through each waypoint. The virtual nodes /// informing those waypoints are never drawn as visible widgets. /// * Line-jump arcs at crossings apply only to straight segments. /// /// Performance: /// * Hover state on two [ValueNotifier]s; the painter is wrapped in a /// [RepaintBoundary] so hover only repaints the painter, not the widget /// tree. /// * Pan/zoom is pure Matrix4 transform via [InteractiveViewer]; no rebuild. class TopologyCanvas extends StatefulWidget { const TopologyCanvas({ super.key, required this.graph, required this.viewMode, this.selectedDeviceId, this.onDeviceTap, }); final TopologyGraph graph; final TopologyViewMode viewMode; final String? selectedDeviceId; final void Function(NetworkDevice device)? onDeviceTap; @override State createState() => _TopologyCanvasState(); } class _TopologyCanvasState extends State with SingleTickerProviderStateMixin { // ─── Hover state ───────────────────────────────────────────────────────── final ValueNotifier _hoveredEdgeId = ValueNotifier(null); final ValueNotifier _hoveredDeviceId = ValueNotifier(null); // ─── Animation ─────────────────────────────────────────────────────────── late AnimationController _layoutAnim; // ─── Layout snapshots (for interpolation) ──────────────────────────────── Map _prevPositions = const {}; Map _targetPositions = const {}; Map> _prevWaypoints = const {}; Map> _targetWaypoints = const {}; Size _canvasSize = const Size(600, 400); Map> _deviceToEdges = const {}; // ─── Constants ─────────────────────────────────────────────────────────── static const Size _nodeSize = Size(160, 110); static const double _hitTolerance = 8; static const double _jumpRadius = 6; @override void initState() { super.initState(); _layoutAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _runLayout(animate: false); } @override void didUpdateWidget(covariant TopologyCanvas oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.graph, widget.graph) || oldWidget.viewMode != widget.viewMode) { _runLayout(animate: true); } } @override void dispose() { _hoveredEdgeId.dispose(); _hoveredDeviceId.dispose(); _layoutAnim.dispose(); super.dispose(); } // ─── Layout orchestration ──────────────────────────────────────────────── void _runLayout({required bool animate}) { // Snapshot current interpolated positions BEFORE recomputing target. final newPrev = animate ? Map.from(_currentPositions()) : {}; final newPrevWaypoints = animate ? Map>.from(_currentWaypoints()) : >{}; final input = _buildSugiyamaInput(); final result = computeSugiyamaLayout(input); setState(() { _prevPositions = newPrev.isEmpty ? result.nodePositions : newPrev; _targetPositions = result.nodePositions; _prevWaypoints = newPrevWaypoints.isEmpty ? result.edgeWaypoints : newPrevWaypoints; _targetWaypoints = result.edgeWaypoints; _canvasSize = result.canvasSize; _deviceToEdges = _computeDeviceEdgeMap(); }); if (animate) { _layoutAnim.value = 0; _layoutAnim.forward(); } else { _layoutAnim.value = 1.0; } } SugiyamaInput _buildSugiyamaInput() { final nodeIds = widget.graph.nodes.map((n) => n.device.id).toList(); final edges = widget.graph.edges .map((e) => SugiyamaEdge( id: e.link.id, from: e.fromDeviceId, to: e.toDeviceId, )) .toList(); Map? preassigned; if (widget.viewMode == TopologyViewMode.logical) { preassigned = {}; for (final node in widget.graph.nodes) { final role = node.device.role; if (role != null) { preassigned[node.device.id] = _roleToLayer(role); } } } // Physical view: no preassignment; the algorithm derives layers from // graph structure via longest-path. We could supply location depth as a // hint here in a future iteration. return SugiyamaInput( nodeIds: nodeIds, edges: edges, preassignedLayers: preassigned, // Sizes default for now; future: pass per-device width if cards become // variable-width. ); } int _roleToLayer(NetworkDeviceRole role) { switch (role) { case NetworkDeviceRole.core: return 0; case NetworkDeviceRole.distribution: return 1; case NetworkDeviceRole.access: return 2; case NetworkDeviceRole.edge: return 3; case NetworkDeviceRole.endpoint: return 4; } } // ─── Interpolation ─────────────────────────────────────────────────────── /// Node positions at the current animation `t`. Linearly interpolates between /// previous and target positions. Nodes that newly appeared use target as /// both endpoints (they pop in place). Map _currentPositions() { final t = _layoutAnim.value; if (t >= 1.0) return _targetPositions; if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions; final out = {}; for (final entry in _targetPositions.entries) { final target = entry.value; final prev = _prevPositions[entry.key] ?? target; out[entry.key] = Offset.lerp(prev, target, t)!; } return out; } /// Edge waypoints at the current animation `t`. Lerps element-wise when the /// previous and target waypoint lists have the same length; otherwise /// snaps to target (the rare case of an edge going from N-layer to M-layer). Map> _currentWaypoints() { final t = _layoutAnim.value; if (t >= 1.0) return _targetWaypoints; if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints; final out = >{}; for (final entry in _targetWaypoints.entries) { final target = entry.value; final prev = _prevWaypoints[entry.key]; if (prev == null || prev.length != target.length) { out[entry.key] = target; continue; } out[entry.key] = [ for (var i = 0; i < target.length; i++) Offset.lerp(prev[i], target[i], t)!, ]; } return out; } Map> _computeDeviceEdgeMap() { final map = >{}; for (final edge in widget.graph.edges) { map.putIfAbsent(edge.fromDeviceId, () => []).add(edge.link.id); map.putIfAbsent(edge.toDeviceId, () => []).add(edge.link.id); } return map; } // ─── Edge geometry ─────────────────────────────────────────────────────── List<_EdgeGeometry> _buildEdgeGeometries( Map positions, Map> waypoints, ) { final out = <_EdgeGeometry>[]; for (final edge in widget.graph.edges) { final fromTopLeft = positions[edge.fromDeviceId]; final toTopLeft = positions[edge.toDeviceId]; if (fromTopLeft == null || toTopLeft == null) continue; final fromCenter = Offset( fromTopLeft.dx + _nodeSize.width / 2, fromTopLeft.dy + _nodeSize.height / 2, ); final toCenter = Offset( toTopLeft.dx + _nodeSize.width / 2, toTopLeft.dy + _nodeSize.height / 2, ); // For edges with waypoints (multi-layer), exit points face the first/last // waypoint rather than the other endpoint, so the curve enters/exits the // card cleanly. final wps = waypoints[edge.link.id] ?? const []; final firstAimAt = wps.isNotEmpty ? wps.first : toCenter; final lastAimAt = wps.isNotEmpty ? wps.last : fromCenter; final fromSide = _sideFacing(fromCenter, firstAimAt); final toSide = _sideFacing(toCenter, lastAimAt); final fromExit = _sideMidpoint(fromTopLeft, fromSide); final toExit = _sideMidpoint(toTopLeft, toSide); out.add(_EdgeGeometry( edgeId: edge.link.id, fromDeviceId: edge.fromDeviceId, toDeviceId: edge.toDeviceId, fromPortLabel: edge.fromPortLabel, toPortLabel: edge.toPortLabel, fromExit: fromExit, toExit: toExit, fromSide: fromSide, toSide: toSide, waypoints: wps, )); } return out; } _CardSide _sideFacing(Offset from, Offset to) { final dx = to.dx - from.dx; final dy = to.dy - from.dy; final aspectRatio = _nodeSize.width / _nodeSize.height; if (dx.abs() * aspectRatio > dy.abs()) { return dx >= 0 ? _CardSide.right : _CardSide.left; } return dy >= 0 ? _CardSide.bottom : _CardSide.top; } Offset _sideMidpoint(Offset topLeft, _CardSide side) { switch (side) { case _CardSide.left: return Offset(topLeft.dx, topLeft.dy + _nodeSize.height / 2); case _CardSide.right: return Offset( topLeft.dx + _nodeSize.width, topLeft.dy + _nodeSize.height / 2); case _CardSide.top: return Offset(topLeft.dx + _nodeSize.width / 2, topLeft.dy); case _CardSide.bottom: return Offset( topLeft.dx + _nodeSize.width / 2, topLeft.dy + _nodeSize.height); } } /// Line-jump arcs at crossings between *straight* edges. Curved (waypoint- /// bearing) edges don't participate — they already route around obstacles /// by going through their virtual-node waypoints. void _computeLineJumps(List<_EdgeGeometry> geos) { for (var i = 0; i < geos.length; i++) { geos[i].jumpPoints.clear(); } for (var i = 0; i < geos.length; i++) { final a = geos[i]; if (a.waypoints.isNotEmpty) continue; for (var j = i + 1; j < geos.length; j++) { final b = geos[j]; if (b.waypoints.isNotEmpty) continue; if (_sharesDevice(a, b)) continue; final hit = _segmentIntersection(a.fromExit, a.toExit, b.fromExit, b.toExit); if (hit != null) b.jumpPoints.add(hit); } } for (final geo in geos) { if (geo.jumpPoints.isEmpty) continue; geo.jumpPoints.sort((p1, p2) { final d1 = (p1 - geo.fromExit).distanceSquared; final d2 = (p2 - geo.fromExit).distanceSquared; return d1.compareTo(d2); }); } } bool _sharesDevice(_EdgeGeometry a, _EdgeGeometry b) { return a.fromDeviceId == b.fromDeviceId || a.fromDeviceId == b.toDeviceId || a.toDeviceId == b.fromDeviceId || a.toDeviceId == b.toDeviceId; } Offset? _segmentIntersection(Offset p1, Offset p2, Offset p3, Offset p4) { final s1x = p2.dx - p1.dx; final s1y = p2.dy - p1.dy; final s2x = p4.dx - p3.dx; final s2y = p4.dy - p3.dy; final denom = -s2x * s1y + s1x * s2y; if (denom.abs() < 1e-6) return null; final s = (-s1y * (p1.dx - p3.dx) + s1x * (p1.dy - p3.dy)) / denom; final t = (s2x * (p1.dy - p3.dy) - s2y * (p1.dx - p3.dx)) / denom; if (s >= 0 && s <= 1 && t >= 0 && t <= 1) { return Offset(p1.dx + t * s1x, p1.dy + t * s1y); } return null; } String? _hitTestEdge(Offset localPos, List<_EdgeGeometry> geos) { String? bestId; double bestDist = _hitTolerance; for (final geo in geos) { // For curved edges, sample the Bézier path and use the nearest sample. // For straight edges, distance to segment. final d = geo.waypoints.isEmpty ? _distanceToSegment(localPos, geo.fromExit, geo.toExit) : _distanceToCurve(localPos, geo); if (d < bestDist) { bestDist = d; bestId = geo.edgeId; } } return bestId; } double _distanceToSegment(Offset p, Offset a, Offset b) { final ax = a.dx, ay = a.dy, bx = b.dx, by = b.dy; final dx = bx - ax; final dy = by - ay; final lenSq = dx * dx + dy * dy; if (lenSq < 1e-6) return (p - a).distance; var t = ((p.dx - ax) * dx + (p.dy - ay) * dy) / lenSq; t = t.clamp(0.0, 1.0); return (p - Offset(ax + t * dx, ay + t * dy)).distance; } /// Approximate distance to a Bézier curve by sampling segments between the /// path control points (fromExit, waypoints..., toExit) and taking the min /// segment distance. Coarse but cheap. double _distanceToCurve(Offset p, _EdgeGeometry geo) { final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; var best = double.infinity; for (var i = 0; i < pts.length - 1; i++) { final d = _distanceToSegment(p, pts[i], pts[i + 1]); if (d < best) best = d; } return best; } // ─── Build ─────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; if (widget.graph.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.hub_outlined, size: 56, color: cs.onSurfaceVariant), const SizedBox(height: 12), Text( 'No devices in this view yet', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text( 'Import a document or add devices manually.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: cs.onSurfaceVariant, ), ), ], ), ), ); } return InteractiveViewer( constrained: false, minScale: 0.2, maxScale: 2.5, boundaryMargin: const EdgeInsets.all(200), child: AnimatedBuilder( animation: _layoutAnim, builder: (context, _) { final positions = _currentPositions(); final waypoints = _currentWaypoints(); final edgeGeos = _buildEdgeGeometries(positions, waypoints); _computeLineJumps(edgeGeos); return SizedBox( width: _canvasSize.width, height: _canvasSize.height, child: MouseRegion( onHover: (event) { final hit = _hitTestEdge(event.localPosition, edgeGeos); if (hit != _hoveredEdgeId.value) { _hoveredEdgeId.value = hit; } }, onExit: (_) => _hoveredEdgeId.value = null, child: Stack( children: [ // Painter — repaints on hover via inner AnimatedBuilder. Positioned.fill( child: RepaintBoundary( child: AnimatedBuilder( animation: _hoveredEdgeId, builder: (_, _) { return CustomPaint( painter: _CanvasPainter( edgeGeos: edgeGeos, jumpRadius: _jumpRadius, hoveredEdgeId: _hoveredEdgeId.value, theme: _CanvasTheme.from(Theme.of(context)), ), ); }, ), ), ), // Devices on top, hover-aware. for (final node in widget.graph.nodes) if (positions[node.device.id] != null) Positioned( left: positions[node.device.id]!.dx, top: positions[node.device.id]!.dy, child: _HoverAwareDevice( node: node, selectedId: widget.selectedDeviceId, hoveredEdgeId: _hoveredEdgeId, hoveredDeviceId: _hoveredDeviceId, connectedEdgeIds: _deviceToEdges[node.device.id] ?? const [], onTap: widget.onDeviceTap, ), ), ], ), ), ); }, ), ); } } enum _CardSide { left, right, top, bottom } class _EdgeGeometry { _EdgeGeometry({ required this.edgeId, required this.fromDeviceId, required this.toDeviceId, required this.fromPortLabel, required this.toPortLabel, required this.fromExit, required this.toExit, required this.fromSide, required this.toSide, required this.waypoints, }); final String edgeId; final String fromDeviceId; final String toDeviceId; final String fromPortLabel; final String toPortLabel; final Offset fromExit; final Offset toExit; final _CardSide fromSide; final _CardSide toSide; /// Intermediate points the edge passes through. Empty for single-layer /// edges (drawn as straight lines). Non-empty for multi-layer edges (drawn /// as sequential quadratic Béziers through these points). final List waypoints; /// Line-jump points along the *main* segment. Only populated for straight /// edges (curved/waypoint edges skip line jumps). final List jumpPoints = []; } // ─── Theme snapshot ──────────────────────────────────────────────────────── class _CanvasTheme { const _CanvasTheme({ required this.defaultEdgeColor, required this.hoverEdgeColor, required this.chipBg, required this.chipHoverBg, required this.chipBorder, required this.chipHoverBorder, required this.chipText, required this.chipHoverText, }); factory _CanvasTheme.from(ThemeData theme) { final cs = theme.colorScheme; return _CanvasTheme( defaultEdgeColor: cs.outline.withValues(alpha: 0.75), hoverEdgeColor: cs.primary, chipBg: cs.surfaceContainerHigh.withValues(alpha: 0.95), chipHoverBg: cs.primaryContainer, chipBorder: cs.outlineVariant.withValues(alpha: 0.7), chipHoverBorder: cs.primary, chipText: cs.onSurfaceVariant, chipHoverText: cs.onPrimaryContainer, ); } final Color defaultEdgeColor; final Color hoverEdgeColor; final Color chipBg; final Color chipHoverBg; final Color chipBorder; final Color chipHoverBorder; final Color chipText; final Color chipHoverText; } // ─── Painter ─────────────────────────────────────────────────────────────── class _CanvasPainter extends CustomPainter { _CanvasPainter({ required this.edgeGeos, required this.jumpRadius, required this.hoveredEdgeId, required this.theme, }); final List<_EdgeGeometry> edgeGeos; final double jumpRadius; final String? hoveredEdgeId; final _CanvasTheme theme; @override void paint(Canvas canvas, Size size) { for (final geo in edgeGeos) { if (geo.edgeId == hoveredEdgeId) continue; _drawEdge(canvas, geo, hovered: false); } for (final geo in edgeGeos) { if (geo.edgeId != hoveredEdgeId) continue; _drawEdge(canvas, geo, hovered: true); } for (final geo in edgeGeos) { if (geo.edgeId == hoveredEdgeId) continue; _drawChips(canvas, geo, hovered: false); } for (final geo in edgeGeos) { if (geo.edgeId != hoveredEdgeId) continue; _drawChips(canvas, geo, hovered: true); } } void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { final color = hovered ? theme.hoverEdgeColor : theme.defaultEdgeColor; final strokeWidth = hovered ? 2.5 : 1.4; final paint = Paint() ..color = color ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; if (geo.waypoints.isNotEmpty) { _drawCurvedEdge(canvas, geo, paint); } else { _drawStraightEdge(canvas, geo, paint); } // Endpoint plug dots. final dotPaint = Paint()..color = color..style = PaintingStyle.fill; final radius = hovered ? 4.0 : 3.0; canvas.drawCircle(geo.fromExit, radius, dotPaint); canvas.drawCircle(geo.toExit, radius, dotPaint); } void _drawStraightEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { final dx = geo.toExit.dx - geo.fromExit.dx; final dy = geo.toExit.dy - geo.fromExit.dy; final len = math.sqrt(dx * dx + dy * dy); if (len < 1) return; final ux = dx / len; final uy = dy / len; final path = Path()..moveTo(geo.fromExit.dx, geo.fromExit.dy); for (final jump in geo.jumpPoints) { final beforeX = jump.dx - ux * jumpRadius; final beforeY = jump.dy - uy * jumpRadius; final afterX = jump.dx + ux * jumpRadius; final afterY = jump.dy + uy * jumpRadius; path.lineTo(beforeX, beforeY); path.arcToPoint( Offset(afterX, afterY), radius: Radius.circular(jumpRadius), clockwise: false, ); } path.lineTo(geo.toExit.dx, geo.toExit.dy); canvas.drawPath(path, paint); } /// Render an edge through its waypoints as sequential quadratic Bézier /// curves. The control point for each segment is the waypoint itself, /// with each segment's anchor being the midpoint between consecutive /// waypoints. This produces a smooth C1-continuous curve passing AT each /// waypoint, mimicking what the eye expects from "a cable bending through /// multiple connection points." void _drawCurvedEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; final path = Path()..moveTo(pts.first.dx, pts.first.dy); if (pts.length == 2) { path.lineTo(pts.last.dx, pts.last.dy); canvas.drawPath(path, paint); return; } // For each intermediate waypoint, draw a quadratic Bézier from the // previous-anchor to the next-anchor, with this waypoint as the control. // First anchor: midpoint between pts[0] and pts[1]. Last anchor: midpoint // between pts[n-2] and pts[n-1]. This produces a smooth curve passing // through every waypoint. var anchor = Offset( (pts[0].dx + pts[1].dx) / 2, (pts[0].dy + pts[1].dy) / 2, ); path.lineTo(anchor.dx, anchor.dy); for (var i = 1; i < pts.length - 1; i++) { final nextAnchor = Offset( (pts[i].dx + pts[i + 1].dx) / 2, (pts[i].dy + pts[i + 1].dy) / 2, ); path.quadraticBezierTo(pts[i].dx, pts[i].dy, nextAnchor.dx, nextAnchor.dy); anchor = nextAnchor; } path.lineTo(pts.last.dx, pts.last.dy); canvas.drawPath(path, paint); } void _drawChips(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { _drawChip( canvas, anchor: geo.fromExit, side: geo.fromSide, label: geo.fromPortLabel, hovered: hovered, ); _drawChip( canvas, anchor: geo.toExit, side: geo.toSide, label: geo.toPortLabel, hovered: hovered, ); } void _drawChip( Canvas canvas, { required Offset anchor, required _CardSide side, required String label, required bool hovered, }) { if (label.isEmpty) return; final tp = TextPainter( text: TextSpan( text: label, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: hovered ? theme.chipHoverText : theme.chipText, fontFeatures: const [FontFeature.tabularFigures()], ), ), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: '…', )..layout(maxWidth: 100); const padH = 6.0; const padV = 2.0; final chipW = tp.width + padH * 2; final chipH = tp.height + padV * 2; const gap = 6.0; Offset chipTopLeft; switch (side) { case _CardSide.left: chipTopLeft = Offset(anchor.dx - gap - chipW, anchor.dy - chipH / 2); break; case _CardSide.right: chipTopLeft = Offset(anchor.dx + gap, anchor.dy - chipH / 2); break; case _CardSide.top: chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy - gap - chipH); break; case _CardSide.bottom: chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy + gap); break; } final rect = Rect.fromLTWH(chipTopLeft.dx, chipTopLeft.dy, chipW, chipH); final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6)); final bgPaint = Paint() ..color = hovered ? theme.chipHoverBg : theme.chipBg ..style = PaintingStyle.fill; canvas.drawRRect(rrect, bgPaint); final borderPaint = Paint() ..color = hovered ? theme.chipHoverBorder : theme.chipBorder ..style = PaintingStyle.stroke ..strokeWidth = hovered ? 1.2 : 0.7; canvas.drawRRect(rrect, borderPaint); tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV)); } @override bool shouldRepaint(covariant _CanvasPainter old) { return old.edgeGeos != edgeGeos || old.hoveredEdgeId != hoveredEdgeId || old.theme.defaultEdgeColor != theme.defaultEdgeColor; } } // ─── Hover-aware device ─────────────────────────────────────────────────── class _HoverAwareDevice extends StatelessWidget { const _HoverAwareDevice({ required this.node, required this.selectedId, required this.hoveredEdgeId, required this.hoveredDeviceId, required this.connectedEdgeIds, required this.onTap, }); final TopologyNode node; final String? selectedId; final ValueNotifier hoveredEdgeId; final ValueNotifier hoveredDeviceId; final List connectedEdgeIds; final void Function(NetworkDevice device)? onTap; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => hoveredDeviceId.value = node.device.id, onExit: (_) { if (hoveredDeviceId.value == node.device.id) { hoveredDeviceId.value = null; } }, child: AnimatedBuilder( animation: Listenable.merge([hoveredEdgeId, hoveredDeviceId]), builder: (_, _) { final isOwnHover = hoveredDeviceId.value == node.device.id; final isEdgeEndpoint = hoveredEdgeId.value != null && connectedEdgeIds.contains(hoveredEdgeId.value); return DeviceNode( device: node.device, portCount: node.ports.length, isSelected: selectedId == node.device.id, isHighlighted: isOwnHover || isEdgeEndpoint, onTap: () => onTap?.call(node.device), ); }, ), ); } }