Network map enhancements and fixes

This commit is contained in:
2026-06-05 16:42:56 +08:00
parent e2ddc9a3ae
commit d813ee45a2
14 changed files with 2473 additions and 395 deletions
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
/// Vertical +//fit-to-view zoom controls for the topology canvas.
/// Requires a [TransformationController] shared with the [InteractiveViewer].
class ZoomControls extends StatelessWidget {
const ZoomControls({
super.key,
required this.controller,
required this.canvasSize,
required this.viewportSize,
});
final TransformationController controller;
/// Full canvas size (from [computeSugiyamaLayout] result).
final Size canvasSize;
/// Viewport widget size (the screen area the canvas is displayed in).
final Size viewportSize;
static const double _zoomIn = 1.25;
static const double _zoomOut = 0.8;
static const double _minScale = 0.2;
static const double _maxScale = 2.5;
void _applyZoom(double factor) {
final current = controller.value.getMaxScaleOnAxis();
final next = (current * factor).clamp(_minScale, _maxScale);
final scale = next / current;
final cx = viewportSize.width / 2;
final cy = viewportSize.height / 2;
// Compose: T(cx,cy) * Scale(s) * T(-cx,-cy) * currentTransform
final result = Matrix4.translationValues(cx, cy, 0);
result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0));
result.multiply(Matrix4.translationValues(-cx, -cy, 0));
result.multiply(controller.value);
controller.value = result;
}
void _fitToView() {
if (canvasSize.isEmpty) return;
final scaleX = viewportSize.width / canvasSize.width;
final scaleY = viewportSize.height / canvasSize.height;
final scale = (scaleX < scaleY ? scaleX : scaleY).clamp(_minScale, _maxScale);
final tx = (viewportSize.width - canvasSize.width * scale) / 2;
final ty = (viewportSize.height - canvasSize.height * scale) / 2;
// Build: T(tx,ty,0) * Scale(s,s,1) — maps canvas (0,0) → (tx,ty) on screen
final result = Matrix4.translationValues(tx, ty, 0);
result.multiply(Matrix4.diagonal3Values(scale, scale, 1.0));
controller.value = result;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
elevation: 2,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ZoomButton(
icon: Icons.add,
tooltip: 'Zoom in',
onTap: () => _applyZoom(_zoomIn),
),
_Divider(color: cs.outlineVariant.withValues(alpha: 0.4)),
_ZoomButton(
icon: Icons.remove,
tooltip: 'Zoom out',
onTap: () => _applyZoom(_zoomOut),
),
_Divider(color: cs.outlineVariant.withValues(alpha: 0.4)),
_ZoomButton(
icon: Icons.fit_screen_outlined,
tooltip: 'Fit to view',
onTap: _fitToView,
),
],
),
),
);
}
}
class _ZoomButton extends StatelessWidget {
const _ZoomButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 40,
height: 40,
child: Icon(icon, size: 18, color: cs.onSurfaceVariant),
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Divider(height: 1, color: color),
);
}
}