hikari-extra-components 0.2.0

Advanced UI components (node graph, rich text, etc.) for the Hikari design system
// node_graph/minimap.rs
// Minimap state model - Framework Agnostic

/// Data model for node graph minimap
///
/// Previously a Dioxus component. Now a pure state model
/// that can be rendered with any framework.
#[derive(Clone, Debug, PartialEq)]
pub struct NodeGraphMinimap {
    /// Minimap width in pixels
    pub width: f64,

    /// Minimap height in pixels
    pub height: f64,

    /// Current zoom level of main canvas
    pub zoom: f64,

    /// Current pan of main canvas
    pub pan: (f64, f64),

    /// Nodes to display (positions are in canvas coordinates)
    pub nodes: Vec<MinimapNode>,

    /// Connections to display
    pub connections: Vec<MinimapConnection>,

    /// Whether the minimap is visible
    pub visible: bool,
}

/// A node as displayed in the minimap
#[derive(Clone, Debug, PartialEq)]
pub struct MinimapNode {
    pub id: String,
    pub position: (f64, f64),
    pub size: (f64, f64),
}

/// A connection as displayed in the minimap
#[derive(Clone, Debug, PartialEq)]
pub struct MinimapConnection {
    pub id: String,
    pub from: (f64, f64),
    pub to: (f64, f64),
}

impl NodeGraphMinimap {
    /// Create a new minimap state
    pub fn new(width: f64, height: f64) -> Self {
        Self {
            width,
            height,
            zoom: 1.0,
            pan: (0.0, 0.0),
            nodes: Vec::new(),
            connections: Vec::new(),
            visible: true,
        }
    }

    /// Update the view state (zoom and pan from main canvas)
    pub fn update_view(&mut self, zoom: f64, pan: (f64, f64)) {
        self.zoom = zoom;
        self.pan = pan;
    }

    /// Set the nodes to display
    pub fn set_nodes(&mut self, nodes: Vec<MinimapNode>) {
        self.nodes = nodes;
    }

    /// Set the connections to display
    pub fn set_connections(&mut self, connections: Vec<MinimapConnection>) {
        self.connections = connections;
    }

    /// Calculate the viewport rectangle in minimap coordinates
    pub fn viewport_rect(&self, canvas_width: f64, canvas_height: f64) -> (f64, f64, f64, f64) {
        // The viewport represents what's visible in the main canvas
        // Map canvas coordinates to minimap coordinates
        let scale_x = self.width / (canvas_width * self.zoom);
        let scale_y = self.height / (canvas_height * self.zoom);

        let vp_width = canvas_width * scale_x;
        let vp_height = canvas_height * scale_y;

        // Calculate viewport position based on pan
        let vp_x = -self.pan.0 * scale_x;
        let vp_y = -self.pan.1 * scale_y;

        (vp_x, vp_y, vp_width, vp_height)
    }

    /// Convert a click position in minimap to canvas pan coordinates
    pub fn click_to_pan(
        &self,
        click_x: f64,
        click_y: f64,
        canvas_width: f64,
        canvas_height: f64,
    ) -> (f64, f64) {
        // Reverse the viewport calculation
        let total_width = canvas_width * self.zoom;
        let total_height = canvas_height * self.zoom;

        let new_pan_x = (click_x / self.width) * total_width - total_width / 2.0;
        let new_pan_y = (click_y / self.height) * total_height - total_height / 2.0;

        (new_pan_x, new_pan_y)
    }

    /// Get the CSS style string for the minimap container
    pub fn container_style(&self) -> String {
        format!(
            "position: absolute; bottom: 10px; right: 10px; width: {}px; height: {}px;",
            self.width, self.height
        )
    }

    /// Get the CSS style string for the viewport indicator
    pub fn viewport_style(&self, canvas_width: f64, canvas_height: f64) -> String {
        let (x, y, w, h) = self.viewport_rect(canvas_width, canvas_height);
        format!(
            "position: absolute; left: {}px; top: {}px; width: {}px; height: {}px; border: 1px solid var(--hi-color-primary);",
            x, y, w, h
        )
    }
}

use tairitsu_vdom::svg::SafeSvg;
use tairitsu_vdom::{VElement, VNode};

pub fn render_minimap(minimap: &NodeGraphMinimap) -> VNode {
    if !minimap.visible {
        return VNode::Element(
            VElement::new("div").class("hi-node-graph-minimap hi-minimap-hidden"),
        );
    }

    let canvas_w = 1200.0;
    let canvas_h = 800.0;
    let scale_x = minimap.width / canvas_w;
    let scale_y = minimap.height / canvas_h;

    let mut svg_parts = String::new();
    svg_parts.push_str(&format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" class="hi-node-graph-minimap" width="{}" height="{}">"#,
        minimap.width,
        minimap.height,
    ));

    svg_parts.push_str(
        r#"<rect width="100%" height="100%" fill="var(--hi-color-minimap-bg, #f8fafc)" rx="4"/>"#,
    );

    svg_parts.push_str(r#"<g class="hi-minimap-connections">"#);
    for conn in &minimap.connections {
        svg_parts.push_str(&format!(
            r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="var(--hi-color-connection, #94a3b8)" stroke-width="1"/>"#,
            conn.from.0 * scale_x,
            conn.from.1 * scale_y,
            conn.to.0 * scale_x,
            conn.to.1 * scale_y,
        ));
    }
    svg_parts.push_str("</g>");

    svg_parts.push_str(r#"<g class="hi-minimap-nodes">"#);
    for node in &minimap.nodes {
        svg_parts.push_str(&format!(
            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="var(--hi-color-node, #EEA2A4)" rx="2"/>"#,
            node.position.0 * scale_x,
            node.position.1 * scale_y,
            node.size.0 * scale_x,
            node.size.1 * scale_y,
        ));
    }
    svg_parts.push_str("</g>");

    let (vp_x, vp_y, vp_w, vp_h) = minimap.viewport_rect(canvas_w, canvas_h);
    svg_parts.push_str(&format!(
        r#"<rect x="{}" y="{}" width="{}" height="{}" fill="none" stroke="var(--hi-color-primary, #EEA2A4)" stroke-width="1.5" rx="1"/>"#,
        vp_x, vp_y, vp_w, vp_h,
    ));

    svg_parts.push_str("</svg>");

    VNode::Element(
        VElement::new("div")
            .class("hi-node-graph-minimap-container")
            .attr("data-action", "minimap-click")
            .style(minimap.container_style())
            .safe_svg(SafeSvg::new(&svg_parts)),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_minimap_new() {
        let minimap = NodeGraphMinimap::new(200.0, 150.0);
        assert_eq!(minimap.width, 200.0);
        assert_eq!(minimap.height, 150.0);
        assert_eq!(minimap.zoom, 1.0);
        assert!(minimap.visible);
    }

    #[test]
    fn test_update_view() {
        let mut minimap = NodeGraphMinimap::new(200.0, 150.0);
        minimap.update_view(1.5, (10.0, 20.0));
        assert_eq!(minimap.zoom, 1.5);
        assert_eq!(minimap.pan, (10.0, 20.0));
    }

    #[test]
    fn test_viewport_rect() {
        let mut minimap = NodeGraphMinimap::new(200.0, 150.0);
        minimap.update_view(1.0, (0.0, 0.0));

        let (x, y, w, h) = minimap.viewport_rect(1200.0, 800.0);
        assert_eq!(x, 0.0);
        assert_eq!(y, 0.0);
        // Width and height are scaled to fit the minimap
        // When canvas is larger than minimap and zoom is 1.0, viewport fills minimap
        assert_eq!(w, 200.0);
        assert_eq!(h, 150.0);
    }

    #[test]
    fn test_click_to_pan() {
        let minimap = NodeGraphMinimap::new(200.0, 150.0);
        let pan = minimap.click_to_pan(100.0, 75.0, 1200.0, 800.0);
        // Center click should result in centered pan
        assert_eq!(pan, (0.0, 0.0));
    }

    #[test]
    fn test_set_nodes() {
        let mut minimap = NodeGraphMinimap::new(200.0, 150.0);
        let nodes = vec![MinimapNode {
            id: "node1".to_string(),
            position: (100.0, 100.0),
            size: (200.0, 150.0),
        }];

        minimap.set_nodes(nodes);
        assert_eq!(minimap.nodes.len(), 1);
    }
}