aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/configuration.rs195
-rw-r--r--src/geometry.rs142
-rw-r--r--src/lib.rs105
-rw-r--r--src/patterns.rs77
-rw-r--r--src/renderer.rs823
-rw-r--r--src/shapes.rs95
-rw-r--r--src/smart_positioning.rs316
-rw-r--r--src/stage_type.rs199
-rw-r--r--src/utils.rs53
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, &note_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, &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 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(&note.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
+ }
+}