dioxus-flow 0.1.3

A react-flow-like node graph component library for Dioxus
Documentation
//! A minimap overview of the graph with the current viewport indicated.

use dioxus::prelude::*;

use crate::state::{use_overlay_inset, FlowCore, Interaction};
use crate::types::{Point, Rect, Side};

/// A minimap showing all nodes and the visible viewport. Click to jump.
/// Render as a child of [`crate::Flow`].
#[component]
pub fn MiniMap(
    #[props(default = 200.0)] width: f64,
    #[props(default = 140.0)] height: f64,
    class: Option<String>,
) -> Element {
    let core = use_context::<FlowCore>();
    // Panel height + 14px offset + breathing room: fit-view keeps nodes
    // from landing underneath the minimap.
    use_overlay_inset(Side::Bottom, height + 26.0);
    // The graph's own bounds, behind a memo. This used to be unioned inline,
    // which meant walking every node rect on every pan and zoom frame — the
    // one place a viewport change was doing work proportional to the size of
    // the graph. It only changes when the graph does.
    let content = use_memo(move || {
        let geoms = core.geoms.read();
        geoms
            .iter()
            .map(|geom| geom.rect)
            .reduce(|acc, rect| acc.union(&rect))
    });
    let vp = *core.viewport.read();
    let container = *core.container.read();

    // An empty graph has nothing to map; a blank card is just noise.
    let Some(content) = *content.read() else {
        return rsx! {};
    };

    // Visible region of the canvas, in flow coordinates.
    let visible = Rect::from_points(
        vp.screen_to_flow(Point::ZERO),
        (container.width / vp.zoom, container.height / vp.zoom).into(),
    );
    let world = content.union(&visible);
    // Pad the world a little so rects don't touch the minimap border.
    let pad = (world.width.max(world.height) * 0.05).max(10.0);
    let world = Rect::new(
        world.x - pad,
        world.y - pad,
        world.width + 2.0 * pad,
        world.height + 2.0 * pad,
    );

    let class = format!(
        "df-minimap{}",
        class
            .as_deref()
            .map(|c| format!(" {c}"))
            .unwrap_or_default()
    );

    let on_pointer_down = move |evt: Event<PointerData>| {
        evt.stop_propagation();
        core.interaction.clone().set(Interaction::Pressed);
        // Map the click (svg element coords, uniform "meet" scaling) back to
        // flow coordinates and center there.
        let p = evt.element_coordinates();
        let scale = (width / world.width).min(height / world.height);
        let dx = (width - world.width * scale) / 2.0;
        let dy = (height - world.height * scale) / 2.0;
        let flow = Point::new(world.x + (p.x - dx) / scale, world.y + (p.y - dy) / scale);
        core.center_on(flow, 250);
    };
    let on_pointer_up = move |_| {
        let mut interaction = core.interaction;
        if *interaction.peek() == Interaction::Pressed {
            interaction.set(Interaction::None);
        }
    };

    rsx! {
        svg {
            class,
            width,
            height,
            view_box: "{world.x} {world.y} {world.width} {world.height}",
            preserve_aspect_ratio: "xMidYMid meet",
            "role": "img",
            "aria-label": "Graph overview; click to move the view",
            onpointerdown: on_pointer_down,
            onpointerup: on_pointer_up,
            MiniMapNodes {}
            rect {
                class: "df-minimap-viewport",
                x: visible.x,
                y: visible.y,
                width: visible.width,
                height: visible.height,
            }
        }
    }
}

/// The node rects, as two paths: one for the graph, one for what is selected.
///
/// The minimap is the one layer that cannot be tiled away, because showing
/// everything at once is its whole job — so instead it draws everything with
/// as few elements as possible. One `<rect>` per node put 20 000 elements on
/// the page and re-rastered all of them whenever the viewBox moved; two paths
/// carry the same ink. Splitting by selection rather than emitting one path
/// per node is what keeps the selected nodes styleable.
///
/// Isolated from [`MiniMap`] so panning and zooming — which re-render the
/// parent every frame for the viewBox and the viewport indicator — diff a
/// couple of `d` attributes instead of rebuilding the graph.
#[component]
fn MiniMapNodes() -> Element {
    let core = use_context::<FlowCore>();
    let paths = use_memo(move || {
        let geoms = core.geoms.read();
        let mut plain = String::new();
        let mut selected = String::new();
        for geom in geoms.iter() {
            let r = geom.rect;
            if !(r.x.is_finite() && r.y.is_finite() && r.width.is_finite() && r.height.is_finite())
            {
                continue;
            }
            let into = if geom.selected {
                &mut selected
            } else {
                &mut plain
            };
            // One closed subpath per node. Subpaths do not join, so a fill of
            // many rectangles is exactly what one rectangle each would draw.
            use std::fmt::Write;
            let _ = write!(
                into,
                "M{} {}h{}v{}h{}z",
                r.x, r.y, r.width, r.height, -r.width
            );
        }
        (plain, selected)
    });
    let (plain, selected) = paths.read().clone();
    rsx! {
        if !plain.is_empty() {
            path { class: "df-minimap-node", d: plain }
        }
        if !selected.is_empty() {
            path { class: "df-minimap-node df-selected", d: selected }
        }
    }
}