249 lines
7.7 KiB
Dart
249 lines
7.7 KiB
Dart
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<String, Offset> nodePositions;
|
|
|
|
final Size canvasSize;
|
|
final TransformationController controller;
|
|
final Size viewportSize;
|
|
|
|
static const Size _minimapSize = Size(160, 100);
|
|
|
|
@override
|
|
State<TopologyMinimap> createState() => _TopologyMinimapState();
|
|
}
|
|
|
|
class _TopologyMinimapState extends State<TopologyMinimap> {
|
|
@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<String, Offset> 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;
|
|
}
|