vibe-graph-bevy 0.1.0

3D force-directed graph visualizer using Bevy
Documentation
use bevy::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeLabelMode {
    Capped,
    SelectionOnly,
    All,
}

impl NodeLabelMode {
    pub fn label(self) -> &'static str {
        match self {
            Self::Capped => "Capped",
            Self::SelectionOnly => "Selected",
            Self::All => "All",
        }
    }

    pub fn description(self) -> &'static str {
        match self {
            Self::Capped => "Show labels when the graph is under the cap; always show hovered or selected labels.",
            Self::SelectionOnly => "Only show labels for hovered or selected nodes.",
            Self::All => "Show every node label. This can be expensive on large graphs.",
        }
    }

    pub fn next(self) -> Self {
        match self {
            Self::Capped => Self::SelectionOnly,
            Self::SelectionOnly => Self::All,
            Self::All => Self::Capped,
        }
    }
}

#[derive(Resource, Debug, Clone)]
pub struct NodeRenderSettings {
    pub labels_enabled: bool,
    pub label_mode: NodeLabelMode,
    pub max_labels: usize,
    pub label_scale: f32,
    pub truncate_len: usize,
    pub label_offset: f32,
}

impl Default for NodeRenderSettings {
    fn default() -> Self {
        Self {
            labels_enabled: true,
            label_mode: NodeLabelMode::Capped,
            max_labels: 250,
            label_scale: 0.8,
            truncate_len: 28,
            label_offset: 2.2,
        }
    }
}

#[derive(Debug, Clone)]
pub struct NodeVisualSpec {
    pub index: usize,
    pub label: String,
    pub kind: Option<vibe_graph_core::GraphNodeKind>,
    pub radius: f32,
    pub label_visible_by_default: bool,
}

pub fn node_radius_for_count(node_count: usize) -> f32 {
    if node_count >= 5000 {
        0.3
    } else if node_count >= 1000 {
        0.5
    } else {
        0.8
    }
}

pub fn scaled_node_radius(node_count: usize, node_size: f32) -> f32 {
    node_radius_for_count(node_count) * node_size
}

pub fn visual_spec_for(
    layout: &crate::graph::GraphLayout,
    render_settings: &NodeRenderSettings,
    node_size: f32,
    index: usize,
) -> NodeVisualSpec {
    let label = display_label(layout, render_settings, index);
    let kind = layout
        .source_graph
        .as_ref()
        .and_then(|graph| graph.nodes.get(index))
        .map(|node| node.kind);
    let label_visible_by_default = match render_settings.label_mode {
        NodeLabelMode::All => true,
        NodeLabelMode::Capped => layout.node_count <= render_settings.max_labels,
        NodeLabelMode::SelectionOnly => false,
    };

    NodeVisualSpec {
        index,
        label,
        kind,
        radius: scaled_node_radius(layout.node_count, node_size),
        label_visible_by_default,
    }
}

pub fn label_visible_for(
    settings: &NodeRenderSettings,
    node_count: usize,
    is_selected_or_hovered: bool,
) -> bool {
    if !settings.labels_enabled {
        return false;
    }

    is_selected_or_hovered
        || match settings.label_mode {
            NodeLabelMode::All => true,
            NodeLabelMode::Capped => node_count <= settings.max_labels,
            NodeLabelMode::SelectionOnly => false,
        }
}

fn display_label(
    layout: &crate::graph::GraphLayout,
    render_settings: &NodeRenderSettings,
    index: usize,
) -> String {
    let raw = layout
        .source_graph
        .as_ref()
        .and_then(|graph| graph.nodes.get(index))
        .map(|node| {
            node.metadata
                .get("path")
                .and_then(|path| path.rsplit('/').next())
                .filter(|name| !name.is_empty())
                .unwrap_or(&node.name)
                .to_string()
        })
        .or_else(|| layout.labels.get(index).cloned())
        .unwrap_or_else(|| format!("#{index}"));

    truncate_label(&raw, render_settings.truncate_len)
}

fn truncate_label(label: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return String::new();
    }

    let mut chars = label.chars();
    let prefix: String = chars.by_ref().take(max_chars).collect();
    if chars.next().is_some() {
        format!("{prefix}...")
    } else {
        prefix
    }
}

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

    #[test]
    fn truncates_long_labels_with_ascii_suffix() {
        assert_eq!(truncate_label("abcdefghijkl", 5), "abcde...");
        assert_eq!(truncate_label("abc", 5), "abc");
    }

    #[test]
    fn capped_mode_only_defaults_under_limit() {
        let settings = NodeRenderSettings {
            label_mode: NodeLabelMode::Capped,
            max_labels: 2,
            ..Default::default()
        };

        assert!(label_visible_for(&settings, 2, false));
        assert!(!label_visible_for(&settings, 3, false));
        assert!(label_visible_for(&settings, 3, true));
    }
}