diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-16 10:40:44 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-16 10:55:34 +0100 |
| commit | f08648e4b5a9f7855a2ed9068a386e2f3c596322 (patch) | |
| tree | 1f1567ea609a1f74aaeab3cbbfdb249c5c47d723 /src/renderer.rs | |
Initial implementation
Diffstat (limited to 'src/renderer.rs')
| -rw-r--r-- | src/renderer.rs | 823 |
1 files changed, 823 insertions, 0 deletions
diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..9991d56 --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,823 @@ +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<Vec<u8>, 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<String, RenderError> { + 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<String, (f64, f64)> = 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<String, (f64, f64)> = 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<Point> = 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<String, (f64, f64)> = 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<Vec<String>, 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<String, (f64, f64)> = 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), + ] +} |