use super::{
component::ComponentConfig, ComponentPosition, ComponentSpan, DashboardComponent,
DashboardTheme,
};
use crate::error::PdfError;
use crate::graphics::Color;
use crate::page::Page;
use crate::Font;
#[derive(Debug, Clone)]
pub struct KpiCard {
config: ComponentConfig,
title: String,
value: String,
value_format: ValueFormat,
trend: Option<TrendInfo>,
subtitle: Option<String>,
color_theme: Option<KpiColorTheme>,
sparkline: Option<SparklineData>,
icon: Option<String>,
style: KpiCardStyle,
}
impl KpiCard {
pub fn new<T: Into<String>, V: Into<String>>(title: T, value: V) -> Self {
Self {
config: ComponentConfig::new(ComponentSpan::new(12)), title: title.into(),
value: value.into(),
value_format: ValueFormat::default(),
trend: None,
subtitle: None,
color_theme: None,
sparkline: None,
icon: None,
style: KpiCardStyle::default(),
}
}
pub fn with_trend(mut self, change: f64, direction: TrendDirection) -> Self {
self.trend = Some(TrendInfo {
change,
direction,
period: "vs previous".to_string(),
is_good: matches!(direction, TrendDirection::Up),
});
self
}
pub fn with_trend_period<T: Into<String>>(
mut self,
change: f64,
direction: TrendDirection,
period: T,
) -> Self {
self.trend = Some(TrendInfo {
change,
direction,
period: period.into(),
is_good: matches!(direction, TrendDirection::Up),
});
self
}
pub fn trend_is_good(mut self, is_good: bool) -> Self {
if let Some(ref mut trend) = self.trend {
trend.is_good = is_good;
}
self
}
pub fn with_subtitle<T: Into<String>>(mut self, subtitle: T) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn with_format(mut self, format: ValueFormat) -> Self {
self.value_format = format;
self
}
pub fn as_currency<T: Into<String>>(mut self, symbol: T) -> Self {
self.value_format = ValueFormat::Currency {
symbol: symbol.into(),
decimal_places: 2,
};
self
}
pub fn as_percentage(mut self, decimal_places: u8) -> Self {
self.value_format = ValueFormat::Percentage { decimal_places };
self
}
pub fn as_number(mut self, decimal_places: u8, thousands_separator: bool) -> Self {
self.value_format = ValueFormat::Number {
decimal_places,
thousands_separator,
};
self
}
pub fn color(mut self, color: Color) -> Self {
self.color_theme = Some(KpiColorTheme::from_primary(color));
self
}
pub fn with_colors(mut self, theme: KpiColorTheme) -> Self {
self.color_theme = Some(theme);
self
}
pub fn with_sparkline(mut self, data: Vec<f64>) -> Self {
self.sparkline = Some(SparklineData::new(data));
self
}
pub fn with_sparkline_labeled(mut self, data: Vec<f64>, labels: Vec<String>) -> Self {
self.sparkline = Some(SparklineData::with_labels(data, labels));
self
}
pub fn with_icon<T: Into<String>>(mut self, icon: T) -> Self {
self.icon = Some(icon.into());
self
}
pub fn with_style(mut self, style: KpiCardStyle) -> Self {
self.style = style;
self
}
}
impl DashboardComponent for KpiCard {
fn render(
&self,
page: &mut Page,
position: ComponentPosition,
theme: &DashboardTheme,
) -> Result<(), PdfError> {
let card_area = position.with_padding(self.style.padding);
let default_colors = KpiColorTheme::from_theme(theme);
let colors = self.color_theme.as_ref().unwrap_or(&default_colors);
let graphics = page.graphics();
graphics
.set_fill_color(colors.background)
.rect(position.x, position.y, position.width, position.height)
.fill();
if self.style.show_border {
graphics
.set_stroke_color(colors.border)
.set_line_width(1.0)
.rect(position.x, position.y, position.width, position.height)
.stroke();
}
let layout = self.calculate_layout(card_area);
if let Some(ref icon) = self.icon {
self.render_icon(page, layout.icon_area, icon, colors)?;
}
self.render_title(page, layout.title_area, colors, theme)?;
self.render_value(page, layout.value_area, colors, theme)?;
if let Some(ref trend) = self.trend {
self.render_trend(page, layout.trend_area, trend, colors, theme)?;
}
if let Some(ref subtitle) = self.subtitle {
self.render_subtitle(page, layout.subtitle_area, subtitle, colors, theme)?;
}
if let Some(ref sparkline) = self.sparkline {
self.render_sparkline(page, layout.sparkline_area, sparkline, colors)?;
}
Ok(())
}
fn get_span(&self) -> ComponentSpan {
self.config.span
}
fn set_span(&mut self, span: ComponentSpan) {
self.config.span = span;
}
fn preferred_height(&self, _available_width: f64) -> f64 {
let mut height = 120.0;
if self.subtitle.is_some() {
height += 20.0;
}
if self.sparkline.is_some() {
height += 40.0;
}
height += 2.0 * self.style.padding;
height
}
fn minimum_width(&self) -> f64 {
120.0 }
fn estimated_render_time_ms(&self) -> u32 {
let mut time = 15;
if self.sparkline.is_some() {
time += 10; }
if self.trend.is_some() {
time += 3; }
time
}
fn estimated_memory_mb(&self) -> f64 {
0.05 }
fn complexity_score(&self) -> u8 {
let mut score = 20;
if self.sparkline.is_some() {
score += 30; }
if self.trend.is_some() {
score += 10;
}
if self.icon.is_some() {
score += 5;
}
score.min(100)
}
fn component_type(&self) -> &'static str {
"KpiCard"
}
}
#[derive(Debug, Clone)]
struct KpiCardLayout {
icon_area: ComponentPosition,
title_area: ComponentPosition,
value_area: ComponentPosition,
trend_area: ComponentPosition,
subtitle_area: ComponentPosition,
sparkline_area: ComponentPosition,
}
impl KpiCard {
fn calculate_layout(&self, card_area: ComponentPosition) -> KpiCardLayout {
let bottom_y = card_area.y;
let mut current_y = bottom_y;
let line_height = 16.0;
let padding = 8.0;
let icon_size = 20.0;
let icon_area = ComponentPosition::new(
card_area.x + card_area.width - icon_size - padding,
card_area.y + card_area.height - icon_size - padding, icon_size,
icon_size,
);
current_y += padding;
let sparkline_height = 20.0;
let sparkline_area = ComponentPosition::new(
card_area.x + padding,
current_y,
card_area.width - padding * 2.0,
sparkline_height,
);
current_y += sparkline_height;
current_y += padding / 2.0;
let subtitle_area = ComponentPosition::new(
card_area.x + padding,
current_y,
card_area.width - padding * 2.0,
line_height,
);
current_y += line_height;
current_y += padding / 2.0;
let value_height = 24.0;
let value_area = ComponentPosition::new(
card_area.x + padding,
current_y,
card_area.width * 0.65,
value_height,
);
let trend_area = ComponentPosition::new(
card_area.x + card_area.width * 0.65,
current_y,
card_area.width * 0.35 - padding,
value_height,
);
current_y += value_height;
current_y += padding / 2.0;
let title_area = ComponentPosition::new(
card_area.x + padding,
current_y,
card_area.width
- padding * 2.0
- (if self.icon.is_some() {
icon_size + padding
} else {
0.0
}),
line_height,
);
KpiCardLayout {
icon_area,
title_area,
value_area,
trend_area,
subtitle_area,
sparkline_area,
}
}
fn draw_background(
&self,
page: &mut Page,
position: ComponentPosition,
colors: &KpiColorTheme,
_theme: &DashboardTheme,
) -> Result<(), PdfError> {
page.graphics()
.set_fill_color(colors.background)
.rect(position.x, position.y, position.width, position.height)
.fill();
Ok(())
}
fn draw_border(
&self,
page: &mut Page,
position: ComponentPosition,
colors: &KpiColorTheme,
_theme: &DashboardTheme,
) -> Result<(), PdfError> {
page.graphics()
.set_stroke_color(colors.border)
.set_line_width(1.0)
.rect(position.x, position.y, position.width, position.height)
.stroke();
Ok(())
}
fn render_icon(
&self,
_page: &mut Page,
_area: ComponentPosition,
_icon: &str,
_colors: &KpiColorTheme,
) -> Result<(), PdfError> {
Ok(())
}
fn render_title(
&self,
page: &mut Page,
area: ComponentPosition,
colors: &KpiColorTheme,
theme: &DashboardTheme,
) -> Result<(), PdfError> {
page.text()
.set_font(Font::Helvetica, theme.typography.body_size)
.set_fill_color(colors.text_secondary)
.at(area.x, area.y)
.write(&self.title)?;
Ok(())
}
fn render_value(
&self,
page: &mut Page,
area: ComponentPosition,
colors: &KpiColorTheme,
_theme: &DashboardTheme,
) -> Result<(), PdfError> {
let formatted_value = self.format_value();
page.text()
.set_font(Font::HelveticaBold, 18.0)
.set_fill_color(colors.text_primary)
.at(area.x, area.y)
.write(&formatted_value)?;
Ok(())
}
fn render_trend(
&self,
page: &mut Page,
area: ComponentPosition,
trend: &TrendInfo,
colors: &KpiColorTheme,
theme: &DashboardTheme,
) -> Result<(), PdfError> {
let trend_text = format!(
"{}{:.1}%",
match trend.direction {
TrendDirection::Up => "+",
TrendDirection::Down => "-",
TrendDirection::Flat => "",
},
trend.change.abs()
);
let trend_color = if trend.is_good {
colors.success
} else {
colors.danger
};
page.text()
.set_font(Font::Helvetica, theme.typography.body_size)
.set_fill_color(trend_color)
.at(area.x, area.y)
.write(&trend_text)?;
Ok(())
}
fn render_subtitle(
&self,
page: &mut Page,
area: ComponentPosition,
subtitle: &str,
colors: &KpiColorTheme,
theme: &DashboardTheme,
) -> Result<(), PdfError> {
page.text()
.set_font(Font::Helvetica, theme.typography.caption_size)
.set_fill_color(colors.text_muted)
.at(area.x, area.y)
.write(subtitle)?;
Ok(())
}
fn render_sparkline(
&self,
page: &mut Page,
area: ComponentPosition,
sparkline: &SparklineData,
colors: &KpiColorTheme,
) -> Result<(), PdfError> {
if sparkline.data.is_empty() {
return Ok(());
}
let min_val = sparkline.data.iter().fold(f64::INFINITY, |a, &b| a.min(b));
let max_val = sparkline
.data
.iter()
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
if (max_val - min_val).abs() < f64::EPSILON {
return Ok(()); }
let graphics = page.graphics();
graphics
.set_stroke_color(colors.primary)
.set_line_width(1.5);
let step_x = area.width / (sparkline.data.len() - 1) as f64;
let mut first_point = true;
for (i, &value) in sparkline.data.iter().enumerate() {
let x = area.x + i as f64 * step_x;
let normalized = (value - min_val) / (max_val - min_val);
let y = area.y + (1.0 - normalized) * area.height;
if first_point {
graphics.move_to(x, y);
first_point = false;
} else {
graphics.line_to(x, y);
}
}
graphics.stroke();
Ok(())
}
fn format_value(&self) -> String {
match &self.value_format {
ValueFormat::Raw => self.value.clone(),
ValueFormat::Currency {
symbol,
decimal_places,
} => {
if let Ok(num) = self.value.parse::<f64>() {
format!(
"{symbol}{num:.prec$}",
symbol = symbol,
num = num,
prec = *decimal_places as usize
)
} else {
self.value.clone()
}
}
ValueFormat::Percentage { decimal_places } => {
if let Ok(num) = self.value.parse::<f64>() {
format!("{:.1$}%", num, *decimal_places as usize)
} else {
self.value.clone()
}
}
ValueFormat::Number {
decimal_places,
thousands_separator,
} => {
if let Ok(num) = self.value.parse::<f64>() {
let formatted = format!("{:.1$}", num, *decimal_places as usize);
if *thousands_separator {
formatted
} else {
formatted
}
} else {
self.value.clone()
}
}
}
}
}
#[derive(Debug)]
pub struct KpiCardBuilder {
card: KpiCard,
}
impl KpiCardBuilder {
pub fn new<T: Into<String>, V: Into<String>>(title: T, value: V) -> Self {
Self {
card: KpiCard::new(title, value),
}
}
pub fn trend(mut self, change: f64, direction: TrendDirection) -> Self {
self.card = self.card.with_trend(change, direction);
self
}
pub fn subtitle<T: Into<String>>(mut self, subtitle: T) -> Self {
self.card = self.card.with_subtitle(subtitle);
self
}
pub fn color(mut self, color: Color) -> Self {
self.card = self.card.color(color);
self
}
pub fn sparkline(mut self, data: Vec<f64>) -> Self {
self.card = self.card.with_sparkline(data);
self
}
pub fn build(self) -> KpiCard {
self.card
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendDirection {
Up,
Down,
Flat,
}
#[derive(Debug, Clone)]
pub struct TrendInfo {
pub change: f64,
pub direction: TrendDirection,
pub period: String,
pub is_good: bool,
}
#[derive(Debug, Clone)]
pub enum ValueFormat {
Raw,
Currency { symbol: String, decimal_places: u8 },
Percentage { decimal_places: u8 },
Number {
decimal_places: u8,
thousands_separator: bool,
},
}
impl Default for ValueFormat {
fn default() -> Self {
Self::Raw
}
}
#[derive(Debug, Clone)]
pub struct KpiColorTheme {
pub primary: Color,
pub background: Color,
pub border: Color,
pub text_primary: Color,
pub text_secondary: Color,
pub text_muted: Color,
pub accent: Color,
pub success: Color,
pub danger: Color,
}
impl KpiColorTheme {
pub fn from_primary(primary: Color) -> Self {
Self {
primary,
background: Color::white(),
border: Color::hex("#e0e0e0"),
text_primary: Color::hex("#212529"),
text_secondary: Color::hex("#6c757d"),
text_muted: Color::hex("#adb5bd"),
accent: primary,
success: Color::hex("#28a745"),
danger: Color::hex("#dc3545"),
}
}
pub fn from_theme(theme: &DashboardTheme) -> Self {
Self {
primary: theme.colors.primary,
background: theme.colors.surface,
border: theme.colors.border,
text_primary: theme.colors.text_primary,
text_secondary: theme.colors.text_secondary,
text_muted: theme.colors.text_muted,
accent: theme.colors.accent,
success: theme.colors.success,
danger: theme.colors.danger,
}
}
}
#[derive(Debug, Clone)]
pub struct SparklineData {
pub data: Vec<f64>,
pub labels: Option<Vec<String>>,
}
impl SparklineData {
pub fn new(data: Vec<f64>) -> Self {
Self { data, labels: None }
}
pub fn with_labels(data: Vec<f64>, labels: Vec<String>) -> Self {
Self {
data,
labels: Some(labels),
}
}
}
#[derive(Debug, Clone)]
pub struct KpiCardStyle {
pub padding: f64,
pub show_border: bool,
pub show_shadow: bool,
pub corner_radius: f64,
}
impl Default for KpiCardStyle {
fn default() -> Self {
Self {
padding: 12.0,
show_border: true,
show_shadow: false,
corner_radius: 4.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kpi_card_creation() {
let card = KpiCard::new("Revenue", "$1.2M");
assert_eq!(card.title, "Revenue");
assert_eq!(card.value, "$1.2M");
assert_eq!(card.get_span().columns, 12); }
#[test]
fn test_kpi_card_with_trend() {
let card = KpiCard::new("Sales", "1,247")
.with_trend(12.5, TrendDirection::Up)
.trend_is_good(true);
let trend = card.trend.unwrap();
assert_eq!(trend.change, 12.5);
assert_eq!(trend.direction, TrendDirection::Up);
assert!(trend.is_good);
}
#[test]
fn test_kpi_card_formatting() {
let card = KpiCard::new("Price", "1299.99").as_currency("$");
let formatted = card.format_value();
assert_eq!(formatted, "$1299.99");
}
#[test]
fn test_kpi_card_builder() {
let card = KpiCardBuilder::new("Conversion", "3.2")
.subtitle("vs last month")
.trend(0.3, TrendDirection::Up)
.color(Color::blue())
.sparkline(vec![3.1, 3.0, 3.2, 3.4, 3.2])
.build();
assert_eq!(card.title, "Conversion");
assert!(card.subtitle.is_some());
assert!(card.trend.is_some());
assert!(card.sparkline.is_some());
}
#[test]
fn test_sparkline_data() {
let data = vec![10.0, 12.0, 8.0, 15.0, 11.0];
let sparkline = SparklineData::new(data.clone());
assert_eq!(sparkline.data, data);
assert!(sparkline.labels.is_none());
}
#[test]
fn test_kpi_color_theme() {
let theme = KpiColorTheme::from_primary(Color::blue());
assert_eq!(theme.primary, Color::blue());
assert_eq!(theme.accent, Color::blue());
}
}