import 'package:flutter/material.dart'; import '../../../models/network/network_device.dart'; import '../../../models/network/topology_graph.dart'; /// Scaled-down overview of the topology canvas with a viewport indicator. /// /// Shows all device nodes as small colour-coded rectangles and all edges as /// thin lines. A semi-transparent white rectangle indicates the current /// viewport within the full canvas. Tapping on the minimap pans the main /// canvas to centre on that point. class TopologyMinimap extends StatefulWidget { const TopologyMinimap({ super.key, required this.graph, required this.nodePositions, required this.canvasSize, required this.controller, required this.viewportSize, }); final TopologyGraph graph; /// Current interpolated node positions (top-left corners) from the canvas. final Map nodePositions; final Size canvasSize; final TransformationController controller; final Size viewportSize; static const Size _minimapSize = Size(160, 100); @override State createState() => _TopologyMinimapState(); } class _TopologyMinimapState extends State { @override void initState() { super.initState(); widget.controller.addListener(_onTransformChanged); } @override void didUpdateWidget(covariant TopologyMinimap oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onTransformChanged); widget.controller.addListener(_onTransformChanged); } } @override void dispose() { widget.controller.removeListener(_onTransformChanged); super.dispose(); } void _onTransformChanged() => setState(() {}); double get _scaleX { if (widget.canvasSize.width == 0) return 1; return TopologyMinimap._minimapSize.width / widget.canvasSize.width; } double get _scaleY { if (widget.canvasSize.height == 0) return 1; return TopologyMinimap._minimapSize.height / widget.canvasSize.height; } double get _scale => _scaleX < _scaleY ? _scaleX : _scaleY; Offset _toMinimap(Offset canvasPoint) { final s = _scale; final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2; final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2; return Offset(canvasPoint.dx * s + offsetX, canvasPoint.dy * s + offsetY); } /// Convert a tap on the minimap to a canvas point, then pan the main canvas /// so that point appears at the viewport centre. void _onTap(Offset localPos) { final s = _scale; if (s == 0) return; final offsetX = (TopologyMinimap._minimapSize.width - widget.canvasSize.width * s) / 2; final offsetY = (TopologyMinimap._minimapSize.height - widget.canvasSize.height * s) / 2; final canvasX = (localPos.dx - offsetX) / s; final canvasY = (localPos.dy - offsetY) / s; final currentScale = widget.controller.value.getMaxScaleOnAxis(); final tx = widget.viewportSize.width / 2 - canvasX * currentScale; final ty = widget.viewportSize.height / 2 - canvasY * currentScale; final result = Matrix4.translationValues(tx, ty, 0); result.multiply(Matrix4.diagonal3Values(currentScale, currentScale, 1.0)); widget.controller.value = result; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return GestureDetector( onTapDown: (d) => _onTap(d.localPosition), child: Material( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), elevation: 2, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: TopologyMinimap._minimapSize.width, height: TopologyMinimap._minimapSize.height, child: CustomPaint( painter: _MinimapPainter( graph: widget.graph, nodePositions: widget.nodePositions, canvasSize: widget.canvasSize, controller: widget.controller, viewportSize: widget.viewportSize, colorScheme: cs, scale: _scale, toMinimap: _toMinimap, ), ), ), ), ), ); } } class _MinimapPainter extends CustomPainter { _MinimapPainter({ required this.graph, required this.nodePositions, required this.canvasSize, required this.controller, required this.viewportSize, required this.colorScheme, required this.scale, required this.toMinimap, }); final TopologyGraph graph; final Map nodePositions; final Size canvasSize; final TransformationController controller; final Size viewportSize; final ColorScheme colorScheme; final double scale; final Offset Function(Offset) toMinimap; static const Size _nodeRectSize = Size(8, 5); Color _roleColor(NetworkDeviceRole? role) { switch (role) { case NetworkDeviceRole.core: return colorScheme.error; case NetworkDeviceRole.distribution: return colorScheme.tertiary; case NetworkDeviceRole.access: return colorScheme.primary; case NetworkDeviceRole.edge: return colorScheme.secondary; case NetworkDeviceRole.endpoint: case null: return colorScheme.outline; } } @override void paint(Canvas canvas, Size size) { // Edges final edgePaint = Paint() ..color = colorScheme.outline.withValues(alpha: 0.4) ..strokeWidth = 0.6 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; for (final edge in graph.edges) { final fromPos = nodePositions[edge.fromDeviceId]; final toPos = nodePositions[edge.toDeviceId]; if (fromPos == null || toPos == null) continue; // Use node centres final from = toMinimap(fromPos + const Offset(80, 55)); final to = toMinimap(toPos + const Offset(80, 55)); canvas.drawLine(from, to, edgePaint); } // Device nodes for (final node in graph.nodes) { final pos = nodePositions[node.device.id]; if (pos == null) continue; final minimapPos = toMinimap(pos); final rect = Rect.fromLTWH( minimapPos.dx - _nodeRectSize.width / 2, minimapPos.dy - _nodeRectSize.height / 2, _nodeRectSize.width, _nodeRectSize.height, ); canvas.drawRRect( RRect.fromRectAndRadius(rect, const Radius.circular(1.5)), Paint() ..color = _roleColor(node.device.role) ..style = PaintingStyle.fill, ); } // Viewport rectangle final m = controller.value; final currentScale = m.getMaxScaleOnAxis(); if (currentScale > 0) { // The translation is in the 3rd column of the matrix final tx = m.getTranslation().x; final ty = m.getTranslation().y; // Viewport in canvas coordinates final vpLeft = -tx / currentScale; final vpTop = -ty / currentScale; final vpWidth = viewportSize.width / currentScale; final vpHeight = viewportSize.height / currentScale; final tl = toMinimap(Offset(vpLeft, vpTop)); final br = toMinimap(Offset(vpLeft + vpWidth, vpTop + vpHeight)); final vpRect = Rect.fromPoints(tl, br); // Fill canvas.drawRect( vpRect, Paint() ..color = colorScheme.primary.withValues(alpha: 0.08) ..style = PaintingStyle.fill, ); // Border canvas.drawRect( vpRect, Paint() ..color = colorScheme.primary.withValues(alpha: 0.6) ..strokeWidth = 1.0 ..style = PaintingStyle.stroke, ); } } @override bool shouldRepaint(covariant _MinimapPainter old) => true; }