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), ); } }