diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8f77e5e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.gitea] +index = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/" diff --git a/Cargo.lock b/Cargo.lock index 39a5d43..685bdc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -398,6 +398,8 @@ dependencies = [ [[package]] name = "dexpr" version = "0.1.0" +source = "sparse+https://gitea.duhanbalci.com/api/packages/duhanbalci/cargo/" +checksum = "66f1b8752c5d700b0399128c3ba4d5cad1204be8b29de8489d2c4b3c53f975c8" dependencies = [ "bumpalo", "indexmap", diff --git a/core/src/models.rs b/core/src/models.rs index 9ce11d6..23e6eb7 100644 --- a/core/src/models.rs +++ b/core/src/models.rs @@ -166,6 +166,95 @@ pub struct RichTextSpan { pub style: TextStyle, } +// --- Chart --- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChartType { + Bar, + Line, + Pie, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GroupMode { + Grouped, + Stacked, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChartTitle { + pub text: String, + pub font_size: Option, + pub color: Option, + pub align: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChartLegend { + pub show: bool, + pub position: Option, + pub font_size: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChartLabels { + pub show: bool, + pub font_size: Option, + pub color: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChartAxis { + pub x_label: Option, + pub y_label: Option, + pub show_grid: Option, + pub grid_color: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ChartStyle { + pub colors: Option>, + pub background_color: Option, + pub bar_gap: Option, + pub line_width: Option, + pub show_points: Option, + pub curve_type: Option, + pub inner_radius: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartElement { + pub id: String, + pub position: PositionMode, + pub size: SizeConstraint, + pub chart_type: ChartType, + pub data_source: ArrayBinding, + pub category_field: String, + pub value_field: String, + #[serde(default)] + pub group_field: Option, + #[serde(default)] + pub group_mode: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub legend: Option, + #[serde(default)] + pub labels: Option, + #[serde(default)] + pub axis: Option, + #[serde(default)] + pub style: ChartStyle, +} + // --- Element tipleri --- #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -205,6 +294,8 @@ pub enum TemplateElement { CalculatedText(CalculatedTextElement), #[serde(rename = "rich_text")] RichText(RichTextElement), + #[serde(rename = "chart")] + Chart(ChartElement), } impl TemplateElement { @@ -224,6 +315,7 @@ impl TemplateElement { Self::Checkbox(e) => &e.id, Self::CalculatedText(e) => &e.id, Self::RichText(e) => &e.id, + Self::Chart(e) => &e.id, } } @@ -243,6 +335,7 @@ impl TemplateElement { Self::Checkbox(e) => &e.position, Self::CalculatedText(e) => &e.position, Self::RichText(e) => &e.position, + Self::Chart(e) => &e.position, } } @@ -270,6 +363,7 @@ impl TemplateElement { Self::Checkbox(e) => &e.size, Self::CalculatedText(e) => &e.size, Self::RichText(e) => &e.size, + Self::Chart(e) => &e.size, } } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 629a5c7..588b722 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -386,6 +386,22 @@ const defaultInvoiceTemplate: Template = { borderWidth: 0.5, }, }, + // --- Kalem Tutarlari Grafik --- + { + id: 'el_chart_bar', + type: 'chart', + position: { type: 'flow' }, + size: { width: sz.fr(), height: sz.fixed(60) }, + chartType: 'bar', + dataSource: { type: 'array', path: 'kalemler' }, + categoryField: 'adi', + valueField: 'tutar', + title: { text: 'Kalem Tutarlari', fontSize: 3.5, color: '#1e293b', align: 'center' }, + legend: { show: false }, + labels: { show: true, fontSize: 2.2, color: '#333' }, + axis: { showGrid: true }, + style: { colors: ['#4F46E5', '#10B981', '#F59E0B', '#EF4444'] }, + }, // --- Toplamlar --- { id: 'c_toplamlar_row', diff --git a/frontend/src/components/editor/LayoutRenderer.vue b/frontend/src/components/editor/LayoutRenderer.vue index 47c55e8..bba765f 100644 --- a/frontend/src/components/editor/LayoutRenderer.vue +++ b/frontend/src/components/editor/LayoutRenderer.vue @@ -301,6 +301,22 @@ watch( :style="{ ...elStyle(el), ...shapeStyle(el) }" /> + +
+
+
+ Grafik +
+
+
diff --git a/frontend/src/components/panels/PropertiesPanel.vue b/frontend/src/components/panels/PropertiesPanel.vue index a8f8f4b..7f5dbf5 100644 --- a/frontend/src/components/panels/PropertiesPanel.vue +++ b/frontend/src/components/panels/PropertiesPanel.vue @@ -15,6 +15,7 @@ import type { CheckboxElement, CalculatedTextElement, RichTextElement, + ChartElement, } from '../../core/types' import PositioningProperties from '../properties/PositioningProperties.vue' import SizeProperties from '../properties/SizeProperties.vue' @@ -30,6 +31,7 @@ import CalculatedTextProperties from '../properties/CalculatedTextProperties.vue import RichTextProperties from '../properties/RichTextProperties.vue' import ContainerProperties from '../properties/ContainerProperties.vue' import RepeatingTableProperties from '../properties/RepeatingTableProperties.vue' +import ChartProperties from '../properties/ChartProperties.vue' import '../../styles/properties.css' const templateStore = useTemplateStore() @@ -62,6 +64,7 @@ const elementTypeLabel = computed(() => { case 'calculated_text': return 'Hesaplanan Metin' case 'rich_text': return 'Zengin Metin' case 'page_break': return 'Sayfa Sonu' + case 'chart': return 'Grafik' default: return 'Eleman' } }) @@ -160,6 +163,10 @@ function deleteElement() { v-if="selectedElement.type === 'repeating_table'" :element="(selectedElement as RepeatingTableElement)" /> + +
Sayfa Ust/Alt Bilgi
diff --git a/frontend/src/components/panels/ToolboxPanel.vue b/frontend/src/components/panels/ToolboxPanel.vue index 4b80313..c377643 100644 --- a/frontend/src/components/panels/ToolboxPanel.vue +++ b/frontend/src/components/panels/ToolboxPanel.vue @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/src/core/types.ts b/frontend/src/core/types.ts index 0599699..c205cbd 100644 --- a/frontend/src/core/types.ts +++ b/frontend/src/core/types.ts @@ -217,6 +217,62 @@ export interface PageBreakElement extends BaseElement { style: Record } +// --- Chart --- + +export type ChartType = 'bar' | 'line' | 'pie' +export type GroupMode = 'grouped' | 'stacked' + +export interface ChartTitle { + text: string + fontSize?: number + color?: string + align?: 'left' | 'center' | 'right' +} + +export interface ChartLegend { + show: boolean + position?: 'top' | 'bottom' | 'right' + fontSize?: number +} + +export interface ChartLabels { + show: boolean + fontSize?: number + color?: string +} + +export interface ChartAxis { + xLabel?: string + yLabel?: string + showGrid?: boolean + gridColor?: string +} + +export interface ChartStyle { + colors?: string[] + backgroundColor?: string + barGap?: number // 0.0-1.0 + lineWidth?: number // mm + showPoints?: boolean + curveType?: 'linear' | 'smooth' + innerRadius?: number // 0=pie, >0=donut (0-0.9) +} + +export interface ChartElement extends BaseElement { + type: 'chart' + chartType: ChartType + dataSource: ArrayBinding + categoryField: string + valueField: string + groupField?: string + groupMode?: GroupMode + title?: ChartTitle + legend?: ChartLegend + labels?: ChartLabels + axis?: ChartAxis + style: ChartStyle +} + export interface ContainerElement extends BaseElement { type: 'container' direction: 'row' | 'column' @@ -237,7 +293,7 @@ export interface RepeatingTableElement extends BaseElement { repeatHeader?: boolean } -export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement +export type LeafElement = StaticTextElement | TextElement | LineElement | RepeatingTableElement | ImageElement | PageNumberElement | BarcodeElement | PageBreakElement | CurrentDateElement | ShapeElement | CheckboxElement | CalculatedTextElement | RichTextElement | ChartElement export type TemplateElement = LeafElement | ContainerElement // --- Template --- diff --git a/layout-engine/Cargo.toml b/layout-engine/Cargo.toml index 6e013de..433a325 100644 --- a/layout-engine/Cargo.toml +++ b/layout-engine/Cargo.toml @@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] dreport-core = { path = "../core" } -dexpr = { path = "../../rust-expr" } +dexpr = { version = "0.1.0", registry = "gitea" } taffy = "0.9" cosmic-text = { version = "0.18", default-features = false, features = ["std", "swash"] } serde = { version = "1", features = ["derive"] } diff --git a/layout-engine/src/chart_render.rs b/layout-engine/src/chart_render.rs new file mode 100644 index 0000000..6d63061 --- /dev/null +++ b/layout-engine/src/chart_render.rs @@ -0,0 +1,791 @@ +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##""##, + width_mm, height_mm + ) + .unwrap(); + write!( + svg, + r##""##, + width_mm, height_mm, bg + ) + .unwrap(); + + // Max sayida renk: kategoriler + seriler + let n_colors = data.categories.len().max(data.series.len()).max(1); + let palette: Vec = (0..n_colors) + .map(|i| { + if let Some(ref user_colors) = data.style.colors { + if i < user_colors.len() { + return user_colors[i].clone(); + } + } + DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string() + }) + .collect(); + // Margin hesaplari + let mut margin_top = 2.0_f64; + let mut margin_bottom = 4.0_f64; + let mut margin_left = 8.0_f64; + let margin_right = 4.0_f64; + + // Title + if let Some(ref title) = data.title { + if !title.text.is_empty() { + let font_size = title.font_size.unwrap_or(4.0); + margin_top += font_size * 0.4 + 2.0; + let color = title.color.as_deref().unwrap_or("#333333"); + let align = title.align.as_deref().unwrap_or("center"); + let x = match align { + "left" => margin_left, + "right" => width_mm - margin_right, + _ => width_mm / 2.0, + }; + let anchor = match align { + "left" => "start", + "right" => "end", + _ => "middle", + }; + write!( + svg, + r##"{}"##, + x, + margin_top - 1.0, + font_size, + color, + anchor, + escape_xml(&title.text) + ) + .unwrap(); + } + } + + // Legend space + let legend_show = data.legend.as_ref().is_some_and(|l| l.show); + let legend_pos = data + .legend + .as_ref() + .and_then(|l| l.position.as_deref()) + .unwrap_or("bottom"); + let legend_font = data + .legend + .as_ref() + .and_then(|l| l.font_size) + .unwrap_or(2.8); + + if legend_show && data.series.len() > 1 { + match legend_pos { + "top" => margin_top += legend_font + 3.0, + "bottom" => margin_bottom += legend_font + 3.0, + _ => {} // right — icerde handle edilecek + } + } + + // Axis labels icin yer ac (bar ve line) + let has_axis = !matches!(data.chart_type, ChartType::Pie); + if has_axis { + if data.axis.as_ref().and_then(|a| a.x_label.as_ref()).is_some() { + margin_bottom += 4.0; + } + if data.axis.as_ref().and_then(|a| a.y_label.as_ref()).is_some() { + margin_left += 4.0; + } + // Category labels icin alt bosluk + let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0); + let n_cats = data.categories.len(); + let available_w = width_mm - margin_left - margin_right; + let cat_width = if n_cats > 0 { + available_w / n_cats as f64 + } else { + available_w + }; + let max_chars_fit = (cat_width / 1.25).max(1.0) as usize; + let will_rotate = max_label_len > max_chars_fit; + if will_rotate { + // Rotated labels (-45°): dikey ≈ text_width * sin(45°), yatay ≈ text_width * cos(45°) + let char_w_mm = 1.1; + let max_text_w = max_label_len as f64 * char_w_mm; + let label_v = max_text_w * 0.707; // sin(45°) + margin_bottom += label_v.min(25.0).max(6.0); + // Sol taraftaki label yana tasabilir + let label_h = max_text_w * 0.707; // cos(45°) + let extra_left = (label_h - cat_width / 2.0).max(0.0); + margin_left += extra_left.min(10.0); + } else { + margin_bottom += 4.0; + } + // Y-axis value labels icin sol bosluk + margin_left += 6.0; + } + + let plot_x = margin_left; + let plot_y = margin_top; + let plot_w = (width_mm - margin_left - margin_right).max(1.0); + let plot_h = (height_mm - margin_top - margin_bottom).max(1.0); + + match data.chart_type { + ChartType::Bar => render_bar(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h), + ChartType::Line => render_line(&mut svg, data, &palette, plot_x, plot_y, plot_w, plot_h), + ChartType::Pie => render_pie(&mut svg, data, &palette, width_mm, height_mm, plot_x, plot_y, plot_w, plot_h), + } + + // Legend render + if legend_show && data.series.len() > 1 { + render_legend(&mut svg, data, &palette, legend_pos, legend_font, width_mm, height_mm, margin_left, margin_top, plot_w, plot_h); + } + + // Axis labels + if has_axis { + if let Some(ref axis) = data.axis { + if let Some(ref x_label) = axis.x_label { + let x = plot_x + plot_w / 2.0; + let y = height_mm - 2.0; + write!( + svg, + r##"{}"##, + x, y, escape_xml(x_label) + ) + .unwrap(); + } + if let Some(ref y_label) = axis.y_label { + let x = 3.0; + let y = plot_y + plot_h / 2.0; + write!( + svg, + r##"{}"##, + x, y, x, y, escape_xml(y_label) + ) + .unwrap(); + } + } + } + + svg.push_str(""); + 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.9; + 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(2.5); + 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(); + } + + // Label + 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(); + } + + 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('"', """) +} diff --git a/layout-engine/src/data_resolve.rs b/layout-engine/src/data_resolve.rs index 38b97f3..ba90a38 100644 --- a/layout-engine/src/data_resolve.rs +++ b/layout-engine/src/data_resolve.rs @@ -68,6 +68,26 @@ pub struct ResolvedRichSpan { pub color: Option, } +/// Çözümlenmiş chart verisi +#[derive(Debug, Clone)] +pub struct ResolvedChartData { + pub chart_type: ChartType, + pub categories: Vec, + pub series: Vec, + pub title: Option, + pub legend: Option, + pub labels: Option, + pub axis: Option, + pub style: ChartStyle, + pub group_mode: Option, +} + +#[derive(Debug, Clone)] +pub struct ChartSeries { + pub name: String, + pub values: Vec, +} + /// Her element ID'si için çözümlenmiş text içeriğini tutar. /// Table ve barcode gibi özel tipler de burada çözülür. #[derive(Debug, Clone)] @@ -84,6 +104,8 @@ pub struct ResolvedData { pub page_number_formats: HashMap, /// element_id → çözümlenmiş rich text span listesi pub rich_texts: HashMap>, + /// element_id → çözümlenmiş chart verisi + pub charts: HashMap, } #[derive(Debug, Clone)] @@ -123,6 +145,7 @@ pub fn resolve_template(template: &Template, data: &Value) -> ResolvedData { images: HashMap::new(), page_number_formats: HashMap::new(), rich_texts: HashMap::new(), + charts: HashMap::new(), }; if let Some(ref header) = template.header { resolve_element(&TemplateElement::Container(header.clone()), data, &mut resolved); @@ -248,12 +271,110 @@ fn resolve_element(el: &TemplateElement, data: &Value, resolved: &mut ResolvedDa .collect(); resolved.rich_texts.insert(e.id.clone(), spans); } + TemplateElement::Chart(e) => { + let array = resolve_path(data, &e.data_source.path); + let chart_data = match array { + Value::Array(items) if !items.is_empty() => { + resolve_chart_data(e, items) + } + _ => ResolvedChartData { + chart_type: e.chart_type.clone(), + categories: vec![], + series: vec![], + title: e.title.clone(), + legend: e.legend.clone(), + labels: e.labels.clone(), + axis: e.axis.clone(), + style: e.style.clone(), + group_mode: e.group_mode.clone(), + }, + }; + resolved.charts.insert(e.id.clone(), chart_data); + } TemplateElement::Line(_) => {} TemplateElement::Shape(_) => {} TemplateElement::PageBreak(_) => {} } } +fn resolve_chart_data(e: &ChartElement, items: &[Value]) -> ResolvedChartData { + let (categories, series) = if let Some(ref group_field) = e.group_field { + // Grouped: her distinct group değeri bir seri olur + let mut category_order: Vec = Vec::new(); + let mut category_set = std::collections::HashSet::new(); + let mut group_order: Vec = Vec::new(); + let mut group_set = std::collections::HashSet::new(); + // group_name → (category → value) (birden fazla aynı group+category olursa topla) + let mut group_data: HashMap> = HashMap::new(); + + for item in items { + let cat = value_to_string(resolve_path(item, &e.category_field)); + let val = resolve_path(item, &e.value_field) + .as_f64() + .unwrap_or(0.0); + let grp = value_to_string(resolve_path(item, group_field)); + + if category_set.insert(cat.clone()) { + category_order.push(cat.clone()); + } + if group_set.insert(grp.clone()) { + group_order.push(grp.clone()); + } + *group_data + .entry(grp) + .or_default() + .entry(cat) + .or_insert(0.0) += val; + } + + let series = group_order + .iter() + .map(|grp| { + let grp_map = group_data.get(grp).unwrap(); + let values = category_order + .iter() + .map(|cat| *grp_map.get(cat).unwrap_or(&0.0)) + .collect(); + ChartSeries { + name: grp.clone(), + values, + } + }) + .collect(); + + (category_order, series) + } else { + // Tek seri + let mut categories = Vec::new(); + let mut values = Vec::new(); + for item in items { + categories.push(value_to_string(resolve_path(item, &e.category_field))); + values.push( + resolve_path(item, &e.value_field) + .as_f64() + .unwrap_or(0.0), + ); + } + let series = vec![ChartSeries { + name: e.value_field.clone(), + values, + }]; + (categories, series) + }; + + ResolvedChartData { + chart_type: e.chart_type.clone(), + categories, + series, + title: e.title.clone(), + legend: e.legend.clone(), + labels: e.labels.clone(), + axis: e.axis.clone(), + style: e.style.clone(), + group_mode: e.group_mode.clone(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/layout-engine/src/lib.rs b/layout-engine/src/lib.rs index b8e8fe3..4303956 100644 --- a/layout-engine/src/lib.rs +++ b/layout-engine/src/lib.rs @@ -10,11 +10,12 @@ pub mod expr_eval; pub mod wasm_api; pub mod barcode_gen; +pub mod chart_render; #[cfg(not(target_arch = "wasm32"))] pub mod pdf_render; -use dreport_core::models::Template; +use dreport_core::models::{ChartType, Template}; use serde::{Deserialize, Serialize}; // --- Layout sonuç tipleri --- @@ -73,6 +74,56 @@ pub enum ResolvedContent { rows: Vec>, column_widths_mm: Vec, }, + #[serde(rename = "chart")] + Chart { + svg: String, + /// PDF render icin chart verisi (frontend bunu kullanmaz) + #[serde(flatten)] + chart_data: ChartRenderData, + }, +} + +/// PDF renderer icin chart verisi — ResolvedContent::Chart icinde tasınır +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartRenderData { + pub chart_type: ChartType, + pub categories: Vec, + pub series: Vec, + #[serde(default)] + pub title_text: Option, + #[serde(default)] + pub title_font_size: Option, + #[serde(default)] + pub title_color: Option, + #[serde(default)] + pub colors: Vec, + #[serde(default)] + pub show_labels: bool, + #[serde(default)] + pub label_font_size: Option, + #[serde(default)] + pub show_grid: bool, + #[serde(default)] + pub grid_color: Option, + #[serde(default)] + pub bar_gap: Option, + #[serde(default)] + pub stacked: bool, + #[serde(default)] + pub inner_radius: Option, + #[serde(default)] + pub show_points: Option, + #[serde(default)] + pub line_width: Option, + #[serde(default)] + pub background_color: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChartSeriesData { + pub name: String, + pub values: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/layout-engine/src/pdf_render.rs b/layout-engine/src/pdf_render.rs index a934ee1..399f987 100644 --- a/layout-engine/src/pdf_render.rs +++ b/layout-engine/src/pdf_render.rs @@ -21,6 +21,11 @@ fn mm(v: f64) -> f32 { v as f32 * MM_TO_PT } +/// f64 mm degerini f32 pt'ye cevir (chart render icin) +fn pt(mm_val: f64) -> f32 { + mm_val as f32 * MM_TO_PT +} + /// Hex renk (#RRGGBB veya #RGB) → rgb::Color fn parse_color(hex: &str) -> rgb::Color { let hex = hex.trim_start_matches('#'); @@ -248,6 +253,9 @@ fn render_element( ResolvedContent::RichText { spans } => { render_rich_text(surface, x, y, w, h, spans, &el.style, fonts, measurer); } + ResolvedContent::Chart { chart_data, .. } => { + render_chart(surface, x, y, w, h, chart_data, fonts, measurer); + } } } @@ -740,6 +748,388 @@ fn embed_png( surface.pop(); } +fn render_chart( + surface: &mut krilla::surface::Surface<'_>, + x: f32, + y: f32, + w: f32, + h: f32, + data: &crate::ChartRenderData, + fonts: &FontCollection, + measurer: &mut TextMeasurer, +) { + // Tum hesaplar mm cinsinden yapilir, cizim pt'ye cevrilir + // base_x_mm, base_y_mm: element'in sayfa uzerindeki mm pozisyonu + let base_x_mm: f64 = (x / MM_TO_PT) as f64; + let base_y_mm: f64 = (y / MM_TO_PT) as f64; + let w_mm: f64 = (w / MM_TO_PT) as f64; + let h_mm: f64 = (h / MM_TO_PT) as f64; + + // Background + chart_rect(surface, base_x_mm, base_y_mm, w_mm, h_mm, + parse_color(data.background_color.as_deref().unwrap_or("#FFFFFF"))); + + // Margin'ler (SVG renderer ile ayni mantik) + let mut mt = 2.0_f64; + let mut mb = 4.0_f64; + let ml = 14.0_f64; + let mr = 4.0_f64; + + // Title + if let Some(ref title) = data.title_text { + if !title.is_empty() { + let fs = data.title_font_size.unwrap_or(4.0); + mt += fs * 0.4 + 2.0; + let color = parse_color(data.title_color.as_deref().unwrap_or("#333333")); + let font = fonts.get(None, Some("bold")); + if let Some(f) = font { + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + let fs_pt = fs as f32; + let (tw, _) = measurer.measure(title, None, fs_pt, Some("bold"), None); + let tx = pt(base_x_mm + w_mm / 2.0) - tw / 2.0; + let ty = pt(base_y_mm + mt - 1.0); + surface.draw_text( + Point::from_xy(tx, ty), + f.clone(), fs_pt, title, false, TextDirection::Auto, + ); + } + } + } + + let is_pie = matches!(data.chart_type, dreport_core::models::ChartType::Pie); + + if !is_pie { + let max_label_len = data.categories.iter().map(|c| c.len()).max().unwrap_or(0); + if max_label_len > 6 { mb += 10.0; } else { mb += 4.0; } + mb += 4.0; + } + + let plot_x = base_x_mm + ml; + let plot_y = base_y_mm + mt; + let plot_w = (w_mm - ml - mr).max(1.0); + let plot_h = (h_mm - mt - mb).max(1.0); + + use dreport_core::models::ChartType; + match data.chart_type { + ChartType::Bar => render_chart_bar(surface, data, plot_x, plot_y, plot_w, plot_h), + ChartType::Line => render_chart_line(surface, data, plot_x, plot_y, plot_w, plot_h), + ChartType::Pie => render_chart_pie(surface, data, plot_x, plot_y, plot_w, plot_h), + } +} + +/// mm degerlerini pt'ye cevirip rect ciz +fn chart_rect(surface: &mut krilla::surface::Surface<'_>, rx: f64, ry: f64, rw: f64, rh: f64, color: rgb::Color) { + let (rx, ry, rw, rh) = (pt(rx), pt(ry), pt(rw), pt(rh)); + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + let path = { + let mut pb = PathBuilder::new(); + if let Some(r) = krilla::geom::Rect::from_xywh(rx, ry, rw, rh) { + pb.push_rect(r); + } + pb.finish() + }; + if let Some(p) = path { + surface.draw_path(&p); + } +} + +fn chart_line_seg(surface: &mut krilla::surface::Surface<'_>, x1: f64, y1: f64, x2: f64, y2: f64, color: rgb::Color, width: f32) { + let (x1, y1, x2, y2) = (pt(x1), pt(y1), pt(x2), pt(y2)); + surface.set_fill(None); + surface.set_stroke(Some(Stroke { + paint: color.into(), + width, + opacity: NormalizedF32::ONE, + ..Default::default() + })); + let path = { + let mut pb = PathBuilder::new(); + pb.move_to(x1, y1); + pb.line_to(x2, y2); + pb.finish() + }; + if let Some(p) = path { + surface.draw_path(&p); + } +} + +/// Bar chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu) +fn render_chart_bar( + surface: &mut krilla::surface::Surface<'_>, + data: &crate::ChartRenderData, + px: f64, py: f64, pw: f64, ph: f64, +) { + if data.categories.is_empty() || data.series.is_empty() { return; } + + let (min_val, max_val) = chart_value_range(data); + let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val }; + + let n_cats = data.categories.len(); + let n_series = data.series.len(); + let cat_width = pw / n_cats as f64; + let bar_gap = data.bar_gap.unwrap_or(0.2).clamp(0.0, 0.8); + let group_width = cat_width * (1.0 - bar_gap); + + // Grid + if data.show_grid { + let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB")); + for i in 0..=5 { + let frac = i as f64 / 5.0; + let gy = py + ph - frac * ph; + chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4); + } + } + + // Axis lines + let ac = parse_color("#9CA3AF"); + chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); + chart_line_seg(surface, px, py, px, py + ph, ac, 0.8); + + // Bars + if data.stacked { + for ci in 0..n_cats { + let mut y_off = 0.0_f64; + for (si, series) in data.series.iter().enumerate() { + let val = series.values.get(ci).copied().unwrap_or(0.0); + let bh = (val / range) * ph; + let by = py + ph - y_off - bh; + let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0; + let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); + chart_rect(surface, bx, by, group_width, bh.max(0.0), color); + y_off += bh; + } + } + } else { + let bar_w = group_width / n_series as f64; + for ci in 0..n_cats { + for (si, series) in data.series.iter().enumerate() { + let val = series.values.get(ci).copied().unwrap_or(0.0); + let bh = ((val - min_val) / range) * ph; + let bx = px + ci as f64 * cat_width + cat_width * bar_gap / 2.0 + si as f64 * bar_w; + let by = py + ph - bh; + let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); + chart_rect(surface, bx, by, bar_w.max(0.1), bh.max(0.0), color); + } + } + } +} + +/// Line chart — tum koordinatlar mm cinsinden (mutlak sayfa pozisyonu) +fn render_chart_line( + surface: &mut krilla::surface::Surface<'_>, + data: &crate::ChartRenderData, + px: f64, py: f64, pw: f64, ph: f64, +) { + if data.categories.is_empty() || data.series.is_empty() { return; } + + let (min_val, max_val) = chart_value_range(data); + let range = if (max_val - min_val).abs() < 1e-10 { 1.0 } else { max_val - min_val }; + let n_cats = data.categories.len(); + let line_w = data.line_width.unwrap_or(0.5); + let show_points = data.show_points.unwrap_or(true); + + // Grid + if data.show_grid { + let gc = parse_color(data.grid_color.as_deref().unwrap_or("#E5E7EB")); + for i in 0..=5 { + let frac = i as f64 / 5.0; + let gy = py + ph - frac * ph; + chart_line_seg(surface, px, gy, px + pw, gy, gc, 0.4); + } + } + + // Axis + let ac = parse_color("#9CA3AF"); + chart_line_seg(surface, px, py + ph, px + pw, py + ph, ac, 0.8); + + for (si, series) in data.series.iter().enumerate() { + let color = parse_color(data.colors.get(si).map(|s| s.as_str()).unwrap_or("#4F46E5")); + + let points: Vec<(f64, f64)> = series.values.iter().enumerate().map(|(ci, val)| { + let xp = if n_cats == 1 { px + pw / 2.0 } else { px + ci as f64 * pw / (n_cats - 1) as f64 }; + let yp = py + ph - ((val - min_val) / range) * ph; + (xp, yp) + }).collect(); + + // Polyline + surface.set_fill(None); + surface.set_stroke(Some(Stroke { + paint: color.into(), + width: pt(line_w), + opacity: NormalizedF32::ONE, + ..Default::default() + })); + let path = { + let mut pb = PathBuilder::new(); + for (i, (lx, ly)) in points.iter().enumerate() { + if i == 0 { pb.move_to(pt(*lx), pt(*ly)); } + else { pb.line_to(pt(*lx), pt(*ly)); } + } + pb.finish() + }; + if let Some(p) = path { surface.draw_path(&p); } + + // Points + if show_points { + for (lx, ly) in &points { + let r = pt(0.8); + let cx = pt(*lx); + let cy = pt(*ly); + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(None); + let circle = { + let mut pb = PathBuilder::new(); + let k = r * 0.5522848; + pb.move_to(cx, cy - r); + pb.cubic_to(cx + k, cy - r, cx + r, cy - k, cx + r, cy); + pb.cubic_to(cx + r, cy + k, cx + k, cy + r, cx, cy + r); + pb.cubic_to(cx - k, cy + r, cx - r, cy + k, cx - r, cy); + pb.cubic_to(cx - r, cy - k, cx - k, cy - r, cx, cy - r); + pb.close(); + pb.finish() + }; + if let Some(p) = circle { surface.draw_path(&p); } + } + } + } +} + +/// Pie/donut chart — tum koordinatlar mm cinsinden +fn render_chart_pie( + surface: &mut krilla::surface::Surface<'_>, + data: &crate::ChartRenderData, + px: f64, py: f64, pw: f64, ph: f64, +) { + let values: Vec = if data.series.len() == 1 { + data.series[0].values.clone() + } else { + 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 { return; } + + let cx = px + pw / 2.0; + let cy = py + ph / 2.0; + let radius = pw.min(ph) / 2.0 * 0.9; + let inner_frac = data.inner_radius.unwrap_or(0.0).clamp(0.0, 0.9); + let inner_r = radius * inner_frac; + + let mut start_angle = -std::f64::consts::FRAC_PI_2; + + 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 color = parse_color(data.colors.get(i).map(|s| s.as_str()).unwrap_or("#4F46E5")); + + surface.set_fill(Some(fill_from_color(color))); + surface.set_stroke(Some(Stroke { + paint: rgb::Color::new(255, 255, 255).into(), + width: 0.8, + opacity: NormalizedF32::ONE, + ..Default::default() + })); + + let path = build_arc_path(cx, cy, radius, inner_r, start_angle, end_angle); + if let Some(p) = path { surface.draw_path(&p); } + + start_angle = end_angle; + } +} + +/// Arc path olustur — pie/donut dilimi (mm cinsinden, pt'ye cevrilir) +fn build_arc_path( + cx: f64, cy: f64, + radius: f64, inner_r: f64, + start: f64, end: f64, +) -> Option { + let mut pb = PathBuilder::new(); + + let sx = pt(cx + radius * start.cos()); + let sy = pt(cy + radius * start.sin()); + + if inner_r > 0.0 { + pb.move_to(sx, sy); + approximate_arc(&mut pb, cx, cy, radius, start, end); + let ix = pt(cx + inner_r * end.cos()); + let iy = pt(cy + inner_r * end.sin()); + pb.line_to(ix, iy); + approximate_arc(&mut pb, cx, cy, inner_r, end, start); + pb.close(); + } else { + pb.move_to(pt(cx), pt(cy)); + pb.line_to(sx, sy); + approximate_arc(&mut pb, cx, cy, radius, start, end); + pb.close(); + } + + pb.finish() +} + +/// Arc'i cubic bezier segmentleriyle yaklasik ciz (her segment ≤ 90°) +fn approximate_arc( + pb: &mut PathBuilder, + cx: f64, cy: f64, + r: f64, + start: f64, end: f64, +) { + let sweep = end - start; + let n_segs = ((sweep.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1); + let seg_sweep = sweep / n_segs as f64; + + for i in 0..n_segs { + let a1 = start + i as f64 * seg_sweep; + let a2 = a1 + seg_sweep; + let alpha = seg_sweep / 2.0; + let cos_a = alpha.cos(); + let k = (4.0 / 3.0) * (1.0 - cos_a) / alpha.sin(); + + let p2x = cx + r * a2.cos(); + let p2y = cy + r * a2.sin(); + let p1x = cx + r * a1.cos(); + let p1y = cy + r * a1.sin(); + + let c1x = p1x - k * r * a1.sin(); + let c1y = p1y + k * r * a1.cos(); + let c2x = p2x + k * r * a2.sin(); + let c2y = p2y - k * r * a2.cos(); + + pb.cubic_to(pt(c1x), pt(c1y), pt(c2x), pt(c2y), pt(p2x), pt(p2y)); + } +} + +fn chart_value_range(data: &crate::ChartRenderData) -> (f64, f64) { + if data.series.is_empty() { + return (0.0, 1.0); + } + if data.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); + } + } + if min_v > 0.0 { min_v = 0.0; } + max_v *= 1.05; + (min_v, max_v) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/layout-engine/src/table_layout.rs b/layout-engine/src/table_layout.rs index 9ca3037..627df5c 100644 --- a/layout-engine/src/table_layout.rs +++ b/layout-engine/src/table_layout.rs @@ -435,6 +435,7 @@ mod tests { images: HashMap::new(), page_number_formats: HashMap::new(), rich_texts: HashMap::new(), + charts: HashMap::new(), } } diff --git a/layout-engine/src/tree.rs b/layout-engine/src/tree.rs index d829715..6665ccf 100644 --- a/layout-engine/src/tree.rs +++ b/layout-engine/src/tree.rs @@ -92,7 +92,7 @@ pub fn compute( ) .unwrap(); - let body_elements = collect_layout(&taffy, root_node, &node_map, 0.0, 0.0); + let body_elements = collect_layout(&taffy, root_node, &node_map, resolved, 0.0, 0.0); // --- 4. Container break modlarını topla --- let break_modes = collect_break_modes(&template.root); @@ -155,7 +155,7 @@ fn compute_section( ) .unwrap(); - let elements = collect_layout(&taffy, section_node, &node_map, 0.0, 0.0); + let elements = collect_layout(&taffy, section_node, &node_map, resolved, 0.0, 0.0); // Section yüksekliği let section_layout = taffy.layout(section_node).unwrap(); @@ -553,6 +553,28 @@ fn build_element( ); node } + TemplateElement::Chart(e) => { + let mut style = sizing::leaf_style(&e.size, &e.position, parent_direction); + // Default minimum boyut — Auto ise chart cok kucuk olmasin + if matches!(e.size.width, SizeValue::Auto) { + style.min_size.width = Dimension::length(mm_to_pt(80.0)); + } + if matches!(e.size.height, SizeValue::Auto) { + style.min_size.height = Dimension::length(mm_to_pt(60.0)); + } + let node = taffy.new_leaf(style).unwrap(); + node_map.insert( + node, + NodeInfo { + element_id: e.id.clone(), + element_type: "chart".to_string(), + content: None, // SVG collect_layout'ta uretilecek + style: ResolvedStyle::default(), + children_ids: vec![], + }, + ); + node + } TemplateElement::PageBreak(e) => { // Küçük yükseklik — editörde görünür olması için (0.5mm ≈ 1.4pt) let style = Style { @@ -694,6 +716,7 @@ fn collect_layout( taffy: &TaffyTree, node: NodeId, node_map: &HashMap, + resolved: &ResolvedData, parent_x_mm: f64, parent_y_mm: f64, ) -> Vec { @@ -709,6 +732,52 @@ fn collect_layout( let w_mm = pt_to_mm(layout.size.width); let h_mm = pt_to_mm(layout.size.height); + // Chart elementleri icin SVG uret (boyutlar artik belli) + let content = if info.element_type == "chart" { + resolved.charts.get(&info.element_id).map(|cd| { + use crate::{ChartRenderData, ChartSeriesData}; + use crate::chart_render::DEFAULT_COLORS; + + // Renk paleti olustur + let n_colors = cd.categories.len().max(cd.series.len()).max(1); + let colors: Vec = (0..n_colors) + .map(|i| { + cd.style.colors.as_ref() + .and_then(|c| c.get(i).cloned()) + .unwrap_or_else(|| DEFAULT_COLORS[i % DEFAULT_COLORS.len()].to_string()) + }) + .collect(); + + ResolvedContent::Chart { + svg: crate::chart_render::render_svg(cd, w_mm, h_mm), + chart_data: ChartRenderData { + chart_type: cd.chart_type.clone(), + categories: cd.categories.clone(), + series: cd.series.iter().map(|s| ChartSeriesData { + name: s.name.clone(), + values: s.values.clone(), + }).collect(), + title_text: cd.title.as_ref().map(|t| t.text.clone()), + title_font_size: cd.title.as_ref().and_then(|t| t.font_size), + title_color: cd.title.as_ref().and_then(|t| t.color.clone()), + colors, + show_labels: cd.labels.as_ref().is_some_and(|l| l.show), + label_font_size: cd.labels.as_ref().and_then(|l| l.font_size), + show_grid: cd.axis.as_ref().and_then(|a| a.show_grid).unwrap_or(true), + grid_color: cd.axis.as_ref().and_then(|a| a.grid_color.clone()), + bar_gap: cd.style.bar_gap, + stacked: matches!(cd.group_mode, Some(dreport_core::models::GroupMode::Stacked)), + inner_radius: cd.style.inner_radius, + show_points: cd.style.show_points, + line_width: cd.style.line_width, + background_color: cd.style.background_color.clone(), + }, + } + }) + } else { + info.content.clone() + }; + elements.push(ElementLayout { id: info.element_id.clone(), x_mm, @@ -716,7 +785,7 @@ fn collect_layout( width_mm: w_mm, height_mm: h_mm, element_type: info.element_type.clone(), - content: info.content.clone(), + content, style: info.style.clone(), children: info.children_ids.clone(), }); @@ -724,7 +793,7 @@ fn collect_layout( // Child node'ları da topla let children = taffy.children(node).unwrap(); for child_node in children { - let child_elements = collect_layout(taffy, child_node, node_map, x_mm, y_mm); + let child_elements = collect_layout(taffy, child_node, node_map, resolved, x_mm, y_mm); elements.extend(child_elements); }