use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{error::Result, visual::Color};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesignTokens {
pub color: ColorTokens,
pub text: TextTokens,
pub spacing: SpacingTokens,
pub border: BorderTokens,
pub effect: EffectTokens,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorTokens {
pub primary: Vec<String>,
pub theme: Vec<String>,
pub neutral00: String,
pub neutral05: String,
pub neutral10: String,
pub neutral15: String,
pub neutral20: String,
pub neutral25: String,
pub neutral30: String,
pub neutral35: String,
pub neutral40: String,
pub neutral45: String,
pub neutral50: String,
pub neutral55: String,
pub neutral60: String,
pub neutral65: String,
pub neutral70: String,
pub neutral75: String,
pub neutral80: String,
pub neutral85: String,
pub neutral90: String,
pub neutral95: String,
pub neutral99: String,
pub accent05: String,
pub accent10: String,
pub accent15: String,
pub accent20: String,
pub accent25: String,
pub accent30: String,
pub accent35: String,
pub accent40: String,
pub accent45: String,
pub accent50: String,
pub accent55: String,
pub accent60: String,
pub accent65: String,
pub accent70: String,
pub accent75: String,
pub accent80: String,
pub accent85: String,
pub accent90: String,
pub accent95: String,
pub transparent: String,
pub text_primary: String,
pub text_secondary: String,
pub text_tertiary: String,
pub secondary: String,
pub tertiary: String,
pub quaternary: String,
pub disabled: String,
pub highlight: String,
pub border: String,
pub border_tint: String,
pub border_shade: String,
pub background: String,
pub background_tint: String,
pub background_transparent: String,
pub background_shade: String,
pub shadow: String,
pub shadow_tint: String,
pub axis_line: String,
pub axis_line_tint: String,
pub axis_tick: String,
pub axis_tick_minor: String,
pub axis_label: String,
pub split_line: String,
pub axis_split_line: String,
pub axis_minor_split_line: String,
pub accent: String,
pub success: String,
pub warning: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextTokens {
pub title_size: f64,
pub subtitle_size: f64,
pub body_size: f64,
pub caption_size: f64,
pub font_family: String,
pub title_weight: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpacingTokens {
pub xs: f64,
pub sm: f64,
pub md: f64,
pub lg: f64,
pub xl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BorderTokens {
pub thin: f64,
pub normal: f64,
pub thick: f64,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectTokens {
pub shadow_color: String,
pub shadow_blur: f64,
pub shadow_offset_x: f64,
pub shadow_offset_y: f64,
}
impl Default for DesignTokens {
fn default() -> Self {
Self::echarts_v6()
}
}
impl DesignTokens {
pub fn echarts_v6() -> Self {
let theme_colors = vec![
"#5070dd".to_string(), "#b6d634".to_string(), "#505372".to_string(), "#ff994d".to_string(), "#0ca8df".to_string(), "#ffd10a".to_string(), "#fb628b".to_string(), "#785db0".to_string(), "#3fbe95".to_string(), ];
Self {
color: ColorTokens {
primary: theme_colors.clone(),
theme: theme_colors.clone(),
neutral00: "#ffffff".to_string(),
neutral05: "#f4f7fd".to_string(),
neutral10: "#e8ebf0".to_string(),
neutral15: "#dbdee4".to_string(),
neutral20: "#cfd2d7".to_string(),
neutral25: "#c3c5cb".to_string(),
neutral30: "#b7b9be".to_string(),
neutral35: "#aaacb2".to_string(),
neutral40: "#9ea0a5".to_string(),
neutral45: "#929399".to_string(),
neutral50: "#86878c".to_string(),
neutral55: "#797b7f".to_string(),
neutral60: "#6d6e73".to_string(),
neutral65: "#616266".to_string(),
neutral70: "#54555a".to_string(),
neutral75: "#48494d".to_string(),
neutral80: "#3c3c41".to_string(),
neutral85: "#303034".to_string(),
neutral90: "#232328".to_string(),
neutral95: "#17171b".to_string(),
neutral99: "#000000".to_string(),
accent05: "#eff1f9".to_string(),
accent10: "#e0e4f2".to_string(),
accent15: "#d0d6ec".to_string(),
accent20: "#c0c9e6".to_string(),
accent25: "#b1bbdf".to_string(),
accent30: "#a1aed9".to_string(),
accent35: "#91a0d3".to_string(),
accent40: "#8292cc".to_string(),
accent45: "#7285c6".to_string(),
accent50: "#6578ba".to_string(),
accent55: "#5c6da9".to_string(),
accent60: "#536298".to_string(),
accent65: "#4a5787".to_string(),
accent70: "#404c76".to_string(),
accent75: "#374165".to_string(),
accent80: "#2e3654".to_string(),
accent85: "#252b43".to_string(),
accent90: "#1b2032".to_string(),
accent95: "#121521".to_string(),
transparent: "rgba(0,0,0,0)".to_string(),
text_primary: "#3c3c41".to_string(), text_secondary: "#54555a".to_string(), text_tertiary: "#6d6e73".to_string(), secondary: "#54555a".to_string(), tertiary: "#6d6e73".to_string(), quaternary: "#86878c".to_string(), disabled: "#cfd2d7".to_string(), highlight: "rgba(255,231,130,0.8)".to_string(),
border: "#b7b9be".to_string(), border_tint: "#cfd2d7".to_string(), border_shade: "#aaacb2".to_string(),
background: "#f4f7fd".to_string(), background_tint: "rgba(234,237,245,0.5)".to_string(),
background_transparent: "rgba(255,255,255,0)".to_string(),
background_shade: "#e8ebf0".to_string(),
shadow: "rgba(0,0,0,0.2)".to_string(),
shadow_tint: "rgba(129,130,136,0.2)".to_string(),
axis_line: "#54555a".to_string(), axis_line_tint: "#9ea0a5".to_string(), axis_tick: "#54555a".to_string(), axis_tick_minor: "#6d6e73".to_string(), axis_label: "#54555a".to_string(), split_line: "#dbdee4".to_string(), axis_split_line: "#dbdee4".to_string(), axis_minor_split_line: "#f4f7fd".to_string(),
accent: "#5070dd".to_string(), success: "#b6d634".to_string(), warning: "#ffd10a".to_string(), error: "#fb628b".to_string(), },
text: TextTokens {
title_size: 18.0,
subtitle_size: 12.0,
body_size: 12.0,
caption_size: 10.0,
font_family: "sans-serif".to_string(),
title_weight: "normal".to_string(),
},
spacing: SpacingTokens {
xs: 4.0,
sm: 8.0,
md: 12.0,
lg: 16.0,
xl: 24.0,
},
border: BorderTokens {
thin: 0.5,
normal: 1.0,
thick: 2.0,
color: "#cccccc".to_string(),
},
effect: EffectTokens {
shadow_color: "rgba(0, 0, 0, 0.1)".to_string(),
shadow_blur: 4.0,
shadow_offset_x: 0.0,
shadow_offset_y: 2.0,
},
}
}
pub fn vintage() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#d87c7c".to_string(),
"#919e8b".to_string(),
"#d7ab82".to_string(),
"#6e7074".to_string(),
"#61a0a8".to_string(),
"#efa18d".to_string(),
"#787464".to_string(),
"#cc7e63".to_string(),
"#724e58".to_string(),
"#4b565b".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens.color.background = "#fef8ef".to_string();
tokens
}
pub fn macarons() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#2ec7c9".to_string(),
"#b6a2de".to_string(),
"#5ab1ef".to_string(),
"#ffb980".to_string(),
"#d87a80".to_string(),
"#8d98b3".to_string(),
"#e5cf0d".to_string(),
"#97b552".to_string(),
"#95706d".to_string(),
"#dc69aa".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens
}
pub fn infographic() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#c1232b".to_string(),
"#27727b".to_string(),
"#fcce10".to_string(),
"#e87c25".to_string(),
"#b5c334".to_string(),
"#fe8463".to_string(),
"#9bca63".to_string(),
"#fad860".to_string(),
"#f3a43b".to_string(),
"#60c0dd".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens
}
pub fn shine() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#c12e34".to_string(),
"#e6b600".to_string(),
"#0098d9".to_string(),
"#2b821d".to_string(),
"#005eaa".to_string(),
"#339ca8".to_string(),
"#cda819".to_string(),
"#32a487".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens
}
pub fn roma() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#ff8a45".to_string(),
"#e6b600".to_string(),
"#0098d9".to_string(),
"#61a0a8".to_string(),
"#2ec7c9".to_string(),
"#b6a2de".to_string(),
"#91ca48".to_string(),
"#749f83".to_string(),
"#ca8622".to_string(),
"#bdaa8d".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens
}
pub fn dark() -> Self {
let mut tokens = Self::echarts_v6();
tokens.color.primary = vec![
"#4992ff".to_string(),
"#7cffb2".to_string(),
"#fddd60".to_string(),
"#ff6e76".to_string(),
"#58d9f9".to_string(),
"#05c091".to_string(),
"#ff8a45".to_string(),
"#8d48e3".to_string(),
"#dd79ff".to_string(),
];
tokens.color.theme = tokens.color.primary.clone();
tokens.color.background = "#1a1a1a".to_string();
tokens.color.text_primary = "#eeeeee".to_string();
tokens.color.text_secondary = "#cccccc".to_string();
tokens.color.text_tertiary = "#999999".to_string();
tokens
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<DesignTokens>,
pub color: Vec<String>,
pub background_color: String,
pub title: TitleTheme,
pub legend: LegendTheme,
pub axis: AxisTheme,
pub series: HashMap<String, SeriesTheme>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TitleTheme {
pub text_style: TextStyleTheme,
pub subtext_style: TextStyleTheme,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegendTheme {
pub text_style: TextStyleTheme,
pub item_width: f64,
pub item_height: f64,
pub symbol_size: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AxisTheme {
pub axis_line: LineStyleTheme,
pub axis_tick: LineStyleTheme,
pub axis_label: TextStyleTheme,
pub split_line: LineStyleTheme,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextStyleTheme {
pub color: String,
pub font_size: f64,
pub font_family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineStyleTheme {
pub color: String,
pub width: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeriesTheme {
pub item_style: ItemStyleTheme,
pub line_style: Option<LineStyleTheme>,
pub label: Option<TextStyleTheme>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemStyleTheme {
pub color: Option<String>,
pub border_color: Option<String>,
pub border_width: f64,
}
impl Theme {
pub fn from_tokens(name: &str, tokens: DesignTokens) -> Self {
let color = tokens.color.primary.clone();
let bg = tokens.color.background.clone();
let text_primary = tokens.color.text_primary.clone();
let text_secondary = tokens.color.text_secondary.clone();
let font_family = tokens.text.font_family.clone();
let title_size = tokens.text.title_size;
let body_size = tokens.text.body_size;
let axis_line_color = tokens.color.axis_line.clone();
let axis_label_color = tokens.color.axis_label.clone();
let split_line_color = tokens.color.split_line.clone();
let border_thin = tokens.border.thin;
let mut series_themes = HashMap::new();
series_themes.insert(
"bar".to_string(),
SeriesTheme {
item_style: ItemStyleTheme {
color: None,
border_color: None,
border_width: 0.0,
},
line_style: None,
label: Some(TextStyleTheme {
color: text_primary.clone(),
font_size: body_size,
font_family: font_family.clone(),
}),
},
);
series_themes.insert(
"line".to_string(),
SeriesTheme {
item_style: ItemStyleTheme {
color: None,
border_color: None,
border_width: border_thin,
},
line_style: Some(LineStyleTheme {
color: tokens.color.accent.clone(),
width: 2.0,
}),
label: Some(TextStyleTheme {
color: text_primary.clone(),
font_size: body_size,
font_family: font_family.clone(),
}),
},
);
series_themes.insert(
"pie".to_string(),
SeriesTheme {
item_style: ItemStyleTheme {
color: None,
border_color: Some(bg.clone()),
border_width: 1.0,
},
line_style: None,
label: Some(TextStyleTheme {
color: text_primary.clone(),
font_size: body_size,
font_family: font_family.clone(),
}),
},
);
series_themes.insert(
"scatter".to_string(),
SeriesTheme {
item_style: ItemStyleTheme {
color: None,
border_color: Some(bg.clone()),
border_width: 1.0,
},
line_style: None,
label: Some(TextStyleTheme {
color: text_primary.clone(),
font_size: body_size,
font_family: font_family.clone(),
}),
},
);
let subtitle_size = tokens.text.subtitle_size;
Self {
name: name.to_string(),
tokens: Some(tokens),
color,
background_color: bg,
title: TitleTheme {
text_style: TextStyleTheme {
color: text_primary.clone(),
font_size: title_size,
font_family: font_family.clone(),
},
subtext_style: TextStyleTheme {
color: text_secondary.clone(),
font_size: subtitle_size,
font_family: font_family.clone(),
},
},
legend: LegendTheme {
text_style: TextStyleTheme {
color: text_primary.clone(),
font_size: body_size,
font_family: font_family.clone(),
},
item_width: 80.0,
item_height: 20.0,
symbol_size: 10.0,
},
axis: AxisTheme {
axis_line: LineStyleTheme {
color: axis_line_color.clone(),
width: 1.0,
},
axis_tick: LineStyleTheme {
color: axis_line_color.clone(),
width: 1.0,
},
axis_label: TextStyleTheme {
color: axis_label_color.clone(),
font_size: body_size,
font_family: font_family.clone(),
},
split_line: LineStyleTheme {
color: split_line_color.clone(),
width: 1.0,
},
},
series: series_themes,
}
}
pub fn echarts() -> Self {
Self::from_tokens("echarts", DesignTokens::echarts_v6())
}
pub fn light() -> Self {
let mut theme = Self::echarts();
theme.name = "light".to_string();
theme
}
pub fn dark() -> Self {
Self::from_tokens("dark", DesignTokens::dark())
}
pub fn vintage() -> Self {
Self::from_tokens("vintage", DesignTokens::vintage())
}
pub fn macarons() -> Self {
Self::from_tokens("macarons", DesignTokens::macarons())
}
pub fn infographic() -> Self {
Self::from_tokens("infographic", DesignTokens::infographic())
}
pub fn shine() -> Self {
Self::from_tokens("shine", DesignTokens::shine())
}
pub fn roma() -> Self {
Self::from_tokens("roma", DesignTokens::roma())
}
pub fn get_color(&self, index: usize) -> Result<Color> {
let color_str = self.color.get(index % self.color.len()).ok_or_else(|| {
crate::error::ChartError::InvalidColor("No color available".to_string())
})?;
Color::from_hex(color_str).ok_or_else(|| {
crate::error::ChartError::InvalidColor(format!("Invalid color: {}", color_str))
})
}
pub fn get_theme_color(&self, index: usize) -> Color {
let tokens = self.tokens();
let color_str = tokens
.color
.theme
.get(index % tokens.color.theme.len())
.unwrap_or(&tokens.color.primary[0]);
Color::from_hex(color_str).unwrap_or(Color::new(80, 112, 221))
}
pub fn get_background_color(&self) -> Color {
Color::from_hex(&self.tokens().color.background).unwrap_or(Color::new(255, 255, 255))
}
pub fn get_title_text_style(&self) -> TextStyleTheme {
let tokens = self.tokens();
TextStyleTheme {
color: tokens.color.text_primary.clone(),
font_size: tokens.text.title_size,
font_family: tokens.text.font_family.clone(),
}
}
pub fn get_subtitle_text_style(&self) -> TextStyleTheme {
let tokens = self.tokens();
TextStyleTheme {
color: tokens.color.text_secondary.clone(),
font_size: tokens.text.subtitle_size,
font_family: tokens.text.font_family.clone(),
}
}
pub fn get_legend_text_style(&self) -> TextStyleTheme {
let tokens = self.tokens();
TextStyleTheme {
color: tokens.color.text_primary.clone(),
font_size: tokens.text.body_size,
font_family: tokens.text.font_family.clone(),
}
}
pub fn get_legend_config(&self) -> (f64, f64, f64) {
let _tokens = self.tokens();
(80.0, 20.0, 10.0) }
pub fn get_axis_label_style(&self) -> TextStyleTheme {
let tokens = self.tokens();
TextStyleTheme {
color: tokens.color.axis_label.clone(),
font_size: tokens.text.body_size,
font_family: tokens.text.font_family.clone(),
}
}
pub fn get_axis_line_style(&self) -> LineStyleTheme {
let tokens = self.tokens();
LineStyleTheme {
color: tokens.color.axis_line.clone(),
width: 1.0,
}
}
pub fn get_axis_tick_style(&self) -> LineStyleTheme {
let tokens = self.tokens();
LineStyleTheme {
color: tokens.color.axis_tick.clone(),
width: 1.0,
}
}
pub fn get_split_line_style(&self) -> LineStyleTheme {
let tokens = self.tokens();
LineStyleTheme {
color: tokens.color.axis_split_line.clone(),
width: 1.0,
}
}
pub fn tokens(&self) -> &DesignTokens {
self.tokens.as_ref().unwrap_or_else(|| {
static DEFAULT_TOKENS: std::sync::OnceLock<DesignTokens> = std::sync::OnceLock::new();
DEFAULT_TOKENS.get_or_init(DesignTokens::echarts_v6)
})
}
}
#[derive(Debug, Clone)]
pub struct ThemeRegistry {
themes: HashMap<String, Theme>,
}
impl ThemeRegistry {
pub fn new() -> Self {
let mut registry = Self {
themes: HashMap::new(),
};
registry.register(Theme::echarts());
registry.register(Theme::light());
registry.register(Theme::dark());
registry.register(Theme::vintage());
registry.register(Theme::macarons());
registry.register(Theme::infographic());
registry.register(Theme::shine());
registry.register(Theme::roma());
registry
}
pub fn default_theme(&self) -> &Theme {
self.themes
.get("echarts")
.or_else(|| self.themes.get("light"))
.expect("Default theme should exist")
}
pub fn register(&mut self, theme: Theme) {
self.themes.insert(theme.name.clone(), theme);
}
pub fn get(&self, name: &str) -> Option<&Theme> {
self.themes.get(name)
}
pub fn available_themes(&self) -> Vec<&String> {
self.themes.keys().collect()
}
}
impl Default for ThemeRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn load_theme(name: &str) -> Result<Theme> {
let theme_registry = ThemeRegistry::new();
theme_registry
.get(name)
.ok_or_else(|| {
crate::error::ChartError::ThemeNotFound(format!("Theme not found: {}", name))
})
.cloned()
}