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 | |
Initial implementation
Diffstat (limited to 'src')
| -rw-r--r-- | src/configuration.rs | 195 | ||||
| -rw-r--r-- | src/geometry.rs | 142 | ||||
| -rw-r--r-- | src/lib.rs | 105 | ||||
| -rw-r--r-- | src/patterns.rs | 77 | ||||
| -rw-r--r-- | src/renderer.rs | 823 | ||||
| -rw-r--r-- | src/shapes.rs | 95 | ||||
| -rw-r--r-- | src/smart_positioning.rs | 316 | ||||
| -rw-r--r-- | src/stage_type.rs | 199 | ||||
| -rw-r--r-- | src/utils.rs | 53 |
9 files changed, 2005 insertions, 0 deletions
diff --git a/src/configuration.rs b/src/configuration.rs new file mode 100644 index 0000000..775d6cd --- /dev/null +++ b/src/configuration.rs @@ -0,0 +1,195 @@ +/// Configuration options for rendering +#[derive(Debug, Clone)] +pub struct Options { + pub show_background: bool, + pub smart_label_positioning: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + show_background: true, + smart_label_positioning: true, + } + } +} + +/// 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>, +} + +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(), + 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 + ], + } + } +} + +/// Size configuration +#[derive(Debug, Clone)] +pub struct Sizes { + pub map_width: f64, + pub map_height: f64, + pub padding: f64, + pub bottom_padding: f64, + pub line_width: f64, + pub vertex_size: f64, + pub arrowhead_size: f64, + pub stage_height: f64, +} + +impl Default for Sizes { + fn default() -> Self { + Self { + map_width: 1300.0, + map_height: 1000.0, + padding: 42.0, + bottom_padding: 120.0, + line_width: 0.5, + vertex_size: 25.0, + arrowhead_size: 10.0, + stage_height: 100.0, + } + } +} + +/// Font configuration +#[derive(Debug, Clone)] +pub struct Fonts { + pub family: String, + pub axis_label: f64, + pub vertex_label: f64, + pub note: f64, +} + +impl Default for Fonts { + fn default() -> Self { + Self { + family: "Helvetica Neue, Helvetica, Arial, sans-serif".to_string(), + axis_label: 14.0, + vertex_label: 12.0, + note: 12.0, + } + } +} + +/// Line height configuration +#[derive(Debug, Clone)] +pub struct LineHeights { + pub vertex_label: f64, + pub note: f64, +} + +impl Default for LineHeights { + fn default() -> Self { + Self { + vertex_label: 18.0, + note: 18.0, + } + } +} + +/// Opacity configuration +#[derive(Debug, Clone)] +pub struct Opacity { + pub groups: f64, +} + +impl Default for Opacity { + fn default() -> Self { + Self { groups: 0.1 } + } +} + +/// Theme configuration +#[derive(Debug, Clone, Default)] +pub struct Theme { + pub colors: Colors, + pub sizes: Sizes, + pub fonts: Fonts, + pub line_heights: LineHeights, + pub opacity: Opacity, +} + +/// Main configuration struct +#[derive(Debug, Clone, Default)] +pub struct Configuration { + pub options: Options, + pub theme: Theme, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_configuration() { + let config = Configuration::default(); + + // Test options + assert!(config.options.show_background); + assert!(config.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 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 fonts + assert_eq!( + config.theme.fonts.family, + "Helvetica Neue, Helvetica, Arial, sans-serif" + ); + 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); + + // Test line heights + assert_eq!(config.theme.line_heights.vertex_label, 18.0); + assert_eq!(config.theme.line_heights.note, 18.0); + + // Test opacity + assert_eq!(config.theme.opacity.groups, 0.1); + } +} diff --git a/src/geometry.rs b/src/geometry.rs new file mode 100644 index 0000000..5099ada --- /dev/null +++ b/src/geometry.rs @@ -0,0 +1,142 @@ +/// Represents a line segment +#[derive(Debug, Clone, Copy)] +pub struct Line { + pub start: (f64, f64), + pub end: (f64, f64), +} + +/// Represents a rectangle +#[derive(Debug, Clone, Copy)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + 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 { + x, + y, + width, + height, + } + } +} + +/// Checks if a point is inside a rectangle +pub fn point_in_rect(point: (f64, f64), rect: &Rect) -> bool { + point.0 >= rect.x + && point.0 <= rect.x + rect.width + && point.1 >= rect.y + && point.1 <= rect.y + rect.height +} + +/// Checks if two rectangles intersect (AABB collision) +pub fn rect_intersects_rect(r1: &Rect, r2: &Rect) -> bool { + !(r1.x + r1.width < r2.x + || r2.x + r2.width < r1.x + || r1.y + r1.height < r2.y + || r2.y + r2.height < r1.y) +} + +/// Checks if two line segments intersect using parametric equations +pub fn line_intersects_line(l1: &Line, l2: &Line) -> bool { + let x1 = l1.start.0; + let y1 = l1.start.1; + let x2 = l1.end.0; + let y2 = l1.end.1; + let x3 = l2.start.0; + let y3 = l2.start.1; + let x4 = l2.end.0; + let y4 = l2.end.1; + + let denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if denom.abs() < 1e-10 { + return false; // Parallel or coincident + } + + let t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + let u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; + + (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) +} + +/// 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.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 + ]; + + edges.iter().any(|edge| line_intersects_line(line, edge)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_point_in_rect() { + let rect = Rect::new(10.0, 10.0, 20.0, 20.0); + assert!(point_in_rect((15.0, 15.0), &rect)); + assert!(point_in_rect((10.0, 10.0), &rect)); + assert!(point_in_rect((30.0, 30.0), &rect)); + assert!(!point_in_rect((5.0, 15.0), &rect)); + assert!(!point_in_rect((35.0, 15.0), &rect)); + } + + #[test] + fn test_rect_intersects_rect() { + let r1 = Rect::new(0.0, 0.0, 10.0, 10.0); + let r2 = Rect::new(5.0, 5.0, 10.0, 10.0); + let r3 = Rect::new(20.0, 20.0, 10.0, 10.0); + + assert!(rect_intersects_rect(&r1, &r2)); + assert!(rect_intersects_rect(&r2, &r1)); + assert!(!rect_intersects_rect(&r1, &r3)); + } + + #[test] + fn test_line_intersects_line() { + let l1 = Line::new((0.0, 0.0), (10.0, 10.0)); + let l2 = Line::new((0.0, 10.0), (10.0, 0.0)); + let l3 = Line::new((20.0, 20.0), (30.0, 30.0)); + + assert!(line_intersects_line(&l1, &l2)); + assert!(!line_intersects_line(&l1, &l3)); + } + + #[test] + fn test_line_intersects_rect() { + let rect = Rect::new(10.0, 10.0, 20.0, 20.0); + let l1 = Line::new((0.0, 15.0), (40.0, 15.0)); // Horizontal through + let l2 = Line::new((15.0, 0.0), (15.0, 40.0)); // Vertical through + let l3 = Line::new((0.0, 0.0), (5.0, 5.0)); // Outside + + assert!(line_intersects_rect(&l1, &rect)); + assert!(line_intersects_rect(&l2, &rect)); + assert!(!line_intersects_rect(&l3, &rect)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b56c670 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,105 @@ +mod configuration; +mod geometry; +mod patterns; +mod renderer; +mod shapes; +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 stage_type::StageType; + +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] 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) +} + +#[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); + } + + #[test] + fn test_default_stage_type() { + let stage = StageType::default(); + assert_eq!(stage, StageType::Activities); + } +} diff --git a/src/patterns.rs b/src/patterns.rs new file mode 100644 index 0000000..12c790a --- /dev/null +++ b/src/patterns.rs @@ -0,0 +1,77 @@ +use crate::utils::parse_color; + +/// 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, +]; + +pub const SHINGLES: [u8; 64] = [ + 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, +]; + +pub const SHADOW_GRID: [u8; 64] = [ + 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, +]; + +pub const WICKER: [u8; 64] = [ + 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, + 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, +]; + +/// 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)?; + + { + let ctx = cairo::Context::new(&surface)?; + let fg = parse_color(foreground); + let bg = parse_color(background); + + 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 }; + + ctx.set_source_rgb(r, g, b); + ctx.rectangle(x as f64, y as f64, 1.0, 1.0); + ctx.fill()?; + } + } + } + + let pattern = cairo::SurfacePattern::create(&surface); + pattern.set_extend(cairo::Extend::Repeat); + Ok(pattern) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pattern_data_lengths() { + assert_eq!(STITCH.len(), 64); + assert_eq!(SHINGLES.len(), 64); + assert_eq!(SHADOW_GRID.len(), 64); + assert_eq!(WICKER.len(), 64); + } + + #[test] + fn test_pattern_values() { + // All pattern values should be 0 or 1 + for &val in &STITCH { + assert!(val == 0 || val == 1); + } + for &val in &SHINGLES { + assert!(val == 0 || val == 1); + } + } +} 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), + ] +} diff --git a/src/shapes.rs b/src/shapes.rs new file mode 100644 index 0000000..2b8db2c --- /dev/null +++ b/src/shapes.rs @@ -0,0 +1,95 @@ +use wmap_parser::Shape; + +/// Draws a vertex shape at the given position +pub fn draw_vertex( + ctx: &cairo::Context, + shape: &Shape, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), cairo::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), + } +} + +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()?; + 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()?; + 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()?; + Ok(()) +} + +fn draw_x( + ctx: &cairo::Context, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), cairo::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()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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(); + + 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()); + } +} diff --git a/src/smart_positioning.rs b/src/smart_positioning.rs new file mode 100644 index 0000000..2def897 --- /dev/null +++ b/src/smart_positioning.rs @@ -0,0 +1,316 @@ +use crate::geometry::{Line, Rect}; +use crate::utils::percent_to_pixel; +use wmap_parser::{Component, Map}; + +#[derive(Debug, Clone)] +pub struct LabelPosition { + pub x: f64, + pub y: f64, +} + +/// Calculate the optimal position for a vertex label to minimize collisions +pub fn calculate_optimal_label_position( + component: &Component, + map: &Map, + map_width: f64, + map_height: f64, + vertex_size: f64, + ctx: &cairo::Context, + font_size: f64, +) -> Result<LabelPosition, cairo::Error> { + 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)?; + let label_width = extents.width() + 4.0; + let label_height = extents.height() + 2.0; + + // Generate candidate positions + let candidates = generate_candidate_positions( + vertex_pixel_x, + vertex_pixel_y, + vertex_size, + label_width, + label_height, + ); + + // Collect all lines (dependencies, evolutions, axis lines) + let all_lines = collect_all_lines(map, map_width, map_height, vertex_size); + + // Collect note rectangles + let note_rects = collect_note_rects(map, map_width, map_height, ctx, font_size)?; + + // Check if default position (right) has no collisions + let default_position = &candidates[0]; + let default_rect = Rect::new( + default_position.x, + default_position.y, + label_width, + label_height, + ); + + if count_line_collisions(&default_rect, &all_lines) == 0 + && count_note_collisions(&default_rect, ¬e_rects) == 0 + { + return Ok(default_position.clone()); + } + + // Score all positions and find the best + let mut best_position = default_position.clone(); + let mut best_score = f64::INFINITY; + + 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 note_collision_count = count_note_collisions(&label_rect, ¬e_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 distance = ((label_center_x - vertex_center_x).powi(2) + + (label_center_y - vertex_center_y).powi(2)) + .sqrt(); + + // Calculate ownership penalty + let ownership_penalty = calculate_ownership_penalty( + &label_rect, + component, + &map.components, + map_width, + map_height, + vertex_size, + ); + + // Score: collisions * 100 + note_collisions * 500 + distance * 0.5 + index * 0.1 + ownership + let score = (collision_count as f64) * 100.0 + + (note_collision_count as f64) * 500.0 + + distance * 0.5 + + (index as f64) * 0.1 + + ownership_penalty; + + if score < best_score { + best_score = score; + best_position = position.clone(); + } + } + + Ok(best_position) +} + +/// Generate 8 candidate positions around a vertex +fn generate_candidate_positions( + vertex_x: f64, + vertex_y: f64, + vertex_size: f64, + label_width: f64, + label_height: f64, +) -> Vec<LabelPosition> { + let padding = 5.0; + + vec![ + // Right (default) + LabelPosition { + x: vertex_x + vertex_size + padding, + y: vertex_y + (vertex_size - label_height) / 2.0, + }, + // Left + LabelPosition { + x: vertex_x - label_width - padding, + y: vertex_y + (vertex_size - label_height) / 2.0, + }, + // Top + LabelPosition { + x: vertex_x + (vertex_size - label_width) / 2.0, + y: vertex_y - label_height - padding, + }, + // Bottom + LabelPosition { + x: vertex_x + (vertex_size - label_width) / 2.0, + y: vertex_y + vertex_size + padding, + }, + // Top-right + LabelPosition { + x: vertex_x + vertex_size + padding, + y: vertex_y - label_height - padding, + }, + // Top-left + LabelPosition { + x: vertex_x - label_width - padding, + y: vertex_y - label_height - padding, + }, + // Bottom-right + LabelPosition { + x: vertex_x + vertex_size + padding, + y: vertex_y + vertex_size + padding, + }, + // Bottom-left + LabelPosition { + x: vertex_x - label_width - padding, + y: vertex_y + vertex_size + padding, + }, + ] +} + +/// Collect all lines from dependencies, evolutions, and axis lines +fn collect_all_lines(map: &Map, map_width: f64, map_height: f64, vertex_size: f64) -> Vec<Line> { + use std::collections::HashMap; + + let mut lines = Vec::new(); + + // Build component position map + let mut component_map: HashMap<String, (f64, f64)> = HashMap::new(); + for component in &map.components { + let x = percent_to_pixel(component.coordinates.0, map_width); + let y = percent_to_pixel(component.coordinates.1, map_height); + component_map.insert(component.label.to_lowercase(), (x, y)); + } + + // Collect dependency lines + for dependency in &map.dependencies { + if let (Some(&origin), Some(&destination)) = ( + component_map.get(&dependency.from.to_lowercase()), + component_map.get(&dependency.to.to_lowercase()), + ) { + let slope = (destination.1 - origin.1) / (destination.0 - origin.0); + let angle = slope.atan(); + let multiplier = if slope < 0.0 { -1.0 } else { 1.0 }; + + let offset_origin_x = origin.0 + multiplier * (vertex_size / 2.0) * angle.cos(); + let offset_origin_y = origin.1 + multiplier * (vertex_size / 2.0) * angle.sin(); + let offset_dest_x = destination.0 - multiplier * (vertex_size / 2.0) * angle.cos(); + let offset_dest_y = destination.1 - multiplier * (vertex_size / 2.0) * angle.sin(); + + let adjusted_origin_x = offset_origin_x + vertex_size / 2.0; + let adjusted_origin_y = offset_origin_y + vertex_size / 2.0; + let adjusted_dest_x = offset_dest_x + vertex_size / 2.0; + let adjusted_dest_y = offset_dest_y + vertex_size / 2.0; + + lines.push(Line::new( + (adjusted_origin_x, adjusted_origin_y), + (adjusted_dest_x, adjusted_dest_y), + )); + } + } + + // Collect evolution lines + for evolution in &map.evolutions { + if let Some(&origin) = component_map.get(&evolution.component.to_lowercase()) { + let evolution_offset = percent_to_pixel(evolution.value, map_width); + let destination_x = (origin.0 + evolution_offset).max(0.0).min(map_width); + let destination_y = origin.1; + + let multiplier = if destination_x > origin.0 { 1.0 } else { -1.0 }; + + let offset_origin_x = origin.0 + multiplier * (vertex_size / 2.0) + vertex_size / 2.0; + let offset_origin_y = origin.1 + vertex_size / 2.0; + let offset_dest_x = + destination_x - multiplier * (vertex_size / 2.0) + vertex_size / 2.0; + let offset_dest_y = destination_y + vertex_size / 2.0; + + lines.push(Line::new( + (offset_origin_x, offset_origin_y), + (offset_dest_x, offset_dest_y), + )); + } + } + + // Add axis lines + lines.push(Line::new((0.0, 0.0), (0.0, map_height))); // Y-axis + lines.push(Line::new((0.0, map_height), (map_width, map_height))); // X-axis + + lines +} + +/// Collect rectangles for all notes +fn collect_note_rects( + map: &Map, + map_width: f64, + map_height: f64, + ctx: &cairo::Context, + font_size: f64, +) -> Result<Vec<Rect>, cairo::Error> { + let mut rects = Vec::new(); + + ctx.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 width = extents.width() + 10.0; // padding + let height = extents.height() + 10.0; // padding + + rects.push(Rect::new(x, y, width, height)); + } + + 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() +} + +/// Count how many note rectangles overlap with a label rectangle +fn count_note_collisions(label_rect: &Rect, note_rects: &[Rect]) -> usize { + note_rects + .iter() + .filter(|note_rect| crate::geometry::rect_intersects_rect(label_rect, note_rect)) + .count() +} + +/// Calculate ownership penalty if label is closer to another vertex +fn calculate_ownership_penalty( + label_rect: &Rect, + own_component: &Component, + all_components: &[Component], + map_width: f64, + map_height: f64, + vertex_size: f64, +) -> f64 { + if all_components.is_empty() { + return 0.0; + } + + let label_center_x = label_rect.x + label_rect.width / 2.0; + let label_center_y = label_rect.y + label_rect.height / 2.0; + + let own_vertex_x = percent_to_pixel(own_component.coordinates.0, map_width); + let own_vertex_y = percent_to_pixel(own_component.coordinates.1, map_height); + let own_center_x = own_vertex_x + vertex_size / 2.0; + let own_center_y = own_vertex_y + vertex_size / 2.0; + + let distance_to_own = + ((label_center_x - own_center_x).powi(2) + (label_center_y - own_center_y).powi(2)).sqrt(); + + for other_component in all_components { + if other_component.label == own_component.label { + continue; + } + + let other_vertex_x = percent_to_pixel(other_component.coordinates.0, map_width); + let other_vertex_y = percent_to_pixel(other_component.coordinates.1, map_height); + let other_center_x = other_vertex_x + vertex_size / 2.0; + let other_center_y = other_vertex_y + vertex_size / 2.0; + + let distance_to_other = ((label_center_x - other_center_x).powi(2) + + (label_center_y - other_center_y).powi(2)) + .sqrt(); + + if distance_to_other < distance_to_own { + return 50.0; + } + } + + 0.0 +} diff --git a/src/stage_type.rs b/src/stage_type.rs new file mode 100644 index 0000000..f3ff056 --- /dev/null +++ b/src/stage_type.rs @@ -0,0 +1,199 @@ +/// Stage type used to analyze different aspects of a Wardley map +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StageType { + #[default] + Activities, + Behavior, + Certainty, + Comparison, + Cynefin, + Data, + DecisionDrivers, + Efficiency, + Failure, + FocusOfValue, + Knowledge, + KnowledgeManagement, + Market, + MarketAction, + MarketPerception, + PerceptionInIndustry, + Practice, + PublicationTypes, + Ubiquity, + Understanding, + UserPerception, + EvolutionStage, +} + +impl StageType { + /// Returns the name of this stage type + pub fn name(&self) -> &'static str { + match self { + Self::Activities => "Activities", + Self::Behavior => "Behavior", + Self::Certainty => "Certainty", + Self::Comparison => "Comparison", + Self::Cynefin => "Cynefin", + Self::Data => "Data", + Self::DecisionDrivers => "Decision Drivers", + Self::Efficiency => "Efficiency", + Self::Failure => "Failure", + Self::FocusOfValue => "Focus Of Value", + Self::Knowledge => "Knowledge", + Self::KnowledgeManagement => "Knowledge Management", + Self::Market => "Market", + Self::MarketAction => "Market Action", + Self::MarketPerception => "Market Perception", + Self::PerceptionInIndustry => "Perception In Industry", + Self::Practice => "Practice", + Self::PublicationTypes => "Publication Types", + Self::Ubiquity => "Ubiquity", + Self::Understanding => "Understanding", + Self::UserPerception => "User Perception", + Self::EvolutionStage => "Evolution Stage", + } + } + + /// Returns the four evolution stage labels for this stage type + pub fn stages(&self) -> [&'static str; 4] { + match self { + Self::Activities => [ + "Genesis", + "Custom", + "Product (+rental)", + "Commodity (+utility)", + ], + Self::Behavior => [ + "Uncertain when to use", + "Learning when to use", + "Learning through use", + "Known / common usage", + ], + Self::Certainty => [ + "Poorly Understood / exploring the unknown", + "Rapid Increase In Learning / discovery becomes refining", + "Rapid increase in use / increasing fit for purpose", + "Commonly understood (in terms of use)", + ], + Self::Comparison => [ + "Constantly changing / a differential / unstable", + "Learning from others / testing the water / some evidential support", + "Competing models / feature difference / evidential support", + "Essential / any advantage is operational / accepted norm", + ], + Self::Cynefin => ["Chaotic", "Complex", "Complicated", "Clear"], + Self::Data => ["Unmodelled", "Divergent", "Convergent", "Modelled"], + Self::DecisionDrivers => [ + "Heritage / culture", + "Analyses & synthesis", + "Analyses & synthesis", + "Previous Experience", + ], + Self::Efficiency => [ + "Reducing the cost of change (experimentation)", + "Reducing cost of waste (Learning)", + "Reducing cost of waste (Learning)", + "Reducing cost of deviation (Volume)", + ], + Self::Failure => [ + "High / tolerated / assumed to be wrong", + "Moderate / unsurprising if wrong but disappointed", + "Not tolerated / focus on constant improvement / assumed to be in the right direction / resistance to changing the model", + "Surprised by failure / focus on operational efficiency", + ], + Self::FocusOfValue => [ + "High future worth but immediate investment", + "Seeking ways to profit and a ROI / seeking confirmation of value", + "High profitability per unit / a valuable model / a feeling of understanding / focus on exploitation", + "High volume / reducing margin / important but invisible / an essential component of something more complex", + ], + Self::Knowledge => ["Concept", "Hypothesis", "Theory", "Accepted"], + Self::KnowledgeManagement => [ + "Uncertain", + "Learning on use / focused on testing prediction", + "Learning on operation / using prediction / verification", + "Known / accepted", + ], + Self::Market => [ + "Undefined Market", + "Forming Market / an array of competing forms and models of understanding", + "Growing Market / consolidation to a few competing but more accepted forms", + "Mature Market / stabilised to an accepted form", + ], + Self::MarketAction => [ + "Gambling / driven by gut", + "Exploring a \"found\" value", + "Market analysis / listening to customers", + "Metric driven / build what is needed", + ], + Self::MarketPerception => [ + "Chaotic (non-linear) / domain of the \"crazy\"", + "Domain of \"experts\"", + "Increasing expectation of use / domain of \"professionals\"", + "Ordered (appearance of being linear) / trivial / formula to be applied", + ], + Self::PerceptionInIndustry => [ + "Future source of competitive advantage / unpredictable / unknown", + "Seen as a scompetitive advantage / a differential / ROI / case examples", + "Advantage through implementation / features / this model is better than that", + "Cost of doing business / accepted / specific defined models", + ], + Self::Practice => ["Novel", "Emerging", "Good", "Best"], + Self::PublicationTypes => [ + "Describe the wonder of the thing / the discovery of some marvel / a new land / an unknown frontier", + "Focused on build / construct / awareness and learning / many models of explanation / no accepted forms / a wild west", + "Maintenance / operations / installation / comparison between competing forms / feature analysis", + "Focused on use / increasingly an accepted, almost invisible component", + ], + Self::Ubiquity => [ + "Rare", + "Slowly Increasing", + "Rapidly Increasing", + "Widespread in the applicable market / ecosystem", + ], + Self::Understanding => [ + "Poorly Understood / unpredictable", + "Increasing understanding / development of measures", + "Increasing education / constant refinement of needs / measures", + "Believed to be well defined / stable / measurable", + ], + Self::UserPerception => [ + "Different / confusing / exciting / surprising / dangerous", + "Leading edge / emerging / unceirtanty over results", + "Increasingly common / disappointed if not used or available / feeling left behind", + "Standard / expected / feeling of shock if not used", + ], + Self::EvolutionStage => ["Stage I", "Stage II", "Stage III", "Stage IV"], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stage_type_names() { + assert_eq!(StageType::Activities.name(), "Activities"); + assert_eq!(StageType::Behavior.name(), "Behavior"); + assert_eq!(StageType::Cynefin.name(), "Cynefin"); + } + + #[test] + fn test_stage_type_stages() { + let activities = StageType::Activities.stages(); + assert_eq!(activities[0], "Genesis"); + assert_eq!(activities[1], "Custom"); + assert_eq!(activities[2], "Product (+rental)"); + assert_eq!(activities[3], "Commodity (+utility)"); + + let cynefin = StageType::Cynefin.stages(); + assert_eq!(cynefin, ["Chaotic", "Complex", "Complicated", "Clear"]); + } + + #[test] + fn test_default_stage_type() { + assert_eq!(StageType::default(), StageType::Activities); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..b6738d4 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,53 @@ +/// Converts a percentage (0-100) to pixels based on the dimension +#[inline] +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::*; + + #[test] + fn test_percent_to_pixel() { + assert_eq!(percent_to_pixel(0.0, 100.0), 0.0); + assert_eq!(percent_to_pixel(50.0, 100.0), 50.0); + 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 + } +} |