use std::f64::consts::PI;
use std::fs::read_to_string;
use std::io::Cursor;
use cairo::{
Context, Error, FontSlant, FontWeight, Format, ImageSurface, LineCap, LineJoin, SvgSurface,
};
use geo::{ConcaveHull, LineString, Point};
use rustc_hash::FxHashMap;
use tempfile::NamedTempFile;
use wmap_parser::{Map, Stage};
use crate::configuration::Configuration;
use crate::error::RenderError;
use crate::patterns::{SHADOW_GRID, SHINGLES, STITCH, WICKER, create_pattern};
use crate::shapes::draw_vertex;
use crate::smart_positioning::{
PositioningDimensions, build_spatial_grid, calculate_optimal_label_position_cached,
collect_all_lines, collect_note_rects,
};
use crate::stage_type::StageType;
use crate::utils::percent_to_pixel;
pub fn render_to_png(
map: &Map,
stage_type: &StageType,
configuration: &Configuration,
) -> Result<Vec<u8>, RenderError> {
#[allow(clippy::cast_possible_truncation)]
let total_width = (configuration.theme.sizes.map_width
+ configuration.theme.sizes.padding * 2.0)
.round() as i32;
#[allow(clippy::cast_possible_truncation)]
let total_height = (configuration.theme.sizes.map_height
+ configuration.theme.sizes.padding
+ configuration.theme.sizes.bottom_padding)
.round() as i32;
let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?;
let context = Context::new(&surface)?;
render(&context, map, stage_type, configuration)?;
let mut buffer = Cursor::new(Vec::new());
surface.write_to_png(&mut buffer)?;
Ok(buffer.into_inner())
}
pub fn render_to_surface(
map: &Map,
stage_type: &StageType,
configuration: &Configuration,
) -> Result<ImageSurface, RenderError> {
#[allow(clippy::cast_possible_truncation)]
let total_width = (configuration.theme.sizes.map_width
+ configuration.theme.sizes.padding * 2.0)
.round() as i32;
#[allow(clippy::cast_possible_truncation)]
let total_height = (configuration.theme.sizes.map_height
+ configuration.theme.sizes.padding
+ configuration.theme.sizes.bottom_padding)
.round() as i32;
let surface = ImageSurface::create(Format::ARgb32, total_width, total_height)?;
let context = Context::new(&surface)?;
render(&context, map, stage_type, configuration)?;
Ok(surface)
}
pub fn render_to_svg(
map: &Map,
stage_type: &StageType,
configuration: &Configuration,
) -> Result<String, RenderError> {
let total_width = configuration.theme.sizes.map_width + configuration.theme.sizes.padding * 2.0;
let total_height = configuration.theme.sizes.map_height
+ configuration.theme.sizes.padding
+ configuration.theme.sizes.bottom_padding;
let temp_file = NamedTempFile::new()?;
let path = temp_file
.path()
.to_str()
.ok_or_else(|| RenderError::InvalidConfig("Failed to create temp file path".to_string()))?;
let surface = SvgSurface::new(total_width, total_height, Some(path))?;
let context = Context::new(&surface)?;
render(&context, map, stage_type, configuration)?;
drop(context);
surface.finish();
let svg_content = read_to_string(path)?;
Ok(svg_content)
}
fn render(
context: &Context,
map: &Map,
stage_type: &StageType,
configuration: &Configuration,
) -> Result<(), RenderError> {
context.save()?;
context.translate(
configuration.theme.sizes.padding,
configuration.theme.sizes.padding,
);
let mut component_map: FxHashMap<String, (f64, f64)> =
FxHashMap::with_capacity_and_hasher(map.components.len(), rustc_hash::FxBuildHasher);
for component in &map.components {
let x = percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
let y = percent_to_pixel(
component.coordinates.1,
configuration.theme.sizes.map_height,
);
component_map.insert(component.label.to_lowercase(), (x, y));
}
draw_background(context, configuration)?;
if configuration.options.show_background {
draw_stage_patterns(context, map, configuration)?;
}
draw_axes(context, stage_type, map, configuration)?;
draw_stage_dividers(context, map, configuration)?;
draw_dependencies(context, map, configuration, &component_map)?;
draw_groups(context, map, configuration, &component_map)?;
draw_vertices(context, map, configuration)?;
draw_vertex_labels(context, map, configuration, &component_map)?;
draw_inertias(context, map, configuration, &component_map)?;
draw_evolutions(context, map, configuration, &component_map)?;
draw_notes(context, map, configuration)?;
context.restore()?;
Ok(())
}
fn draw_background(context: &Context, configuration: &Configuration) -> Result<(), RenderError> {
context.save()?;
let (r, g, b) = configuration.theme.colors.background;
context.set_source_rgb(r, g, b);
context.paint()?;
context.restore()?;
Ok(())
}
fn draw_stage_patterns(
context: &Context,
map: &Map,
configuration: &Configuration,
) -> Result<(), RenderError> {
let stages = get_stage_positions(map, configuration);
let pattern0 = create_pattern(
&STITCH,
configuration.theme.colors.stage_foreground,
configuration.theme.colors.stage_background,
)?;
let pattern1 = create_pattern(
&SHINGLES,
configuration.theme.colors.stage_foreground,
configuration.theme.colors.stage_background,
)?;
let pattern2 = create_pattern(
&SHADOW_GRID,
configuration.theme.colors.stage_foreground,
configuration.theme.colors.stage_background,
)?;
let pattern3 = create_pattern(
&WICKER,
configuration.theme.colors.stage_foreground,
configuration.theme.colors.stage_background,
)?;
context.save()?;
context.rectangle(0.0, 0.0, stages[0], configuration.theme.sizes.map_height);
context.set_source(&pattern0)?;
context.fill()?;
context.rectangle(
stages[0],
0.0,
stages[1] - stages[0],
configuration.theme.sizes.map_height,
);
context.set_source(&pattern1)?;
context.fill()?;
context.rectangle(
stages[1],
0.0,
stages[2] - stages[1],
configuration.theme.sizes.map_height,
);
context.set_source(&pattern2)?;
context.fill()?;
context.rectangle(
stages[2],
0.0,
configuration.theme.sizes.map_width - stages[2],
configuration.theme.sizes.map_height,
);
context.set_source(&pattern3)?;
context.fill()?;
context.restore()?;
Ok(())
}
fn draw_axes(
context: &Context,
stage_type: &StageType,
map: &Map,
configuration: &Configuration,
) -> Result<(), RenderError> {
let sizes = &configuration.theme.sizes;
let fonts = &configuration.theme.fonts;
context.save()?;
let (red, green, blue) = configuration.theme.colors.axis;
context.set_source_rgb(red, green, blue);
context.set_line_width(sizes.line_width * 2.0);
context.move_to(0.0, 0.0);
context.line_to(0.0, sizes.map_height);
context.line_to(sizes.map_width, sizes.map_height);
context.stroke()?;
let (red, green, blue) = configuration.theme.colors.label;
context.set_source_rgb(red, green, blue);
context.select_font_face(
&configuration.theme.fonts.face,
FontSlant::Normal,
FontWeight::Normal,
);
context.set_font_size(fonts.axis_label);
context.set_line_width(1.0);
context.save()?;
context.translate(-20.0, 45.0);
context.rotate(-PI / 2.0);
context.move_to(0.0, 0.0);
context.show_text("Visible")?;
context.restore()?;
context.save()?;
context.translate(-20.0, sizes.map_height);
context.rotate(-PI / 2.0);
context.move_to(0.0, 0.0);
context.show_text("Invisible")?;
context.restore()?;
let stage_height = sizes.stage_height;
let text_offset_y = -stage_height / 4.0 + sizes.vertex_size / 2.0;
context.move_to(0.0, text_offset_y);
context.show_text("Uncharted")?;
let text_ext = context.text_extents("Industrialised")?;
context.move_to(sizes.map_width - text_ext.width(), text_offset_y);
context.show_text("Industrialised")?;
let stage_labels = stage_type.stages();
let stages = get_stage_positions(map, configuration);
let padding = 5.0; let stage_label_y = sizes.map_height + padding + sizes.vertex_size / 2.0;
let line_height = fonts.axis_label * 1.2;
let stage_widths = [
stages[0],
stages[1] - stages[0],
stages[2] - stages[1],
sizes.map_width - stages[2],
];
for (i, &label) in stage_labels.iter().enumerate() {
let stage_x = match i {
0 => 0.0,
1 => stages[0],
2 => stages[1],
3 => stages[2],
_ => continue,
};
let wrapped_lines = wrap_text(context, label, stage_widths[i])?;
for (line_index, line) in wrapped_lines.iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let text_y = stage_label_y + line_index as f64 * line_height;
context.move_to(stage_x, text_y);
context.show_text(line)?;
}
}
context.restore()?;
Ok(())
}
fn draw_stage_dividers(
context: &Context,
map: &Map,
configuration: &Configuration,
) -> Result<(), RenderError> {
let stages = get_stage_positions(map, configuration);
context.save()?;
let (r, g, b) = configuration.theme.colors.axis;
context.set_source_rgb(r, g, b);
context.set_line_width(configuration.theme.sizes.line_width * 2.0);
context.set_dash(&[10.0, 18.0], 0.0);
for &stage_x in &stages {
context.move_to(stage_x, 0.0);
context.line_to(stage_x, configuration.theme.sizes.map_height);
}
context.stroke()?;
context.restore()?;
Ok(())
}
fn draw_dependencies(
context: &Context,
map: &Map,
configuration: &Configuration,
component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
) -> Result<(), RenderError> {
context.save()?;
let (r, g, b) = configuration.theme.colors.vertex;
context.set_source_rgb(r, g, b);
context.set_line_width(configuration.theme.sizes.line_width);
let half_width = configuration.theme.sizes.vertex_size / 2.0;
let half_height = configuration.theme.sizes.vertex_size / 2.0;
let arrow_angle = PI / 4.0;
for dependency in &map.dependencies {
let Some(from_coords) = component_map.get(&dependency.from.to_lowercase()) else {
continue;
};
let Some(to_coords) = component_map.get(&dependency.to.to_lowercase()) else {
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();
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;
context.move_to(offset_origin_x, offset_origin_y);
context.line_to(offset_dest_x, offset_dest_y);
if dependency.is_directed {
let upper_angle = angle - arrow_angle;
let lower_angle = angle + arrow_angle;
let arrowhead_size = configuration.theme.sizes.arrowhead_size;
context.move_to(
offset_dest_x - arrowhead_size * upper_angle.cos(),
offset_dest_y - arrowhead_size * upper_angle.sin(),
);
context.line_to(offset_dest_x, offset_dest_y);
context.line_to(
offset_dest_x - arrowhead_size * lower_angle.cos(),
offset_dest_y - arrowhead_size * lower_angle.sin(),
);
}
}
context.stroke()?;
context.restore()?;
Ok(())
}
fn draw_groups(
context: &Context,
map: &Map,
configuration: &Configuration,
component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
) -> Result<(), RenderError> {
if map.groups.is_empty() {
return Ok(());
}
#[allow(clippy::cast_possible_truncation)]
let width =
(configuration.theme.sizes.map_width + configuration.theme.sizes.vertex_size) as i32;
#[allow(clippy::cast_possible_truncation)]
let height =
(configuration.theme.sizes.map_height + configuration.theme.sizes.vertex_size) as i32;
let offscreen = ImageSurface::create(Format::ARgb32, width, height)?;
let offscreen_context = Context::new(&offscreen)?;
offscreen_context.translate(
configuration.theme.sizes.vertex_size / 2.0,
configuration.theme.sizes.vertex_size / 2.0,
);
for (group_index, group) in map.groups.iter().enumerate() {
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 = &configuration.theme.colors.groups
[group_index % configuration.theme.colors.groups.len()];
let is_line = component_coords.len() == 2;
let coords = if component_coords.len() == 1 {
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 = (f64::from(i) / f64::from(segments)) * 2.0 * PI;
circle_coords.push((x + radius * angle.cos(), y + radius * angle.sin()));
}
circle_coords
} else if is_line {
component_coords
} else {
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();
hull.exterior().coords().map(|c| (c.x, c.y)).collect()
};
if coords.is_empty() {
continue;
}
let (r, g, b) = *color;
offscreen_context.set_source_rgb(r, g, b);
offscreen_context.move_to(coords[0].0, coords[0].1);
for &(x, y) in &coords[1..] {
offscreen_context.line_to(x, y);
}
if !is_line {
offscreen_context.close_path();
offscreen_context.fill_preserve()?;
}
offscreen_context.set_line_width(configuration.theme.sizes.vertex_size * 1.75);
offscreen_context.set_line_cap(LineCap::Round);
offscreen_context.set_line_join(LineJoin::Round);
offscreen_context.stroke()?;
}
context.save()?;
context.set_source_surface(&offscreen, 0.0, 0.0)?;
context.paint_with_alpha(configuration.theme.opacity.groups)?;
context.restore()?;
Ok(())
}
fn draw_vertices(
context: &Context,
map: &Map,
configuration: &Configuration,
) -> Result<(), RenderError> {
context.save()?;
let (red, green, blue) = configuration.theme.colors.vertex;
context.set_source_rgb(red, green, blue);
for component in &map.components {
let vertex_x =
percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
let vertex_y = percent_to_pixel(
component.coordinates.1,
configuration.theme.sizes.map_height,
);
draw_vertex(
context,
&component.shape,
vertex_x,
vertex_y,
configuration.theme.sizes.vertex_size,
configuration.theme.sizes.vertex_size,
)?;
}
context.restore()?;
Ok(())
}
fn draw_vertex_labels(
context: &Context,
map: &Map,
configuration: &Configuration,
component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
) -> Result<(), RenderError> {
context.save()?;
let (red, green, blue) = configuration.theme.colors.label;
context.set_source_rgb(red, green, blue);
context.select_font_face(
&configuration.theme.fonts.face,
FontSlant::Normal,
FontWeight::Normal,
);
context.set_font_size(configuration.theme.fonts.vertex_label);
if configuration.options.smart_label_positioning {
let has_significant_complexity = map.dependencies.len() + map.evolutions.len() > 10;
if has_significant_complexity {
let all_lines = collect_all_lines(
map,
configuration.theme.sizes.map_width,
configuration.theme.sizes.map_height,
configuration.theme.sizes.vertex_size,
component_map,
);
let spatial_grid = build_spatial_grid(
&all_lines,
configuration.theme.sizes.map_width,
configuration.theme.sizes.map_height,
);
let note_rects = collect_note_rects(
map,
configuration.theme.sizes.map_width,
configuration.theme.sizes.map_height,
context,
configuration.theme.fonts.vertex_label,
)?;
for component in &map.components {
let position = calculate_optimal_label_position_cached(
component,
&PositioningDimensions {
map_size: (
configuration.theme.sizes.map_width,
configuration.theme.sizes.map_height,
),
vertex_size: configuration.theme.sizes.vertex_size,
font_size: configuration.theme.fonts.vertex_label,
},
context,
&all_lines,
&spatial_grid,
¬e_rects,
)?;
let label_x = position.x;
let label_y = position.y + 7.0;
context.move_to(label_x, label_y);
context.show_text(&component.label)?;
}
} else {
for component in &map.components {
let vertex_x =
percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
let vertex_y = percent_to_pixel(
component.coordinates.1,
configuration.theme.sizes.map_height,
);
let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
let label_y = vertex_y
+ configuration.theme.sizes.vertex_size / 2.0
+ configuration.theme.fonts.vertex_label / 3.0;
context.move_to(label_x, label_y);
context.show_text(&component.label)?;
}
}
} else {
for component in &map.components {
let vertex_x =
percent_to_pixel(component.coordinates.0, configuration.theme.sizes.map_width);
let vertex_y = percent_to_pixel(
component.coordinates.1,
configuration.theme.sizes.map_height,
);
let label_x = vertex_x + configuration.theme.sizes.vertex_size + 5.0;
let label_y = vertex_y
+ configuration.theme.sizes.vertex_size / 2.0
+ configuration.theme.fonts.vertex_label / 3.0;
context.move_to(label_x, label_y);
context.show_text(&component.label)?;
}
}
context.restore()?;
Ok(())
}
fn draw_inertias(
context: &Context,
map: &Map,
configuration: &Configuration,
component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
) -> Result<(), RenderError> {
context.save()?;
let (red, green, blue) = configuration.theme.colors.inertia;
context.set_source_rgb(red, green, blue);
let corner_radius = 2.0;
let rect_width = configuration.theme.sizes.vertex_size / 2.0;
let rect_height = configuration.theme.sizes.vertex_size * 2.0;
for inertia in &map.inertias {
if let Some(&(origin_x, origin_y)) = component_map.get(&inertia.component.to_lowercase()) {
let inertia_x = origin_x + 3.0 * configuration.theme.sizes.vertex_size;
let inertia_y = origin_y - (configuration.theme.sizes.vertex_size * 2.0) / 3.0;
draw_rounded_rect(
context,
inertia_x,
inertia_y,
rect_width,
rect_height,
corner_radius,
);
context.fill()?;
}
}
context.restore()?;
Ok(())
}
fn draw_rounded_rect(context: &Context, x: f64, y: f64, width: f64, height: f64, radius: f64) {
let r = radius;
context.new_path();
context.arc(
x + r,
y + r,
r,
std::f64::consts::PI,
3.0 * std::f64::consts::PI / 2.0,
);
context.arc(
x + width - r,
y + r,
r,
3.0 * std::f64::consts::PI / 2.0,
0.0,
);
context.arc(
x + width - r,
y + height - r,
r,
0.0,
std::f64::consts::PI / 2.0,
);
context.arc(
x + r,
y + height - r,
r,
std::f64::consts::PI / 2.0,
std::f64::consts::PI,
);
context.close_path();
}
fn wrap_text(context: &Context, text: &str, max_width: f64) -> Result<Vec<String>, Error> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut lines = Vec::new();
let mut current_line = String::new();
for word in words {
let test_line = if current_line.is_empty() {
word.to_string()
} else {
format!("{current_line} {word}")
};
let extents = context.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 lines.is_empty() {
lines.push(text.to_string());
}
Ok(lines)
}
fn draw_evolutions(
context: &Context,
map: &Map,
configuration: &Configuration,
component_map: &rustc_hash::FxHashMap<String, (f64, f64)>,
) -> Result<(), RenderError> {
context.save()?;
let (r, g, b) = configuration.theme.colors.evolution;
context.set_source_rgb(r, g, b);
context.set_line_width(configuration.theme.sizes.line_width * 2.0);
context.set_dash(&[10.0], 0.0);
for evolution in &map.evolutions {
if let Some(&(origin_x, origin_y)) = component_map.get(&evolution.component.to_lowercase())
{
let evolution_offset =
percent_to_pixel(evolution.value, configuration.theme.sizes.map_width);
let dest_x = (origin_x + evolution_offset)
.max(0.0)
.min(configuration.theme.sizes.map_width);
let dest_y = origin_y;
let multiplier = if dest_x > origin_x { 1.0 } else { -1.0 };
let offset_origin_x = origin_x
+ multiplier * (configuration.theme.sizes.vertex_size / 2.0)
+ configuration.theme.sizes.vertex_size / 2.0;
let offset_origin_y = origin_y + configuration.theme.sizes.vertex_size / 2.0;
let offset_dest_x = dest_x - multiplier * (configuration.theme.sizes.vertex_size / 2.0)
+ configuration.theme.sizes.vertex_size / 2.0;
let offset_dest_y = dest_y + configuration.theme.sizes.vertex_size / 2.0;
context.move_to(offset_origin_x, offset_origin_y);
context.line_to(offset_dest_x, offset_dest_y);
let upper_angle = -PI / 4.0;
let lower_angle = PI / 4.0;
let arrowhead_size = configuration.theme.sizes.arrowhead_size;
context.move_to(offset_dest_x, offset_dest_y);
context.line_to(
offset_dest_x - multiplier * arrowhead_size * upper_angle.cos(),
offset_dest_y - multiplier * arrowhead_size * upper_angle.sin(),
);
context.move_to(offset_dest_x, offset_dest_y);
context.line_to(
offset_dest_x - multiplier * arrowhead_size * lower_angle.cos(),
offset_dest_y - multiplier * arrowhead_size * lower_angle.sin(),
);
}
}
context.stroke()?;
context.restore()?;
Ok(())
}
fn draw_notes(
context: &Context,
map: &Map,
configuration: &Configuration,
) -> Result<(), RenderError> {
let padding = 5.0;
let max_width = 400.0;
context.save()?;
context.select_font_face(
&configuration.theme.fonts.face,
FontSlant::Normal,
FontWeight::Normal,
);
context.set_font_size(configuration.theme.fonts.note);
for note in &map.notes {
let note_x = percent_to_pixel(note.coordinates.0, configuration.theme.sizes.map_width);
let note_y = percent_to_pixel(note.coordinates.1, configuration.theme.sizes.map_height);
let text = note.text.replace("\\n", "\n");
let lines: Vec<&str> = text.split('\n').collect();
let mut widest_line = 0.0;
for line in &lines {
let extents = context.text_extents(line)?;
if extents.width() > widest_line {
widest_line = extents.width();
}
}
let box_width = f64::min(max_width, widest_line + padding * 2.0);
#[allow(clippy::cast_precision_loss)]
let box_height = lines.len() as f64 * configuration.theme.line_heights.note + padding * 2.0;
let (red, green, blue) = configuration.theme.colors.background;
context.set_source_rgb(red, green, blue);
context.rectangle(note_x, note_y, box_width, box_height);
context.fill()?;
let (red, green, blue) = configuration.theme.colors.vertex;
context.set_source_rgb(red, green, blue);
context.set_line_width(configuration.theme.sizes.line_width);
context.rectangle(note_x, note_y, box_width, box_height);
context.stroke()?;
let (red, green, blue) = configuration.theme.colors.label;
context.set_source_rgb(red, green, blue);
for (i, line) in lines.iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let line_offset = i as f64 * configuration.theme.line_heights.note;
context.move_to(
note_x + padding,
note_y + padding + line_offset + configuration.theme.fonts.note,
);
context.show_text(line)?;
}
}
context.restore()?;
Ok(())
}
fn get_stage_positions(map: &Map, configuration: &Configuration) -> [f64; 3] {
let default_stages = [25.0, 50.0, 75.0];
let mut stages = default_stages;
for stage_data in &map.stages {
let index = match stage_data.stage {
Stage::I => 0,
Stage::Ii => 1,
Stage::Iii => 2,
Stage::Iv => continue, };
stages[index] = stage_data.value; }
[
percent_to_pixel(stages[0], configuration.theme.sizes.map_width),
percent_to_pixel(stages[1], configuration.theme.sizes.map_width),
percent_to_pixel(stages[2], configuration.theme.sizes.map_width),
]
}