diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-16 12:07:43 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-16 16:34:52 +0100 |
| commit | afd0794104228cd20e177f41dc68e9ae7ecaabda (patch) | |
| tree | a7ee69438b0906b03b4b4a88fd376023a96acce0 /src | |
| parent | 2ae9158285a22c023ebd0f88bbadd8b0832079ea (diff) | |
Performance improvements, prep for publish.
Diffstat (limited to 'src')
| -rw-r--r-- | src/configuration.rs | 152 | ||||
| -rw-r--r-- | src/error.rs | 31 | ||||
| -rw-r--r-- | src/geometry.rs | 102 | ||||
| -rw-r--r-- | src/lib.rs | 102 | ||||
| -rw-r--r-- | src/patterns.rs | 37 | ||||
| -rw-r--r-- | src/renderer.rs | 773 | ||||
| -rw-r--r-- | src/shapes.rs | 97 | ||||
| -rw-r--r-- | src/smart_positioning.rs | 85 | ||||
| -rw-r--r-- | src/stage_type.rs | 2 | ||||
| -rw-r--r-- | src/utils.rs | 35 |
10 files changed, 767 insertions, 649 deletions
diff --git a/src/configuration.rs b/src/configuration.rs index 775d6cd..bc99781 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -17,34 +17,34 @@ impl Default for Options { /// Color configuration #[derive(Debug, Clone)] pub struct Colors { - pub background: String, - pub axis: String, - pub vertex: String, - pub label: String, - pub stage_foreground: String, - pub stage_background: String, - pub inertia: String, - pub evolution: String, - pub groups: Vec<String>, + pub background: (f64, f64, f64), + pub axis: (f64, f64, f64), + pub vertex: (f64, f64, f64), + pub label: (f64, f64, f64), + pub stage_foreground: (f64, f64, f64), + pub stage_background: (f64, f64, f64), + pub inertia: (f64, f64, f64), + pub evolution: (f64, f64, f64), + pub groups: Vec<(f64, f64, f64)>, } impl Default for Colors { fn default() -> Self { Self { - background: "#FFFFFF".to_string(), - axis: "#0F261F".to_string(), - vertex: "#0F261F".to_string(), - label: "#0F261F".to_string(), - stage_foreground: "#DAE6E3".to_string(), - stage_background: "#FFFFFF".to_string(), - inertia: "#FA2B00".to_string(), - evolution: "#4F8FE6".to_string(), + background: (1.0, 1.0, 1.0), + axis: (0.058824, 0.149020, 0.121569), // Deep Slate Green + vertex: (0.058824, 0.149020, 0.121569), + label: (0.058824, 0.149020, 0.121569), + stage_foreground: (0.854901, 0.901960, 0.890196), + stage_background: (1.0, 1.0, 1.0), + inertia: (0.980392, 0.168627, 0.0), + evolution: (0.309804, 0.560784, 0.901961), groups: vec![ - "#3A5BA0".to_string(), // Olympic Blue - "#C44536".to_string(), // Jasper Red - "#8FBE92".to_string(), // Light Porcelain Green - "#FAD55C".to_string(), // Naples Yellow - "#EC9CAB".to_string(), // Hermosa Pink + (0.227451, 0.356863, 0.627451), // Olympic Blue + (0.768627, 0.270588, 0.211765), // Jasper Red + (0.560784, 0.745098, 0.572549), // Light Porcelain Green + (0.980392, 0.835294, 0.360784), // Naples Yellow + (0.925490, 0.611765, 0.670588), // Hermosa Pink ], } } @@ -81,7 +81,7 @@ impl Default for Sizes { /// Font configuration #[derive(Debug, Clone)] pub struct Fonts { - pub family: String, + pub face: String, pub axis_label: f64, pub vertex_label: f64, pub note: f64, @@ -90,7 +90,7 @@ pub struct Fonts { impl Default for Fonts { fn default() -> Self { Self { - family: "Helvetica Neue, Helvetica, Arial, sans-serif".to_string(), + face: "sans-serif".to_string(), axis_label: 14.0, vertex_label: 12.0, note: 12.0, @@ -149,47 +149,89 @@ mod tests { #[test] fn test_default_configuration() { - let config = Configuration::default(); + let configuration = Configuration::default(); + const ALLOWED_ERROR_VEHICLE_LENGTH_CM: f64 = 0.1; // Test options - assert!(config.options.show_background); - assert!(config.options.smart_label_positioning); + assert!(configuration.options.show_background); + assert!(configuration.options.smart_label_positioning); - // Test colors (matching JS implementation exactly) - assert_eq!(config.theme.colors.background, "#FFFFFF"); - assert_eq!(config.theme.colors.axis, "#0F261F"); - assert_eq!(config.theme.colors.vertex, "#0F261F"); - assert_eq!(config.theme.colors.label, "#0F261F"); - assert_eq!(config.theme.colors.stage_foreground, "#DAE6E3"); - assert_eq!(config.theme.colors.stage_background, "#FFFFFF"); - assert_eq!(config.theme.colors.inertia, "#FA2B00"); - assert_eq!(config.theme.colors.evolution, "#4F8FE6"); - assert_eq!(config.theme.colors.groups.len(), 5); + // Test colors + assert_eq!(configuration.theme.colors.background, (1.0, 1.0, 1.0)); + assert_eq!( + configuration.theme.colors.axis, + (0.058_824, 0.149_020, 0.121_569) + ); + assert_eq!( + configuration.theme.colors.vertex, + (0.058_824, 0.149_020, 0.121_569) + ); + assert_eq!( + configuration.theme.colors.label, + (0.058_824, 0.149_020, 0.121_569) + ); + assert_eq!( + configuration.theme.colors.stage_foreground, + (0.854_901, 0.901_960, 0.890_196) + ); + assert_eq!(configuration.theme.colors.stage_background, (1.0, 1.0, 1.0)); + assert_eq!( + configuration.theme.colors.inertia, + (0.980_392, 0.168_627, 0.0) + ); + assert_eq!( + configuration.theme.colors.evolution, + (0.309_804, 0.560_784, 0.901_961) + ); + assert_eq!(configuration.theme.colors.groups.len(), 5); - // Test sizes (matching JS implementation with extra bottom padding for wrapped labels) - assert_eq!(config.theme.sizes.map_width, 1300.0); - assert_eq!(config.theme.sizes.map_height, 1000.0); - assert_eq!(config.theme.sizes.padding, 42.0); - assert_eq!(config.theme.sizes.bottom_padding, 120.0); - assert_eq!(config.theme.sizes.line_width, 0.5); - assert_eq!(config.theme.sizes.vertex_size, 25.0); - assert_eq!(config.theme.sizes.arrowhead_size, 10.0); - assert_eq!(config.theme.sizes.stage_height, 100.0); + // Test sizes + assert!( + (configuration.theme.sizes.map_width - 1300.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.sizes.map_height - 1000.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!((configuration.theme.sizes.padding - 42.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM); + assert!( + (configuration.theme.sizes.bottom_padding - 120.0).abs() + < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.sizes.line_width - 0.5).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.sizes.vertex_size - 25.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.sizes.arrowhead_size - 10.0).abs() + < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.sizes.stage_height - 100.0).abs() + < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); // Test fonts - assert_eq!( - config.theme.fonts.family, - "Helvetica Neue, Helvetica, Arial, sans-serif" + assert_eq!(configuration.theme.fonts.face, "sans-serif"); + assert!( + (configuration.theme.fonts.axis_label - 14.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.fonts.vertex_label - 12.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM ); - assert_eq!(config.theme.fonts.axis_label, 14.0); - assert_eq!(config.theme.fonts.vertex_label, 12.0); - assert_eq!(config.theme.fonts.note, 12.0); + assert!((configuration.theme.fonts.note - 12.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM); // Test line heights - assert_eq!(config.theme.line_heights.vertex_label, 18.0); - assert_eq!(config.theme.line_heights.note, 18.0); + assert!( + (configuration.theme.line_heights.vertex_label - 18.0).abs() + < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); + assert!( + (configuration.theme.line_heights.note - 18.0).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM + ); // Test opacity - assert_eq!(config.theme.opacity.groups, 0.1); + assert!((configuration.theme.opacity.groups - 0.1).abs() < ALLOWED_ERROR_VEHICLE_LENGTH_CM); } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1d6790b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,31 @@ +use cairo::{Error as CairoError, IoError}; +use std::io::Error as StdError; +use thiserror::Error; + +/// Error types for rendering +#[derive(Debug, Error)] +pub enum RenderError { + #[error("Cairo rendering error: {0}")] + Cairo(String), + + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + + #[error("Concave hull calculation failed")] + GeometryError, + + #[error("IO error: {0}")] + Io(#[from] StdError), +} + +impl From<CairoError> for RenderError { + fn from(err: CairoError) -> Self { + RenderError::Cairo(err.to_string()) + } +} + +impl From<IoError> for RenderError { + fn from(err: IoError) -> Self { + RenderError::Cairo(err.to_string()) + } +} diff --git a/src/geometry.rs b/src/geometry.rs index 5099ada..0841183 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -5,6 +5,12 @@ pub struct Line { pub end: (f64, f64), } +impl Line { + pub fn new(start: (f64, f64), end: (f64, f64)) -> Self { + Self { start, end } + } +} + /// Represents a rectangle #[derive(Debug, Clone, Copy)] pub struct Rect { @@ -14,12 +20,6 @@ pub struct Rect { pub height: f64, } -impl Line { - pub fn new(start: (f64, f64), end: (f64, f64)) -> Self { - Self { start, end } - } -} - impl Rect { pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { Self { @@ -71,28 +71,106 @@ pub fn line_intersects_line(l1: &Line, l2: &Line) -> bool { /// Checks if a line segment intersects a rectangle pub fn line_intersects_rect(line: &Line, rect: &Rect) -> bool { - // Check if either endpoint is inside the rectangle if point_in_rect(line.start, rect) || point_in_rect(line.end, rect) { return true; } - // Check intersection with all four edges let edges = [ - Line::new((rect.x, rect.y), (rect.x + rect.width, rect.y)), // Top + Line::new((rect.x, rect.y), (rect.x + rect.width, rect.y)), Line::new( (rect.x + rect.width, rect.y), (rect.x + rect.width, rect.y + rect.height), - ), // Right + ), Line::new( (rect.x + rect.width, rect.y + rect.height), (rect.x, rect.y + rect.height), - ), // Bottom - Line::new((rect.x, rect.y + rect.height), (rect.x, rect.y)), // Left + ), + Line::new((rect.x, rect.y + rect.height), (rect.x, rect.y)), ]; edges.iter().any(|edge| line_intersects_line(line, edge)) } +/// Spatial hash grid for fast collision detection +/// that stores cells as indices of lines. +pub struct SpatialGrid { + cell_size: f64, + cells: Vec<Vec<usize>>, + columns: usize, + rows: usize, +} + +impl SpatialGrid { + /// Creates a new spatial grid for the given dimensions + pub fn new(width: f64, height: f64, cell_size: f64) -> Self { + let columns = ((width / cell_size).ceil() as usize).max(1); + let rows = ((height / cell_size).ceil() as usize).max(1); + let cells = vec![Vec::new(); columns * rows]; + + Self { + cell_size, + cells, + columns, + rows, + } + } + + /// Inserts a line into the spatial grid + pub fn insert_line(&mut self, line_idx: usize, line: &Line) { + let min_x = line.start.0.min(line.end.0); + let max_x = line.start.0.max(line.end.0); + let min_y = line.start.1.min(line.end.1); + let max_y = line.start.1.max(line.end.1); + + let start_column = ((min_x / self.cell_size).floor() as usize).min(self.columns - 1); + let end_column = ((max_x / self.cell_size).floor() as usize).min(self.columns - 1); + let start_row = ((min_y / self.cell_size).floor() as usize).min(self.rows - 1); + let end_row = ((max_y / self.cell_size).floor() as usize).min(self.rows - 1); + + for row in start_row..=end_row { + for col in start_column..=end_column { + let cell_idx = row * self.columns + col; + self.cells[cell_idx].push(line_idx); + } + } + } + + /// Queries the spatial grid for lines that might intersect with the given rectangle + /// Returns line indices without duplicates + pub fn query_rect(&self, rect: &Rect) -> impl Iterator<Item = usize> + '_ { + let min_x = rect.x; + let max_x = rect.x + rect.width; + let min_y = rect.y; + let max_y = rect.y + rect.height; + + let start_column = ((min_x / self.cell_size).floor() as isize) + .max(0) + .min(self.columns as isize - 1) as usize; + let end_column = ((max_x / self.cell_size).floor() as isize) + .max(0) + .min(self.columns as isize - 1) as usize; + let start_row = ((min_y / self.cell_size).floor() as isize) + .max(0) + .min(self.rows as isize - 1) as usize; + let end_row = ((max_y / self.cell_size).floor() as isize) + .max(0) + .min(self.rows as isize - 1) as usize; + + let mut line_indices = Vec::new(); + for row in start_row..=end_row { + for column in start_column..=end_column { + let cell_idx = row * self.columns + column; + line_indices.extend_from_slice(&self.cells[cell_idx]); + } + } + + line_indices.sort_unstable(); + line_indices.dedup(); + + line_indices.into_iter() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1,4 +1,5 @@ mod configuration; +mod error; mod geometry; mod patterns; mod renderer; @@ -7,114 +8,21 @@ mod smart_positioning; mod stage_type; mod utils; -// Re-export parser types -pub use wmap_parser::{ - Component, Dependency, Evolution, Group, Inertia, Map, Note, Shape, Stage, StageData, -}; - -// Re-export our types pub use configuration::{ Colors, Configuration, Fonts, LineHeights, Opacity, Options, Sizes, Theme, }; +pub use renderer::{render_to_png, render_to_surface, render_to_svg}; pub use stage_type::StageType; -use thiserror::Error; -use cairo::ImageSurface; - -/// Error types for rendering -#[derive(Debug, Error)] -pub enum RenderError { - #[error("Cairo rendering error: {0}")] - Cairo(String), - - #[error("Invalid configuration: {0}")] - InvalidConfig(String), - - #[error("Concave hull calculation failed")] - GeometryError, - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -impl From<cairo::Error> for RenderError { - fn from(err: cairo::Error) -> Self { - RenderError::Cairo(err.to_string()) - } -} - -impl From<cairo::IoError> for RenderError { - fn from(err: cairo::IoError) -> Self { - RenderError::Cairo(err.to_string()) - } -} - -/// 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 -pub fn render_to_png( - map: &Map, - stage_type: StageType, - config: &Configuration, -) -> Result<Vec<u8>, RenderError> { - renderer::render_to_png(map, stage_type, config) -} - -/// 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 -pub fn render_to_svg( - map: &Map, - stage_type: StageType, - config: &Configuration, -) -> Result<String, RenderError> { - renderer::render_to_svg(map, stage_type, config) -} - -/// 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 -/// -/// A string containing the SVG data -pub fn render_to_surface( - map: &Map, - stage_type: StageType, - config: &Configuration, -) -> Result<ImageSurface, RenderError> { - renderer::render_to_surface(map, stage_type, config) -} - #[cfg(test)] mod tests { use super::*; #[test] fn test_default_configuration() { - let config = Configuration::default(); - assert!(config.options.show_background); - assert!(config.options.smart_label_positioning); + let configuration = Configuration::default(); + assert!(configuration.options.show_background); + assert!(configuration.options.smart_label_positioning); } #[test] diff --git a/src/patterns.rs b/src/patterns.rs index 12c790a..254433f 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -1,7 +1,5 @@ -use crate::utils::parse_color; +use cairo::{Context, Error, Extend, Format, ImageSurface, SurfacePattern}; -/// 8x8 1-bit pattern data -/// 0 = foreground color, 1 = background color pub const STITCH: [u8; 64] = [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, @@ -25,30 +23,28 @@ pub const WICKER: [u8; 64] = [ /// Creates a cairo surface pattern from pattern data pub fn create_pattern( pattern_data: &[u8; 64], - foreground: &str, - background: &str, -) -> Result<cairo::SurfacePattern, cairo::Error> { - let surface = cairo::ImageSurface::create(cairo::Format::Rgb24, 8, 8)?; + foreground: (f64, f64, f64), + background: (f64, f64, f64), +) -> Result<SurfacePattern, Error> { + let surface = ImageSurface::create(Format::Rgb24, 8, 8)?; { - let ctx = cairo::Context::new(&surface)?; - let fg = parse_color(foreground); - let bg = parse_color(background); + let context = Context::new(&surface)?; for y in 0..8 { for x in 0..8 { let value = pattern_data[y * 8 + x]; - let (r, g, b) = if value == 0 { fg } else { bg }; + let (r, g, b) = if value == 0 { foreground } else { background }; - ctx.set_source_rgb(r, g, b); - ctx.rectangle(x as f64, y as f64, 1.0, 1.0); - ctx.fill()?; + context.set_source_rgb(r, g, b); + context.rectangle(x as f64, y as f64, 1.0, 1.0); + context.fill()?; } } } - let pattern = cairo::SurfacePattern::create(&surface); - pattern.set_extend(cairo::Extend::Repeat); + let pattern = SurfacePattern::create(&surface); + pattern.set_extend(Extend::Repeat); Ok(pattern) } @@ -66,12 +62,11 @@ mod tests { #[test] fn test_pattern_values() { - // All pattern values should be 0 or 1 - for &val in &STITCH { - assert!(val == 0 || val == 1); + for &pixel in &STITCH { + assert!(pixel == 0 || pixel == 1); } - for &val in &SHINGLES { - assert!(val == 0 || val == 1); + for &pixel in &SHINGLES { + assert!(pixel == 0 || pixel == 1); } } } diff --git a/src/renderer.rs b/src/renderer.rs index 74f4708..a673bac 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,65 +1,108 @@ +use std::f64::consts::PI; +use std::fs::read_to_string; use std::io::Cursor; -use wmap_parser::Map; + +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, set_color}; -use crate::RenderError; -use cairo::{Context, Error, FontSlant, FontWeight, Format, ImageSurface, LineCap, LineJoin, SvgSurface}; +use crate::utils::percent_to_pixel; -/// Renders a map to PNG format +/// 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 pub fn render_to_png( map: &Map, stage_type: StageType, - config: &Configuration, + configuration: &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 total_width = + (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) as i32; + let total_height = (configuration.theme.sizes.map_height + + configuration.theme.sizes.padding + + configuration.theme.sizes.bottom_padding) as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; - let ctx = Context::new(&surface)?; + let context = Context::new(&surface)?; - render(&ctx, map, stage_type, config)?; + 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 map to the context +/// 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 +/// +/// A string containing the SVG data pub fn render_to_surface( map: &Map, stage_type: StageType, - config: &Configuration, + configuration: &Configuration, ) -> Result<ImageSurface, 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 total_width = + (configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0) as i32; + let total_height = (configuration.theme.sizes.map_height + + configuration.theme.sizes.padding + + configuration.theme.sizes.bottom_padding) as i32; let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?; - let ctx = Context::new(&surface)?; + let context = Context::new(&surface)?; - render(&ctx, map, stage_type, config)?; + render(&context, map, stage_type, configuration)?; Ok(surface) } -/// Renders a map to SVG format +/// 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 pub fn render_to_svg( map: &Map, stage_type: StageType, - config: &Configuration, + configuration: &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 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 @@ -68,201 +111,204 @@ pub fn render_to_svg( .ok_or_else(|| RenderError::InvalidConfig("Failed to create temp file path".to_string()))?; let surface = SvgSurface::new(total_width, total_height, Some(path))?; - let ctx = Context::new(&surface)?; + let context = Context::new(&surface)?; - render(&ctx, map, stage_type, config)?; + render(&context, map, stage_type, configuration)?; - drop(ctx); + drop(context); surface.finish(); - let svg_content = fs::read_to_string(path)?; + let svg_content = read_to_string(path)?; Ok(svg_content) } /// Core rendering function fn render( - ctx: &Context, + context: &Context, map: &Map, stage_type: StageType, - config: &Configuration, + configuration: &Configuration, ) -> Result<(), RenderError> { - use std::collections::HashMap; - // Apply padding transform - ctx.save()?; - ctx.translate(config.theme.sizes.padding, config.theme.sizes.padding); + context.save()?; + context.translate( + configuration.theme.sizes.padding, + configuration.theme.sizes.padding, + ); - // Build component map once and reuse it - let component_map: HashMap<String, (f64, f64)> = map.components - .iter() - .map(|component| { - 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.label.to_lowercase(), (x, y)) - }) - .collect(); + // 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<String, (f64, f64)> = + FxHashMap::with_capacity_and_hasher(map.components.len(), Default::default()); + 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(ctx, config)?; + draw_background(context, configuration)?; - if config.options.show_background { - draw_stage_patterns(ctx, stage_type, map, config)?; + if configuration.options.show_background { + draw_stage_patterns(context, stage_type, map, configuration)?; } - draw_axes(ctx, stage_type, map, config)?; - draw_stage_dividers(ctx, map, config)?; - draw_dependencies(ctx, map, config, &component_map)?; - draw_groups(ctx, map, config, &component_map)?; - draw_vertices(ctx, map, config)?; - draw_vertex_labels(ctx, map, config, &component_map)?; - draw_inertias(ctx, map, config, &component_map)?; - draw_evolutions(ctx, map, config, &component_map)?; - draw_notes(ctx, map, config)?; + 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)?; - ctx.restore()?; + context.restore()?; Ok(()) } -fn draw_background(ctx: &Context, config: &Configuration) -> Result<(), RenderError> { - ctx.save()?; - set_color(ctx, &config.theme.colors.background); - ctx.paint()?; - ctx.restore()?; +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( - ctx: &Context, + context: &Context, _stage_type: StageType, map: &Map, - config: &Configuration, + configuration: &Configuration, ) -> Result<(), RenderError> { - use crate::patterns::{create_pattern, SHADOW_GRID, SHINGLES, STITCH, WICKER}; + let stages = get_stage_positions(map, configuration); - 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, - )?, - ]; + // 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, + )?; - ctx.save()?; + context.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()?; + 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 - ctx.rectangle( + context.rectangle( stages[0], 0.0, stages[1] - stages[0], - config.theme.sizes.map_height, + configuration.theme.sizes.map_height, ); - ctx.set_source(&patterns[1])?; - ctx.fill()?; + context.set_source(&pattern1)?; + context.fill()?; // Draw pattern for stage 2 to stage 3 - ctx.rectangle( + context.rectangle( stages[1], 0.0, stages[2] - stages[1], - config.theme.sizes.map_height, + configuration.theme.sizes.map_height, ); - ctx.set_source(&patterns[2])?; - ctx.fill()?; + context.set_source(&pattern2)?; + context.fill()?; // Draw pattern for stage 3 to end - ctx.rectangle( + context.rectangle( stages[2], 0.0, - config.theme.sizes.map_width - stages[2], - config.theme.sizes.map_height, + configuration.theme.sizes.map_width - stages[2], + configuration.theme.sizes.map_height, ); - ctx.set_source(&patterns[3])?; - ctx.fill()?; + context.set_source(&pattern3)?; + context.fill()?; - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_axes( - ctx: &Context, + context: &Context, stage_type: StageType, map: &Map, - config: &Configuration, + configuration: &Configuration, ) -> Result<(), RenderError> { - use std::f64::consts::PI; + let sizes = &configuration.theme.sizes; + let fonts = &configuration.theme.fonts; - 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); + context.save()?; + let (r, g, b) = configuration.theme.colors.axis; + context.set_source_rgb(r, g, b); + context.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()?; + 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 - set_color(ctx, &config.theme.colors.label); - ctx.select_font_face( - "sans-serif", + let (r, g, b) = configuration.theme.colors.label; + context.set_source_rgb(r, g, b); + context.select_font_face( + &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); - ctx.set_font_size(fonts.axis_label); - ctx.set_line_width(1.0); + context.set_font_size(fonts.axis_label); + context.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()?; + 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()?; - 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()?; + 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; - ctx.move_to(0.0, text_offset_y); - ctx.show_text("Uncharted")?; + context.move_to(0.0, text_offset_y); + context.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")?; + 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, config); + 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 @@ -285,55 +331,55 @@ fn draw_axes( _ => continue, }; - let wrapped_lines = wrap_text(ctx, label, stage_widths[i])?; + let wrapped_lines = wrap_text(context, 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)?; + context.move_to(x, y); + context.show_text(line)?; } } - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_stage_dividers( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, + configuration: &Configuration, ) -> Result<(), RenderError> { - let stages = get_stage_positions(map, config); + let stages = get_stage_positions(map, configuration); - 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); + 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 { - ctx.move_to(stage_x, 0.0); - ctx.line_to(stage_x, config.theme.sizes.map_height); + context.move_to(stage_x, 0.0); + context.line_to(stage_x, configuration.theme.sizes.map_height); } - ctx.stroke()?; - ctx.restore()?; + context.stroke()?; + context.restore()?; Ok(()) } fn draw_dependencies( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, - component_map: &std::collections::HashMap<String, (f64, f64)>, + configuration: &Configuration, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Result<(), RenderError> { - use std::f64::consts::PI; - - ctx.save()?; - set_color(ctx, &config.theme.colors.vertex); - ctx.set_line_width(config.theme.sizes.line_width); + 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 = config.theme.sizes.vertex_size / 2.0; - let half_height = config.theme.sizes.vertex_size / 2.0; + 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 { @@ -362,21 +408,21 @@ fn draw_dependencies( 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); + 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 = config.theme.sizes.arrowhead_size; + let arrowhead_size = configuration.theme.sizes.arrowhead_size; - ctx.move_to( + context.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( + 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(), ); @@ -384,35 +430,34 @@ fn draw_dependencies( } // Batch stroke all paths at once - ctx.stroke()?; + context.stroke()?; - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_groups( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, - component_map: &std::collections::HashMap<String, (f64, f64)>, + configuration: &Configuration, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Result<(), RenderError> { - use geo::{ConcaveHull, LineString, Point}; - use std::f64::consts::PI; - if map.groups.is_empty() { return Ok(()); } // Create ONE offscreen surface for ALL groups 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 width = + (configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32; + let height = + (configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32; let offscreen = ImageSurface::create(Format::ARgb32, width, height)?; - let offscreen_ctx = Context::new(&offscreen)?; + let offscreen_context = 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, + 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() { @@ -426,7 +471,8 @@ fn draw_groups( continue; } - let color = &config.theme.colors.groups[group_index % config.theme.colors.groups.len()]; + 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 { @@ -451,7 +497,7 @@ fn draw_groups( .collect(); let line_string: LineString = points.into(); - let hull = line_string.concave_hull(2.0); + let hull = line_string.concave_hull(); hull.exterior().coords().map(|c| (c.x, c.y)).collect() }; @@ -460,163 +506,205 @@ fn draw_groups( } // Draw path on offscreen context - set_color(&offscreen_ctx, color); - offscreen_ctx.move_to(coords[0].0, coords[0].1); + 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_ctx.line_to(x, y); + offscreen_context.line_to(x, y); } // For polygons (not lines), close path and fill if !is_line { - offscreen_ctx.close_path(); - offscreen_ctx.fill_preserve()?; + offscreen_context.close_path(); + offscreen_context.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(LineCap::Round); - offscreen_ctx.set_line_join(LineJoin::Round); - offscreen_ctx.stroke()?; + 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) - ctx.save()?; - ctx.set_source_surface(&offscreen, 0.0, 0.0)?; - ctx.paint_with_alpha(config.theme.opacity.groups)?; - ctx.restore()?; + 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( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, + configuration: &Configuration, ) -> Result<(), RenderError> { - use crate::shapes::draw_vertex; - - ctx.save()?; - set_color(ctx, &config.theme.colors.vertex); + context.save()?; + let (r, g, b) = configuration.theme.colors.vertex; + context.set_source_rgb(r, g, b); 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); + 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, + ); draw_vertex( - ctx, + context, &component.shape, x, y, - config.theme.sizes.vertex_size, - config.theme.sizes.vertex_size, + configuration.theme.sizes.vertex_size, + configuration.theme.sizes.vertex_size, )?; } - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_vertex_labels( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, - component_map: &std::collections::HashMap<String, (f64, f64)>, + configuration: &Configuration, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Result<(), RenderError> { - ctx.save()?; - set_color(ctx, &config.theme.colors.label); - ctx.select_font_face( - "sans-serif", + context.save()?; + let (r, g, b) = configuration.theme.colors.label; + context.set_source_rgb(r, g, b); + context.select_font_face( + &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); - ctx.set_font_size(config.theme.fonts.vertex_label); + context.set_font_size(configuration.theme.fonts.vertex_label); - if config.options.smart_label_positioning { - // Pre-compute collision data once for all labels - use crate::smart_positioning::{collect_all_lines, collect_note_rects, calculate_optimal_label_position_cached}; + 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; - let all_lines = collect_all_lines( - map, - config.theme.sizes.map_width, - config.theme.sizes.map_height, - config.theme.sizes.vertex_size, - component_map, - ); + if has_significant_complexity { + // Pre-compute collision data once for all labels - let note_rects = collect_note_rects( - map, - config.theme.sizes.map_width, - config.theme.sizes.map_height, - ctx, - config.theme.fonts.vertex_label, - )?; + let all_lines = collect_all_lines( + map, + configuration.theme.sizes.map_width, + configuration.theme.sizes.map_height, + configuration.theme.sizes.vertex_size, + component_map, + ); - for component in &map.components { - let position = calculate_optimal_label_position_cached( - component, - config.theme.sizes.map_width, - config.theme.sizes.map_height, - config.theme.sizes.vertex_size, - ctx, - config.theme.fonts.vertex_label, + let spatial_grid = build_spatial_grid( &all_lines, - ¬e_rects, + 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, )?; - let label_x = position.x; - let label_y = position.y + 7.0; // Adjust Y position for text baseline + 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 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, + ); + let label_x = x + configuration.theme.sizes.vertex_size + 5.0; + let label_y = y + + configuration.theme.sizes.vertex_size / 2.0 + + configuration.theme.fonts.vertex_label / 3.0; - ctx.move_to(label_x, label_y); - ctx.show_text(&component.label)?; + 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 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); - let label_x = x + config.theme.sizes.vertex_size + 5.0; - let label_y = y + config.theme.sizes.vertex_size / 2.0 + config.theme.fonts.vertex_label / 3.0; + 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, + ); + let label_x = x + configuration.theme.sizes.vertex_size + 5.0; + let label_y = y + + configuration.theme.sizes.vertex_size / 2.0 + + configuration.theme.fonts.vertex_label / 3.0; - ctx.move_to(label_x, label_y); - ctx.show_text(&component.label)?; + context.move_to(label_x, label_y); + context.show_text(&component.label)?; } } - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_inertias( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, - component_map: &std::collections::HashMap<String, (f64, f64)>, + configuration: &Configuration, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Result<(), RenderError> { - ctx.save()?; - set_color(ctx, &config.theme.colors.inertia); + context.save()?; + let (r, g, b) = configuration.theme.colors.inertia; + context.set_source_rgb(r, g, b); 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; + 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 x = origin_x + 3.0 * config.theme.sizes.vertex_size; - let y = origin_y - (config.theme.sizes.vertex_size * 2.0) / 3.0; + let x = origin_x + 3.0 * configuration.theme.sizes.vertex_size; + let y = origin_y - (configuration.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()?; + draw_rounded_rect(context, x, y, rect_width, rect_height, corner_radius)?; + context.fill()?; } } - ctx.restore()?; + context.restore()?; Ok(()) } fn draw_rounded_rect( - ctx: &Context, + context: &Context, x: f64, y: f64, width: f64, @@ -624,45 +712,41 @@ fn draw_rounded_rect( radius: f64, ) -> Result<(), Error> { let r = radius; - ctx.new_path(); - ctx.arc( + context.new_path(); + context.arc( x + r, y + r, r, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0, ); - ctx.arc( + context.arc( x + width - r, y + r, r, 3.0 * std::f64::consts::PI / 2.0, 0.0, ); - ctx.arc( + context.arc( x + width - r, y + height - r, r, 0.0, std::f64::consts::PI / 2.0, ); - ctx.arc( + context.arc( x + r, y + height - r, r, std::f64::consts::PI / 2.0, std::f64::consts::PI, ); - ctx.close_path(); + context.close_path(); Ok(()) } /// Wraps text to fit within a given width, breaking at word boundaries -fn wrap_text( - ctx: &Context, - text: &str, - max_width: f64, -) -> Result<Vec<String>, Error> { +fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result<Vec<String>, Error> { let words: Vec<&str> = text.split_whitespace().collect(); let mut lines = Vec::new(); let mut current_line = String::new(); @@ -674,7 +758,7 @@ fn wrap_text( format!("{} {}", current_line, word) }; - let extents = ctx.text_extents(&test_line)?; + let extents = context.text_extents(&test_line)?; if extents.width() <= max_width { current_line = test_line; @@ -699,26 +783,26 @@ fn wrap_text( } fn draw_evolutions( - ctx: &Context, + context: &Context, map: &Map, - config: &Configuration, - component_map: &std::collections::HashMap<String, (f64, f64)>, + configuration: &Configuration, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Result<(), RenderError> { - use std::f64::consts::PI; - - 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); + 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, config.theme.sizes.map_width); + let evolution_offset = + percent_to_pixel(evolution.value, configuration.theme.sizes.map_width); let dest_x = (origin_x + evolution_offset) .max(0.0) - .min(config.theme.sizes.map_width); + .min(configuration.theme.sizes.map_width); let dest_y = origin_y; // Determine direction @@ -726,30 +810,30 @@ fn draw_evolutions( // 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; + + 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 - ctx.move_to(offset_origin_x, offset_origin_y); - ctx.line_to(offset_dest_x, offset_dest_y); + 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 = config.theme.sizes.arrowhead_size; + let arrowhead_size = configuration.theme.sizes.arrowhead_size; - ctx.move_to(offset_dest_x, offset_dest_y); - ctx.line_to( + 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(), ); - ctx.move_to(offset_dest_x, offset_dest_y); - ctx.line_to( + 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(), ); @@ -757,27 +841,31 @@ fn draw_evolutions( } // Batch stroke all paths at once - ctx.stroke()?; + context.stroke()?; - ctx.restore()?; + context.restore()?; Ok(()) } -fn draw_notes(ctx: &Context, map: &Map, config: &Configuration) -> Result<(), RenderError> { +fn draw_notes( + context: &Context, + map: &Map, + configuration: &Configuration, +) -> Result<(), RenderError> { let padding = 5.0; let max_width = 400.0; - ctx.save()?; - ctx.select_font_face( - "sans-serif", + context.save()?; + context.select_font_face( + &configuration.theme.fonts.face, FontSlant::Normal, FontWeight::Normal, ); - ctx.set_font_size(config.theme.fonts.note); + context.set_font_size(configuration.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); + let x = percent_to_pixel(note.coordinates.0, configuration.theme.sizes.map_width); + let 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"); @@ -786,45 +874,48 @@ fn draw_notes(ctx: &Context, map: &Map, config: &Configuration) -> Result<(), Re // Calculate box dimensions let mut widest_line = 0.0; for line in &lines { - let extents = ctx.text_extents(line)?; + 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); - let box_height = lines.len() as f64 * config.theme.line_heights.note + padding * 2.0; + let box_height = lines.len() as f64 * configuration.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()?; + let (r, g, b) = configuration.theme.colors.background; + context.set_source_rgb(r, g, b); + context.rectangle(x, y, box_width, box_height); + context.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()?; + let (r, g, b) = configuration.theme.colors.vertex; + context.set_source_rgb(r, g, b); + context.set_line_width(configuration.theme.sizes.line_width); + context.rectangle(x, y, box_width, box_height); + context.stroke()?; // Draw text - set_color(ctx, &config.theme.colors.label); + let (r, g, b) = configuration.theme.colors.label; + context.set_source_rgb(r, g, b); for (i, line) in lines.iter().enumerate() { - ctx.move_to( + context.move_to( x + padding, - y + padding + i as f64 * config.theme.line_heights.note + config.theme.fonts.note, + y + padding + + i as f64 * configuration.theme.line_heights.note + + configuration.theme.fonts.note, ); - ctx.show_text(line)?; + context.show_text(line)?; } } - ctx.restore()?; + context.restore()?; Ok(()) } /// Gets the pixel positions of evolution stages -fn get_stage_positions(map: &Map, config: &Configuration) -> [f64; 3] { - use wmap_parser::Stage; - +fn get_stage_positions(map: &Map, configuration: &Configuration) -> [f64; 3] { let default_stages = [25.0, 50.0, 75.0]; let mut stages = default_stages; @@ -840,8 +931,8 @@ fn get_stage_positions(map: &Map, config: &Configuration) -> [f64; 3] { } [ - 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), + 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), ] } diff --git a/src/shapes.rs b/src/shapes.rs index 2b8db2c..bf408be 100644 --- a/src/shapes.rs +++ b/src/shapes.rs @@ -1,95 +1,74 @@ +use cairo::{Context, Error}; +use std::f64::consts::PI; use wmap_parser::Shape; /// Draws a vertex shape at the given position pub fn draw_vertex( - ctx: &cairo::Context, + context: &Context, shape: &Shape, x: f64, y: f64, width: f64, height: f64, -) -> Result<(), cairo::Error> { +) -> Result<(), Error> { match shape { - Shape::Circle => draw_circle(ctx, x, y, width, height), - Shape::Square => draw_square(ctx, x, y, width, height), - Shape::Triangle => draw_triangle(ctx, x, y, width, height), - Shape::X => draw_x(ctx, x, y, width, height), + Shape::Circle => draw_circle(context, x, y, width, height), + Shape::Square => draw_square(context, x, y, width, height), + Shape::Triangle => draw_triangle(context, x, y, width, height), + Shape::X => draw_x(context, x, y, width, height), } } -fn draw_circle( - ctx: &cairo::Context, - x: f64, - y: f64, - width: f64, - height: f64, -) -> Result<(), cairo::Error> { - ctx.save()?; - ctx.translate(x + width / 2.0, y + height / 2.0); - ctx.scale(width / 2.0, height / 2.0); - ctx.arc(0.0, 0.0, 1.0, 0.0, 2.0 * std::f64::consts::PI); - ctx.restore()?; - ctx.fill()?; +fn draw_circle(context: &Context, x: f64, y: f64, width: f64, height: f64) -> Result<(), Error> { + context.save()?; + context.translate(x + width / 2.0, y + height / 2.0); + context.scale(width / 2.0, height / 2.0); + context.arc(0.0, 0.0, 1.0, 0.0, 2.0 * PI); + context.restore()?; + context.fill()?; Ok(()) } -fn draw_square( - ctx: &cairo::Context, - x: f64, - y: f64, - width: f64, - height: f64, -) -> Result<(), cairo::Error> { - ctx.rectangle(x, y, width, height); - ctx.fill()?; +fn draw_square(context: &Context, x: f64, y: f64, width: f64, height: f64) -> Result<(), Error> { + context.rectangle(x, y, width, height); + context.fill()?; Ok(()) } -fn draw_triangle( - ctx: &cairo::Context, - x: f64, - y: f64, - width: f64, - height: f64, -) -> Result<(), cairo::Error> { - ctx.move_to(x + width / 2.0, y); - ctx.line_to(x + width, y + height); - ctx.line_to(x, y + height); - ctx.close_path(); - ctx.fill()?; +fn draw_triangle(context: &Context, x: f64, y: f64, width: f64, height: f64) -> Result<(), Error> { + context.move_to(x + width / 2.0, y); + context.line_to(x + width, y + height); + context.line_to(x, y + height); + context.close_path(); + context.fill()?; Ok(()) } -fn draw_x( - ctx: &cairo::Context, - x: f64, - y: f64, - width: f64, - height: f64, -) -> Result<(), cairo::Error> { +fn draw_x(context: &Context, x: f64, y: f64, width: f64, height: f64) -> Result<(), Error> { let line_width = 2.0; - ctx.set_line_width(line_width); - ctx.move_to(x, y); - ctx.line_to(x + width, y + height); - ctx.move_to(x + width, y); - ctx.line_to(x, y + height); - ctx.stroke()?; + context.set_line_width(line_width); + context.move_to(x, y); + context.line_to(x + width, y + height); + context.move_to(x + width, y); + context.line_to(x, y + height); + context.stroke()?; Ok(()) } #[cfg(test)] mod tests { use super::*; + use cairo::{Context, Format, ImageSurface}; #[test] fn test_draw_shapes() { // Just verify the functions don't panic - let surface = cairo::ImageSurface::create(cairo::Format::Rgb24, 100, 100).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); + let surface = ImageSurface::create(Format::Rgb24, 100, 100).unwrap(); + let context = Context::new(&surface).unwrap(); - assert!(draw_vertex(&ctx, &Shape::Circle, 0.0, 0.0, 25.0, 25.0).is_ok()); - assert!(draw_vertex(&ctx, &Shape::Square, 0.0, 0.0, 25.0, 25.0).is_ok()); - assert!(draw_vertex(&ctx, &Shape::Triangle, 0.0, 0.0, 25.0, 25.0).is_ok()); - assert!(draw_vertex(&ctx, &Shape::X, 0.0, 0.0, 25.0, 25.0).is_ok()); + assert!(draw_vertex(&context, &Shape::Circle, 0.0, 0.0, 25.0, 25.0).is_ok()); + assert!(draw_vertex(&context, &Shape::Square, 0.0, 0.0, 25.0, 25.0).is_ok()); + assert!(draw_vertex(&context, &Shape::Triangle, 0.0, 0.0, 25.0, 25.0).is_ok()); + assert!(draw_vertex(&context, &Shape::X, 0.0, 0.0, 25.0, 25.0).is_ok()); } } diff --git a/src/smart_positioning.rs b/src/smart_positioning.rs index 77fd1b6..81a2c3d 100644 --- a/src/smart_positioning.rs +++ b/src/smart_positioning.rs @@ -1,5 +1,7 @@ -use crate::geometry::{Line, Rect}; +use crate::geometry::{Line, Rect, SpatialGrid}; use crate::utils::percent_to_pixel; + +use cairo::{Context, Error}; use wmap_parser::{Component, Map}; #[derive(Debug, Clone)] @@ -8,23 +10,30 @@ pub struct LabelPosition { pub y: f64, } -/// Calculate the optimal position for a vertex label with pre-cached collision data +#[derive(Debug, Clone)] +pub struct PositioningDimensions { + pub map_size: (f64, f64), + pub vertex_size: f64, + pub font_size: f64, +} + +/// Calculate the optimal position for a vertex label with pre-cached collision data and spatial grid pub fn calculate_optimal_label_position_cached( component: &Component, - map_width: f64, - map_height: f64, - vertex_size: f64, - ctx: &cairo::Context, - font_size: f64, + dimensions: &PositioningDimensions, + context: &Context, all_lines: &[Line], + spatial_grid: &SpatialGrid, note_rects: &[Rect], -) -> Result<LabelPosition, cairo::Error> { +) -> Result<LabelPosition, Error> { + let map_width = dimensions.map_size.0; + let map_height = dimensions.map_size.1; let vertex_pixel_x = percent_to_pixel(component.coordinates.0, map_width); let vertex_pixel_y = percent_to_pixel(component.coordinates.1, map_height); // Calculate label size - ctx.set_font_size(font_size); - let extents = ctx.text_extents(&component.label)?; + context.set_font_size(dimensions.font_size); + let extents = context.text_extents(&component.label)?; let label_width = extents.width() + 4.0; let label_height = extents.height() + 2.0; @@ -32,7 +41,7 @@ pub fn calculate_optimal_label_position_cached( let candidates = generate_candidate_positions( vertex_pixel_x, vertex_pixel_y, - vertex_size, + dimensions.vertex_size, label_width, label_height, ); @@ -46,7 +55,7 @@ pub fn calculate_optimal_label_position_cached( label_height, ); - if count_line_collisions(&default_rect, all_lines) == 0 + if count_line_collisions_with_grid(&default_rect, all_lines, spatial_grid) == 0 && count_note_collisions(&default_rect, note_rects) == 0 { return Ok(default_position.clone()); @@ -59,14 +68,14 @@ pub fn calculate_optimal_label_position_cached( for (index, position) in candidates.iter().enumerate() { let label_rect = Rect::new(position.x, position.y, label_width, label_height); - let collision_count = count_line_collisions(&label_rect, all_lines); + let collision_count = count_line_collisions_with_grid(&label_rect, all_lines, spatial_grid); let note_collision_count = count_note_collisions(&label_rect, note_rects); // Distance from label center to vertex center let label_center_x = position.x + label_width / 2.0; let label_center_y = position.y + label_height / 2.0; - let vertex_center_x = vertex_pixel_x + vertex_size / 2.0; - let vertex_center_y = vertex_pixel_y + vertex_size / 2.0; + let vertex_center_x = vertex_pixel_x + dimensions.vertex_size / 2.0; + let vertex_center_y = vertex_pixel_y + dimensions.vertex_size / 2.0; let distance = ((label_center_x - vertex_center_x).powi(2) + (label_center_y - vertex_center_y).powi(2)) .sqrt(); @@ -76,7 +85,7 @@ pub fn calculate_optimal_label_position_cached( &label_rect, vertex_pixel_x, vertex_pixel_y, - vertex_size, + dimensions.vertex_size, ); // Score: collisions * 100 + note_collisions * 500 + distance * 0.5 + index * 0.1 + ownership @@ -95,7 +104,6 @@ pub fn calculate_optimal_label_position_cached( Ok(best_position) } - /// Generate 8 candidate positions around a vertex fn generate_candidate_positions( vertex_x: f64, @@ -156,7 +164,7 @@ pub fn collect_all_lines( map_width: f64, map_height: f64, vertex_size: f64, - component_map: &std::collections::HashMap<String, (f64, f64)>, + component_map: &rustc_hash::FxHashMap<String, (f64, f64)>, ) -> Vec<Line> { let mut lines = Vec::new(); @@ -216,24 +224,38 @@ pub fn collect_all_lines( lines } +/// Build a spatial grid from a collection of lines for fast collision detection +pub fn build_spatial_grid(lines: &[Line], map_width: f64, map_height: f64) -> SpatialGrid { + // Use a cell size that balances grid overhead with query efficiency + // For typical maps, 100x100 pixel cells work well + let cell_size = 100.0; + let mut grid = SpatialGrid::new(map_width, map_height, cell_size); + + for (idx, line) in lines.iter().enumerate() { + grid.insert_line(idx, line); + } + + grid +} + /// Collect rectangles for all notes pub fn collect_note_rects( map: &Map, map_width: f64, map_height: f64, - ctx: &cairo::Context, + context: &Context, font_size: f64, -) -> Result<Vec<Rect>, cairo::Error> { +) -> Result<Vec<Rect>, Error> { let mut rects = Vec::new(); - ctx.set_font_size(font_size); + context.set_font_size(font_size); for note in &map.notes { let x = percent_to_pixel(note.coordinates.0, map_width); let y = percent_to_pixel(note.coordinates.1, map_height); // Calculate note size (simplified - just use text extents) - let extents = ctx.text_extents(¬e.text)?; + let extents = context.text_extents(¬e.text)?; let width = extents.width() + 10.0; // padding let height = extents.height() + 10.0; // padding @@ -243,11 +265,15 @@ pub fn collect_note_rects( Ok(rects) } -/// Count how many lines intersect with a label rectangle -fn count_line_collisions(label_rect: &Rect, lines: &[Line]) -> usize { - lines - .iter() - .filter(|line| crate::geometry::line_intersects_rect(line, label_rect)) +/// Count how many lines intersect with a label rectangle using spatial grid +fn count_line_collisions_with_grid( + label_rect: &Rect, + lines: &[Line], + spatial_grid: &SpatialGrid, +) -> usize { + spatial_grid + .query_rect(label_rect) + .filter(|&idx| crate::geometry::line_intersects_rect(&lines[idx], label_rect)) .count() } @@ -272,8 +298,9 @@ fn calculate_ownership_penalty_fast( let vertex_center_x = vertex_x + vertex_size / 2.0; let vertex_center_y = vertex_y + vertex_size / 2.0; - let distance = - ((label_center_x - vertex_center_x).powi(2) + (label_center_y - vertex_center_y).powi(2)).sqrt(); + let distance = ((label_center_x - vertex_center_x).powi(2) + + (label_center_y - vertex_center_y).powi(2)) + .sqrt(); // Apply penalty if label is too far from its vertex if distance > 100.0 { diff --git a/src/stage_type.rs b/src/stage_type.rs index f3ff056..a003317 100644 --- a/src/stage_type.rs +++ b/src/stage_type.rs @@ -28,6 +28,7 @@ pub enum StageType { impl StageType { /// Returns the name of this stage type + #[must_use] pub fn name(&self) -> &'static str { match self { Self::Activities => "Activities", @@ -56,6 +57,7 @@ impl StageType { } /// Returns the four evolution stage labels for this stage type + #[must_use] pub fn stages(&self) -> [&'static str; 4] { match self { Self::Activities => [ diff --git a/src/utils.rs b/src/utils.rs index b6738d4..8f6699a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,26 +4,6 @@ pub fn percent_to_pixel(percentage: f64, dimension: f64) -> f64 { (percentage * dimension) / 100.0 } -/// Parses a hex color string to RGB components (0.0-1.0) -pub fn parse_color(color: &str) -> (f64, f64, f64) { - let color = color.trim_start_matches('#'); - if color.len() != 6 { - return (0.0, 0.0, 0.0); // Default to black on error - } - - let r = u8::from_str_radix(&color[0..2], 16).unwrap_or(0) as f64 / 255.0; - let g = u8::from_str_radix(&color[2..4], 16).unwrap_or(0) as f64 / 255.0; - let b = u8::from_str_radix(&color[4..6], 16).unwrap_or(0) as f64 / 255.0; - - (r, g, b) -} - -/// Sets the cairo context color from a hex string -pub fn set_color(ctx: &cairo::Context, color: &str) { - let (r, g, b) = parse_color(color); - ctx.set_source_rgb(r, g, b); -} - #[cfg(test)] mod tests { use super::*; @@ -35,19 +15,4 @@ mod tests { assert_eq!(percent_to_pixel(100.0, 100.0), 100.0); assert_eq!(percent_to_pixel(25.0, 1300.0), 325.0); } - - #[test] - fn test_parse_color() { - // Test white - assert_eq!(parse_color("#FFFFFF"), (1.0, 1.0, 1.0)); - - // Test black - assert_eq!(parse_color("#000000"), (0.0, 0.0, 0.0)); - - // Test custom color (#0F261F) - let (r, g, b) = parse_color("#0F261F"); - assert!((r - 0.058823).abs() < 0.001); // 15/255 - assert!((g - 0.149019).abs() < 0.001); // 38/255 - assert!((b - 0.121568).abs() < 0.001); // 31/255 - } } |