use crate::data_resolve::ResolvedChartData;
use dreport_core::models::{ChartType, GroupMode};
use std::fmt::Write;
pub const DEFAULT_COLORS: &[&str] = &[
"#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16",
];
fn color_at(palette: &[String], i: usize) -> &str {
&palette[i % palette.len()]
}
/// mm cinsinden chart SVG uret
pub fn render_svg(data: &ResolvedChartData, width_mm: f64, height_mm: f64) -> String {
let mut svg = String::with_capacity(4096);
let bg = data
.style
.background_color
.as_deref()
.unwrap_or("#FFFFFF");
write!(
svg,
r##"");
svg
}
fn render_bar(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
if data.categories.is_empty() || data.series.is_empty() {
return;
}
let stacked = matches!(data.group_mode, Some(GroupMode::Stacked));
let (min_val, max_val) = value_range(data, stacked);
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
let grid_color = data
.axis
.as_ref()
.and_then(|a| a.grid_color.as_deref())
.unwrap_or("#E5E7EB");
// Grid + Y axis labels
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
let n_cats = data.categories.len();
let n_series = data.series.len();
let cat_width = pw / n_cats as f64;
let bar_gap = data.style.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8);
let group_width = cat_width * (1.0 - bar_gap);
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
for ci in 0..data.categories.len() {
let cat_x = px + ci as f64 * cat_width;
if stacked {
let mut y_offset = 0.0_f64;
for (si, series) in data.series.iter().enumerate() {
let val = series.values.get(ci).copied().unwrap_or(0.0);
let bar_h = (val / range) * ph;
let bar_y = py + ph - y_offset - bar_h;
write!(
svg,
r##""##,
cat_x + cat_width * bar_gap / 2.0,
bar_y,
group_width,
bar_h.max(0.0),
color_at(palette,si)
)
.unwrap();
if show_labels && val > 0.0 {
write!(
svg,
r##"{}"##,
cat_x + cat_width / 2.0,
bar_y + bar_h / 2.0 + label_font * 0.15,
label_font,
label_color,
format_value(val)
)
.unwrap();
}
y_offset += bar_h;
}
} else {
// Grouped
let bar_w = group_width / n_series as f64;
for (si, series) in data.series.iter().enumerate() {
let val = series.values.get(ci).copied().unwrap_or(0.0);
let bar_h = ((val - min_val) / range) * ph;
let bar_x = cat_x + cat_width * bar_gap / 2.0 + si as f64 * bar_w;
let bar_y = py + ph - bar_h;
write!(
svg,
r##""##,
bar_x,
bar_y,
bar_w.max(0.1),
bar_h.max(0.0),
color_at(palette,si)
)
.unwrap();
if show_labels {
write!(
svg,
r##"{}"##,
bar_x + bar_w / 2.0,
bar_y - 0.8,
label_font,
label_color,
format_value(val)
)
.unwrap();
}
}
}
}
// X axis labels — rotate if too many categories
render_x_labels(svg, &data.categories, px, py + ph, pw, n_cats);
// X axis line
write!(
svg,
r##""##,
px, py + ph, px + pw, py + ph
)
.unwrap();
}
fn render_line(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
if data.categories.is_empty() || data.series.is_empty() {
return;
}
let (min_val, max_val) = value_range(data, false);
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
let show_grid = data.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true);
let grid_color = data
.axis
.as_ref()
.and_then(|a| a.grid_color.as_deref())
.unwrap_or("#E5E7EB");
render_y_axis(svg, min_val, max_val, px, py, pw, ph, show_grid, grid_color);
let n_cats = data.categories.len();
let line_w = data.style.line_width.unwrap_or(0.5);
let show_points = data.style.show_points.unwrap_or(true);
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(2.2);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
for (si, series) in data.series.iter().enumerate() {
let color = color_at(palette,si);
let mut points = String::new();
let mut point_circles = String::new();
for (ci, val) in series.values.iter().enumerate() {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = py + ph - ((val - min_val) / range) * ph;
write!(points, "{:.2},{:.2} ", x, y).unwrap();
if show_points {
write!(
point_circles,
r##""##,
x, y, color
)
.unwrap();
}
if show_labels {
write!(
svg,
r##"{}"##,
x, y - 1.5, label_font, label_color, format_value(*val)
)
.unwrap();
}
}
write!(
svg,
r##""##,
points.trim(),
color,
line_w
)
.unwrap();
svg.push_str(&point_circles);
}
// X axis labels — for line chart, spacing is different
render_x_labels_line(svg, &data.categories, px, py + ph, pw, n_cats);
// Axis lines
write!(
svg,
r##""##,
px, py + ph, px + pw, py + ph
)
.unwrap();
}
fn render_pie(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
_total_w: f64,
_total_h: f64,
px: f64,
py: f64,
pw: f64,
ph: f64,
) {
// Pie icin ilk serinin degerlerini kullan (veya tum serilerin toplamlarini)
let values: Vec = if data.series.len() == 1 {
data.series[0].values.clone()
} else {
// Birden fazla seri varsa, her kategori icin toplam al
data.categories
.iter()
.enumerate()
.map(|(ci, _)| {
data.series
.iter()
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
.sum()
})
.collect()
};
let total: f64 = values.iter().sum();
if total <= 0.0 || data.categories.is_empty() {
return;
}
let cx = px + pw / 2.0;
let cy = py + ph / 2.0;
let radius = pw.min(ph) / 2.0 * 0.65;
let inner_frac = data.style.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9);
let inner_r = radius * inner_frac;
let show_labels = data.labels.as_ref().is_some_and(|l| l.show);
let label_font = data.labels.as_ref().and_then(|l| l.font_size).unwrap_or(3.0);
let label_color = data
.labels
.as_ref()
.and_then(|l| l.color.as_deref())
.unwrap_or("#333");
let mut start_angle = -std::f64::consts::FRAC_PI_2; // 12 o'clock
for (i, val) in values.iter().enumerate() {
if *val <= 0.0 {
continue;
}
let sweep = (val / total) * std::f64::consts::TAU;
let end_angle = start_angle + sweep;
let large_arc = if sweep > std::f64::consts::PI {
1
} else {
0
};
let x1 = cx + radius * start_angle.cos();
let y1 = cy + radius * start_angle.sin();
let x2 = cx + radius * end_angle.cos();
let y2 = cy + radius * end_angle.sin();
let color = color_at(palette,i);
if inner_r > 0.0 {
// Donut
let ix1 = cx + inner_r * start_angle.cos();
let iy1 = cy + inner_r * start_angle.sin();
let ix2 = cx + inner_r * end_angle.cos();
let iy2 = cy + inner_r * end_angle.sin();
write!(
svg,
r##""##,
x1, y1, radius, radius, large_arc, x2, y2,
ix2, iy2, inner_r, inner_r, large_arc, ix1, iy1,
color
)
.unwrap();
} else {
// Full pie
write!(
svg,
r##""##,
cx, cy, x1, y1, radius, radius, large_arc, x2, y2, color
)
.unwrap();
}
// Percentage label inside slice
if show_labels {
let mid_angle = start_angle + sweep / 2.0;
let label_r = if inner_r > 0.0 {
(radius + inner_r) / 2.0
} else {
radius * 0.65
};
let lx = cx + label_r * mid_angle.cos();
let ly = cy + label_r * mid_angle.sin();
let pct = (val / total * 100.0).round();
write!(
svg,
r##"{}%"##,
lx, ly, label_font, label_color, pct
)
.unwrap();
}
// Category name label outside slice with leader line
if i < data.categories.len() {
let mid_angle = start_angle + sweep / 2.0;
let line_start_r = radius; // starts at pie edge
let line_end_r = radius + 3.0;
let text_r = radius + 4.0;
// Leader line from pie edge to label
let lx1 = cx + line_start_r * mid_angle.cos();
let ly1 = cy + line_start_r * mid_angle.sin();
let lx2 = cx + line_end_r * mid_angle.cos();
let ly2 = cy + line_end_r * mid_angle.sin();
write!(
svg,
r##""##,
lx1, ly1, lx2, ly2
)
.unwrap();
// Category text
let tx = cx + text_r * mid_angle.cos();
let ty = cy + text_r * mid_angle.sin();
let anchor = if mid_angle.cos() >= 0.0 { "start" } else { "end" };
write!(
svg,
r##"{}"##,
tx, ty, anchor, escape_xml(&data.categories[i])
)
.unwrap();
}
start_angle = end_angle;
}
}
fn render_legend(
svg: &mut String,
data: &ResolvedChartData,
palette: &[String],
position: &str,
font_size: f64,
total_w: f64,
total_h: f64,
margin_left: f64,
margin_top: f64,
plot_w: f64,
_plot_h: f64,
) {
let names: Vec<&str> = if matches!(data.chart_type, ChartType::Pie) {
data.categories.iter().map(|s| s.as_str()).collect()
} else {
data.series.iter().map(|s| s.name.as_str()).collect()
};
let item_w = 3.0 + font_size * 0.4; // color rect + gap
let spacing = 4.0;
match position {
"top" => {
let y = margin_top - font_size - 1.5;
let mut x = margin_left;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##""##,
x, y - font_size * 0.3, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"{}"##,
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
)
.unwrap();
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
}
}
"right" => {
let x = margin_left + plot_w + 4.0;
let mut y = margin_top + 2.0;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##""##,
x, y, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"{}"##,
x + item_w, y + font_size * 0.7, font_size, escape_xml(name)
)
.unwrap();
y += font_size + 2.0;
}
}
_ => {
// bottom (default)
let y = total_h - 3.0;
let total_legend_w: f64 = names
.iter()
.map(|n| item_w + n.len() as f64 * font_size * 0.5 + spacing)
.sum::()
- spacing;
let mut x = (total_w - total_legend_w) / 2.0;
for (i, name) in names.iter().enumerate() {
write!(
svg,
r##""##,
x, y - font_size * 0.3, color_at(palette,i)
)
.unwrap();
write!(
svg,
r##"{}"##,
x + item_w, y + font_size * 0.3, font_size, escape_xml(name)
)
.unwrap();
x += item_w + name.len() as f64 * font_size * 0.5 + spacing;
}
}
}
}
/// X-axis labels ortak render — bar chart icin (slot-based spacing)
fn render_x_labels(
svg: &mut String,
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
n_cats: usize,
) {
if n_cats == 0 {
return;
}
let cat_width = pw / n_cats as f64;
let max_chars = (cat_width / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = px + ci as f64 * cat_width + cat_width / 2.0;
let y = baseline_y + 2.5;
render_single_x_label(svg, cat, x, y, needs_rotate);
}
}
/// X-axis labels — line chart icin (point-based spacing)
fn render_x_labels_line(
svg: &mut String,
categories: &[String],
px: f64,
baseline_y: f64,
pw: f64,
n_cats: usize,
) {
if n_cats == 0 {
return;
}
let spacing = if n_cats == 1 { pw } else { pw / (n_cats - 1) as f64 };
let max_chars = (spacing / 1.25).max(1.0) as usize;
let needs_rotate = categories.iter().any(|c| c.len() > max_chars);
for (ci, cat) in categories.iter().enumerate() {
let x = if n_cats == 1 {
px + pw / 2.0
} else {
px + ci as f64 * pw / (n_cats - 1) as f64
};
let y = baseline_y + 2.5;
render_single_x_label(svg, cat, x, y, needs_rotate);
}
}
/// Tek bir X-axis label render — rotate gerekiyorsa -45° ile, anchor "end"
/// Anchor noktasi bar/point'in tam altinda, text sola yukari dogru uzanir
fn render_single_x_label(svg: &mut String, text: &str, x: f64, y: f64, rotate: bool) {
if rotate {
// -45° rotate, text-anchor="end": text, anchor noktasindan sola-yukari dogru uzanir
// Bu sayede text asagi-sola tasmaz, sadece yukari-sola gider (plot area icinde kalir)
write!(
svg,
r##"{}"##,
x, y, x, y, escape_xml(text)
)
.unwrap();
} else {
write!(
svg,
r##"{}"##,
x, y, escape_xml(text)
)
.unwrap();
}
}
fn render_y_axis(
svg: &mut String,
min_val: f64,
max_val: f64,
px: f64,
py: f64,
pw: f64,
ph: f64,
show_grid: bool,
grid_color: &str,
) {
let range = if (max_val - min_val).abs() < 1e-10 {
1.0
} else {
max_val - min_val
};
let tick_count = 5;
for i in 0..=tick_count {
let frac = i as f64 / tick_count as f64;
let val = min_val + frac * range;
let y = py + ph - frac * ph;
// Label
write!(
svg,
r##"{}"##,
px - 1.5,
y + 0.8,
format_value(val)
)
.unwrap();
// Grid line
if show_grid {
write!(
svg,
r##""##,
px, y, px + pw, y, grid_color
)
.unwrap();
}
}
// Y axis line
write!(
svg,
r##""##,
px, py, px, py + ph
)
.unwrap();
}
/// Tum serilerdeki min/max deger araligini bul
fn value_range(data: &ResolvedChartData, stacked: bool) -> (f64, f64) {
if data.series.is_empty() {
return (0.0, 1.0);
}
if stacked {
let n = data.categories.len();
let mut max_stack = 0.0_f64;
for ci in 0..n {
let sum: f64 = data
.series
.iter()
.map(|s| s.values.get(ci).copied().unwrap_or(0.0))
.sum();
max_stack = max_stack.max(sum);
}
(0.0, max_stack * 1.05)
} else {
let mut min_v = f64::MAX;
let mut max_v = f64::MIN;
for series in &data.series {
for val in &series.values {
min_v = min_v.min(*val);
max_v = max_v.max(*val);
}
}
// min sifirdan buyukse sifirdan basla
if min_v > 0.0 {
min_v = 0.0;
}
max_v *= 1.05;
(min_v, max_v)
}
}
fn format_value(v: f64) -> String {
if v.abs() >= 1_000_000.0 {
format!("{:.1}M", v / 1_000_000.0)
} else if v.abs() >= 1_000.0 {
format!("{:.1}K", v / 1_000.0)
} else if v.fract().abs() < 1e-10 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}