Skip to main content

wmap_renderer/
renderer.rs

1use std::f64::consts::PI;
2use std::fs::read_to_string;
3use std::io::Cursor;
4
5use cairo::{
6    Context, Error, FontSlant, FontWeight, Format, ImageSurface, LineCap, LineJoin, SvgSurface,
7};
8use geo::{ConcaveHull, LineString, Point};
9use rustc_hash::FxHashMap;
10use tempfile::NamedTempFile;
11use wmap_parser::{Map, Stage};
12
13use crate::configuration::Configuration;
14use crate::error::RenderError;
15use crate::patterns::{SHADOW_GRID, SHINGLES, STITCH, WICKER, create_pattern};
16use crate::shapes::draw_vertex;
17use crate::smart_positioning::{
18    PositioningDimensions, build_spatial_grid, calculate_optimal_label_position_cached,
19    collect_all_lines, collect_note_rects,
20};
21use crate::stage_type::StageType;
22use crate::utils::percent_to_pixel;
23
24/// Renders a Wardley map to PNG format
25///
26/// # Arguments
27///
28/// * `map` - The parsed Wardley map
29/// * `stage_type` - The stage type to use for axis labels
30/// * `config` - Configuration options
31///
32/// # Returns
33///
34/// A vector of bytes containing the PNG image data
35///
36/// # Errors
37///
38/// Returns `RenderError` if the surface creation, rendering, or PNG encoding fails
39pub fn render_to_png(
40    map: &Map,
41    stage_type: &StageType,
42    configuration: &Configuration,
43) -> Result<Vec<u8>, RenderError> {
44    // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels
45    // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps
46    #[allow(clippy::cast_possible_truncation)]
47    let total_width = (configuration.theme.sizes.map_width
48        + configuration.theme.sizes.padding * 2.0)
49        .round() as i32;
50    #[allow(clippy::cast_possible_truncation)]
51    let total_height = (configuration.theme.sizes.map_height
52        + configuration.theme.sizes.padding
53        + configuration.theme.sizes.bottom_padding)
54        .round() as i32;
55
56    let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?;
57    let context = Context::new(&surface)?;
58
59    render(&context, map, stage_type, configuration)?;
60
61    let mut buffer = Cursor::new(Vec::new());
62    surface.write_to_png(&mut buffer)?;
63    Ok(buffer.into_inner())
64}
65
66/// Renders a Wardley map to a cairo context.
67///
68/// # Arguments
69///
70/// * `map` - The parsed Wardley map
71/// * `stage_type` - The stage type to use for axis labels
72/// * `config` - Configuration options
73///
74/// # Returns
75///
76/// An `ImageSurface` containing the rendered map
77///
78/// # Errors
79///
80/// Returns `RenderError` if the surface creation or rendering fails
81pub fn render_to_surface(
82    map: &Map,
83    stage_type: &StageType,
84    configuration: &Configuration,
85) -> Result<ImageSurface, RenderError> {
86    // Safe cast: map dimensions are typically in the range of hundreds to thousands of pixels
87    // Cairo's i32 surface dimensions can handle up to 2^31-1, so truncation is not a concern for typical maps
88    #[allow(clippy::cast_possible_truncation)]
89    let total_width = (configuration.theme.sizes.map_width
90        + configuration.theme.sizes.padding * 2.0)
91        .round() as i32;
92    #[allow(clippy::cast_possible_truncation)]
93    let total_height = (configuration.theme.sizes.map_height
94        + configuration.theme.sizes.padding
95        + configuration.theme.sizes.bottom_padding)
96        .round() as i32;
97
98    let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?;
99    let context = Context::new(&surface)?;
100
101    render(&context, map, stage_type, configuration)?;
102
103    Ok(surface)
104}
105
106/// Renders a Wardley map to SVG format
107///
108/// # Arguments
109///
110/// * `map` - The parsed Wardley map
111/// * `stage_type` - The stage type to use for axis labels
112/// * `config` - Configuration options
113///
114/// # Returns
115///
116/// A string containing the SVG data
117///
118/// # Errors
119///
120/// Returns `RenderError` if the surface creation, rendering, or file I/O fails
121pub fn render_to_svg(
122    map: &Map,
123    stage_type: &StageType,
124    configuration: &Configuration,
125) -> Result<String, RenderError> {
126    let total_width = configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0;
127    let total_height = configuration.theme.sizes.map_height
128        + configuration.theme.sizes.padding
129        + configuration.theme.sizes.bottom_padding;
130
131    let temp_file = NamedTempFile::new()?;
132    let path = temp_file
133        .path()
134        .to_str()
135        .ok_or_else(|| RenderError::InvalidConfig("Failed to create temp file path".to_string()))?;
136
137    let surface = SvgSurface::new(total_width, total_height, Some(path))?;
138    let context = Context::new(&surface)?;
139
140    render(&context, map, stage_type, configuration)?;
141
142    drop(context);
143    surface.finish();
144
145    let svg_content = read_to_string(path)?;
146    Ok(svg_content)
147}
148
149/// Core rendering function
150fn render(
151    context: &Context,
152    map: &Map,
153    stage_type: &StageType,
154    configuration: &Configuration,
155) -> Result<(), RenderError> {
156    // Apply padding transform
157    context.save()?;
158    context.translate(
159        configuration.theme.sizes.padding,
160        configuration.theme.sizes.padding,
161    );
162
163    // Build component map once and reuse it (pre-allocate capacity for better performance)
164    // Use FxHashMap for faster hashing (we don't need cryptographic security)
165    let mut component_map: FxHashMap<String, (f64, f64)> =
166        FxHashMap::with_capacity_and_hasher(map.components.len(), rustc_hash::FxBuildHasher);
167    for component in &map.components {
168        let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
169        let y = percent_to_pixel(
170            component.coordinates.1,
171            configuration.theme.sizes.map_height,
172        );
173        component_map.insert(component.label.to_lowercase(), (x, y));
174    }
175
176    // Render in order (back to front)
177    draw_background(context, configuration)?;
178
179    if configuration.options.show_background {
180        draw_stage_patterns(context, map, configuration)?;
181    }
182
183    draw_axes(context, stage_type, map, configuration)?;
184    draw_stage_dividers(context, map, configuration)?;
185    draw_dependencies(context, map, configuration, &component_map)?;
186    draw_groups(context, map, configuration, &component_map)?;
187    draw_vertices(context, map, configuration)?;
188    draw_vertex_labels(context, map, configuration, &component_map)?;
189    draw_inertias(context, map, configuration, &component_map)?;
190    draw_evolutions(context, map, configuration, &component_map)?;
191    draw_notes(context, map, configuration)?;
192
193    context.restore()?;
194    Ok(())
195}
196
197fn draw_background(context: &Context, configuration: &Configuration) -> Result<(), RenderError> {
198    context.save()?;
199    let (r, g, b) = configuration.theme.colors.background;
200    context.set_source_rgb(r, g, b);
201    context.paint()?;
202    context.restore()?;
203    Ok(())
204}
205
206fn draw_stage_patterns(
207    context: &Context,
208    map: &Map,
209    configuration: &Configuration,
210) -> Result<(), RenderError> {
211    let stages = get_stage_positions(map, configuration);
212
213    // Create patterns once (they're small and fast to create)
214    let pattern0 = create_pattern(
215        &STITCH,
216        configuration.theme.colors.stage_foreground,
217        configuration.theme.colors.stage_background,
218    )?;
219    let pattern1 = create_pattern(
220        &SHINGLES,
221        configuration.theme.colors.stage_foreground,
222        configuration.theme.colors.stage_background,
223    )?;
224    let pattern2 = create_pattern(
225        &SHADOW_GRID,
226        configuration.theme.colors.stage_foreground,
227        configuration.theme.colors.stage_background,
228    )?;
229    let pattern3 = create_pattern(
230        &WICKER,
231        configuration.theme.colors.stage_foreground,
232        configuration.theme.colors.stage_background,
233    )?;
234
235    context.save()?;
236
237    // Draw pattern for stage 0 to stage 1
238    context.rectangle(0.0, 0.0, stages[0], configuration.theme.sizes.map_height);
239    context.set_source(&pattern0)?;
240    context.fill()?;
241
242    // Draw pattern for stage 1 to stage 2
243    context.rectangle(
244        stages[0],
245        0.0,
246        stages[1] - stages[0],
247        configuration.theme.sizes.map_height,
248    );
249    context.set_source(&pattern1)?;
250    context.fill()?;
251
252    // Draw pattern for stage 2 to stage 3
253    context.rectangle(
254        stages[1],
255        0.0,
256        stages[2] - stages[1],
257        configuration.theme.sizes.map_height,
258    );
259    context.set_source(&pattern2)?;
260    context.fill()?;
261
262    // Draw pattern for stage 3 to end
263    context.rectangle(
264        stages[2],
265        0.0,
266        configuration.theme.sizes.map_width - stages[2],
267        configuration.theme.sizes.map_height,
268    );
269    context.set_source(&pattern3)?;
270    context.fill()?;
271
272    context.restore()?;
273    Ok(())
274}
275
276fn draw_axes(
277    context: &Context,
278    stage_type: &StageType,
279    map: &Map,
280    configuration: &Configuration,
281) -> Result<(), RenderError> {
282    let sizes = &configuration.theme.sizes;
283    let fonts = &configuration.theme.fonts;
284
285    context.save()?;
286    let (red, green, blue) = configuration.theme.colors.axis;
287    context.set_source_rgb(red, green, blue);
288    context.set_line_width(sizes.line_width * 2.0);
289
290    // Y-axis and X-axis
291    context.move_to(0.0, 0.0);
292    context.line_to(0.0, sizes.map_height);
293    context.line_to(sizes.map_width, sizes.map_height);
294    context.stroke()?;
295
296    // Text setup
297    let (red, green, blue) = configuration.theme.colors.label;
298    context.set_source_rgb(red, green, blue);
299    context.select_font_face(
300        &configuration.theme.fonts.face,
301        FontSlant::Normal,
302        FontWeight::Normal,
303    );
304    context.set_font_size(fonts.axis_label);
305    context.set_line_width(1.0);
306
307    // Y-axis labels (rotated -90 degrees)
308    context.save()?;
309    context.translate(-20.0, 45.0);
310    context.rotate(-PI / 2.0);
311    context.move_to(0.0, 0.0);
312    context.show_text("Visible")?;
313    context.restore()?;
314
315    context.save()?;
316    context.translate(-20.0, sizes.map_height);
317    context.rotate(-PI / 2.0);
318    context.move_to(0.0, 0.0);
319    context.show_text("Invisible")?;
320    context.restore()?;
321
322    // X-axis labels (above the map, at negative Y, offset by half vertex height)
323    let stage_height = sizes.stage_height;
324    let text_offset_y = -stage_height / 4.0 + sizes.vertex_size / 2.0;
325    context.move_to(0.0, text_offset_y);
326    context.show_text("Uncharted")?;
327
328    let text_ext = context.text_extents("Industrialised")?;
329    context.move_to(sizes.map_width - text_ext.width(), text_offset_y);
330    context.show_text("Industrialised")?;
331
332    // Stage labels (below the map, offset by half vertex height, with text wrapping)
333    let stage_labels = stage_type.stages();
334    let stages = get_stage_positions(map, configuration);
335    let padding = 5.0; // kAxisLabelPadding
336    let stage_label_y = sizes.map_height + padding + sizes.vertex_size / 2.0;
337    let line_height = fonts.axis_label * 1.2; // 120% line height
338
339    // Calculate widths for each stage
340    let stage_widths = [
341        stages[0],
342        stages[1] - stages[0],
343        stages[2] - stages[1],
344        sizes.map_width - stages[2],
345    ];
346
347    // Draw each stage label with wrapping
348    for (i, &label) in stage_labels.iter().enumerate() {
349        let stage_x = match i {
350            0 => 0.0,
351            1 => stages[0],
352            2 => stages[1],
353            3 => stages[2],
354            _ => continue,
355        };
356
357        let wrapped_lines = wrap_text(context, label, stage_widths[i])?;
358
359        for (line_index, line) in wrapped_lines.iter().enumerate() {
360            // Safe cast: line_index is a small number (typically < 10 for wrapped stage labels)
361            #[allow(clippy::cast_precision_loss)]
362            let text_y = stage_label_y + line_index as f64 * line_height;
363            context.move_to(stage_x, text_y);
364            context.show_text(line)?;
365        }
366    }
367
368    context.restore()?;
369    Ok(())
370}
371
372fn draw_stage_dividers(
373    context: &Context,
374    map: &Map,
375    configuration: &Configuration,
376) -> Result<(), RenderError> {
377    let stages = get_stage_positions(map, configuration);
378
379    context.save()?;
380    let (r, g, b) = configuration.theme.colors.axis;
381    context.set_source_rgb(r, g, b);
382    context.set_line_width(configuration.theme.sizes.line_width * 2.0);
383    context.set_dash(&[10.0, 18.0], 0.0);
384
385    for &stage_x in &stages {
386        context.move_to(stage_x, 0.0);
387        context.line_to(stage_x, configuration.theme.sizes.map_height);
388    }
389
390    context.stroke()?;
391    context.restore()?;
392    Ok(())
393}
394
395fn draw_dependencies(
396    context: &Context,
397    map: &Map,
398    configuration: &Configuration,
399    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
400) -> Result<(), RenderError> {
401    context.save()?;
402    let (r, g, b) = configuration.theme.colors.vertex;
403    context.set_source_rgb(r, g, b);
404    context.set_line_width(configuration.theme.sizes.line_width);
405
406    let half_width = configuration.theme.sizes.vertex_size / 2.0;
407    let half_height = configuration.theme.sizes.vertex_size / 2.0;
408    let arrow_angle = PI / 4.0; // 45 degrees
409
410    for dependency in &map.dependencies {
411        let Some(from_coords) = component_map.get(&dependency.from.to_lowercase()) else {
412            continue;
413        };
414        let Some(to_coords) = component_map.get(&dependency.to.to_lowercase()) else {
415            continue;
416        };
417
418        let origin_x = from_coords.0 + half_width;
419        let origin_y = from_coords.1 + half_height;
420        let dest_x = to_coords.0 + half_width;
421        let dest_y = to_coords.1 + half_height;
422
423        let angle = (dest_y - origin_y).atan2(dest_x - origin_x);
424        let cosine = angle.cos();
425        let sine = angle.sin();
426
427        // Offset from vertex edges
428        let offset_origin_x = origin_x + half_width * cosine;
429        let offset_origin_y = origin_y + half_height * sine;
430        let offset_dest_x = dest_x - half_width * cosine;
431        let offset_dest_y = dest_y - half_height * sine;
432
433        // Draw line
434        context.move_to(offset_origin_x, offset_origin_y);
435        context.line_to(offset_dest_x, offset_dest_y);
436
437        // Draw arrowhead if directed
438        if dependency.is_directed {
439            let upper_angle = angle - arrow_angle;
440            let lower_angle = angle + arrow_angle;
441            let arrowhead_size = configuration.theme.sizes.arrowhead_size;
442
443            context.move_to(
444                offset_dest_x - arrowhead_size * upper_angle.cos(),
445                offset_dest_y - arrowhead_size * upper_angle.sin(),
446            );
447            context.line_to(offset_dest_x, offset_dest_y);
448            context.line_to(
449                offset_dest_x - arrowhead_size * lower_angle.cos(),
450                offset_dest_y - arrowhead_size * lower_angle.sin(),
451            );
452        }
453    }
454
455    // Batch stroke all paths at once
456    context.stroke()?;
457
458    context.restore()?;
459    Ok(())
460}
461
462fn draw_groups(
463    context: &Context,
464    map: &Map,
465    configuration: &Configuration,
466    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
467) -> Result<(), RenderError> {
468    if map.groups.is_empty() {
469        return Ok(());
470    }
471
472    // Create ONE offscreen surface for ALL groups for opacity handling
473    // Safe cast: map dimensions plus vertex size are small values (< 10000 typically)
474    #[allow(clippy::cast_possible_truncation)]
475    let width =
476        (configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32;
477    #[allow(clippy::cast_possible_truncation)]
478    let height =
479        (configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32;
480    let offscreen = ImageSurface::create(Format::ARgb32, width, height)?;
481    let offscreen_context = Context::new(&offscreen)?;
482
483    // Translate to account for vertex width/height
484    offscreen_context.translate(
485        configuration.theme.sizes.vertex_size / 2.0,
486        configuration.theme.sizes.vertex_size / 2.0,
487    );
488
489    for (group_index, group) in map.groups.iter().enumerate() {
490        let component_coords: Vec<(f64, f64)> = group
491            .components
492            .iter()
493            .filter_map(|label| component_map.get(&label.to_lowercase()).copied())
494            .collect();
495
496        if component_coords.is_empty() {
497            continue;
498        }
499
500        let color = &configuration.theme.colors.groups
501            [group_index % configuration.theme.colors.groups.len()];
502        let is_line = component_coords.len() == 2;
503
504        let coords = if component_coords.len() == 1 {
505            // For 1 component, create a small circle
506            let (x, y) = component_coords[0];
507            let radius = 3.0;
508            let segments = 16;
509            let mut circle_coords = Vec::new();
510            for i in 0..=segments {
511                let angle = (f64::from(i) / f64::from(segments)) * 2.0 * PI;
512                circle_coords.push((x + radius * angle.cos(), y + radius * angle.sin()));
513            }
514            circle_coords
515        } else if is_line {
516            // For 2 components, simple line
517            component_coords
518        } else {
519            // For 3+ components, use concave hull
520            let points: Vec<Point> = component_coords
521                .iter()
522                .map(|&(x, y)| Point::new(x, y))
523                .collect();
524
525            let line_string: LineString = points.into();
526            let hull = line_string.concave_hull();
527            hull.exterior().coords().map(|c| (c.x, c.y)).collect()
528        };
529
530        if coords.is_empty() {
531            continue;
532        }
533
534        // Draw path on offscreen context
535        let (r, g, b) = *color;
536        offscreen_context.set_source_rgb(r, g, b);
537        offscreen_context.move_to(coords[0].0, coords[0].1);
538        for &(x, y) in &coords[1..] {
539            offscreen_context.line_to(x, y);
540        }
541
542        // For polygons (not lines), close path and fill
543        if !is_line {
544            offscreen_context.close_path();
545            offscreen_context.fill_preserve()?;
546        }
547
548        // Draw stroke with round caps and joins for smoother appearance
549        offscreen_context.set_line_width(configuration.theme.sizes.vertex_size * 1.75);
550        offscreen_context.set_line_cap(LineCap::Round);
551        offscreen_context.set_line_join(LineJoin::Round);
552        offscreen_context.stroke()?;
553    }
554
555    // Composite entire offscreen surface onto main context with opacity (once for all groups)
556    context.save()?;
557    context.set_source_surface(&offscreen, 0.0, 0.0)?;
558    context.paint_with_alpha(configuration.theme.opacity.groups)?;
559    context.restore()?;
560
561    Ok(())
562}
563
564fn draw_vertices(
565    context: &Context,
566    map: &Map,
567    configuration: &Configuration,
568) -> Result<(), RenderError> {
569    context.save()?;
570    let (red, green, blue) = configuration.theme.colors.vertex;
571    context.set_source_rgb(red, green, blue);
572
573    for component in &map.components {
574        let vertex_x =
575            percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
576        let vertex_y = percent_to_pixel(
577            component.coordinates.1,
578            configuration.theme.sizes.map_height,
579        );
580
581        draw_vertex(
582            context,
583            &component.shape,
584            vertex_x,
585            vertex_y,
586            configuration.theme.sizes.vertex_size,
587            configuration.theme.sizes.vertex_size,
588        )?;
589    }
590
591    context.restore()?;
592    Ok(())
593}
594
595fn draw_vertex_labels(
596    context: &Context,
597    map: &Map,
598    configuration: &Configuration,
599    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
600) -> Result<(), RenderError> {
601    context.save()?;
602    let (red, green, blue) = configuration.theme.colors.label;
603    context.set_source_rgb(red, green, blue);
604    context.select_font_face(
605        &configuration.theme.fonts.face,
606        FontSlant::Normal,
607        FontWeight::Normal,
608    );
609    context.set_font_size(configuration.theme.fonts.vertex_label);
610
611    if configuration.options.smart_label_positioning {
612        // For maps with very few dependencies, smart positioning overhead isn't worth it
613        let has_significant_complexity = map.dependencies.len() + map.evolutions.len() > 10;
614
615        if has_significant_complexity {
616            // Pre-compute collision data once for all labels
617
618            let all_lines = collect_all_lines(
619                map,
620                configuration.theme.sizes.map_width,
621                configuration.theme.sizes.map_height,
622                configuration.theme.sizes.vertex_size,
623                component_map,
624            );
625
626            let spatial_grid = build_spatial_grid(
627                &all_lines,
628                configuration.theme.sizes.map_width,
629                configuration.theme.sizes.map_height,
630            );
631
632            let note_rects = collect_note_rects(
633                map,
634                configuration.theme.sizes.map_width,
635                configuration.theme.sizes.map_height,
636                context,
637                configuration.theme.fonts.vertex_label,
638            )?;
639
640            for component in &map.components {
641                let position = calculate_optimal_label_position_cached(
642                    component,
643                    &PositioningDimensions {
644                        map_size: (
645                            configuration.theme.sizes.map_width,
646                            configuration.theme.sizes.map_height,
647                        ),
648                        vertex_size: configuration.theme.sizes.vertex_size,
649                        font_size: configuration.theme.fonts.vertex_label,
650                    },
651                    context,
652                    &all_lines,
653                    &spatial_grid,
654                    &note_rects,
655                )?;
656
657                let label_x = position.x;
658                let label_y = position.y + 7.0; // Adjust Y position for text baseline
659
660                context.move_to(label_x, label_y);
661                context.show_text(&component.label)?;
662            }
663        } else {
664            // Use simple positioning for simple maps
665            for component in &map.components {
666                let vertex_x =
667                    percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
668                let vertex_y = percent_to_pixel(
669                    component.coordinates.1,
670                    configuration.theme.sizes.map_height,
671                );
672                let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
673                let label_y = vertex_y
674                    + configuration.theme.sizes.vertex_size / 2.0
675                    + configuration.theme.fonts.vertex_label / 3.0;
676
677                context.move_to(label_x, label_y);
678                context.show_text(&component.label)?;
679            }
680        }
681    } else {
682        // Simple label positioning (to the right of vertex)
683        for component in &map.components {
684            let vertex_x =
685                percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
686            let vertex_y = percent_to_pixel(
687                component.coordinates.1,
688                configuration.theme.sizes.map_height,
689            );
690            let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
691            let label_y = vertex_y
692                + configuration.theme.sizes.vertex_size / 2.0
693                + configuration.theme.fonts.vertex_label / 3.0;
694
695            context.move_to(label_x, label_y);
696            context.show_text(&component.label)?;
697        }
698    }
699
700    context.restore()?;
701    Ok(())
702}
703
704fn draw_inertias(
705    context: &Context,
706    map: &Map,
707    configuration: &Configuration,
708    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
709) -> Result<(), RenderError> {
710    context.save()?;
711    let (red, green, blue) = configuration.theme.colors.inertia;
712    context.set_source_rgb(red, green, blue);
713
714    let corner_radius = 2.0;
715    let rect_width = configuration.theme.sizes.vertex_size / 2.0;
716    let rect_height = configuration.theme.sizes.vertex_size * 2.0;
717
718    for inertia in &map.inertias {
719        if let Some(&(origin_x, origin_y)) = component_map.get(&inertia.component.to_lowercase()) {
720            // Position relative to vertex top-left corner
721            let inertia_x = origin_x + 3.0 * configuration.theme.sizes.vertex_size;
722            let inertia_y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0;
723
724            // Draw rounded rectangle
725            draw_rounded_rect(
726                context,
727                inertia_x,
728                inertia_y,
729                rect_width,
730                rect_height,
731                corner_radius,
732            );
733            context.fill()?;
734        }
735    }
736
737    context.restore()?;
738    Ok(())
739}
740
741fn draw_rounded_rect(context: &Context, x: f64, y: f64, width: f64, height: f64, radius: f64) {
742    let r = radius;
743    context.new_path();
744    context.arc(
745        x + r,
746        y + r,
747        r,
748        std::f64::consts::PI,
749        3.0 * std::f64::consts::PI / 2.0,
750    );
751    context.arc(
752        x + width - r,
753        y + r,
754        r,
755        3.0 * std::f64::consts::PI / 2.0,
756        0.0,
757    );
758    context.arc(
759        x + width - r,
760        y + height - r,
761        r,
762        0.0,
763        std::f64::consts::PI / 2.0,
764    );
765    context.arc(
766        x + r,
767        y + height - r,
768        r,
769        std::f64::consts::PI / 2.0,
770        std::f64::consts::PI,
771    );
772    context.close_path();
773}
774
775/// Wraps text to fit within a given width, breaking at word boundaries
776fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result<Vec<String>, Error> {
777    let words: Vec<&str> = text.split_whitespace().collect();
778    let mut lines = Vec::new();
779    let mut current_line = String::new();
780
781    for word in words {
782        let test_line = if current_line.is_empty() {
783            word.to_string()
784        } else {
785            format!("{current_line} {word}")
786        };
787
788        let extents = context.text_extents(&test_line)?;
789
790        if extents.width() <= max_width {
791            current_line = test_line;
792        } else {
793            if !current_line.is_empty() {
794                lines.push(current_line);
795            }
796            current_line = word.to_string();
797        }
798    }
799
800    if !current_line.is_empty() {
801        lines.push(current_line);
802    }
803
804    // If no lines were created (single very long word), just use the original text
805    if lines.is_empty() {
806        lines.push(text.to_string());
807    }
808
809    Ok(lines)
810}
811
812fn draw_evolutions(
813    context: &Context,
814    map: &Map,
815    configuration: &Configuration,
816    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
817) -> Result<(), RenderError> {
818    context.save()?;
819    let (r, g, b) = configuration.theme.colors.evolution;
820    context.set_source_rgb(r, g, b);
821    context.set_line_width(configuration.theme.sizes.line_width * 2.0);
822    context.set_dash(&[10.0], 0.0);
823
824    for evolution in &map.evolutions {
825        if let Some(&(origin_x, origin_y)) = component_map.get(&evolution.component.to_lowercase())
826        {
827            // Calculate destination with clamping
828            let evolution_offset =
829                percent_to_pixel(evolution.value, configuration.theme.sizes.map_width);
830            let dest_x = (origin_x + evolution_offset)
831                .max(0.0)
832                .min(configuration.theme.sizes.map_width);
833            let dest_y = origin_y;
834
835            // Determine direction
836            let multiplier = if dest_x > origin_x { 1.0 } else { -1.0 };
837
838            // Calculate offsets from top-left corner to center the line on the vertex
839            let offset_origin_x = origin_x
840                + multiplier * (configuration.theme.sizes.vertex_size / 2.0)
841                + configuration.theme.sizes.vertex_size / 2.0;
842            let offset_origin_y = origin_y + configuration.theme.sizes.vertex_size / 2.0;
843            let offset_dest_x = dest_x - multiplier * (configuration.theme.sizes.vertex_size / 2.0)
844                + configuration.theme.sizes.vertex_size / 2.0;
845            let offset_dest_y = dest_y + configuration.theme.sizes.vertex_size / 2.0;
846
847            // Draw dashed line
848            context.move_to(offset_origin_x, offset_origin_y);
849            context.line_to(offset_dest_x, offset_dest_y);
850
851            // Draw arrowhead at destination only
852            let upper_angle = -PI / 4.0;
853            let lower_angle = PI / 4.0;
854            let arrowhead_size = configuration.theme.sizes.arrowhead_size;
855
856            context.move_to(offset_dest_x, offset_dest_y);
857            context.line_to(
858                offset_dest_x - multiplier * arrowhead_size * upper_angle.cos(),
859                offset_dest_y - multiplier * arrowhead_size * upper_angle.sin(),
860            );
861
862            context.move_to(offset_dest_x, offset_dest_y);
863            context.line_to(
864                offset_dest_x - multiplier * arrowhead_size * lower_angle.cos(),
865                offset_dest_y - multiplier * arrowhead_size * lower_angle.sin(),
866            );
867        }
868    }
869
870    // Batch stroke all paths at once
871    context.stroke()?;
872
873    context.restore()?;
874    Ok(())
875}
876
877fn draw_notes(
878    context: &Context,
879    map: &Map,
880    configuration: &Configuration,
881) -> Result<(), RenderError> {
882    let padding = 5.0;
883    let max_width = 400.0;
884
885    context.save()?;
886    context.select_font_face(
887        &configuration.theme.fonts.face,
888        FontSlant::Normal,
889        FontWeight::Normal,
890    );
891    context.set_font_size(configuration.theme.fonts.note);
892
893    for note in &map.notes {
894        let note_x = percent_to_pixel(note.coordinates.0, configuration.theme.sizes.map_width);
895        let note_y = percent_to_pixel(note.coordinates.1, configuration.theme.sizes.map_height);
896
897        // Split text by newlines (handle both \\n escape and actual newlines)
898        let text = note.text.replace("\\n", "\n");
899        let lines: Vec<&str> = text.split('\n').collect();
900
901        // Calculate box dimensions
902        let mut widest_line = 0.0;
903        for line in &lines {
904            let extents = context.text_extents(line)?;
905            if extents.width() > widest_line {
906                widest_line = extents.width();
907            }
908        }
909
910        let box_width = f64::min(max_width, widest_line + padding * 2.0);
911        // Safe cast: number of lines in a note is small (typically < 100)
912        #[allow(clippy::cast_precision_loss)]
913        let box_height = lines.len() as f64 * configuration.theme.line_heights.note + padding * 2.0;
914
915        // Draw background
916        let (red, green, blue) = configuration.theme.colors.background;
917        context.set_source_rgb(red, green, blue);
918        context.rectangle(note_x, note_y, box_width, box_height);
919        context.fill()?;
920
921        // Draw border
922        let (red, green, blue) = configuration.theme.colors.vertex;
923        context.set_source_rgb(red, green, blue);
924        context.set_line_width(configuration.theme.sizes.line_width);
925        context.rectangle(note_x, note_y, box_width, box_height);
926        context.stroke()?;
927
928        // Draw text
929        let (red, green, blue) = configuration.theme.colors.label;
930        context.set_source_rgb(red, green, blue);
931        for (i, line) in lines.iter().enumerate() {
932            // Safe cast: line index is a small number (typically < 100 for note text)
933            #[allow(clippy::cast_precision_loss)]
934            let line_offset = i as f64 * configuration.theme.line_heights.note;
935            context.move_to(
936                note_x + padding,
937                note_y + padding + line_offset + configuration.theme.fonts.note,
938            );
939            context.show_text(line)?;
940        }
941    }
942
943    context.restore()?;
944    Ok(())
945}
946
947/// Gets the pixel positions of evolution stages
948fn get_stage_positions(map: &Map, configuration: &Configuration) -> [f64; 3] {
949    let default_stages = [25.0, 50.0, 75.0];
950    let mut stages = default_stages;
951
952    // Override with map-specific stages if provided
953    for stage_data in &map.stages {
954        let index = match stage_data.stage {
955            Stage::I => 0,
956            Stage::Ii => 1,
957            Stage::Iii => 2,
958            Stage::Iv => continue, // Stage IV doesn't have a divider line
959        };
960        stages[index] = stage_data.value; // Value is already a percentage (0-100)
961    }
962
963    [
964        percent_to_pixel(stages[0], configuration.theme.sizes.map_width),
965        percent_to_pixel(stages[1], configuration.theme.sizes.map_width),
966        percent_to_pixel(stages[2], configuration.theme.sizes.map_width),
967    ]
968}