1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
//! A small, self-container pan-and-zoom area for [`egui`].
//!
//! Throughout this module, we use the following conventions or naming the different spaces:
//! * `ui`-space: The _global_ `egui` space.
//! * `view`-space: The space where the pan-and-zoom area is drawn.
//! * `scene`-space: The space where the actual content is drawn.

use egui::{emath::TSTransform, Rect, Response, Ui, UiBuilder, Vec2};

/// Helper function to handle pan and zoom interactions on a response.
fn register_pan_and_zoom(ui: &Ui, resp: &Response, ui_from_scene: &mut TSTransform) {
    if resp.dragged() {
        ui_from_scene.translation += ui_from_scene.scaling * resp.drag_delta();
    }

    if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) {
        if resp.contains_pointer() {
            let pointer_in_scene = ui_from_scene.inverse() * mouse_pos;
            let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
            let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);

            // Most of the time we can return early. This is also important to
            // avoid `ui_from_scene` to change slightly due to floating point errors.
            if zoom_delta == 1.0 && pan_delta == Vec2::ZERO {
                return;
            }

            // Zoom in on pointer, but only if we are not zoomed out too far.
            if zoom_delta < 1.0 || ui_from_scene.scaling < 1.0 {
                *ui_from_scene = *ui_from_scene
                    * TSTransform::from_translation(pointer_in_scene.to_vec2())
                    * TSTransform::from_scaling(zoom_delta)
                    * TSTransform::from_translation(-pointer_in_scene.to_vec2());

                // We clamp the resulting scaling to avoid zooming out too far.
                ui_from_scene.scaling = ui_from_scene.scaling.min(1.0);
            }

            // Pan:
            *ui_from_scene = TSTransform::from_translation(pan_delta) * *ui_from_scene;
        }
    }
}

/// Creates a transformation that fits a given scene rectangle into the available screen size.
pub fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform {
    let available_size_in_ui = rect_in_ui.size();

    // Compute the scale factor to fit the bounding rectangle into the available screen size.
    let scale_x = available_size_in_ui.x / rect_in_scene.width();
    let scale_y = available_size_in_ui.y / rect_in_scene.height();

    // Use the smaller of the two scales to ensure the whole rectangle fits on the screen.
    let scale = scale_x.min(scale_y).min(1.0);

    // Compute the translation to center the bounding rect in the screen.
    let center_screen = rect_in_ui.center();
    let center_scene = rect_in_scene.center().to_vec2();

    // Set the transformation to scale and then translate to center.
    TSTransform::from_translation(center_screen.to_vec2() - center_scene * scale)
        * TSTransform::from_scaling(scale)
}

/// Provides a zoom-pan area for a given view.
///
/// Will fill the entire `max_rect` of the `parent_ui`.
pub fn zoom_pan_area(
    parent_ui: &mut Ui,
    to_global: &mut TSTransform,
    draw_contents: impl FnOnce(&mut Ui),
) -> Response {
    // Create a new egui paint layer, where we can draw our contents:
    let zoom_pan_layer_id = egui::LayerId::new(
        parent_ui.layer_id().order,
        parent_ui.id().with("zoom_pan_area"),
    );

    // Put the layer directly on-top of the main layer of the ui:
    parent_ui
        .ctx()
        .set_sublayer(parent_ui.layer_id(), zoom_pan_layer_id);

    let global_view_bounds = parent_ui.max_rect();

    let mut local_ui = parent_ui.new_child(
        UiBuilder::new()
            .layer_id(zoom_pan_layer_id)
            .max_rect(to_global.inverse() * global_view_bounds)
            .sense(egui::Sense::click_and_drag()),
    );
    local_ui.set_min_size(local_ui.max_rect().size()); // Allocate all available space

    // Set proper clip-rect so we can interact with the background:
    local_ui.set_clip_rect(local_ui.max_rect());

    let pan_response = local_ui.response();

    // Update the `to_global` transform based on use interaction:
    register_pan_and_zoom(&local_ui, &pan_response, to_global);

    // Update the clip-rect with the new transform, to avoid frame-delays
    local_ui.set_clip_rect(to_global.inverse() * global_view_bounds);

    // Add the actual contents to the area:
    draw_contents(&mut local_ui);

    // Tell egui to apply the transform on the layer:
    local_ui
        .ctx()
        .set_transform_layer(zoom_pan_layer_id, *to_global);

    pan_response
}