soma-ui 0.1.0

A Leptos 0.8 component library: 160+ accessible UI components, blocks, charts, and a ChartDB-style schema diagram — with a prebuilt stylesheet.
Documentation
use leptos::prelude::*;
use crate::charts::{
    ChartPoint, ChartSeries,
    CHART_PALETTE, W, H, PAD_L, PAD_B, PAD_T, PAD_R,
    render_axes_and_grid,
};

#[derive(Clone, Debug, PartialEq, Default)]
pub enum BarVariant {
    #[default]
    Default,
    Horizontal,
    Stacked,
    Grouped,
}

#[component]
pub fn BarChart(
    data: Vec<ChartPoint>,
    #[prop(optional, into)] series: Vec<ChartSeries>,
    #[prop(default = BarVariant::Default)] variant: BarVariant,
    #[prop(default = String::new())] class: String,
) -> impl IntoView {
    // Normalise: if series is empty, wrap `data` as one series.
    let all_series: Vec<ChartSeries> = if series.is_empty() {
        vec![ChartSeries { points: data.clone() }]
    } else {
        series.clone()
    };

    let plot_w = W - PAD_L - PAD_R;
    let plot_h = H - PAD_T - PAD_B;
    let bottom = PAD_T + plot_h;

    if all_series.is_empty() || all_series[0].points.is_empty() {
        return view! {
            <svg viewBox="0 0 320 200" class=format!("w-full h-auto {}", class)></svg>
        }
        .into_any();
    }

    let n = all_series[0].points.len();
    let num_series = all_series.len();

    let content: AnyView = match variant {
        BarVariant::Default => {
            let s = &all_series[0];
            let max_val = s.points.iter().map(|p| p.value).fold(0.0_f64, f64::max).max(1.0);
            let slot = plot_w / n as f64;
            let bar_w = slot * 0.6;
            let gap = slot * 0.2;

            let bars: Vec<_> = s.points.iter().enumerate().map(|(i, p)| {
                let bar_h = (p.value / max_val) * plot_h;
                let x = PAD_L + gap + i as f64 * slot;
                let y = bottom - bar_h;
                let label = p.label.clone();
                view! {
                    <>
                        <rect x=format!("{x:.1}") y=format!("{y:.1}")
                            width=format!("{bar_w:.1}") height=format!("{bar_h:.1}")
                            fill="hsl(var(--primary))" rx="2" />
                        <text x=format!("{:.1}", x + bar_w / 2.0)
                            y=format!("{:.1}", H - 6.0)
                            text-anchor="middle" font-size="8"
                            class="fill-muted-foreground">{label}</text>
                    </>
                }
            }).collect();

            view! {
                <>
                    {render_axes_and_grid()}
                    {bars}
                </>
            }
            .into_any()
        }

        BarVariant::Horizontal => {
            let s = &all_series[0];
            let max_val = s.points.iter().map(|p| p.value).fold(0.0_f64, f64::max).max(1.0);
            let slot = plot_h / n as f64;
            let bar_h = slot * 0.6;
            let gap = slot * 0.2;

            // Vertical gridlines on the value (x) axis
            let h_gridlines: Vec<_> = (1..=4).map(|i| {
                let x = PAD_L + plot_w * (i as f64 / 4.0);
                view! {
                    <line
                        x1=format!("{x:.1}") y1=PAD_T.to_string()
                        x2=format!("{x:.1}") y2=format!("{bottom:.1}")
                        stroke="currentColor" stroke-width="0.5"
                        class="text-border" stroke-dasharray="3,3"
                    />
                }
            }).collect();

            let bars: Vec<_> = s.points.iter().enumerate().map(|(i, p)| {
                let bar_len = (p.value / max_val) * plot_w;
                let y = PAD_T + gap + i as f64 * slot;
                let label = p.label.clone();
                view! {
                    <>
                        <rect x=format!("{PAD_L:.1}") y=format!("{y:.1}")
                            width=format!("{bar_len:.1}") height=format!("{bar_h:.1}")
                            fill="hsl(var(--primary))" rx="2" />
                        <text x=format!("{:.1}", PAD_L - 3.0)
                            y=format!("{:.1}", y + bar_h / 2.0 + 3.0)
                            text-anchor="end" font-size="8"
                            class="fill-muted-foreground">{label}</text>
                    </>
                }
            }).collect();

            view! {
                <>
                    {h_gridlines}
                    <line x1=PAD_L.to_string() y1=format!("{bottom:.1}")
                          x2=(W - PAD_R).to_string() y2=format!("{bottom:.1}")
                          stroke="currentColor" stroke-width="1" class="text-border" />
                    <line x1=PAD_L.to_string() y1=PAD_T.to_string()
                          x2=PAD_L.to_string() y2=format!("{bottom:.1}")
                          stroke="currentColor" stroke-width="1" class="text-border" />
                    {bars}
                </>
            }
            .into_any()
        }

        BarVariant::Stacked => {
            let max_val = (0..n)
                .map(|i| {
                    all_series.iter().map(|s| s.points.get(i).map(|p| p.value).unwrap_or(0.0)).sum::<f64>()
                })
                .fold(0.0_f64, f64::max)
                .max(1.0);

            let slot = plot_w / n as f64;
            let bar_w = slot * 0.6;
            let gap = slot * 0.2;

            let mut all_rects: Vec<AnyView> = Vec::new();

            let x_labels: Vec<_> = all_series[0].points.iter().enumerate().map(|(i, p)| {
                let x = PAD_L + gap + i as f64 * slot;
                let label = p.label.clone();
                view! {
                    <text x=format!("{:.1}", x + bar_w / 2.0)
                        y=format!("{:.1}", H - 6.0)
                        text-anchor="middle" font-size="8"
                        class="fill-muted-foreground">{label}</text>
                }
            }).collect();

            for i in 0..n {
                let mut y_cursor = bottom;
                for si in 0..num_series {
                    let v = all_series[si].points.get(i).map(|p| p.value).unwrap_or(0.0);
                    let bar_h = (v / max_val) * plot_h;
                    let x = PAD_L + gap + i as f64 * slot;
                    let y = y_cursor - bar_h;
                    let color = CHART_PALETTE[si % CHART_PALETTE.len()].to_string();
                    all_rects.push(view! {
                        <rect x=format!("{x:.1}") y=format!("{y:.1}")
                            width=format!("{bar_w:.1}") height=format!("{bar_h:.1}")
                            fill=color rx="2" />
                    }.into_any());
                    y_cursor = y;
                }
            }

            view! {
                <>
                    {render_axes_and_grid()}
                    {all_rects}
                    {x_labels}
                </>
            }
            .into_any()
        }

        BarVariant::Grouped => {
            let max_val = all_series
                .iter()
                .flat_map(|s| s.points.iter().map(|p| p.value))
                .fold(0.0_f64, f64::max)
                .max(1.0);

            let slot = plot_w / n as f64;
            let group_w = slot * 0.8;
            let group_gap = slot * 0.1;
            let sub_w = group_w / num_series as f64;

            let mut all_rects: Vec<AnyView> = Vec::new();

            let x_labels: Vec<_> = all_series[0].points.iter().enumerate().map(|(i, p)| {
                let group_x = PAD_L + group_gap + i as f64 * slot;
                let label = p.label.clone();
                view! {
                    <text x=format!("{:.1}", group_x + group_w / 2.0)
                        y=format!("{:.1}", H - 6.0)
                        text-anchor="middle" font-size="8"
                        class="fill-muted-foreground">{label}</text>
                }
            }).collect();

            for i in 0..n {
                let group_x = PAD_L + group_gap + i as f64 * slot;
                for si in 0..num_series {
                    let v = all_series[si].points.get(i).map(|p| p.value).unwrap_or(0.0);
                    let bar_h = (v / max_val) * plot_h;
                    let x = group_x + si as f64 * sub_w;
                    let y = bottom - bar_h;
                    let color = CHART_PALETTE[si % CHART_PALETTE.len()].to_string();
                    all_rects.push(view! {
                        <rect x=format!("{x:.1}") y=format!("{y:.1}")
                            width=format!("{:.1}", sub_w * 0.85) height=format!("{bar_h:.1}")
                            fill=color rx="2" />
                    }.into_any());
                }
            }

            view! {
                <>
                    {render_axes_and_grid()}
                    {all_rects}
                    {x_labels}
                </>
            }
            .into_any()
        }
    };

    view! {
        <svg viewBox="0 0 320 200" class=format!("w-full h-auto {}", class) aria-hidden="true">
            {content}
        </svg>
    }
    .into_any()
}