use leptos::prelude::*;
#[derive(Clone, Debug, PartialEq)]
pub struct LegendItem {
pub name: String,
pub color: String,
pub visible: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LegendPosition {
#[default]
TopRight,
TopLeft,
BottomRight,
BottomLeft,
ExternalRight,
}
const MAX_LEGEND_NAME_LEN: usize = 20;
fn truncate_name(name: &str) -> String {
if name.len() > MAX_LEGEND_NAME_LEN {
format!("{}…", &name[..MAX_LEGEND_NAME_LEN - 1])
} else {
name.to_string()
}
}
pub fn estimate_legend_width(items: &[LegendItem]) -> f64 {
const PADDING: f64 = 6.0;
const SWATCH: f64 = 10.0;
const CHAR_W: f64 = 6.5;
let max_len = items
.iter()
.map(|i| i.name.len().min(MAX_LEGEND_NAME_LEN))
.max()
.unwrap_or(4) as f64;
SWATCH + 6.0 + max_len * CHAR_W + PADDING * 2.0
}
#[component]
pub fn Legend(
items: Signal<Vec<LegendItem>>,
#[prop(default = LegendPosition::TopRight)]
position: LegendPosition,
#[prop(optional)]
columns: Option<usize>,
inner_width: Memo<f64>,
inner_height: Memo<f64>,
#[prop(optional, into)]
on_toggle: Option<Callback<usize>>,
#[prop(default = "#333".to_string(), into)]
text_color: String,
#[prop(default = "rgba(255,255,255,0.88)".to_string(), into)]
bg: String,
#[prop(default = "#dddddd".to_string(), into)]
border: String,
) -> impl IntoView {
let item_height = 18.0_f64;
let padding = 6.0_f64;
let swatch_size = 10.0_f64;
let get_cols = move || {
if let Some(fixed) = columns {
return fixed.max(1);
}
let n = items.get().len();
let ih = inner_height.get();
let max_single = ((ih * 0.7 - padding * 2.0) / item_height).floor().max(1.0) as usize;
if n <= max_single {
1
} else {
2
}
};
view! {
<g
class="legend"
role="list"
aria-label="Chart legend"
style="pointer-events: none;"
transform=move || {
let items_vec = items.get();
let n = items_vec.len();
let cols = get_cols();
let rows_per_col = n.div_ceil(cols);
let box_h = rows_per_col as f64 * item_height + padding * 2.0;
let max_name_len = items_vec
.iter()
.map(|i| i.name.len().min(MAX_LEGEND_NAME_LEN))
.max()
.unwrap_or(0) as f64;
let col_w = swatch_size + 6.0 + max_name_len * 6.5 + padding;
let box_w = col_w * cols as f64 + padding;
let iw = inner_width.get();
let ih = inner_height.get();
let (x, y) = match position {
LegendPosition::TopRight => (iw - box_w - 8.0, 8.0),
LegendPosition::TopLeft => (8.0, 8.0),
LegendPosition::BottomRight => (iw - box_w - 8.0, ih - box_h - 8.0),
LegendPosition::BottomLeft => (8.0, ih - box_h - 8.0),
LegendPosition::ExternalRight => (iw + 8.0, 8.0),
};
format!("translate({x:.1}, {y:.1})")
}
>
<rect
width=move || {
let max_name_len = items.get().iter().map(|i| i.name.len()).max().unwrap_or(0)
as f64;
let cols = get_cols();
let col_w = swatch_size + 6.0 + max_name_len * 6.5 + padding;
col_w * cols as f64 + padding
}
height=move || {
let n = items.get().len();
let cols = get_cols();
let rows_per_col = n.div_ceil(cols);
rows_per_col as f64 * item_height + padding * 2.0
}
fill=bg.clone()
stroke=border.clone()
stroke-width="1"
rx=4
/>
{move || {
let items_vec = items.get();
let tc = text_color.clone();
let n = items_vec.len();
let cols = get_cols();
let rows_per_col = n.div_ceil(cols);
let max_name_len = items_vec
.iter()
.map(|i| i.name.len().min(MAX_LEGEND_NAME_LEN))
.max()
.unwrap_or(0) as f64;
let col_w = swatch_size + 6.0 + max_name_len * 6.5 + padding;
items_vec
.iter()
.enumerate()
.map(|(i, item)| {
let col = i / rows_per_col;
let row = i % rows_per_col;
let x_offset = col as f64 * col_w;
let y_pos = padding + row as f64 * item_height;
let opacity = if item.visible { "1" } else { "0.3" };
let color = item.color.clone();
let display_name = truncate_name(&item.name);
let fill_color = tc.clone();
let use_circle = i % 2 == 1;
let swatch_x = padding + x_offset;
let swatch_y = y_pos + 2.0;
let swatch_cx = swatch_x + swatch_size / 2.0;
let swatch_cy = swatch_y + swatch_size / 2.0;
let swatch_r = swatch_size / 2.0;
view! {
<g
role="listitem"
style="cursor: pointer; pointer-events: all;"
opacity=opacity
on:click=move |_| {
if let Some(cb) = on_toggle {
cb.run(i);
}
}
>
{if use_circle {
leptos::either::Either::Left(
view! {
<circle cx=swatch_cx cy=swatch_cy r=swatch_r fill=color />
},
)
} else {
leptos::either::Either::Right(
view! {
<rect
x=swatch_x
y=swatch_y
width=swatch_size
height=swatch_size
fill=color
rx=2
/>
},
)
}}
<text
x=padding + x_offset + swatch_size + 4.0
y=y_pos + swatch_size
font-size="11"
fill=fill_color
>
{display_name}
</text>
</g>
}
})
.collect_view()
}}
</g>
}
}