131 lines
3.9 KiB
Dart
131 lines
3.9 KiB
Dart
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),
|
||
);
|
||
}
|
||
}
|