aboutsummaryrefslogtreecommitdiff
path: root/src/renderer.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer.rs')
-rw-r--r--src/renderer.rs823
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),
+ ]
+}