use serde::{Deserialize, Serialize};
fn default_data_font_family() -> String {
"'JetBrains Mono', monospace".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GridStyle {
pub color: String,
pub opacity: f64,
pub width: f64,
pub dash: Option<String>,
pub show_x: bool,
pub show_y: bool,
}
impl Default for GridStyle {
fn default() -> Self {
Self {
color: "#e0e0e0".to_string(),
opacity: 0.3,
width: 0.5,
dash: None,
show_x: true,
show_y: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartTheme {
pub background_color: String,
pub text_color: String,
pub palette: Vec<String>,
pub font_family: String,
#[serde(default = "default_data_font_family")]
pub data_font_family: String,
pub font_size: f64,
pub axis_font_size: f64,
pub grid: GridStyle,
pub axis_color: String,
pub point_radius: f64,
pub stroke_width: f64,
pub point_opacity: f64,
pub line_opacity: f64,
pub area_opacity: f64,
pub title_font_size: f64,
pub title_font_weight: String,
pub title_padding_top: f64,
pub title_padding_bottom: f64,
pub table_bg: String,
pub table_border: String,
pub table_header_bg: String,
pub table_header_text: String,
pub table_text: String,
pub table_hover: String,
pub table_selected: String,
pub table_primary: String,
pub table_primary_hover: String,
pub table_danger: String,
pub table_success: String,
pub table_muted: String,
pub table_input_bg: String,
pub table_input_border: String,
pub table_input_text: String,
pub table_accent: String,
pub tooltip_bg: String,
pub tooltip_text: String,
pub tooltip_text_muted: String,
pub crosshair_color: String,
pub focus_outline: String,
pub bullish_color: String,
pub bearish_color: String,
pub waterfall_start: String,
pub waterfall_positive: String,
pub waterfall_negative: String,
pub waterfall_total: String,
pub connector_line: String,
pub brush_fill: String,
pub brush_stroke: String,
pub zoom_fill: String,
pub zoom_stroke: String,
pub legend_bg: String,
pub legend_border: String,
}
impl Default for ChartTheme {
fn default() -> Self {
Self {
background_color: "#ffffff".to_string(),
text_color: "#333333".to_string(),
palette: vec![
"#ec5b13".to_string(), "#56B4E9".to_string(),
"#009E73".to_string(),
"#E69F00".to_string(),
"#0072B2".to_string(),
"#D55E00".to_string(),
"#CC79A7".to_string(),
"#000000".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#6E7079".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#ffffff".to_string(),
table_border: "#e5e7eb".to_string(),
table_header_bg: "#f8fafc".to_string(),
table_header_text: "#1e293b".to_string(),
table_text: "#334155".to_string(),
table_hover: "#f1f5f9".to_string(),
table_selected: "#e2e8f0".to_string(),
table_primary: "#3b82f6".to_string(),
table_primary_hover: "#2563eb".to_string(),
table_danger: "#ef4444".to_string(),
table_success: "#22c55e".to_string(),
table_muted: "#94a3b8".to_string(),
table_input_bg: "#ffffff".to_string(),
table_input_border: "#cbd5e1".to_string(),
table_input_text: "#0f172a".to_string(),
table_accent: "#6366f1".to_string(),
tooltip_bg: "rgba(30,30,30,0.85)".to_string(),
tooltip_text: "#ffffff".to_string(),
tooltip_text_muted: "#cccccc".to_string(),
crosshair_color: "#999999".to_string(),
focus_outline: "#4992ff".to_string(),
bullish_color: "#26a69a".to_string(),
bearish_color: "#ef5350".to_string(),
waterfall_start: "#4CAF50".to_string(),
waterfall_positive: "#26a69a".to_string(),
waterfall_negative: "#ef5350".to_string(),
waterfall_total: "#607D8B".to_string(),
connector_line: "#9e9e9e".to_string(),
brush_fill: "rgba(73,146,255,0.20)".to_string(),
brush_stroke: "rgba(73,146,255,0.65)".to_string(),
zoom_fill: "rgba(66,135,245,0.15)".to_string(),
zoom_stroke: "rgba(66,135,245,0.80)".to_string(),
legend_bg: "rgba(255,255,255,0.88)".to_string(),
legend_border: "#dddddd".to_string(),
}
}
}
impl ChartTheme {
pub fn dark() -> Self {
Self {
background_color: "#100c2a".to_string(),
text_color: "#eeeeee".to_string(),
palette: 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(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#B9B8CE".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#100c2a".to_string(),
table_border: "#2b2b2b".to_string(),
table_header_bg: "#1a163a".to_string(),
table_header_text: "#eeeeee".to_string(),
table_text: "#cccccc".to_string(),
table_hover: "#1f1b40".to_string(),
table_selected: "#2a2552".to_string(),
table_primary: "#4992ff".to_string(),
table_primary_hover: "#6aa5ff".to_string(),
table_danger: "#ff6e76".to_string(),
table_success: "#05c091".to_string(),
table_muted: "#666666".to_string(),
table_input_bg: "#100c2a".to_string(),
table_input_border: "#3b3b3b".to_string(),
table_input_text: "#eeeeee".to_string(),
table_accent: "#dd79ff".to_string(),
tooltip_bg: "rgba(20,16,50,0.92)".to_string(),
tooltip_text: "#eeeeee".to_string(),
tooltip_text_muted: "#aaaaaa".to_string(),
crosshair_color: "#666666".to_string(),
focus_outline: "#4992ff".to_string(),
bullish_color: "#05c091".to_string(),
bearish_color: "#ff6e76".to_string(),
waterfall_start: "#05c091".to_string(),
waterfall_positive: "#7cffb2".to_string(),
waterfall_negative: "#ff6e76".to_string(),
waterfall_total: "#8d48e3".to_string(),
connector_line: "#666666".to_string(),
brush_fill: "rgba(73,146,255,0.20)".to_string(),
brush_stroke: "rgba(73,146,255,0.65)".to_string(),
zoom_fill: "rgba(73,146,255,0.15)".to_string(),
zoom_stroke: "rgba(73,146,255,0.80)".to_string(),
legend_bg: "rgba(20,16,50,0.88)".to_string(),
legend_border: "#2b2b2b".to_string(),
}
}
pub fn nord() -> Self {
Self {
background_color: "#2e3440".to_string(),
text_color: "#eceff4".to_string(),
palette: vec![
"#88c0d0".to_string(),
"#81a1c1".to_string(),
"#5e81ac".to_string(),
"#a3be8c".to_string(),
"#ebcb8b".to_string(),
"#d08770".to_string(),
"#bf616a".to_string(),
"#b48ead".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#d8dee9".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#2e3440".to_string(),
table_border: "#3b4252".to_string(),
table_header_bg: "#3b4252".to_string(),
table_header_text: "#eceff4".to_string(),
table_text: "#d8dee9".to_string(),
table_hover: "#434c5e".to_string(),
table_selected: "#4c566a".to_string(),
table_primary: "#88c0d0".to_string(),
table_primary_hover: "#81a1c1".to_string(),
table_danger: "#bf616a".to_string(),
table_success: "#a3be8c".to_string(),
table_muted: "#4c566a".to_string(),
table_input_bg: "#2e3440".to_string(),
table_input_border: "#434c5e".to_string(),
table_input_text: "#eceff4".to_string(),
table_accent: "#b48ead".to_string(),
tooltip_bg: "rgba(46,52,64,0.92)".to_string(),
tooltip_text: "#eceff4".to_string(),
tooltip_text_muted: "#d8dee9".to_string(),
crosshair_color: "#4c566a".to_string(),
focus_outline: "#88c0d0".to_string(),
bullish_color: "#a3be8c".to_string(),
bearish_color: "#bf616a".to_string(),
waterfall_start: "#a3be8c".to_string(),
waterfall_positive: "#88c0d0".to_string(),
waterfall_negative: "#bf616a".to_string(),
waterfall_total: "#81a1c1".to_string(),
connector_line: "#4c566a".to_string(),
brush_fill: "rgba(136,192,208,0.20)".to_string(),
brush_stroke: "rgba(136,192,208,0.65)".to_string(),
zoom_fill: "rgba(136,192,208,0.15)".to_string(),
zoom_stroke: "rgba(136,192,208,0.80)".to_string(),
legend_bg: "rgba(46,52,64,0.88)".to_string(),
legend_border: "#3b4252".to_string(),
}
}
#[allow(clippy::type_complexity)]
pub fn solarized_light() -> Self {
Self {
background_color: "#fdf6e3".to_string(),
text_color: "#657b83".to_string(),
palette: vec![
"#268bd2".to_string(),
"#2aa198".to_string(),
"#859900".to_string(),
"#b58900".to_string(),
"#cb4b16".to_string(),
"#dc322f".to_string(),
"#d33682".to_string(),
"#6c71c4".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#93a1a1".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#fdf6e3".to_string(),
table_border: "#eee8d5".to_string(),
table_header_bg: "#eee8d5".to_string(),
table_header_text: "#586e75".to_string(),
table_text: "#657b83".to_string(),
table_hover: "#fdf6e3".to_string(),
table_selected: "#eee8d5".to_string(),
table_primary: "#268bd2".to_string(),
table_primary_hover: "#2aa198".to_string(),
table_danger: "#dc322f".to_string(),
table_success: "#859900".to_string(),
table_muted: "#93a1a1".to_string(),
table_input_bg: "#fdf6e3".to_string(),
table_input_border: "#93a1a1".to_string(),
table_input_text: "#657b83".to_string(),
table_accent: "#d33682".to_string(),
tooltip_bg: "rgba(253,246,227,0.92)".to_string(),
tooltip_text: "#657b83".to_string(),
tooltip_text_muted: "#93a1a1".to_string(),
crosshair_color: "#93a1a1".to_string(),
focus_outline: "#268bd2".to_string(),
bullish_color: "#859900".to_string(),
bearish_color: "#dc322f".to_string(),
waterfall_start: "#859900".to_string(),
waterfall_positive: "#2aa198".to_string(),
waterfall_negative: "#dc322f".to_string(),
waterfall_total: "#268bd2".to_string(),
connector_line: "#93a1a1".to_string(),
brush_fill: "rgba(38,139,210,0.20)".to_string(),
brush_stroke: "rgba(38,139,210,0.65)".to_string(),
zoom_fill: "rgba(38,139,210,0.15)".to_string(),
zoom_stroke: "rgba(38,139,210,0.80)".to_string(),
legend_bg: "rgba(253,246,227,0.88)".to_string(),
legend_border: "#eee8d5".to_string(),
}
}
pub fn solarized_dark() -> Self {
Self {
background_color: "#002b36".to_string(),
text_color: "#839496".to_string(),
palette: vec![
"#268bd2".to_string(),
"#2aa198".to_string(),
"#859900".to_string(),
"#b58900".to_string(),
"#cb4b16".to_string(),
"#dc322f".to_string(),
"#d33682".to_string(),
"#6c71c4".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#586e75".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#002b36".to_string(),
table_border: "#073642".to_string(),
table_header_bg: "#073642".to_string(),
table_header_text: "#93a1a1".to_string(),
table_text: "#839496".to_string(),
table_hover: "#002b36".to_string(),
table_selected: "#073642".to_string(),
table_primary: "#268bd2".to_string(),
table_primary_hover: "#2aa198".to_string(),
table_danger: "#dc322f".to_string(),
table_success: "#859900".to_string(),
table_muted: "#586e75".to_string(),
table_input_bg: "#002b36".to_string(),
table_input_border: "#586e75".to_string(),
table_input_text: "#839496".to_string(),
table_accent: "#d33682".to_string(),
tooltip_bg: "rgba(0,43,54,0.92)".to_string(),
tooltip_text: "#839496".to_string(),
tooltip_text_muted: "#586e75".to_string(),
crosshair_color: "#586e75".to_string(),
focus_outline: "#268bd2".to_string(),
bullish_color: "#859900".to_string(),
bearish_color: "#dc322f".to_string(),
waterfall_start: "#859900".to_string(),
waterfall_positive: "#2aa198".to_string(),
waterfall_negative: "#dc322f".to_string(),
waterfall_total: "#268bd2".to_string(),
connector_line: "#586e75".to_string(),
brush_fill: "rgba(38,139,210,0.20)".to_string(),
brush_stroke: "rgba(38,139,210,0.65)".to_string(),
zoom_fill: "rgba(38,139,210,0.15)".to_string(),
zoom_stroke: "rgba(38,139,210,0.80)".to_string(),
legend_bg: "rgba(0,43,54,0.88)".to_string(),
legend_border: "#073642".to_string(),
}
}
pub fn high_contrast() -> Self {
Self {
background_color: "#000000".to_string(),
text_color: "#ffffff".to_string(),
palette: vec![
"#ffff00".to_string(),
"#00ffff".to_string(),
"#ff00ff".to_string(),
"#00ff00".to_string(),
"#ff6600".to_string(),
"#6666ff".to_string(),
"#ff0066".to_string(),
"#66ff66".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 14.0,
axis_font_size: 12.0,
grid: GridStyle::default(),
axis_color: "#ffffff".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 18.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#000000".to_string(),
table_border: "#333333".to_string(),
table_header_bg: "#111111".to_string(),
table_header_text: "#ffffff".to_string(),
table_text: "#dddddd".to_string(),
table_hover: "#222222".to_string(),
table_selected: "#333333".to_string(),
table_primary: "#ffff00".to_string(),
table_primary_hover: "#cccc00".to_string(),
table_danger: "#ff0066".to_string(),
table_success: "#00ff00".to_string(),
table_muted: "#888888".to_string(),
table_input_bg: "#000000".to_string(),
table_input_border: "#ffffff".to_string(),
table_input_text: "#ffffff".to_string(),
table_accent: "#ff00ff".to_string(),
tooltip_bg: "rgba(0,0,0,0.95)".to_string(),
tooltip_text: "#ffffff".to_string(),
tooltip_text_muted: "#dddddd".to_string(),
crosshair_color: "#ffffff".to_string(),
focus_outline: "#ffff00".to_string(),
bullish_color: "#00ff00".to_string(),
bearish_color: "#ff0066".to_string(),
waterfall_start: "#00ff00".to_string(),
waterfall_positive: "#00ffff".to_string(),
waterfall_negative: "#ff0066".to_string(),
waterfall_total: "#ff00ff".to_string(),
connector_line: "#ffffff".to_string(),
brush_fill: "rgba(255,255,0,0.20)".to_string(),
brush_stroke: "rgba(255,255,0,0.65)".to_string(),
zoom_fill: "rgba(255,255,0,0.15)".to_string(),
zoom_stroke: "rgba(255,255,0,0.80)".to_string(),
legend_bg: "rgba(0,0,0,0.90)".to_string(),
legend_border: "#ffffff".to_string(),
}
}
pub fn monokai() -> Self {
Self {
background_color: "#272822".to_string(),
text_color: "#f8f8f2".to_string(),
palette: vec![
"#a6e22e".to_string(),
"#66d9ef".to_string(),
"#f92672".to_string(),
"#fd971f".to_string(),
"#e6db74".to_string(),
"#ae81ff".to_string(),
"#a1efe4".to_string(),
"#f8f8f2".to_string(),
],
font_family: "'Inter', system-ui, sans-serif".to_string(),
data_font_family: "'JetBrains Mono', monospace".to_string(),
font_size: 12.0,
axis_font_size: 10.0,
grid: GridStyle::default(),
axis_color: "#75715e".to_string(),
point_radius: 5.0,
stroke_width: 5.0,
point_opacity: 0.5,
line_opacity: 0.5,
area_opacity: 0.5,
title_font_size: 16.0,
title_font_weight: "bold".to_string(),
title_padding_top: 10.0,
title_padding_bottom: 20.0,
table_bg: "#272822".to_string(),
table_border: "#3e3d32".to_string(),
table_header_bg: "#3e3d32".to_string(),
table_header_text: "#f8f8f2".to_string(),
table_text: "#e6e6e6".to_string(),
table_hover: "#49483e".to_string(),
table_selected: "#75715e".to_string(),
table_primary: "#66d9ef".to_string(),
table_primary_hover: "#9ef0ff".to_string(),
table_danger: "#f92672".to_string(),
table_success: "#a6e22e".to_string(),
table_muted: "#75715e".to_string(),
table_input_bg: "#272822".to_string(),
table_input_border: "#75715e".to_string(),
table_input_text: "#f8f8f2".to_string(),
table_accent: "#ae81ff".to_string(),
tooltip_bg: "rgba(39,40,34,0.92)".to_string(),
tooltip_text: "#f8f8f2".to_string(),
tooltip_text_muted: "#75715e".to_string(),
crosshair_color: "#75715e".to_string(),
focus_outline: "#66d9ef".to_string(),
bullish_color: "#a6e22e".to_string(),
bearish_color: "#f92672".to_string(),
waterfall_start: "#a6e22e".to_string(),
waterfall_positive: "#66d9ef".to_string(),
waterfall_negative: "#f92672".to_string(),
waterfall_total: "#ae81ff".to_string(),
connector_line: "#75715e".to_string(),
brush_fill: "rgba(102,217,239,0.20)".to_string(),
brush_stroke: "rgba(102,217,239,0.65)".to_string(),
zoom_fill: "rgba(102,217,239,0.15)".to_string(),
zoom_stroke: "rgba(102,217,239,0.80)".to_string(),
legend_bg: "rgba(39,40,34,0.88)".to_string(),
legend_border: "#3e3d32".to_string(),
}
}
pub fn preset_names() -> &'static [&'static str] {
&[
"default",
"dark",
"nord",
"solarized_light",
"solarized_dark",
"high_contrast",
"monokai",
]
}
pub fn from_preset(name: &str) -> Self {
match name {
"dark" => Self::dark(),
"nord" => Self::nord(),
"solarized_light" => Self::solarized_light(),
"solarized_dark" => Self::solarized_dark(),
"high_contrast" => Self::high_contrast(),
"monokai" => Self::monokai(),
_ => Self::default(),
}
}
}
pub fn auto_text_color(background: &str) -> &'static str {
let Some((r, g, b)) = parse_hex_color(background) else {
return "#1a1a1a";
};
let lum = relative_luminance(r, g, b);
if lum > 0.179 {
"#1a1a1a"
} else {
"#ffffff"
}
}
pub fn interpolate_color(c1: &str, c2: &str, t: f64) -> String {
let t = t.clamp(0.0, 1.0);
let Some((r1, g1, b1)) = parse_hex_color(c1) else {
return c1.to_string();
};
let Some((r2, g2, b2)) = parse_hex_color(c2) else {
return c1.to_string();
};
let r = (r1 + (r2 - r1) * t).clamp(0.0, 1.0);
let g = (g1 + (g2 - g1) * t).clamp(0.0, 1.0);
let b = (b1 + (b2 - b1) * t).clamp(0.0, 1.0);
format!(
"#{:02x}{:02x}{:02x}",
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
)
}
pub fn generate_palette(anchors: &[&str], n: usize) -> Vec<String> {
if anchors.len() < 2 || n == 0 {
return vec![];
}
if n == 1 {
return vec![anchors[0].to_string()];
}
let segments = anchors.len() - 1;
(0..n)
.map(|i| {
let t = i as f64 / (n - 1) as f64;
let scaled = t * segments as f64;
let seg = (scaled.floor() as usize).min(segments - 1);
let local_t = scaled - seg as f64;
interpolate_color(anchors[seg], anchors[seg + 1], local_t)
})
.collect()
}
pub const VIRIDIS: [&str; 8] = [
"#440154", "#46327e", "#365c8d", "#277f8e", "#1fa187", "#4ac16d", "#9fda3a", "#fde725",
];
pub const PLASMA: [&str; 8] = [
"#0d0887", "#5b02a3", "#9a179b", "#cb4678", "#eb7852", "#fbb32b", "#eff821", "#f0f921",
];
pub const RD_BU: [&str; 8] = [
"#b2182b", "#d6604d", "#f4a582", "#fddbc7", "#d1e5f0", "#92c5de", "#4393c3", "#2166ac",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ChartConfig {
pub theme: Option<ChartTheme>,
pub title: Option<String>,
pub grid: Option<GridStyle>,
pub show_tooltip: Option<bool>,
pub width: Option<u32>,
pub height: Option<u32>,
pub margin: Option<Margin>,
pub show_legend: Option<bool>,
pub legend_outside: Option<bool>,
pub x_label: Option<String>,
pub y_label: Option<String>,
pub pie_donut: Option<bool>,
pub pie_inner_ratio: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Margin {
pub top: f64,
pub right: f64,
pub bottom: f64,
pub left: f64,
}
impl Default for Margin {
fn default() -> Self {
Self {
top: 20.0,
right: 20.0,
bottom: 50.0,
left: 60.0,
}
}
}
impl ChartConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_grid(mut self, grid: GridStyle) -> Self {
self.grid = Some(grid);
self
}
pub fn with_grid_visible(mut self, show: bool) -> Self {
let mut g = self.grid.unwrap_or_default();
g.show_x = show;
g.show_y = show;
self.grid = Some(g);
self
}
pub fn with_size(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
pub fn with_legend(mut self, show: bool) -> Self {
self.show_legend = Some(show);
self
}
pub fn with_legend_outside(mut self, outside: bool) -> Self {
self.legend_outside = Some(outside);
self
}
pub fn with_x_label(mut self, label: impl Into<String>) -> Self {
self.x_label = Some(label.into());
self
}
pub fn with_y_label(mut self, label: impl Into<String>) -> Self {
self.y_label = Some(label.into());
self
}
pub fn with_pie_donut(mut self, donut: bool) -> Self {
self.pie_donut = Some(donut);
self
}
pub fn with_pie_inner_ratio(mut self, ratio: f64) -> Self {
self.pie_inner_ratio = Some(ratio);
self
}
}
fn parse_hex_color(hex: &str) -> Option<(f64, f64, f64)> {
let hex = hex.strip_prefix('#')?;
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
Some((
f64::from(r) / 255.0,
f64::from(g) / 255.0,
f64::from(b) / 255.0,
))
}
6 | 8 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((
f64::from(r) / 255.0,
f64::from(g) / 255.0,
f64::from(b) / 255.0,
))
}
_ => None,
}
}
fn srgb_to_linear(c: f64) -> f64 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn relative_luminance(r: f64, g: f64, b: f64) -> f64 {
0.2126 * srgb_to_linear(r) + 0.7152 * srgb_to_linear(g) + 0.0722 * srgb_to_linear(b)
}
pub fn contrast_ratio(fg: &str, bg: &str) -> f64 {
let Some((r1, g1, b1)) = parse_hex_color(fg) else {
return 1.0;
};
let Some((r2, g2, b2)) = parse_hex_color(bg) else {
return 1.0;
};
let l1 = relative_luminance(r1, g1, b1);
let l2 = relative_luminance(r2, g2, b2);
let lighter = l1.max(l2);
let darker = l1.min(l2);
(lighter + 0.05) / (darker + 0.05)
}
pub fn meets_wcag_aa_graphics(fg: &str, bg: &str) -> bool {
contrast_ratio(fg, bg) >= 3.0
}
pub fn meets_wcag_aa_text(fg: &str, bg: &str) -> bool {
contrast_ratio(fg, bg) >= 4.5
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ColorScheme {
#[default]
Ocean,
Sunset,
DarkMatter,
Viridis,
Categorical,
OkabeIto,
}
impl ColorScheme {
pub fn palette(&self) -> Vec<&'static str> {
match self {
ColorScheme::Ocean => vec![
"#e0f3ff", "#b3e0ff", "#66b3ff", "#3399ff", "#0073e6", "#005ab3", "#004080", "#003366", ],
ColorScheme::Sunset => vec![
"#fff5e6", "#ffe0b3", "#ffcc80", "#ffb84d", "#ff9933", "#ff6600", "#cc5200", "#b34700", ],
ColorScheme::DarkMatter => vec![
"#f0f0f5", "#d0d0e0", "#a0a0c0", "#7070a0", "#504080", "#403060", "#302040", "#201030", ],
ColorScheme::Viridis => vec![
"#fde724", "#b5de2b", "#6ece58", "#35b779", "#1f9e89", "#26828e", "#31688e", "#443983", ],
ColorScheme::Categorical => vec![
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", ],
ColorScheme::OkabeIto => vec![
"#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000", ],
}
}
pub fn color_at(&self, index: usize) -> &'static str {
let palette = self.palette();
palette[index % palette.len()]
}
pub fn primary(&self) -> &'static str {
self.color_at(0)
}
pub fn secondary(&self) -> &'static str {
let palette = self.palette();
self.color_at(palette.len() / 2)
}
pub fn accent(&self) -> &'static str {
let palette = self.palette();
self.color_at(palette.len() - 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() < eps
}
#[test]
fn test_contrast_ratio_black_white() {
let ratio = contrast_ratio("#000000", "#ffffff");
assert!(approx_eq(ratio, 21.0, 0.1));
}
#[test]
fn test_contrast_ratio_same_color() {
let ratio = contrast_ratio("#ff0000", "#ff0000");
assert!(approx_eq(ratio, 1.0, 0.01));
}
#[test]
fn test_contrast_ratio_invalid_color() {
assert!(approx_eq(contrast_ratio("invalid", "#fff"), 1.0, 0.01));
}
#[test]
fn test_meets_wcag_aa_graphics() {
assert!(meets_wcag_aa_graphics("#000000", "#ffffff"));
assert!(!meets_wcag_aa_graphics("#cccccc", "#ffffff"));
}
#[test]
fn test_meets_wcag_aa_text() {
assert!(meets_wcag_aa_text("#000000", "#ffffff"));
assert!(!meets_wcag_aa_text("#888888", "#ffffff"));
}
#[test]
fn test_parse_shorthand_hex() {
let ratio = contrast_ratio("#000", "#fff");
assert!(approx_eq(ratio, 21.0, 0.1));
}
#[test]
fn test_contrast_ratio_with_alpha_hex() {
let ratio = contrast_ratio("#000000ff", "#ffffffff");
assert!(approx_eq(ratio, 21.0, 0.1));
}
#[test]
fn test_preset_names() {
let names = ChartTheme::preset_names();
assert!(names.contains(&"dark"));
assert!(names.contains(&"nord"));
assert!(names.contains(&"high_contrast"));
}
#[test]
fn test_from_preset() {
let nord = ChartTheme::from_preset("nord");
assert_eq!(nord.background_color, "#2e3440");
let default = ChartTheme::from_preset("unknown");
assert_eq!(default, ChartTheme::default());
}
#[test]
fn test_auto_text_color_dark_bg() {
assert_eq!(auto_text_color("#000000"), "#ffffff");
assert_eq!(auto_text_color("#100c2a"), "#ffffff");
}
#[test]
fn test_auto_text_color_light_bg() {
assert_eq!(auto_text_color("#ffffff"), "#1a1a1a");
assert_eq!(auto_text_color("#fdf6e3"), "#1a1a1a");
}
#[test]
fn test_interpolate_endpoints() {
assert_eq!(interpolate_color("#000000", "#ffffff", 0.0), "#000000");
assert_eq!(interpolate_color("#000000", "#ffffff", 1.0), "#ffffff");
}
#[test]
fn test_interpolate_midpoint() {
let mid = interpolate_color("#000000", "#ffffff", 0.5);
assert_eq!(mid, "#808080");
}
#[test]
fn test_interpolate_clamped() {
assert_eq!(interpolate_color("#000000", "#ffffff", -1.0), "#000000");
assert_eq!(interpolate_color("#000000", "#ffffff", 2.0), "#ffffff");
}
#[test]
fn test_generate_palette_basic() {
let palette = generate_palette(&["#000000", "#ffffff"], 3);
assert_eq!(palette.len(), 3);
assert_eq!(palette[0], "#000000");
assert_eq!(palette[1], "#808080");
assert_eq!(palette[2], "#ffffff");
}
#[test]
fn test_generate_palette_single() {
let palette = generate_palette(&["#ff0000", "#0000ff"], 1);
assert_eq!(palette.len(), 1);
assert_eq!(palette[0], "#ff0000");
}
#[test]
fn test_generate_palette_empty() {
assert!(generate_palette(&["#fff"], 5).is_empty());
assert!(generate_palette(&["#000", "#fff"], 0).is_empty());
}
}