use std::f64::consts::PI; use std::fs::read_to_string; use std::io::Cursor; use cairo::{ Context, Error, FontSlant, FontWeight, Format, ImageSurface, LineCap, LineJoin, SvgSurface, }; use geo::{ConcaveHull, LineString, Point}; use rustc_hash::FxHashMap; use tempfile::NamedTempFile; use wmap_parser::{Map, Stage}; use crate::configuration::Configuration; use crate::error::RenderError; use crate::patterns::{SHADOW_GRID, SHINGLES, STITCH, WICKER, create_pattern}; use crate::shapes::draw_vertex; use crate::smart_positioning::{ PositioningDimensions, build_spatial_grid, calculate_optimal_label_position_cached, collect_all_lines, collect_note_rects, }; use crate::stage_type::StageType; use crate::utils::percent_to_pixel; /// Renders a Wardley map to PNG format /// /// # Arguments /// /// * `map` - The parsed Wardley map /// * `stage_type` - The stage type to use for axis labels /// * `config` - Configuration options /// /// # Returns /// /// A vector of bytes containing the PNG image data /// /// # Errors /// /// Returns `RenderError` if the surface creation, rendering, or PNG encoding fails pub fn render_to_png( map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result, RenderError> { // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps #[allow(clippy::cast_possible_truncation)] let total_width = (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) .round() as i32; #[allow(clippy::cast_possible_truncation)] let total_height = (configuration.theme.sizes.map_height + configuration.theme.sizes.padding + configuration.theme.sizes.bottom_padding) .round() as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; let context = Context::new(&surface)?; render(&context, map, stage_type, configuration)?; let mut buffer = Cursor::new(Vec::new()); surface.write_to_png(&mut buffer)?; Ok(buffer.into_inner()) } /// Renders a Wardley map to a cairo context. /// /// # Arguments /// /// * `map` - The parsed Wardley map /// * `stage_type` - The stage type to use for axis labels /// * `config` - Configuration options /// /// # Returns /// /// An `ImageSurface` containing the rendered map /// /// # Errors /// /// Returns `RenderError` if the surface creation or rendering fails pub fn render_to_surface( map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result { // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps #[allow(clippy::cast_possible_truncation)] let total_width = (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) .round() as i32; #[allow(clippy::cast_possible_truncation)] let total_height = (configuration.theme.sizes.map_height + configuration.theme.sizes.padding + configuration.theme.sizes.bottom_padding) .round() as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; let context = Context::new(&surface)?; render(&context, map, stage_type, configuration)?; Ok(surface) } /// Renders a Wardley map to SVG format /// /// # Arguments /// /// * `map` - The parsed Wardley map /// * `stage_type` - The stage type to use for axis labels /// * `config` - Configuration options /// /// # Returns /// /// A string containing the SVG data /// /// # Errors /// /// Returns `RenderError` if the surface creation, rendering, or file I/O fails pub fn render_to_svg( map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result { let total_width = configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0; let total_height = configuration.theme.sizes.map_height + configuration.theme.sizes.padding + configuration.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 = SvgSurface::new(total_width, total_height, Some(path))?; let context = Context::new(&surface)?; render(&context, map, stage_type, configuration)?; drop(context); surface.finish(); let svg_content = read_to_string(path)?; Ok(svg_content) } /// Core rendering function fn render( context: &Context, map: &Map, stage_type: StageType, configuration: &Configuration, ) -> Result<(), RenderError> { // Apply padding transform context.save()?; context.translate( configuration.theme.sizes.padding, configuration.theme.sizes.padding, ); // Build component map once and reuse it (pre-allocate capacity for better performance) // Use FxHashMap for faster hashing (we don't need cryptographic security) let mut component_map: FxHashMap = FxHashMap::with_capacity_and_hasher(map.components.len(), rustc_hash::FxBuildHasher); for component in &map.components { let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); let y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); component_map.insert(component.label.to_lowercase(), (x, y)); } // Render in order (back to front) draw_background(context, configuration)?; if configuration.options.show_background { draw_stage_patterns(context, stage_type, map, configuration)?; } draw_axes(context, stage_type, map, configuration)?; draw_stage_dividers(context, map, configuration)?; draw_dependencies(context, map, configuration, &component_map)?; draw_groups(context, map, configuration, &component_map)?; draw_vertices(context, map, configuration)?; draw_vertex_labels(context, map, configuration, &component_map)?; draw_inertias(context, map, configuration, &component_map)?; draw_evolutions(context, map, configuration, &component_map)?; draw_notes(context, map, configuration)?; context.restore()?; Ok(()) } fn draw_background(context: &Context, configuration: &Configuration) -> Result<(), RenderError> { context.save()?; let (r, g, b) = configuration.theme.colors.background; context.set_source_rgb(r, g, b); context.paint()?; context.restore()?; Ok(()) } fn draw_stage_patterns( context: &Context, _stage_type: StageType, map: &Map, configuration: &Configuration, ) -> Result<(), RenderError> { let stages = get_stage_positions(map, configuration); // Create patterns once (they're small and fast to create) let pattern0 = create_pattern( &STITCH, configuration.theme.colors.stage_foreground, configuration.theme.colors.stage_background, )?; let pattern1 = create_pattern( &SHINGLES, configuration.theme.colors.stage_foreground, configuration.theme.colors.stage_background, )?; let pattern2 = create_pattern( &SHADOW_GRID, configuration.theme.colors.stage_foreground, configuration.theme.colors.stage_background, )?; let pattern3 = create_pattern( &WICKER, configuration.theme.colors.stage_foreground, configuration.theme.colors.stage_background, )?; context.save()?; // Draw pattern for stage 0 to stage 1 context.rectangle(0.0, 0.0, stages[0], configuration.theme.sizes.map_height); context.set_source(&pattern0)?; context.fill()?; // Draw pattern for stage 1 to stage 2 context.rectangle( stages[0], 0.0, stages[1] - stages[0], configuration.theme.sizes.map_height, ); context.set_source(&pattern1)?; context.fill()?; // Draw pattern for stage 2 to stage 3 context.rectangle( stages[1], 0.0, stages[2] - stages[1], configuration.theme.sizes.map_height, ); context.set_source(&pattern2)?; context.fill()?; // Draw pattern for stage 3 to end context.rectangle( stages[2], 0.0, configuration.theme.sizes.map_width - stages[2], configuration.theme.sizes.map_height, ); context.set_source(&pattern3)?; context.fill()?; context.restore()?; Ok(()) } fn draw_axes( context: &Context, stage_type: StageType, map: &Map, configuration: &Configuration, ) -> Result<(), RenderError> { let sizes = &configuration.theme.sizes; let fonts = &configuration.theme.fonts; context.save()?; let (red, green, blue) = configuration.theme.colors.axis; context.set_source_rgb(red, green, blue); context.set_line_width(sizes.line_width * 2.0); // Y-axis and X-axis context.move_to(0.0, 0.0); context.line_to(0.0, sizes.map_height); context.line_to(sizes.map_width, sizes.map_height); context.stroke()?; // Text setup let (red, green, blue) = configuration.theme.colors.label; context.set_source_rgb(red, green, blue); context.select_font_face( &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); context.set_font_size(fonts.axis_label); context.set_line_width(1.0); // Y-axis labels (rotated -90 degrees) context.save()?; context.translate(-20.0, 45.0); context.rotate(-PI / 2.0); context.move_to(0.0, 0.0); context.show_text("Visible")?; context.restore()?; context.save()?; context.translate(-20.0, sizes.map_height); context.rotate(-PI / 2.0); context.move_to(0.0, 0.0); context.show_text("Invisible")?; context.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; context.move_to(0.0, text_offset_y); context.show_text("Uncharted")?; let text_ext = context.text_extents("Industrialised")?; context.move_to(sizes.map_width - text_ext.width(), text_offset_y); context.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, configuration); 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 stage_x = match i { 0 => 0.0, 1 => stages[0], 2 => stages[1], 3 => stages[2], _ => continue, }; let wrapped_lines = wrap_text(context, label, stage_widths[i])?; for (line_index, line) in wrapped_lines.iter().enumerate() { // Safe cast: line_index is a small number (typically < 10 for wrapped stage labels) #[allow(clippy::cast_precision_loss)] let text_y = stage_label_y + line_index as f64 * line_height; context.move_to(stage_x, text_y); context.show_text(line)?; } } context.restore()?; Ok(()) } fn draw_stage_dividers( context: &Context, map: &Map, configuration: &Configuration, ) -> Result<(), RenderError> { let stages = get_stage_positions(map, configuration); context.save()?; let (r, g, b) = configuration.theme.colors.axis; context.set_source_rgb(r, g, b); context.set_line_width(configuration.theme.sizes.line_width * 2.0); context.set_dash(&[10.0, 18.0], 0.0); for &stage_x in &stages { context.move_to(stage_x, 0.0); context.line_to(stage_x, configuration.theme.sizes.map_height); } context.stroke()?; context.restore()?; Ok(()) } fn draw_dependencies( context: &Context, map: &Map, configuration: &Configuration, component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; let (r, g, b) = configuration.theme.colors.vertex; context.set_source_rgb(r, g, b); context.set_line_width(configuration.theme.sizes.line_width); let half_width = configuration.theme.sizes.vertex_size / 2.0; let half_height = configuration.theme.sizes.vertex_size / 2.0; let arrow_angle = PI / 4.0; // 45 degrees for dependency in &map.dependencies { let Some(from_coords) = component_map.get(&dependency.from.to_lowercase()) else { continue; }; let Some(to_coords) = component_map.get(&dependency.to.to_lowercase()) else { 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 context.move_to(offset_origin_x, offset_origin_y); context.line_to(offset_dest_x, offset_dest_y); // Draw arrowhead if directed if dependency.is_directed { let upper_angle = angle - arrow_angle; let lower_angle = angle + arrow_angle; let arrowhead_size = configuration.theme.sizes.arrowhead_size; context.move_to( offset_dest_x - arrowhead_size * upper_angle.cos(), offset_dest_y - arrowhead_size * upper_angle.sin(), ); context.line_to(offset_dest_x, offset_dest_y); context.line_to( offset_dest_x - arrowhead_size * lower_angle.cos(), offset_dest_y - arrowhead_size * lower_angle.sin(), ); } } // Batch stroke all paths at once context.stroke()?; context.restore()?; Ok(()) } fn draw_groups( context: &Context, map: &Map, configuration: &Configuration, component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { if map.groups.is_empty() { return Ok(()); } // Create ONE offscreen surface for ALL groups for opacity handling // Safe cast: map dimensions plus vertex size are small values (< 10000 typically) #[allow(clippy::cast_possible_truncation)] let width = (configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32; #[allow(clippy::cast_possible_truncation)] let height = (configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32; let offscreen = ImageSurface::create(Format::ARgb32, width, height)?; let offscreen_context = Context::new(&offscreen)?; // Translate to account for vertex width/height offscreen_context.translate( configuration.theme.sizes.vertex_size / 2.0, configuration.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 = &configuration.theme.colors.groups [group_index % configuration.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 = (f64::from(i) / f64::from(segments)) * 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(); hull.exterior().coords().map(|c| (c.x, c.y)).collect() }; if coords.is_empty() { continue; } // Draw path on offscreen context let (r, g, b) = *color; offscreen_context.set_source_rgb(r, g, b); offscreen_context.move_to(coords[0].0, coords[0].1); for &(x, y) in &coords[1..] { offscreen_context.line_to(x, y); } // For polygons (not lines), close path and fill if !is_line { offscreen_context.close_path(); offscreen_context.fill_preserve()?; } // Draw stroke with round caps and joins for smoother appearance offscreen_context.set_line_width(configuration.theme.sizes.vertex_size * 1.75); offscreen_context.set_line_cap(LineCap::Round); offscreen_context.set_line_join(LineJoin::Round); offscreen_context.stroke()?; } // Composite entire offscreen surface onto main context with opacity (once for all groups) context.save()?; context.set_source_surface(&offscreen, 0.0, 0.0)?; context.paint_with_alpha(configuration.theme.opacity.groups)?; context.restore()?; Ok(()) } fn draw_vertices( context: &Context, map: &Map, configuration: &Configuration, ) -> Result<(), RenderError> { context.save()?; let (red, green, blue) = configuration.theme.colors.vertex; context.set_source_rgb(red, green, blue); for component in &map.components { let vertex_x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); draw_vertex( context, &component.shape, vertex_x, vertex_y, configuration.theme.sizes.vertex_size, configuration.theme.sizes.vertex_size, )?; } context.restore()?; Ok(()) } fn draw_vertex_labels( context: &Context, map: &Map, configuration: &Configuration, component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; let (red, green, blue) = configuration.theme.colors.label; context.set_source_rgb(red, green, blue); context.select_font_face( &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); context.set_font_size(configuration.theme.fonts.vertex_label); if configuration.options.smart_label_positioning { // For maps with very few dependencies, smart positioning overhead isn't worth it let has_significant_complexity = map.dependencies.len() + map.evolutions.len() > 10; if has_significant_complexity { // Pre-compute collision data once for all labels let all_lines = collect_all_lines( map, configuration.theme.sizes.map_width, configuration.theme.sizes.map_height, configuration.theme.sizes.vertex_size, component_map, ); let spatial_grid = build_spatial_grid( &all_lines, configuration.theme.sizes.map_width, configuration.theme.sizes.map_height, ); let note_rects = collect_note_rects( map, configuration.theme.sizes.map_width, configuration.theme.sizes.map_height, context, configuration.theme.fonts.vertex_label, )?; for component in &map.components { let position = calculate_optimal_label_position_cached( component, &PositioningDimensions { map_size: ( configuration.theme.sizes.map_width, configuration.theme.sizes.map_height, ), vertex_size: configuration.theme.sizes.vertex_size, font_size: configuration.theme.fonts.vertex_label, }, context, &all_lines, &spatial_grid, ¬e_rects, )?; let label_x = position.x; let label_y = position.y + 7.0; // Adjust Y position for text baseline context.move_to(label_x, label_y); context.show_text(&component.label)?; } } else { // Use simple positioning for simple maps for component in &map.components { let vertex_x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0; let label_y = vertex_y + configuration.theme.sizes.vertex_size / 2.0 + configuration.theme.fonts.vertex_label / 3.0; context.move_to(label_x, label_y); context.show_text(&component.label)?; } } } else { // Simple label positioning (to the right of vertex) for component in &map.components { let vertex_x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width); let vertex_y = percent_to_pixel( component.coordinates.1, configuration.theme.sizes.map_height, ); let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0; let label_y = vertex_y + configuration.theme.sizes.vertex_size / 2.0 + configuration.theme.fonts.vertex_label / 3.0; context.move_to(label_x, label_y); context.show_text(&component.label)?; } } context.restore()?; Ok(()) } fn draw_inertias( context: &Context, map: &Map, configuration: &Configuration, component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; let (red, green, blue) = configuration.theme.colors.inertia; context.set_source_rgb(red, green, blue); let corner_radius = 2.0; let rect_width = configuration.theme.sizes.vertex_size / 2.0; let rect_height = configuration.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 inertia_x = origin_x + 3.0 * configuration.theme.sizes.vertex_size; let inertia_y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0; // Draw rounded rectangle draw_rounded_rect( context, inertia_x, inertia_y, rect_width, rect_height, corner_radius, ); context.fill()?; } } context.restore()?; Ok(()) } fn draw_rounded_rect(context: &Context, x: f64, y: f64, width: f64, height: f64, radius: f64) { let r = radius; context.new_path(); context.arc( x + r, y + r, r, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0, ); context.arc( x + width - r, y + r, r, 3.0 * std::f64::consts::PI / 2.0, 0.0, ); context.arc( x + width - r, y + height - r, r, 0.0, std::f64::consts::PI / 2.0, ); context.arc( x + r, y + height - r, r, std::f64::consts::PI / 2.0, std::f64::consts::PI, ); context.close_path(); } /// Wraps text to fit within a given width, breaking at word boundaries fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result, 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 = context.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( context: &Context, map: &Map, configuration: &Configuration, component_map: &rustc_hash::FxHashMap, ) -> Result<(), RenderError> { context.save()?; let (r, g, b) = configuration.theme.colors.evolution; context.set_source_rgb(r, g, b); context.set_line_width(configuration.theme.sizes.line_width * 2.0); context.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, configuration.theme.sizes.map_width); let dest_x = (origin_x + evolution_offset) .max(0.0) .min(configuration.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 * (configuration.theme.sizes.vertex_size / 2.0) + configuration.theme.sizes.vertex_size / 2.0; let offset_origin_y = origin_y + configuration.theme.sizes.vertex_size / 2.0; let offset_dest_x = dest_x - multiplier * (configuration.theme.sizes.vertex_size / 2.0) + configuration.theme.sizes.vertex_size / 2.0; let offset_dest_y = dest_y + configuration.theme.sizes.vertex_size / 2.0; // Draw dashed line context.move_to(offset_origin_x, offset_origin_y); context.line_to(offset_dest_x, offset_dest_y); // Draw arrowhead at destination only let upper_angle = -PI / 4.0; let lower_angle = PI / 4.0; let arrowhead_size = configuration.theme.sizes.arrowhead_size; context.move_to(offset_dest_x, offset_dest_y); context.line_to( offset_dest_x - multiplier * arrowhead_size * upper_angle.cos(), offset_dest_y - multiplier * arrowhead_size * upper_angle.sin(), ); context.move_to(offset_dest_x, offset_dest_y); context.line_to( offset_dest_x - multiplier * arrowhead_size * lower_angle.cos(), offset_dest_y - multiplier * arrowhead_size * lower_angle.sin(), ); } } // Batch stroke all paths at once context.stroke()?; context.restore()?; Ok(()) } fn draw_notes( context: &Context, map: &Map, configuration: &Configuration, ) -> Result<(), RenderError> { let padding = 5.0; let max_width = 400.0; context.save()?; context.select_font_face( &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); context.set_font_size(configuration.theme.fonts.note); for note in &map.notes { let note_x = percent_to_pixel(note.coordinates.0, configuration.theme.sizes.map_width); let note_y = percent_to_pixel(note.coordinates.1, configuration.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 = context.text_extents(line)?; if extents.width() > widest_line { widest_line = extents.width(); } } let box_width = f64::min(max_width, widest_line + padding * 2.0); // Safe cast: number of lines in a note is small (typically < 100) #[allow(clippy::cast_precision_loss)] let box_height = lines.len() as f64 * configuration.theme.line_heights.note + padding * 2.0; // Draw background let (red, green, blue) = configuration.theme.colors.background; context.set_source_rgb(red, green, blue); context.rectangle(note_x, note_y, box_width, box_height); context.fill()?; // Draw border let (red, green, blue) = configuration.theme.colors.vertex; context.set_source_rgb(red, green, blue); context.set_line_width(configuration.theme.sizes.line_width); context.rectangle(note_x, note_y, box_width, box_height); context.stroke()?; // Draw text let (red, green, blue) = configuration.theme.colors.label; context.set_source_rgb(red, green, blue); for (i, line) in lines.iter().enumerate() { // Safe cast: line index is a small number (typically < 100 for note text) #[allow(clippy::cast_precision_loss)] let line_offset = i as f64 * configuration.theme.line_heights.note; context.move_to( note_x + padding, note_y + padding + line_offset + configuration.theme.fonts.note, ); context.show_text(line)?; } } context.restore()?; Ok(()) } /// Gets the pixel positions of evolution stages fn get_stage_positions(map: &Map, configuration: &Configuration) -> [f64; 3] { 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], configuration.theme.sizes.map_width), percent_to_pixel(stages[1], configuration.theme.sizes.map_width), percent_to_pixel(stages[2], configuration.theme.sizes.map_width), ] }