use std::io::Cursor; use wmap_parser::Map; use crate::configuration::Configuration; use crate::stage_type::StageType; use crate::utils::{percent_to_pixel, set_color}; use crate::RenderError; /// Renders a map to PNG format pub fn render_to_png( map: &Map, stage_type: StageType, config: &Configuration, ) -> Result, RenderError> { let total_width = (config.theme.sizes.map_width + config.theme.sizes.padding * 2.0) as i32; let total_height = (config.theme.sizes.map_height + config.theme.sizes.padding + config.theme.sizes.bottom_padding) as i32; let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, total_width, total_height)?; let ctx = cairo::Context::new(&surface)?; render(&ctx, map, stage_type, config)?; let mut buffer = Cursor::new(Vec::new()); surface.write_to_png(&mut buffer)?; Ok(buffer.into_inner()) } /// Renders a map to SVG format pub fn render_to_svg( map: &Map, stage_type: StageType, config: &Configuration, ) -> Result { use std::fs; use tempfile::NamedTempFile; let total_width = config.theme.sizes.map_width + config.theme.sizes.padding * 2.0; let total_height = config.theme.sizes.map_height + config.theme.sizes.padding + config.theme.sizes.bottom_padding; let temp_file = NamedTempFile::new()?; let path = temp_file .path() .to_str() .ok_or_else(|| RenderError::InvalidConfig("Failed to create temp file path".to_string()))?; let surface = cairo::SvgSurface::new(total_width, total_height, Some(path))?; let ctx = cairo::Context::new(&surface)?; render(&ctx, map, stage_type, config)?; drop(ctx); surface.finish(); let svg_content = fs::read_to_string(path)?; Ok(svg_content) } /// Core rendering function fn render( ctx: &cairo::Context, map: &Map, stage_type: StageType, config: &Configuration, ) -> Result<(), RenderError> { // Apply padding transform ctx.save()?; ctx.translate(config.theme.sizes.padding, config.theme.sizes.padding); // Render in order (back to front) draw_background(ctx, config)?; if config.options.show_background { draw_stage_patterns(ctx, stage_type, map, config)?; } draw_axes(ctx, stage_type, map, config)?; draw_stage_dividers(ctx, map, config)?; draw_dependencies(ctx, map, config)?; draw_groups(ctx, map, config)?; draw_vertices(ctx, map, config)?; draw_vertex_labels(ctx, map, config)?; draw_inertias(ctx, map, config)?; draw_evolutions(ctx, map, config)?; draw_notes(ctx, map, config)?; ctx.restore()?; Ok(()) } fn draw_background(ctx: &cairo::Context, config: &Configuration) -> Result<(), RenderError> { ctx.save()?; set_color(ctx, &config.theme.colors.background); ctx.paint()?; ctx.restore()?; Ok(()) } fn draw_stage_patterns( ctx: &cairo::Context, _stage_type: StageType, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use crate::patterns::{create_pattern, SHADOW_GRID, SHINGLES, STITCH, WICKER}; let stages = get_stage_positions(map, config); let patterns = [ create_pattern( &STITCH, &config.theme.colors.stage_foreground, &config.theme.colors.stage_background, )?, create_pattern( &SHINGLES, &config.theme.colors.stage_foreground, &config.theme.colors.stage_background, )?, create_pattern( &SHADOW_GRID, &config.theme.colors.stage_foreground, &config.theme.colors.stage_background, )?, create_pattern( &WICKER, &config.theme.colors.stage_foreground, &config.theme.colors.stage_background, )?, ]; ctx.save()?; // Draw pattern for stage 0 to stage 1 ctx.rectangle(0.0, 0.0, stages[0], config.theme.sizes.map_height); ctx.set_source(&patterns[0])?; ctx.fill()?; // Draw pattern for stage 1 to stage 2 ctx.rectangle( stages[0], 0.0, stages[1] - stages[0], config.theme.sizes.map_height, ); ctx.set_source(&patterns[1])?; ctx.fill()?; // Draw pattern for stage 2 to stage 3 ctx.rectangle( stages[1], 0.0, stages[2] - stages[1], config.theme.sizes.map_height, ); ctx.set_source(&patterns[2])?; ctx.fill()?; // Draw pattern for stage 3 to end ctx.rectangle( stages[2], 0.0, config.theme.sizes.map_width - stages[2], config.theme.sizes.map_height, ); ctx.set_source(&patterns[3])?; ctx.fill()?; ctx.restore()?; Ok(()) } fn draw_axes( ctx: &cairo::Context, stage_type: StageType, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use std::f64::consts::PI; let sizes = &config.theme.sizes; let fonts = &config.theme.fonts; ctx.save()?; set_color(ctx, &config.theme.colors.axis); ctx.set_line_width(sizes.line_width * 2.0); // Y-axis and X-axis ctx.move_to(0.0, 0.0); ctx.line_to(0.0, sizes.map_height); ctx.line_to(sizes.map_width, sizes.map_height); ctx.stroke()?; // Text setup set_color(ctx, &config.theme.colors.label); ctx.select_font_face( "sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Normal, ); ctx.set_font_size(fonts.axis_label); ctx.set_line_width(1.0); // Y-axis labels (rotated -90 degrees) ctx.save()?; ctx.translate(-20.0, 45.0); ctx.rotate(-PI / 2.0); ctx.move_to(0.0, 0.0); ctx.show_text("Visible")?; ctx.restore()?; ctx.save()?; ctx.translate(-20.0, sizes.map_height); ctx.rotate(-PI / 2.0); ctx.move_to(0.0, 0.0); ctx.show_text("Invisible")?; ctx.restore()?; // X-axis labels (above the map, at negative Y, offset by half vertex height) let stage_height = sizes.stage_height; let text_offset_y = -stage_height / 4.0 + sizes.vertex_size / 2.0; ctx.move_to(0.0, text_offset_y); ctx.show_text("Uncharted")?; let text_ext = ctx.text_extents("Industrialised")?; ctx.move_to(sizes.map_width - text_ext.width(), text_offset_y); ctx.show_text("Industrialised")?; // Stage labels (below the map, offset by half vertex height, with text wrapping) let stage_labels = stage_type.stages(); let stages = get_stage_positions(map, config); let padding = 5.0; // kAxisLabelPadding let stage_label_y = sizes.map_height + padding + sizes.vertex_size / 2.0; let line_height = fonts.axis_label * 1.2; // 120% line height // Calculate widths for each stage let stage_widths = [ stages[0], stages[1] - stages[0], stages[2] - stages[1], sizes.map_width - stages[2], ]; // Draw each stage label with wrapping for (i, &label) in stage_labels.iter().enumerate() { let x = match i { 0 => 0.0, 1 => stages[0], 2 => stages[1], 3 => stages[2], _ => continue, }; let wrapped_lines = wrap_text(ctx, label, stage_widths[i])?; for (line_index, line) in wrapped_lines.iter().enumerate() { let y = stage_label_y + line_index as f64 * line_height; ctx.move_to(x, y); ctx.show_text(line)?; } } ctx.restore()?; Ok(()) } fn draw_stage_dividers( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { let stages = get_stage_positions(map, config); ctx.save()?; set_color(ctx, &config.theme.colors.axis); ctx.set_line_width(config.theme.sizes.line_width * 2.0); ctx.set_dash(&[10.0, 18.0], 0.0); for &stage_x in &stages { ctx.move_to(stage_x, 0.0); ctx.line_to(stage_x, config.theme.sizes.map_height); } ctx.stroke()?; ctx.restore()?; Ok(()) } fn draw_dependencies( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use std::collections::HashMap; use std::f64::consts::PI; // Build component map for fast lookup let mut component_map: HashMap = HashMap::new(); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); component_map.insert(component.label.to_lowercase(), (x, y)); } ctx.save()?; set_color(ctx, &config.theme.colors.vertex); ctx.set_line_width(config.theme.sizes.line_width); let half_width = config.theme.sizes.vertex_size / 2.0; let half_height = config.theme.sizes.vertex_size / 2.0; let arrow_angle = PI / 4.0; // 45 degrees for dependency in &map.dependencies { let from_coords = match component_map.get(&dependency.from.to_lowercase()) { Some(coords) => coords, None => continue, }; let to_coords = match component_map.get(&dependency.to.to_lowercase()) { Some(coords) => coords, None => continue, }; let origin_x = from_coords.0 + half_width; let origin_y = from_coords.1 + half_height; let dest_x = to_coords.0 + half_width; let dest_y = to_coords.1 + half_height; let angle = (dest_y - origin_y).atan2(dest_x - origin_x); let cosine = angle.cos(); let sine = angle.sin(); // Offset from vertex edges let offset_origin_x = origin_x + half_width * cosine; let offset_origin_y = origin_y + half_height * sine; let offset_dest_x = dest_x - half_width * cosine; let offset_dest_y = dest_y - half_height * sine; // Draw line ctx.move_to(offset_origin_x, offset_origin_y); ctx.line_to(offset_dest_x, offset_dest_y); ctx.stroke()?; // Draw arrowhead if directed if dependency.is_directed { let upper_angle = angle - arrow_angle; let lower_angle = angle + arrow_angle; let arrowhead_size = config.theme.sizes.arrowhead_size; ctx.move_to( offset_dest_x - arrowhead_size * upper_angle.cos(), offset_dest_y - arrowhead_size * upper_angle.sin(), ); ctx.line_to(offset_dest_x, offset_dest_y); ctx.line_to( offset_dest_x - arrowhead_size * lower_angle.cos(), offset_dest_y - arrowhead_size * lower_angle.sin(), ); ctx.stroke()?; } } ctx.restore()?; Ok(()) } fn draw_groups(ctx: &cairo::Context, map: &Map, config: &Configuration) -> Result<(), RenderError> { use geo::{ConcaveHull, LineString, Point}; use std::collections::HashMap; use std::f64::consts::PI; // Build component map (top-left corner positions) let mut component_map: HashMap = HashMap::new(); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); component_map.insert(component.label.to_lowercase(), (x, y)); } // Create offscreen surface for opacity handling let width = (config.theme.sizes.map_width + config.theme.sizes.vertex_size) as i32; let height = (config.theme.sizes.map_height + config.theme.sizes.vertex_size) as i32; let offscreen = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height)?; let offscreen_ctx = cairo::Context::new(&offscreen)?; // Translate to account for vertex width/height offscreen_ctx.translate( config.theme.sizes.vertex_size / 2.0, config.theme.sizes.vertex_size / 2.0, ); for (group_index, group) in map.groups.iter().enumerate() { let component_coords: Vec<(f64, f64)> = group .components .iter() .filter_map(|label| component_map.get(&label.to_lowercase()).copied()) .collect(); if component_coords.is_empty() { continue; } let color = &config.theme.colors.groups[group_index % config.theme.colors.groups.len()]; let is_line = component_coords.len() == 2; let coords = if component_coords.len() == 1 { // For 1 component, create a small circle let (x, y) = component_coords[0]; let radius = 3.0; let segments = 16; let mut circle_coords = Vec::new(); for i in 0..=segments { let angle = (i as f64 / segments as f64) * 2.0 * PI; circle_coords.push((x + radius * angle.cos(), y + radius * angle.sin())); } circle_coords } else if is_line { // For 2 components, simple line component_coords } else { // For 3+ components, use concave hull let points: Vec = component_coords .iter() .map(|&(x, y)| Point::new(x, y)) .collect(); let line_string: LineString = points.into(); let hull = line_string.concave_hull(2.0); hull.exterior().coords().map(|c| (c.x, c.y)).collect() }; if coords.is_empty() { continue; } // Clear offscreen canvas offscreen_ctx.set_operator(cairo::Operator::Clear); offscreen_ctx.paint()?; offscreen_ctx.set_operator(cairo::Operator::Over); // Draw path set_color(&offscreen_ctx, color); offscreen_ctx.move_to(coords[0].0, coords[0].1); for &(x, y) in &coords[1..] { offscreen_ctx.line_to(x, y); } // For polygons (not lines), close path and fill if !is_line { offscreen_ctx.close_path(); offscreen_ctx.fill_preserve()?; } // Draw stroke with round caps and joins for smoother appearance offscreen_ctx.set_line_width(config.theme.sizes.vertex_size * 1.75); offscreen_ctx.set_line_cap(cairo::LineCap::Round); offscreen_ctx.set_line_join(cairo::LineJoin::Round); offscreen_ctx.stroke()?; // Composite onto main context with opacity ctx.save()?; ctx.set_source_surface(&offscreen, 0.0, 0.0)?; ctx.paint_with_alpha(config.theme.opacity.groups)?; ctx.restore()?; } Ok(()) } fn draw_vertices( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use crate::shapes::draw_vertex; ctx.save()?; set_color(ctx, &config.theme.colors.vertex); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); draw_vertex( ctx, &component.shape, x, y, config.theme.sizes.vertex_size, config.theme.sizes.vertex_size, )?; } ctx.restore()?; Ok(()) } fn draw_vertex_labels( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { ctx.save()?; set_color(ctx, &config.theme.colors.label); ctx.select_font_face( "sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Normal, ); ctx.set_font_size(config.theme.fonts.vertex_label); for component in &map.components { let (label_x, label_y) = if config.options.smart_label_positioning { // Use smart label positioning to find optimal position use crate::smart_positioning::calculate_optimal_label_position; let position = calculate_optimal_label_position( component, map, config.theme.sizes.map_width, config.theme.sizes.map_height, config.theme.sizes.vertex_size, ctx, config.theme.fonts.vertex_label, )?; // Adjust Y position for text baseline (position.x, position.y + 7.0) } else { // Simple label positioning (to the right of vertex) let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); ( x + config.theme.sizes.vertex_size + 5.0, y + config.theme.sizes.vertex_size / 2.0 + config.theme.fonts.vertex_label / 3.0, ) }; ctx.move_to(label_x, label_y); ctx.show_text(&component.label)?; } ctx.restore()?; Ok(()) } fn draw_inertias( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use std::collections::HashMap; // Build component map (top-left corner positions) let mut component_map: HashMap = HashMap::new(); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); component_map.insert(component.label.to_lowercase(), (x, y)); } ctx.save()?; set_color(ctx, &config.theme.colors.inertia); let corner_radius = 2.0; let rect_width = config.theme.sizes.vertex_size / 2.0; let rect_height = config.theme.sizes.vertex_size * 2.0; for inertia in &map.inertias { if let Some(&(origin_x, origin_y)) = component_map.get(&inertia.component.to_lowercase()) { // Position relative to vertex top-left corner let x = origin_x + 3.0 * config.theme.sizes.vertex_size; let y = origin_y - (config.theme.sizes.vertex_size * 2.0) / 3.0; // Draw rounded rectangle draw_rounded_rect(ctx, x, y, rect_width, rect_height, corner_radius)?; ctx.fill()?; } } ctx.restore()?; Ok(()) } fn draw_rounded_rect( ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f64, radius: f64, ) -> Result<(), cairo::Error> { let r = radius; ctx.new_path(); ctx.arc( x + r, y + r, r, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0, ); ctx.arc( x + width - r, y + r, r, 3.0 * std::f64::consts::PI / 2.0, 0.0, ); ctx.arc( x + width - r, y + height - r, r, 0.0, std::f64::consts::PI / 2.0, ); ctx.arc( x + r, y + height - r, r, std::f64::consts::PI / 2.0, std::f64::consts::PI, ); ctx.close_path(); Ok(()) } /// Wraps text to fit within a given width, breaking at word boundaries fn wrap_text( ctx: &cairo::Context, text: &str, max_width: f64, ) -> Result, cairo::Error> { let words: Vec<&str> = text.split_whitespace().collect(); let mut lines = Vec::new(); let mut current_line = String::new(); for word in words { let test_line = if current_line.is_empty() { word.to_string() } else { format!("{} {}", current_line, word) }; let extents = ctx.text_extents(&test_line)?; if extents.width() <= max_width { current_line = test_line; } else { if !current_line.is_empty() { lines.push(current_line); } current_line = word.to_string(); } } if !current_line.is_empty() { lines.push(current_line); } // If no lines were created (single very long word), just use the original text if lines.is_empty() { lines.push(text.to_string()); } Ok(lines) } fn draw_evolutions( ctx: &cairo::Context, map: &Map, config: &Configuration, ) -> Result<(), RenderError> { use std::collections::HashMap; use std::f64::consts::PI; // Build component map (top-left corner positions) let mut component_map: HashMap = HashMap::new(); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(component.coordinates.1, config.theme.sizes.map_height); component_map.insert(component.label.to_lowercase(), (x, y)); } ctx.save()?; set_color(ctx, &config.theme.colors.evolution); ctx.set_line_width(config.theme.sizes.line_width * 2.0); ctx.set_dash(&[10.0], 0.0); for evolution in &map.evolutions { if let Some(&(origin_x, origin_y)) = component_map.get(&evolution.component.to_lowercase()) { // Calculate destination with clamping let evolution_offset = percent_to_pixel(evolution.value, config.theme.sizes.map_width); let dest_x = (origin_x + evolution_offset) .max(0.0) .min(config.theme.sizes.map_width); let dest_y = origin_y; // Determine direction let multiplier = if dest_x > origin_x { 1.0 } else { -1.0 }; // Calculate offsets from top-left corner to center the line on the vertex let offset_origin_x = origin_x + multiplier * (config.theme.sizes.vertex_size / 2.0) + config.theme.sizes.vertex_size / 2.0; let offset_origin_y = origin_y + config.theme.sizes.vertex_size / 2.0; let offset_dest_x = dest_x - multiplier * (config.theme.sizes.vertex_size / 2.0) + config.theme.sizes.vertex_size / 2.0; let offset_dest_y = dest_y + config.theme.sizes.vertex_size / 2.0; // Draw dashed line ctx.move_to(offset_origin_x, offset_origin_y); ctx.line_to(offset_dest_x, offset_dest_y); ctx.stroke()?; // Draw arrowhead at destination only let upper_angle = -PI / 4.0; let lower_angle = PI / 4.0; let arrowhead_size = config.theme.sizes.arrowhead_size; ctx.move_to(offset_dest_x, offset_dest_y); ctx.line_to( offset_dest_x - multiplier * arrowhead_size * upper_angle.cos(), offset_dest_y - multiplier * arrowhead_size * upper_angle.sin(), ); ctx.stroke()?; ctx.move_to(offset_dest_x, offset_dest_y); ctx.line_to( offset_dest_x - multiplier * arrowhead_size * lower_angle.cos(), offset_dest_y - multiplier * arrowhead_size * lower_angle.sin(), ); ctx.stroke()?; } } ctx.restore()?; Ok(()) } fn draw_notes(ctx: &cairo::Context, map: &Map, config: &Configuration) -> Result<(), RenderError> { let padding = 5.0; let max_width = 400.0; ctx.save()?; ctx.select_font_face( "sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Normal, ); ctx.set_font_size(config.theme.fonts.note); for note in &map.notes { let x = percent_to_pixel(note.coordinates.0, config.theme.sizes.map_width); let y = percent_to_pixel(note.coordinates.1, config.theme.sizes.map_height); // Split text by newlines (handle both \\n escape and actual newlines) let text = note.text.replace("\\n", "\n"); let lines: Vec<&str> = text.split('\n').collect(); // Calculate box dimensions let mut widest_line = 0.0; for line in &lines { let extents = ctx.text_extents(line)?; if extents.width() > widest_line { widest_line = extents.width(); } } let box_width = f64::min(max_width, widest_line + padding * 2.0); let box_height = lines.len() as f64 * config.theme.line_heights.note + padding * 2.0; // Draw background set_color(ctx, &config.theme.colors.background); ctx.rectangle(x, y, box_width, box_height); ctx.fill()?; // Draw border set_color(ctx, &config.theme.colors.vertex); ctx.set_line_width(config.theme.sizes.line_width); ctx.rectangle(x, y, box_width, box_height); ctx.stroke()?; // Draw text set_color(ctx, &config.theme.colors.label); for (i, line) in lines.iter().enumerate() { ctx.move_to( x + padding, y + padding + i as f64 * config.theme.line_heights.note + config.theme.fonts.note, ); ctx.show_text(line)?; } } ctx.restore()?; Ok(()) } /// Gets the pixel positions of evolution stages fn get_stage_positions(map: &Map, config: &Configuration) -> [f64; 3] { use wmap_parser::Stage; let default_stages = [25.0, 50.0, 75.0]; let mut stages = default_stages; // Override with map-specific stages if provided for stage_data in &map.stages { let index = match stage_data.stage { Stage::I => 0, Stage::Ii => 1, Stage::Iii => 2, Stage::Iv => continue, // Stage IV doesn't have a divider line }; stages[index] = stage_data.value; // Value is already a percentage (0-100) } [ percent_to_pixel(stages[0], config.theme.sizes.map_width), percent_to_pixel(stages[1], config.theme.sizes.map_width), percent_to_pixel(stages[2], config.theme.sizes.map_width), ] }