Files
tasq/lib/screens/network_map/widgets/topology_canvas.dart
T

1232 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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] (01) 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),
);
},
),
);
}
}