Skip to main content

dioxus_flow/
minimap.rs

1//! A minimap overview of the graph with the current viewport indicated.
2
3use dioxus::prelude::*;
4
5use crate::state::{use_overlay_inset, FlowCore, Interaction};
6use crate::types::{Point, Rect, Side};
7
8/// A minimap showing all nodes and the visible viewport. Click to jump.
9/// Render as a child of [`crate::Flow`].
10#[component]
11pub fn MiniMap(
12    #[props(default = 200.0)] width: f64,
13    #[props(default = 140.0)] height: f64,
14    class: Option<String>,
15) -> Element {
16    let core = use_context::<FlowCore>();
17    // Panel height + 14px offset + breathing room: fit-view keeps nodes
18    // from landing underneath the minimap.
19    use_overlay_inset(Side::Bottom, height + 26.0);
20    // The graph's own bounds, behind a memo. This used to be unioned inline,
21    // which meant walking every node rect on every pan and zoom frame — the
22    // one place a viewport change was doing work proportional to the size of
23    // the graph. It only changes when the graph does.
24    let content = use_memo(move || {
25        let geoms = core.geoms.read();
26        geoms
27            .iter()
28            .map(|geom| geom.rect)
29            .reduce(|acc, rect| acc.union(&rect))
30    });
31    let vp = *core.viewport.read();
32    let container = *core.container.read();
33
34    // An empty graph has nothing to map; a blank card is just noise.
35    let Some(content) = *content.read() else {
36        return rsx! {};
37    };
38
39    // Visible region of the canvas, in flow coordinates.
40    let visible = Rect::from_points(
41        vp.screen_to_flow(Point::ZERO),
42        (container.width / vp.zoom, container.height / vp.zoom).into(),
43    );
44    let world = content.union(&visible);
45    // Pad the world a little so rects don't touch the minimap border.
46    let pad = (world.width.max(world.height) * 0.05).max(10.0);
47    let world = Rect::new(
48        world.x - pad,
49        world.y - pad,
50        world.width + 2.0 * pad,
51        world.height + 2.0 * pad,
52    );
53
54    let class = format!(
55        "df-minimap{}",
56        class
57            .as_deref()
58            .map(|c| format!(" {c}"))
59            .unwrap_or_default()
60    );
61
62    let on_pointer_down = move |evt: Event<PointerData>| {
63        evt.stop_propagation();
64        core.interaction.clone().set(Interaction::Pressed);
65        // Map the click (svg element coords, uniform "meet" scaling) back to
66        // flow coordinates and center there.
67        let p = evt.element_coordinates();
68        let scale = (width / world.width).min(height / world.height);
69        let dx = (width - world.width * scale) / 2.0;
70        let dy = (height - world.height * scale) / 2.0;
71        let flow = Point::new(world.x + (p.x - dx) / scale, world.y + (p.y - dy) / scale);
72        core.center_on(flow, 250);
73    };
74    let on_pointer_up = move |_| {
75        let mut interaction = core.interaction;
76        if *interaction.peek() == Interaction::Pressed {
77            interaction.set(Interaction::None);
78        }
79    };
80
81    rsx! {
82        svg {
83            class,
84            width,
85            height,
86            view_box: "{world.x} {world.y} {world.width} {world.height}",
87            preserve_aspect_ratio: "xMidYMid meet",
88            "role": "img",
89            "aria-label": "Graph overview; click to move the view",
90            onpointerdown: on_pointer_down,
91            onpointerup: on_pointer_up,
92            MiniMapNodes {}
93            rect {
94                class: "df-minimap-viewport",
95                x: visible.x,
96                y: visible.y,
97                width: visible.width,
98                height: visible.height,
99            }
100        }
101    }
102}
103
104/// The node rects, as two paths: one for the graph, one for what is selected.
105///
106/// The minimap is the one layer that cannot be tiled away, because showing
107/// everything at once is its whole job — so instead it draws everything with
108/// as few elements as possible. One `<rect>` per node put 20 000 elements on
109/// the page and re-rastered all of them whenever the viewBox moved; two paths
110/// carry the same ink. Splitting by selection rather than emitting one path
111/// per node is what keeps the selected nodes styleable.
112///
113/// Isolated from [`MiniMap`] so panning and zooming — which re-render the
114/// parent every frame for the viewBox and the viewport indicator — diff a
115/// couple of `d` attributes instead of rebuilding the graph.
116#[component]
117fn MiniMapNodes() -> Element {
118    let core = use_context::<FlowCore>();
119    let paths = use_memo(move || {
120        let geoms = core.geoms.read();
121        let mut plain = String::new();
122        let mut selected = String::new();
123        for geom in geoms.iter() {
124            let r = geom.rect;
125            if !(r.x.is_finite() && r.y.is_finite() && r.width.is_finite() && r.height.is_finite())
126            {
127                continue;
128            }
129            let into = if geom.selected {
130                &mut selected
131            } else {
132                &mut plain
133            };
134            // One closed subpath per node. Subpaths do not join, so a fill of
135            // many rectangles is exactly what one rectangle each would draw.
136            use std::fmt::Write;
137            let _ = write!(
138                into,
139                "M{} {}h{}v{}h{}z",
140                r.x, r.y, r.width, r.height, -r.width
141            );
142        }
143        (plain, selected)
144    });
145    let (plain, selected) = paths.read().clone();
146    rsx! {
147        if !plain.is_empty() {
148            path { class: "df-minimap-node", d: plain }
149        }
150        if !selected.is_empty() {
151            path { class: "df-minimap-node df-selected", d: selected }
152        }
153    }
154}