import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../models/network/network_device.dart'; import '../../../models/network/network_link.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 (all edge-type combinations), link-type colours, data- /// flow dot animation, port-label chips, multi-layer Bézier routing, role- /// based node sizing, and animated transitions between view modes. class TopologyCanvas extends StatefulWidget { const TopologyCanvas({ super.key, required this.graph, required this.viewMode, this.selectedDeviceId, this.transformationController, this.onDeviceTap, this.onLayoutUpdated, }); final TopologyGraph graph; final TopologyViewMode viewMode; final String? selectedDeviceId; final TransformationController? transformationController; final void Function(NetworkDevice device)? onDeviceTap; /// Called whenever layout positions are recomputed (initial + re-layout). /// Provides the final node top-left positions and total canvas size so the /// minimap can stay in sync without reading canvas internals. final void Function(Map positions, Size canvasSize)? onLayoutUpdated; @override State createState() => _TopologyCanvasState(); } class _TopologyCanvasState extends State with TickerProviderStateMixin { // ─── Hover state ───────────────────────────────────────────────────────── final ValueNotifier _hoveredEdgeId = ValueNotifier(null); final ValueNotifier _hoveredDeviceId = ValueNotifier(null); // ─── Layout animation ───────────────────────────────────────────────────── late AnimationController _layoutAnim; // ─── Data-flow dot animation ────────────────────────────────────────────── late AnimationController _flowAnim; // ─── 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 double _hitTolerance = 8; static const double _jumpRadius = 6; static const int _bezierSamples = 20; static Size _roleSizeFor(NetworkDeviceRole? role) { switch (role) { case NetworkDeviceRole.core: return const Size(180, 120); case NetworkDeviceRole.distribution: return const Size(170, 115); case NetworkDeviceRole.access: return const Size(160, 110); case NetworkDeviceRole.edge: return const Size(150, 100); case NetworkDeviceRole.endpoint: case null: return const Size(140, 90); } } @override void initState() { super.initState(); _layoutAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _flowAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 2400), )..repeat(); _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(); _flowAnim.dispose(); super.dispose(); } // ─── Layout orchestration ──────────────────────────────────────────────── void _runLayout({required bool animate}) { 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; } // Notify minimap with final target positions immediately (no need to wait // for the animation to settle — the minimap shows topology structure, not // animated intermediate state). WidgetsBinding.instance.addPostFrameCallback((_) { widget.onLayoutUpdated?.call(_targetPositions, _canvasSize); }); } 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); } } } // Per-node sizes for Sugiyama compaction final nodeSizes = {}; for (final node in widget.graph.nodes) { nodeSizes[node.device.id] = _roleSizeFor(node.device.role); } return SugiyamaInput( nodeIds: nodeIds, edges: edges, preassignedLayers: preassigned, nodeSizes: nodeSizes, ); } 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 ─────────────────────────────────────────────────────── 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; } 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, ) { // Build a device→role lookup for node sizing final roleOf = {}; for (final node in widget.graph.nodes) { roleOf[node.device.id] = node.device.role; } 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 fromSize = _roleSizeFor(roleOf[edge.fromDeviceId]); final toSize = _roleSizeFor(roleOf[edge.toDeviceId]); final fromCenter = fromTopLeft + Offset(fromSize.width / 2, fromSize.height / 2); final toCenter = toTopLeft + Offset(toSize.width / 2, toSize.height / 2); 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, fromSize); final toSide = _sideFacing(toCenter, lastAimAt, toSize); final fromExit = _sideMidpoint(fromTopLeft, fromSide, fromSize); final toExit = _sideMidpoint(toTopLeft, toSide, toSize); 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, linkKind: edge.link.linkKind, )); } return out; } _CardSide _sideFacing(Offset from, Offset to, Size nodeSize) { 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, Size nodeSize) { 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); } } // ─── Universal jumper detection ────────────────────────────────────────── // // Samples both straight and curved edges into polyline segments, then tests // all cross-edge segment pairs for intersections. The "under" edge (higher // index j) receives the jump arc. /// Returns [n] evenly-spaced points along the edge path. List _sampleEdgePath(_EdgeGeometry geo, int n) { if (geo.waypoints.isEmpty) { return [geo.fromExit, geo.toExit]; } final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; if (pts.length == 2) return pts; final result = []; // Replicate the quadratic Bézier sampling used in _drawCurvedEdge. // Each consecutive triple (prev-anchor, waypoint, next-anchor) is one // quadratic segment. We sample t uniformly across all segments. final anchors = []; anchors.add(pts[0]); for (var i = 1; i < pts.length - 1; i++) { anchors.add(Offset( (pts[i].dx + pts[i + 1].dx) / 2, (pts[i].dy + pts[i + 1].dy) / 2, )); } anchors.add(pts.last); final segCount = anchors.length - 1; for (var k = 0; k < n; k++) { final globalT = k / (n - 1); final segIndexF = globalT * segCount; final segIndex = segIndexF.floor().clamp(0, segCount - 1); final localT = segIndexF - segIndex; final a0 = anchors[segIndex]; final cp = pts[segIndex + 1]; final a1 = anchors[segIndex + 1]; // Quadratic Bézier evaluation: B(t) = (1-t)²·a0 + 2(1-t)t·cp + t²·a1 final mt = 1.0 - localT; result.add(Offset( mt * mt * a0.dx + 2 * mt * localT * cp.dx + localT * localT * a1.dx, mt * mt * a0.dy + 2 * mt * localT * cp.dy + localT * localT * a1.dy, )); } return result; } 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]; final samplesA = _sampleEdgePath(a, _bezierSamples); for (var j = i + 1; j < geos.length; j++) { final b = geos[j]; if (_sharesDevice(a, b)) continue; final samplesB = _sampleEdgePath(b, _bezierSamples); // Stop at the first hit per edge pair — adjacent sample segments near // a crossing can each detect a slightly offset intersection, producing // duplicate jump points. Multiple hits cause the path to draw backwards // between them, placing arcs far from the actual crossing. bool found = false; for (var ai = 0; ai < samplesA.length - 1 && !found; ai++) { for (var bi = 0; bi < samplesB.length - 1; bi++) { final hit = _segmentIntersection( samplesA[ai], samplesA[ai + 1], samplesB[bi], samplesB[bi + 1], ); if (hit != null) { b.jumpPoints.add(hit); found = true; break; } } } } } // Sort jump points along each edge from fromExit outward 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) { 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, 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; } 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), transformationController: widget.transformationController, 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: [ // Dot-grid background — static, never repaints Positioned.fill( child: RepaintBoundary( child: CustomPaint( painter: _GridPainter( dotColor: cs.outlineVariant.withValues(alpha: 0.25), ), ), ), ), // Edges + flow dots — repaints on hover or flow phase Positioned.fill( child: RepaintBoundary( child: AnimatedBuilder( animation: Listenable.merge([_hoveredEdgeId, _flowAnim]), builder: (_, _) { return CustomPaint( painter: _CanvasPainter( edgeGeos: edgeGeos, jumpRadius: _jumpRadius, hoveredEdgeId: _hoveredEdgeId.value, flowPhase: _flowAnim.value, theme: _CanvasTheme.from(Theme.of(context)), ), ); }, ), ), ), // Device nodes on top 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 [], nodeSize: _roleSizeFor(node.device.role), 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, required this.linkKind, }); 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; final List waypoints; final NetworkLinkKind? linkKind; final List jumpPoints = []; } // ─── Theme snapshot ──────────────────────────────────────────────────────── class _CanvasTheme { const _CanvasTheme({ required this.chipBg, required this.chipHoverBg, required this.chipBorder, required this.chipHoverBorder, required this.chipText, required this.chipHoverText, required this.copperColor, required this.fiberColor, required this.wirelessColor, required this.virtualColor, required this.unknownColor, required this.hoverEdgeColor, }); factory _CanvasTheme.from(ThemeData theme) { final cs = theme.colorScheme; return _CanvasTheme( 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, copperColor: cs.primary, fiberColor: cs.tertiary, wirelessColor: cs.secondary, virtualColor: cs.outline, unknownColor: cs.outline, hoverEdgeColor: cs.primary, ); } final Color chipBg; final Color chipHoverBg; final Color chipBorder; final Color chipHoverBorder; final Color chipText; final Color chipHoverText; final Color copperColor; final Color fiberColor; final Color wirelessColor; final Color virtualColor; final Color unknownColor; final Color hoverEdgeColor; Color edgeColorForKind(NetworkLinkKind? kind) { switch (kind) { case NetworkLinkKind.copper: return copperColor; case NetworkLinkKind.fiber: return fiberColor; case NetworkLinkKind.wireless: return wirelessColor; case NetworkLinkKind.virtual: return virtualColor; case NetworkLinkKind.unknown: case null: return unknownColor; } } } // ─── Dot-grid background painter ───────────────────────────────────────────── class _GridPainter extends CustomPainter { const _GridPainter({required this.dotColor}); final Color dotColor; static const double _spacing = 24; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = dotColor ..style = PaintingStyle.fill; var x = _spacing; while (x < size.width) { var y = _spacing; while (y < size.height) { canvas.drawCircle(Offset(x, y), 1.0, paint); y += _spacing; } x += _spacing; } } @override bool shouldRepaint(covariant _GridPainter old) => old.dotColor != dotColor; } // ─── Main canvas painter ────────────────────────────────────────────────────── class _CanvasPainter extends CustomPainter { _CanvasPainter({ required this.edgeGeos, required this.jumpRadius, required this.hoveredEdgeId, required this.flowPhase, required this.theme, }); final List<_EdgeGeometry> edgeGeos; final double jumpRadius; final String? hoveredEdgeId; final double flowPhase; final _CanvasTheme theme; // Pre-compute chip positions keyed by (deviceId, side) to separate overlaps. final Map> _chipSlots = {}; @override void paint(Canvas canvas, Size size) { _chipSlots.clear(); _assignChipSlots(); // Draw non-hovered edges first (behind hovered) 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); } // Chips on top for (final geo in edgeGeos) { _drawChips(canvas, geo, hovered: geo.edgeId == hoveredEdgeId); } } // ─── Chip slot pre-computation ────────────────────────────────────────── // // Group chips by (deviceId, cardSide) and assign staggered offsets so they // don't overlap when multiple edges leave the same side of a device. void _assignChipSlots() { // Collect all chips final grouped = >{}; for (final geo in edgeGeos) { _collectChip(grouped, geo.fromDeviceId, geo.fromSide, geo.fromExit, geo.fromPortLabel, geo.edgeId, isFrom: true); _collectChip(grouped, geo.toDeviceId, geo.toSide, geo.toExit, geo.toPortLabel, geo.edgeId, isFrom: false); } // Sort within each group by the axis perpendicular to the side, then // assign evenly-spaced offsets. for (final entry in grouped.entries) { final slots = entry.value; if (slots.length <= 1) { _chipSlots[entry.key] = slots; continue; } // Sort by anchor position perpendicular to side direction final side = slots.first.side; final isHorizontalSide = side == _CardSide.top || side == _CardSide.bottom; slots.sort((a, b) => isHorizontalSide ? a.anchor.dx.compareTo(b.anchor.dx) : a.anchor.dy.compareTo(b.anchor.dy)); // Measure chip widths and space them out with 4dp gap const chipH = 16.0; const gap = 4.0; var cursor = 0.0; for (final slot in slots) { slot.offset = cursor; cursor += chipH + gap; // approximate; actual chip width varies } // Center the group around the original anchor final total = cursor - gap; for (final slot in slots) { slot.offset -= total / 2; } _chipSlots[entry.key] = slots; } } void _collectChip( Map> grouped, String deviceId, _CardSide side, Offset anchor, String label, String edgeId, { required bool isFrom, }) { if (label.isEmpty) return; final key = '$deviceId:${side.name}'; grouped.putIfAbsent(key, () => []).add(_ChipSlot( deviceId: deviceId, edgeId: edgeId, side: side, anchor: anchor, label: label, isFrom: isFrom, )); } // ─── Edge drawing ──────────────────────────────────────────────────────── void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { final baseColor = theme.edgeColorForKind(geo.linkKind); final color = hovered ? theme.hoverEdgeColor : baseColor.withValues(alpha: 0.75); final strokeWidth = hovered ? 3.0 : 1.8; 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); // Data-flow dots _drawFlowDots(canvas, geo, color); } 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); } 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) { // Treat as straight for jump purposes _drawStraightEdgeFromPts(canvas, pts[0], pts[1], geo.jumpPoints, paint); return; } 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); if (geo.jumpPoints.isEmpty) { canvas.drawPath(path, paint); } else { // saveLayer lets BlendMode.clear punch transparent gaps in the curve so // the arc appears as a visible bridge over the crossing. canvas.saveLayer(null, Paint()); canvas.drawPath(path, paint); final erasePaint = Paint() ..blendMode = BlendMode.clear ..style = PaintingStyle.fill; for (final jump in geo.jumpPoints) { canvas.drawCircle(jump, jumpRadius + 2, erasePaint); } _drawCurvedJumpArcs(canvas, geo, paint); canvas.restore(); } } void _drawStraightEdgeFromPts( Canvas canvas, Offset from, Offset to, List jumps, Paint paint) { final dx = to.dx - from.dx; final dy = to.dy - from.dy; final len = math.sqrt(dx * dx + dy * dy); if (len < 1) return; final ux = dx / len, uy = dy / len; final path = Path()..moveTo(from.dx, from.dy); for (final jump in jumps) { final bx = jump.dx - ux * jumpRadius; final by = jump.dy - uy * jumpRadius; final ax = jump.dx + ux * jumpRadius; final ay = jump.dy + uy * jumpRadius; path.lineTo(bx, by); path.arcToPoint(Offset(ax, ay), radius: Radius.circular(jumpRadius), clockwise: false); } path.lineTo(to.dx, to.dy); canvas.drawPath(path, paint); } /// Draw small arcs at jump points that lie near the curved path. void _drawCurvedJumpArcs( Canvas canvas, _EdgeGeometry geo, Paint paint) { final samples = _sampleCurve(geo, 40); for (final jump in geo.jumpPoints) { // Find the tangent at the nearest sample point Offset? tangent; double bestDist = double.infinity; for (var i = 0; i < samples.length - 1; i++) { final mid = Offset( (samples[i].dx + samples[i + 1].dx) / 2, (samples[i].dy + samples[i + 1].dy) / 2, ); final d = (mid - jump).distance; if (d < bestDist) { bestDist = d; final seg = samples[i + 1] - samples[i]; final segLen = seg.distance; tangent = segLen > 0.01 ? seg / segLen : null; } } if (tangent == null) continue; final tx = tangent.dx, ty = tangent.dy; final before = jump - Offset(tx * jumpRadius, ty * jumpRadius); final after = jump + Offset(tx * jumpRadius, ty * jumpRadius); final arcPath = Path() ..moveTo(before.dx, before.dy) ..arcToPoint( Offset(after.dx, after.dy), radius: Radius.circular(jumpRadius), clockwise: false, ); // White gap first canvas.drawPath(arcPath, paint..style = PaintingStyle.stroke..color = paint.color); } } List _sampleCurve(_EdgeGeometry geo, int n) { final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; if (pts.length == 2) return pts; final anchors = []; anchors.add(pts[0]); for (var i = 1; i < pts.length - 1; i++) { anchors.add(Offset( (pts[i].dx + pts[i + 1].dx) / 2, (pts[i].dy + pts[i + 1].dy) / 2, )); } anchors.add(pts.last); final segCount = anchors.length - 1; final result = []; for (var k = 0; k < n; k++) { final gT = k / (n - 1); final segF = gT * segCount; final seg = segF.floor().clamp(0, segCount - 1); final lt = segF - seg; final a0 = anchors[seg]; final cp = pts[seg + 1]; final a1 = anchors[seg + 1]; final mt = 1.0 - lt; result.add(Offset( mt * mt * a0.dx + 2 * mt * lt * cp.dx + lt * lt * a1.dx, mt * mt * a0.dy + 2 * mt * lt * cp.dy + lt * lt * a1.dy, )); } return result; } // ─── Data-flow dot animation ───────────────────────────────────────────── void _drawFlowDots(Canvas canvas, _EdgeGeometry geo, Color edgeColor) { final dotColor = edgeColor.withValues(alpha: 0.85); const dotCount = 3; const dotRadius = 2.5; final samples = geo.waypoints.isEmpty ? [geo.fromExit, geo.toExit] : _sampleCurve(geo, 40); for (var i = 0; i < dotCount; i++) { final t = (flowPhase + i / dotCount) % 1.0; final pos = _evalPathAt(samples, t); canvas.drawCircle( pos, dotRadius, Paint() ..color = dotColor ..style = PaintingStyle.fill, ); } } /// Evaluate a position at parameter [t] (0–1) along a polyline of [samples]. Offset _evalPathAt(List samples, double t) { if (samples.length == 1) return samples[0]; if (t <= 0) return samples.first; if (t >= 1) return samples.last; final segCount = samples.length - 1; final f = t * segCount; final idx = f.floor().clamp(0, segCount - 1); final local = f - idx; return Offset.lerp(samples[idx], samples[idx + 1], local)!; } // ─── Port chips with separation ────────────────────────────────────────── void _drawChips(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { _drawChipAtSlot(canvas, geo.edgeId, geo.fromDeviceId, geo.fromSide, geo.fromPortLabel, hovered: hovered); _drawChipAtSlot(canvas, geo.edgeId, geo.toDeviceId, geo.toSide, geo.toPortLabel, hovered: hovered); } void _drawChipAtSlot( Canvas canvas, String edgeId, String deviceId, _CardSide side, String label, { required bool hovered, }) { if (label.isEmpty) return; final key = '$deviceId:${side.name}'; final slots = _chipSlots[key]; if (slots == null) return; final slot = slots.firstWhere( (s) => s.edgeId == edgeId, orElse: () => _ChipSlot( deviceId: deviceId, edgeId: edgeId, side: side, anchor: Offset.zero, label: label, isFrom: true, ), ); _drawChip(canvas, anchor: slot.anchor, side: side, label: label, perpOffset: slot.offset, hovered: hovered); } void _drawChip( Canvas canvas, { required Offset anchor, required _CardSide side, required String label, required double perpOffset, 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 + perpOffset, ); case _CardSide.right: chipTopLeft = Offset( anchor.dx + gap, anchor.dy - chipH / 2 + perpOffset, ); case _CardSide.top: chipTopLeft = Offset( anchor.dx - chipW / 2 + perpOffset, anchor.dy - gap - chipH, ); case _CardSide.bottom: chipTopLeft = Offset( anchor.dx - chipW / 2 + perpOffset, anchor.dy + gap, ); } final rect = Rect.fromLTWH(chipTopLeft.dx, chipTopLeft.dy, chipW, chipH); final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6)); canvas.drawRRect( rrect, Paint() ..color = hovered ? theme.chipHoverBg : theme.chipBg ..style = PaintingStyle.fill, ); canvas.drawRRect( rrect, Paint() ..color = hovered ? theme.chipHoverBorder : theme.chipBorder ..style = PaintingStyle.stroke ..strokeWidth = hovered ? 1.2 : 0.7, ); tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV)); } @override bool shouldRepaint(covariant _CanvasPainter old) { return old.edgeGeos != edgeGeos || old.hoveredEdgeId != hoveredEdgeId || old.flowPhase != flowPhase || old.theme.copperColor != theme.copperColor; } } // ─── Chip slot helper ───────────────────────────────────────────────────────── class _ChipSlot { _ChipSlot({ required this.deviceId, required this.edgeId, required this.side, required this.anchor, required this.label, required this.isFrom, }); final String deviceId; final String edgeId; final _CardSide side; final Offset anchor; final String label; final bool isFrom; double offset = 0; } // ─── 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.nodeSize, required this.onTap, }); final TopologyNode node; final String? selectedId; final ValueNotifier hoveredEdgeId; final ValueNotifier hoveredDeviceId; final List connectedEdgeIds; final Size nodeSize; 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, nodeSize: nodeSize, onTap: () => onTap?.call(node.device), ); }, ), ); } }