use leptos::prelude::*;
use lodviz_core::core::scale::{LinearScale, Scale};
use lodviz_core::core::theme::ChartTheme;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KpiFormat {
Number,
Currency,
Percentage,
}
#[derive(Debug, Clone, PartialEq)]
pub struct KpiComparison {
pub baseline: f64,
pub label: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct KpiData {
pub label: String,
pub value: f64,
pub format: KpiFormat,
pub comparison: Option<KpiComparison>,
pub sparkline: Option<Vec<f64>>,
}
impl KpiData {
pub fn new(label: impl Into<String>, value: f64) -> Self {
Self {
label: label.into(),
value,
format: KpiFormat::Number,
comparison: None,
sparkline: None,
}
}
pub fn with_format(mut self, format: KpiFormat) -> Self {
self.format = format;
self
}
pub fn with_comparison(mut self, baseline: f64, label: impl Into<String>) -> Self {
self.comparison = Some(KpiComparison {
baseline,
label: label.into(),
});
self
}
pub fn with_sparkline(mut self, sparkline: Vec<f64>) -> Self {
self.sparkline = Some(sparkline);
self
}
}
fn format_value(value: f64, format: KpiFormat) -> String {
match format {
KpiFormat::Number => {
let abs_val = value.abs();
if abs_val >= 1_000_000.0 {
format!("{:.1}M", value / 1_000_000.0)
} else if abs_val >= 1_000.0 {
format!("{:.1}K", value / 1_000.0)
} else {
format!("{:.0}", value)
}
}
KpiFormat::Currency => {
let abs_val = value.abs();
if abs_val >= 1_000_000.0 {
format!("${:.1}M", value / 1_000_000.0)
} else if abs_val >= 1_000.0 {
format!("${:.1}K", value / 1_000.0)
} else {
format!("${:.0}", value)
}
}
KpiFormat::Percentage => {
format!("{:.1}%", value)
}
}
}
fn generate_sparkline_path(data: &[f64], width: f64, height: f64) -> String {
if data.is_empty() {
return "M 0 0".to_string();
}
let min = data.iter().copied().fold(f64::INFINITY, f64::min);
let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let x_scale = LinearScale::new((0.0, (data.len() - 1) as f64), (0.0, width));
let y_scale = LinearScale::new((min, max), (height, 0.0));
let mut path = String::with_capacity(data.len() * 16);
for (i, &value) in data.iter().enumerate() {
let x = x_scale.map(i as f64);
let y = y_scale.map(value);
if i == 0 {
path.push_str(&format!("M {x:.2} {y:.2}"));
} else {
path.push_str(&format!(" L {x:.2} {y:.2}"));
}
}
path
}
#[component]
pub fn KpiCard(
data: Signal<KpiData>,
#[prop(optional)]
width: Option<u32>,
#[prop(optional)]
height: Option<u32>,
#[prop(optional)]
theme: Option<Signal<ChartTheme>>,
) -> impl IntoView {
let w = width.unwrap_or(300);
let h = height.unwrap_or(200);
let theme = theme.unwrap_or_else(|| Signal::derive(ChartTheme::default));
view! {
<svg
viewBox=format!("0 0 {w} {h}")
width=format!("{w}")
height=format!("{h}")
class="kpi-card"
role="img"
aria-label=move || format!("KPI: {}", data.get().label)
>
<rect
x="0"
y="0"
width=format!("{w}")
height=format!("{h}")
fill=move || theme.get().background_color.clone()
rx="8"
/>
<text
x=format!("{}", w / 2)
y="30"
text-anchor="middle"
fill=move || theme.get().text_color.clone()
font-size="14"
font-family=move || theme.get().font_family.clone()
>
{move || data.get().label.clone()}
</text>
<text
x=format!("{}", w / 2)
y="80"
text-anchor="middle"
fill=move || theme.get().text_color.clone()
font-size="36"
font-weight="bold"
font-family=move || theme.get().font_family.clone()
>
{move || {
let d = data.get();
format_value(d.value, d.format)
}}
</text>
{move || {
data.get()
.comparison
.as_ref()
.map(|comp| {
let d = data.get();
let change = d.value - comp.baseline;
let change_pct = (change / comp.baseline.abs()) * 100.0;
let is_positive = change >= 0.0;
let color = if is_positive { "#10b981" } else { "#ef4444" };
let arrow = if is_positive { "▲" } else { "▼" };
let sign = if is_positive { "+" } else { "" };
view! {
<text
x=format!("{}", w / 2)
y="110"
text-anchor="middle"
fill=color
font-size="14"
font-family=move || theme.get().font_family.clone()
>
{format!("{} {}{:.1}% {}", arrow, sign, change_pct, comp.label)}
</text>
}
})
}}
{move || {
data.get()
.sparkline
.as_ref()
.map(|sparkline_data| {
let sparkline_height = 40.0;
let sparkline_width = (w as f64) * 0.8;
let sparkline_x = ((w as f64) - sparkline_width) / 2.0;
let sparkline_y = (h as f64) - sparkline_height - 20.0;
let path = generate_sparkline_path(
sparkline_data,
sparkline_width,
sparkline_height,
);
view! {
<g transform=format!("translate({sparkline_x:.2}, {sparkline_y:.2})")>
<path
d=path
fill="none"
stroke=move || {
theme
.get()
.palette
.first()
.cloned()
.unwrap_or_else(|| "#3b82f6".to_string())
}
stroke-width="2"
opacity="0.8"
/>
</g>
}
})
}}
</svg>
}
}