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, stage_type, 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    _stage_type: StageType,
209    map: &Map,
210    configuration: &Configuration,
211) -> Result<(), RenderError> {
212    let stages = get_stage_positions(map, configuration);
213
214    // Create patterns once (they're small and fast to create)
215    let pattern0 = create_pattern(
216        &STITCH,
217        configuration.theme.colors.stage_foreground,
218        configuration.theme.colors.stage_background,
219    )?;
220    let pattern1 = create_pattern(
221        &SHINGLES,
222        configuration.theme.colors.stage_foreground,
223        configuration.theme.colors.stage_background,
224    )?;
225    let pattern2 = create_pattern(
226        &SHADOW_GRID,
227        configuration.theme.colors.stage_foreground,
228        configuration.theme.colors.stage_background,
229    )?;
230    let pattern3 = create_pattern(
231        &WICKER,
232        configuration.theme.colors.stage_foreground,
233        configuration.theme.colors.stage_background,
234    )?;
235
236    context.save()?;
237
238    // Draw pattern for stage 0 to stage 1
239    context.rectangle(0.0, 0.0, stages[0], configuration.theme.sizes.map_height);
240    context.set_source(&pattern0)?;
241    context.fill()?;
242
243    // Draw pattern for stage 1 to stage 2
244    context.rectangle(
245        stages[0],
246        0.0,
247        stages[1] - stages[0],
248        configuration.theme.sizes.map_height,
249    );
250    context.set_source(&pattern1)?;
251    context.fill()?;
252
253    // Draw pattern for stage 2 to stage 3
254    context.rectangle(
255        stages[1],
256        0.0,
257        stages[2] - stages[1],
258        configuration.theme.sizes.map_height,
259    );
260    context.set_source(&pattern2)?;
261    context.fill()?;
262
263    // Draw pattern for stage 3 to end
264    context.rectangle(
265        stages[2],
266        0.0,
267        configuration.theme.sizes.map_width - stages[2],
268        configuration.theme.sizes.map_height,
269    );
270    context.set_source(&pattern3)?;
271    context.fill()?;
272
273    context.restore()?;
274    Ok(())
275}
276
277fn draw_axes(
278    context: &Context,
279    stage_type: StageType,
280    map: &Map,
281    configuration: &Configuration,
282) -> Result<(), RenderError> {
283    let sizes = &configuration.theme.sizes;
284    let fonts = &configuration.theme.fonts;
285
286    context.save()?;
287    let (red, green, blue) = configuration.theme.colors.axis;
288    context.set_source_rgb(red, green, blue);
289    context.set_line_width(sizes.line_width * 2.0);
290
291    // Y-axis and X-axis
292    context.move_to(0.0, 0.0);
293    context.line_to(0.0, sizes.map_height);
294    context.line_to(sizes.map_width, sizes.map_height);
295    context.stroke()?;
296
297    // Text setup
298    let (red, green, blue) = configuration.theme.colors.label;
299    context.set_source_rgb(red, green, blue);
300    context.select_font_face(
301        &configuration.theme.fonts.face,
302        FontSlant::Normal,
303        FontWeight::Normal,
304    );
305    context.set_font_size(fonts.axis_label);
306    context.set_line_width(1.0);
307
308    // Y-axis labels (rotated -90 degrees)
309    context.save()?;
310    context.translate(-20.0, 45.0);
311    context.rotate(-PI / 2.0);
312    context.move_to(0.0, 0.0);
313    context.show_text("Visible")?;
314    context.restore()?;
315
316    context.save()?;
317    context.translate(-20.0, sizes.map_height);
318    context.rotate(-PI / 2.0);
319    context.move_to(0.0, 0.0);
320    context.show_text("Invisible")?;
321    context.restore()?;
322
323    // X-axis labels (above the map, at negative Y, offset by half vertex height)
324    let stage_height = sizes.stage_height;
325    let text_offset_y = -stage_height / 4.0 + sizes.vertex_size / 2.0;
326    context.move_to(0.0, text_offset_y);
327    context.show_text("Uncharted")?;
328
329    let text_ext = context.text_extents("Industrialised")?;
330    context.move_to(sizes.map_width - text_ext.width(), text_offset_y);
331    context.show_text("Industrialised")?;
332
333    // Stage labels (below the map, offset by half vertex height, with text wrapping)
334    let stage_labels = stage_type.stages();
335    let stages = get_stage_positions(map, configuration);
336    let padding = 5.0; // kAxisLabelPadding
337    let stage_label_y = sizes.map_height + padding + sizes.vertex_size / 2.0;
338    let line_height = fonts.axis_label * 1.2; // 120% line height
339
340    // Calculate widths for each stage
341    let stage_widths = [
342        stages[0],
343        stages[1] - stages[0],
344        stages[2] - stages[1],
345        sizes.map_width - stages[2],
346    ];
347
348    // Draw each stage label with wrapping
349    for (i, &label) in stage_labels.iter().enumerate() {
350        let stage_x = match i {
351            0 => 0.0,
352            1 => stages[0],
353            2 => stages[1],
354            3 => stages[2],
355            _ => continue,
356        };
357
358        let wrapped_lines = wrap_text(context, label, stage_widths[i])?;
359
360        for (line_index, line) in wrapped_lines.iter().enumerate() {
361            // Safe cast: line_index is a small number (typically < 10 for wrapped stage labels)
362            #[allow(clippy::cast_precision_loss)]
363            let text_y = stage_label_y + line_index as f64 * line_height;
364            context.move_to(stage_x, text_y);
365            context.show_text(line)?;
366        }
367    }
368
369    context.restore()?;
370    Ok(())
371}
372
373fn draw_stage_dividers(
374    context: &Context,
375    map: &Map,
376    configuration: &Configuration,
377) -> Result<(), RenderError> {
378    let stages = get_stage_positions(map, configuration);
379
380    context.save()?;
381    let (r, g, b) = configuration.theme.colors.axis;
382    context.set_source_rgb(r, g, b);
383    context.set_line_width(configuration.theme.sizes.line_width * 2.0);
384    context.set_dash(&[10.0, 18.0], 0.0);
385
386    for &stage_x in &stages {
387        context.move_to(stage_x, 0.0);
388        context.line_to(stage_x, configuration.theme.sizes.map_height);
389    }
390
391    context.stroke()?;
392    context.restore()?;
393    Ok(())
394}
395
396fn draw_dependencies(
397    context: &Context,
398    map: &Map,
399    configuration: &Configuration,
400    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
401) -> Result<(), RenderError> {
402    context.save()?;
403    let (r, g, b) = configuration.theme.colors.vertex;
404    context.set_source_rgb(r, g, b);
405    context.set_line_width(configuration.theme.sizes.line_width);
406
407    let half_width = configuration.theme.sizes.vertex_size / 2.0;
408    let half_height = configuration.theme.sizes.vertex_size / 2.0;
409    let arrow_angle = PI / 4.0; // 45 degrees
410
411    for dependency in &map.dependencies {
412        let Some(from_coords) = component_map.get(&dependency.from.to_lowercase()) else {
413            continue;
414        };
415        let Some(to_coords) = component_map.get(&dependency.to.to_lowercase()) else {
416            continue;
417        };
418
419        let origin_x = from_coords.0 + half_width;
420        let origin_y = from_coords.1 + half_height;
421        let dest_x = to_coords.0 + half_width;
422        let dest_y = to_coords.1 + half_height;
423
424        let angle = (dest_y - origin_y).atan2(dest_x - origin_x);
425        let cosine = angle.cos();
426        let sine = angle.sin();
427
428        // Offset from vertex edges
429        let offset_origin_x = origin_x + half_width * cosine;
430        let offset_origin_y = origin_y + half_height * sine;
431        let offset_dest_x = dest_x - half_width * cosine;
432        let offset_dest_y = dest_y - half_height * sine;
433
434        // Draw line
435        context.move_to(offset_origin_x, offset_origin_y);
436        context.line_to(offset_dest_x, offset_dest_y);
437
438        // Draw arrowhead if directed
439        if dependency.is_directed {
440            let upper_angle = angle - arrow_angle;
441            let lower_angle = angle + arrow_angle;
442            let arrowhead_size = configuration.theme.sizes.arrowhead_size;
443
444            context.move_to(
445                offset_dest_x - arrowhead_size * upper_angle.cos(),
446                offset_dest_y - arrowhead_size * upper_angle.sin(),
447            );
448            context.line_to(offset_dest_x, offset_dest_y);
449            context.line_to(
450                offset_dest_x - arrowhead_size * lower_angle.cos(),
451                offset_dest_y - arrowhead_size * lower_angle.sin(),
452            );
453        }
454    }
455
456    // Batch stroke all paths at once
457    context.stroke()?;
458
459    context.restore()?;
460    Ok(())
461}
462
463fn draw_groups(
464    context: &Context,
465    map: &Map,
466    configuration: &Configuration,
467    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
468) -> Result<(), RenderError> {
469    if map.groups.is_empty() {
470        return Ok(());
471    }
472
473    // Create ONE offscreen surface for ALL groups for opacity handling
474    // Safe cast: map dimensions plus vertex size are small values (< 10000 typically)
475    #[allow(clippy::cast_possible_truncation)]
476    let width =
477        (configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32;
478    #[allow(clippy::cast_possible_truncation)]
479    let height =
480        (configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32;
481    let offscreen = ImageSurface::create(Format::ARgb32, width, height)?;
482    let offscreen_context = Context::new(&offscreen)?;
483
484    // Translate to account for vertex width/height
485    offscreen_context.translate(
486        configuration.theme.sizes.vertex_size / 2.0,
487        configuration.theme.sizes.vertex_size / 2.0,
488    );
489
490    for (group_index, group) in map.groups.iter().enumerate() {
491        let component_coords: Vec<(f64, f64)> = group
492            .components
493            .iter()
494            .filter_map(|label| component_map.get(&label.to_lowercase()).copied())
495            .collect();
496
497        if component_coords.is_empty() {
498            continue;
499        }
500
501        let color = &configuration.theme.colors.groups
502            [group_index % configuration.theme.colors.groups.len()];
503        let is_line = component_coords.len() == 2;
504
505        let coords = if component_coords.len() == 1 {
506            // For 1 component, create a small circle
507            let (x, y) = component_coords[0];
508            let radius = 3.0;
509            let segments = 16;
510            let mut circle_coords = Vec::new();
511            for i in 0..=segments {
512                let angle = (f64::from(i) / f64::from(segments)) * 2.0 * PI;
513                circle_coords.push((x + radius * angle.cos(), y + radius * angle.sin()));
514            }
515            circle_coords
516        } else if is_line {
517            // For 2 components, simple line
518            component_coords
519        } else {
520            // For 3+ components, use concave hull
521            let points: Vec<Point> = component_coords
522                .iter()
523                .map(|&(x, y)| Point::new(x, y))
524                .collect();
525
526            let line_string: LineString = points.into();
527            let hull = line_string.concave_hull();
528            hull.exterior().coords().map(|c| (c.x, c.y)).collect()
529        };
530
531        if coords.is_empty() {
532            continue;
533        }
534
535        // Draw path on offscreen context
536        let (r, g, b) = *color;
537        offscreen_context.set_source_rgb(r, g, b);
538        offscreen_context.move_to(coords[0].0, coords[0].1);
539        for &(x, y) in &coords[1..] {
540            offscreen_context.line_to(x, y);
541        }
542
543        // For polygons (not lines), close path and fill
544        if !is_line {
545            offscreen_context.close_path();
546            offscreen_context.fill_preserve()?;
547        }
548
549        // Draw stroke with round caps and joins for smoother appearance
550        offscreen_context.set_line_width(configuration.theme.sizes.vertex_size * 1.75);
551        offscreen_context.set_line_cap(LineCap::Round);
552        offscreen_context.set_line_join(LineJoin::Round);
553        offscreen_context.stroke()?;
554    }
555
556    // Composite entire offscreen surface onto main context with opacity (once for all groups)
557    context.save()?;
558    context.set_source_surface(&offscreen, 0.0, 0.0)?;
559    context.paint_with_alpha(configuration.theme.opacity.groups)?;
560    context.restore()?;
561
562    Ok(())
563}
564
565fn draw_vertices(
566    context: &Context,
567    map: &Map,
568    configuration: &Configuration,
569) -> Result<(), RenderError> {
570    context.save()?;
571    let (red, green, blue) = configuration.theme.colors.vertex;
572    context.set_source_rgb(red, green, blue);
573
574    for component in &map.components {
575        let vertex_x =
576            percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
577        let vertex_y = percent_to_pixel(
578            component.coordinates.1,
579            configuration.theme.sizes.map_height,
580        );
581
582        draw_vertex(
583            context,
584            &component.shape,
585            vertex_x,
586            vertex_y,
587            configuration.theme.sizes.vertex_size,
588            configuration.theme.sizes.vertex_size,
589        )?;
590    }
591
592    context.restore()?;
593    Ok(())
594}
595
596fn draw_vertex_labels(
597    context: &Context,
598    map: &Map,
599    configuration: &Configuration,
600    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
601) -> Result<(), RenderError> {
602    context.save()?;
603    let (red, green, blue) = configuration.theme.colors.label;
604    context.set_source_rgb(red, green, blue);
605    context.select_font_face(
606        &configuration.theme.fonts.face,
607        FontSlant::Normal,
608        FontWeight::Normal,
609    );
610    context.set_font_size(configuration.theme.fonts.vertex_label);
611
612    if configuration.options.smart_label_positioning {
613        // For maps with very few dependencies, smart positioning overhead isn't worth it
614        let has_significant_complexity = map.dependencies.len() + map.evolutions.len() > 10;
615
616        if has_significant_complexity {
617            // Pre-compute collision data once for all labels
618
619            let all_lines = collect_all_lines(
620                map,
621                configuration.theme.sizes.map_width,
622                configuration.theme.sizes.map_height,
623                configuration.theme.sizes.vertex_size,
624                component_map,
625            );
626
627            let spatial_grid = build_spatial_grid(
628                &all_lines,
629                configuration.theme.sizes.map_width,
630                configuration.theme.sizes.map_height,
631            );
632
633            let note_rects = collect_note_rects(
634                map,
635                configuration.theme.sizes.map_width,
636                configuration.theme.sizes.map_height,
637                context,
638                configuration.theme.fonts.vertex_label,
639            )?;
640
641            for component in &map.components {
642                let position = calculate_optimal_label_position_cached(
643                    component,
644                    &PositioningDimensions {
645                        map_size: (
646                            configuration.theme.sizes.map_width,
647                            configuration.theme.sizes.map_height,
648                        ),
649                        vertex_size: configuration.theme.sizes.vertex_size,
650                        font_size: configuration.theme.fonts.vertex_label,
651                    },
652                    context,
653                    &all_lines,
654                    &spatial_grid,
655                    &note_rects,
656                )?;
657
658                let label_x = position.x;
659                let label_y = position.y + 7.0; // Adjust Y position for text baseline
660
661                context.move_to(label_x, label_y);
662                context.show_text(&component.label)?;
663            }
664        } else {
665            // Use simple positioning for simple maps
666            for component in &map.components {
667                let vertex_x =
668                    percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
669                let vertex_y = percent_to_pixel(
670                    component.coordinates.1,
671                    configuration.theme.sizes.map_height,
672                );
673                let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
674                let label_y = vertex_y
675                    + configuration.theme.sizes.vertex_size / 2.0
676                    + configuration.theme.fonts.vertex_label / 3.0;
677
678                context.move_to(label_x, label_y);
679                context.show_text(&component.label)?;
680            }
681        }
682    } else {
683        // Simple label positioning (to the right of vertex)
684        for component in &map.components {
685            let vertex_x =
686                percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
687            let vertex_y = percent_to_pixel(
688                component.coordinates.1,
689                configuration.theme.sizes.map_height,
690            );
691            let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
692            let label_y = vertex_y
693                + configuration.theme.sizes.vertex_size / 2.0
694                + configuration.theme.fonts.vertex_label / 3.0;
695
696            context.move_to(label_x, label_y);
697            context.show_text(&component.label)?;
698        }
699    }
700
701    context.restore()?;
702    Ok(())
703}
704
705fn draw_inertias(
706    context: &Context,
707    map: &Map,
708    configuration: &Configuration,
709    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
710) -> Result<(), RenderError> {
711    context.save()?;
712    let (red, green, blue) = configuration.theme.colors.inertia;
713    context.set_source_rgb(red, green, blue);
714
715    let corner_radius = 2.0;
716    let rect_width = configuration.theme.sizes.vertex_size / 2.0;
717    let rect_height = configuration.theme.sizes.vertex_size * 2.0;
718
719    for inertia in &map.inertias {
720        if let Some(&(origin_x, origin_y)) = component_map.get(&inertia.component.to_lowercase()) {
721            // Position relative to vertex top-left corner
722            let inertia_x = origin_x + 3.0 * configuration.theme.sizes.vertex_size;
723            let inertia_y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0;
724
725            // Draw rounded rectangle
726            draw_rounded_rect(
727                context,
728                inertia_x,
729                inertia_y,
730                rect_width,
731                rect_height,
732                corner_radius,
733            );
734            context.fill()?;
735        }
736    }
737
738    context.restore()?;
739    Ok(())
740}
741
742fn draw_rounded_rect(context: &Context, x: f64, y: f64, width: f64, height: f64, radius: f64) {
743    let r = radius;
744    context.new_path();
745    context.arc(
746        x + r,
747        y + r,
748        r,
749        std::f64::consts::PI,
750        3.0 * std::f64::consts::PI / 2.0,
751    );
752    context.arc(
753        x + width - r,
754        y + r,
755        r,
756        3.0 * std::f64::consts::PI / 2.0,
757        0.0,
758    );
759    context.arc(
760        x + width - r,
761        y + height - r,
762        r,
763        0.0,
764        std::f64::consts::PI / 2.0,
765    );
766    context.arc(
767        x + r,
768        y + height - r,
769        r,
770        std::f64::consts::PI / 2.0,
771        std::f64::consts::PI,
772    );
773    context.close_path();
774}
775
776/// Wraps text to fit within a given width, breaking at word boundaries
777fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result<Vec<String>, Error> {
778    let words: Vec<&str> = text.split_whitespace().collect();
779    let mut lines = Vec::new();
780    let mut current_line = String::new();
781
782    for word in words {
783        let test_line = if current_line.is_empty() {
784            word.to_string()
785        } else {
786            format!("{current_line} {word}")
787        };
788
789        let extents = context.text_extents(&test_line)?;
790
791        if extents.width() <= max_width {
792            current_line = test_line;
793        } else {
794            if !current_line.is_empty() {
795                lines.push(current_line);
796            }
797            current_line = word.to_string();
798        }
799    }
800
801    if !current_line.is_empty() {
802        lines.push(current_line);
803    }
804
805    // If no lines were created (single very long word), just use the original text
806    if lines.is_empty() {
807        lines.push(text.to_string());
808    }
809
810    Ok(lines)
811}
812
813fn draw_evolutions(
814    context: &Context,
815    map: &Map,
816    configuration: &Configuration,
817    component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
818) -> Result<(), RenderError> {
819    context.save()?;
820    let (r, g, b) = configuration.theme.colors.evolution;
821    context.set_source_rgb(r, g, b);
822    context.set_line_width(configuration.theme.sizes.line_width * 2.0);
823    context.set_dash(&[10.0], 0.0);
824
825    for evolution in &map.evolutions {
826        if let Some(&(origin_x, origin_y)) = component_map.get(&evolution.component.to_lowercase())
827        {
828            // Calculate destination with clamping
829            let evolution_offset =
830                percent_to_pixel(evolution.value, configuration.theme.sizes.map_width);
831            let dest_x = (origin_x + evolution_offset)
832                .max(0.0)
833                .min(configuration.theme.sizes.map_width);
834            let dest_y = origin_y;
835
836            // Determine direction
837            let multiplier = if dest_x > origin_x { 1.0 } else { -1.0 };
838
839            // Calculate offsets from top-left corner to center the line on the vertex
840            let offset_origin_x = origin_x
841                + multiplier * (configuration.theme.sizes.vertex_size / 2.0)
842                + configuration.theme.sizes.vertex_size / 2.0;
843            let offset_origin_y = origin_y + configuration.theme.sizes.vertex_size / 2.0;
844            let offset_dest_x = dest_x - multiplier * (configuration.theme.sizes.vertex_size / 2.0)
845                + configuration.theme.sizes.vertex_size / 2.0;
846            let offset_dest_y = dest_y + configuration.theme.sizes.vertex_size / 2.0;
847
848            // Draw dashed line
849            context.move_to(offset_origin_x, offset_origin_y);
850            context.line_to(offset_dest_x, offset_dest_y);
851
852            // Draw arrowhead at destination only
853            let upper_angle = -PI / 4.0;
854            let lower_angle = PI / 4.0;
855            let arrowhead_size = configuration.theme.sizes.arrowhead_size;
856
857            context.move_to(offset_dest_x, offset_dest_y);
858            context.line_to(
859                offset_dest_x - multiplier * arrowhead_size * upper_angle.cos(),
860                offset_dest_y - multiplier * arrowhead_size * upper_angle.sin(),
861            );
862
863            context.move_to(offset_dest_x, offset_dest_y);
864            context.line_to(
865                offset_dest_x - multiplier * arrowhead_size * lower_angle.cos(),
866                offset_dest_y - multiplier * arrowhead_size * lower_angle.sin(),
867            );
868        }
869    }
870
871    // Batch stroke all paths at once
872    context.stroke()?;
873
874    context.restore()?;
875    Ok(())
876}
877
878fn draw_notes(
879    context: &Context,
880    map: &Map,
881    configuration: &Configuration,
882) -> Result<(), RenderError> {
883    let padding = 5.0;
884    let max_width = 400.0;
885
886    context.save()?;
887    context.select_font_face(
888        &configuration.theme.fonts.face,
889        FontSlant::Normal,
890        FontWeight::Normal,
891    );
892    context.set_font_size(configuration.theme.fonts.note);
893
894    for note in &map.notes {
895        let note_x = percent_to_pixel(note.coordinates.0, configuration.theme.sizes.map_width);
896        let note_y = percent_to_pixel(note.coordinates.1, configuration.theme.sizes.map_height);
897
898        // Split text by newlines (handle both \\n escape and actual newlines)
899        let text = note.text.replace("\\n", "\n");
900        let lines: Vec<&str> = text.split('\n').collect();
901
902        // Calculate box dimensions
903        let mut widest_line = 0.0;
904        for line in &lines {
905            let extents = context.text_extents(line)?;
906            if extents.width() > widest_line {
907                widest_line = extents.width();
908            }
909        }
910
911        let box_width = f64::min(max_width, widest_line + padding * 2.0);
912        // Safe cast: number of lines in a note is small (typically < 100)
913        #[allow(clippy::cast_precision_loss)]
914        let box_height = lines.len() as f64 * configuration.theme.line_heights.note + padding * 2.0;
915
916        // Draw background
917        let (red, green, blue) = configuration.theme.colors.background;
918        context.set_source_rgb(red, green, blue);
919        context.rectangle(note_x, note_y, box_width, box_height);
920        context.fill()?;
921
922        // Draw border
923        let (red, green, blue) = configuration.theme.colors.vertex;
924        context.set_source_rgb(red, green, blue);
925        context.set_line_width(configuration.theme.sizes.line_width);
926        context.rectangle(note_x, note_y, box_width, box_height);
927        context.stroke()?;
928
929        // Draw text
930        let (red, green, blue) = configuration.theme.colors.label;
931        context.set_source_rgb(red, green, blue);
932        for (i, line) in lines.iter().enumerate() {
933            // Safe cast: line index is a small number (typically < 100 for note text)
934            #[allow(clippy::cast_precision_loss)]
935            let line_offset = i as f64 * configuration.theme.line_heights.note;
936            context.move_to(
937                note_x + padding,
938                note_y + padding + line_offset + configuration.theme.fonts.note,
939            );
940            context.show_text(line)?;
941        }
942    }
943
944    context.restore()?;
945    Ok(())
946}
947
948/// Gets the pixel positions of evolution stages
949fn get_stage_positions(map: &Map, configuration: &Configuration) -> [f64; 3] {
950    let default_stages = [25.0, 50.0, 75.0];
951    let mut stages = default_stages;
952
953    // Override with map-specific stages if provided
954    for stage_data in &map.stages {
955        let index = match stage_data.stage {
956            Stage::I => 0,
957            Stage::Ii => 1,
958            Stage::Iii => 2,
959            Stage::Iv => continue, // Stage IV doesn't have a divider line
960        };
961        stages[index] = stage_data.value; // Value is already a percentage (0-100)
962    }
963
964    [
965        percent_to_pixel(stages[0], configuration.theme.sizes.map_width),
966        percent_to_pixel(stages[1], configuration.theme.sizes.map_width),
967        percent_to_pixel(stages[2], configuration.theme.sizes.map_width),
968    ]
969}