Initial Commit : Network Map
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
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<TopologyCanvas> createState() => _TopologyCanvasState();
|
||||
}
|
||||
|
||||
class _TopologyCanvasState extends State<TopologyCanvas>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// ─── Hover state ─────────────────────────────────────────────────────────
|
||||
final ValueNotifier<String?> _hoveredEdgeId = ValueNotifier<String?>(null);
|
||||
final ValueNotifier<String?> _hoveredDeviceId = ValueNotifier<String?>(null);
|
||||
|
||||
// ─── Animation ───────────────────────────────────────────────────────────
|
||||
late AnimationController _layoutAnim;
|
||||
|
||||
// ─── 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 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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<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;
|
||||
}
|
||||
|
||||
/// 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<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,
|
||||
) {
|
||||
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 <Offset>[];
|
||||
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 = <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),
|
||||
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<Offset> waypoints;
|
||||
|
||||
/// Line-jump points along the *main* segment. Only populated for straight
|
||||
/// edges (curved/waypoint edges skip line jumps).
|
||||
final List<Offset> 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 = <Offset>[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<String?> hoveredEdgeId;
|
||||
final ValueNotifier<String?> hoveredDeviceId;
|
||||
final List<String> 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user