1232 lines
41 KiB
Dart
1232 lines
41 KiB
Dart
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<String, Offset> positions, Size canvasSize)?
|
||
onLayoutUpdated;
|
||
|
||
@override
|
||
State<TopologyCanvas> createState() => _TopologyCanvasState();
|
||
}
|
||
|
||
class _TopologyCanvasState extends State<TopologyCanvas>
|
||
with TickerProviderStateMixin {
|
||
// ─── Hover state ─────────────────────────────────────────────────────────
|
||
final ValueNotifier<String?> _hoveredEdgeId = ValueNotifier<String?>(null);
|
||
final ValueNotifier<String?> _hoveredDeviceId = ValueNotifier<String?>(null);
|
||
|
||
// ─── Layout animation ─────────────────────────────────────────────────────
|
||
late AnimationController _layoutAnim;
|
||
|
||
// ─── Data-flow dot animation ──────────────────────────────────────────────
|
||
late AnimationController _flowAnim;
|
||
|
||
// ─── Layout snapshots (for interpolation) ────────────────────────────────
|
||
Map<String, Offset> _prevPositions = const {};
|
||
Map<String, Offset> _targetPositions = const {};
|
||
Map<String, List<Offset>> _prevWaypoints = const {};
|
||
Map<String, List<Offset>> _targetWaypoints = const {};
|
||
Size _canvasSize = const Size(600, 400);
|
||
Map<String, List<String>> _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<String, Offset>.from(_currentPositions())
|
||
: <String, Offset>{};
|
||
final newPrevWaypoints = animate
|
||
? Map<String, List<Offset>>.from(_currentWaypoints())
|
||
: <String, List<Offset>>{};
|
||
|
||
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<String, int>? 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 = <String, Size>{};
|
||
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<String, Offset> _currentPositions() {
|
||
final t = _layoutAnim.value;
|
||
if (t >= 1.0) return _targetPositions;
|
||
if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions;
|
||
final out = <String, Offset>{};
|
||
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<String, List<Offset>> _currentWaypoints() {
|
||
final t = _layoutAnim.value;
|
||
if (t >= 1.0) return _targetWaypoints;
|
||
if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints;
|
||
final out = <String, List<Offset>>{};
|
||
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<String, List<String>> _computeDeviceEdgeMap() {
|
||
final map = <String, List<String>>{};
|
||
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<String, Offset> positions,
|
||
Map<String, List<Offset>> waypoints,
|
||
) {
|
||
// Build a device→role lookup for node sizing
|
||
final roleOf = <String, NetworkDeviceRole?>{};
|
||
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 <Offset>[];
|
||
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<Offset> _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 = <Offset>[];
|
||
// 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 = <Offset>[];
|
||
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 = <Offset>[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<Offset> waypoints;
|
||
final NetworkLinkKind? linkKind;
|
||
|
||
final List<Offset> 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<String, List<_ChipSlot>> _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 = <String, List<_ChipSlot>>{};
|
||
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<String, List<_ChipSlot>> 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 = <Offset>[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<Offset> 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<Offset> _sampleCurve(_EdgeGeometry geo, int n) {
|
||
final pts = [geo.fromExit, ...geo.waypoints, geo.toExit];
|
||
if (pts.length == 2) return pts;
|
||
final anchors = <Offset>[];
|
||
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 = <Offset>[];
|
||
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<Offset> 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<String?> hoveredEdgeId;
|
||
final ValueNotifier<String?> hoveredDeviceId;
|
||
final List<String> 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),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|