use crate::config::Tool;
use crate::draw::{
draw_benchmark_route, draw_coastlines, draw_endpoint_markers, draw_minimap, draw_route_bounds,
draw_windmap, render_route_evolution,
};
use crate::view::ViewTransform;
use bywind::{
BoatConfig, LonLatBbox, MapBounds, TimedWindMap, Topology, WaypointCount,
fmt::{format_duration_breakdown, format_fitness_magnitude, format_land_km, format_pso_delta},
route_evolution_match,
};
mod central;
mod dialogs;
mod menu;
mod panels;
pub(super) const SCALE_MIN: f32 = 1e-5;
pub(super) const SCALE_MAX: f32 = 4.0;
pub(super) const MAP_PADDING: f32 = 20.0;
pub(super) fn min_render_scale(panel_height: f32) -> f32 {
if panel_height > 0.0 {
(panel_height / (180.0 * crate::view::METRES_PER_DEGREE)).max(SCALE_MIN)
} else {
SCALE_MIN
}
}
pub(super) fn format_scale_value(render_scale: f32) -> String {
let m_per_px = f64::from(1.0 / render_scale.max(f32::EPSILON));
if m_per_px >= 1000.0 {
format!("1 px : {} km", format_quantity(m_per_px / 1000.0))
} else if m_per_px >= 1.0 {
format!("1 px : {} m", format_quantity(m_per_px))
} else {
format!("1 px : {} cm", format_quantity(m_per_px * 100.0))
}
}
pub(super) fn parse_scale_value(s: &str) -> Option<f64> {
let body = s
.trim()
.strip_prefix("1 px :")
.or_else(|| s.trim().strip_prefix("1px:"))
.unwrap_or(s)
.trim();
let (num_str, metres_per_unit) = if let Some(n) = body.strip_suffix("km") {
(n.trim(), 1000.0_f64)
} else if let Some(n) = body.strip_suffix("cm") {
(n.trim(), 0.01_f64)
} else if let Some(n) = body.strip_suffix('m') {
(n.trim(), 1.0_f64)
} else {
return None;
};
let n: f64 = num_str.parse().ok()?;
if n <= 0.0 {
return None;
}
Some(1.0 / (n * metres_per_unit))
}
pub(super) fn format_quantity(v: f64) -> String {
if v >= 10.0 {
format!("{v:.0}")
} else if v >= 1.0 {
format!("{v:.1}")
} else {
format!("{v:.2}")
}
}
pub(super) const SLIDER_ROW_MAX_WIDTH: f32 = 240.0;
pub(super) fn paint_endpoint_highlight(ui: &egui::Ui, rect: egui::Rect) {
let t = ui.input(|i| i.time);
let pulse = 0.5 + 0.5 * (t * std::f64::consts::TAU * 1.5).sin();
let alpha = (160.0 + 95.0 * pulse) as u8;
let width = 1.5 + 1.5 * pulse as f32;
let stroke = egui::Stroke::new(
width,
egui::Color32::from_rgba_unmultiplied(255, 140, 40, alpha),
);
ui.painter()
.rect_stroke(rect, 2.0, stroke, egui::StrokeKind::Outside);
}
pub(super) fn int_slider_with_steppers(ui: &mut egui::Ui, value: &mut usize, max: usize) {
ui.horizontal(|ui| {
if ui.small_button("−").clicked() && *value > 0 {
*value -= 1;
}
expand_slider_width(ui);
ui.add(egui::Slider::new(value, 0..=max).show_value(false));
if ui.small_button("+").clicked() && *value < max {
*value += 1;
}
});
}
pub(super) fn log_slider_with_steppers(ui: &mut egui::Ui, value: &mut f32, start: f32, end: f32) {
const STEP_FACTOR: f32 = 1.1;
ui.horizontal(|ui| {
if ui.small_button("−").clicked() {
*value = (*value / STEP_FACTOR).clamp(start, end);
}
expand_slider_width(ui);
ui.add(
egui::Slider::new(value, start..=end)
.logarithmic(true)
.show_value(false),
);
if ui.small_button("+").clicked() {
*value = (*value * STEP_FACTOR).clamp(start, end);
}
});
}
pub(super) fn expand_slider_width(ui: &mut egui::Ui) {
let trailing_button_reserve = 24.0;
let target = (ui.available_width() - trailing_button_reserve).clamp(40.0, SLIDER_ROW_MAX_WIDTH);
ui.spacing_mut().slider_width = target;
}
pub(super) fn format_fuel(kg: f64, tonnes: bool) -> String {
if tonnes {
format!("{:.3} t", kg / 1000.0)
} else {
format!("{kg:.2} kg")
}
}
pub(super) const FUEL_CURVE_SAMPLES: usize = 64;
pub(super) fn draw_fuel_curve(ui: &mut egui::Ui, fuel_a: f64, fuel_b: f64, fuel_c: f64) {
let width = ui.available_width().clamp(80.0, 220.0);
let desired_size = egui::Vec2::new(width, 80.0);
let (rect, _) = ui.allocate_exact_size(desired_size, egui::Sense::hover());
let fuel_rate = |mcr_01: f64| -> f64 {
if mcr_01 <= 0.0 {
0.0
} else {
mcr_01 * (fuel_a + fuel_b * mcr_01 + fuel_c * mcr_01 * mcr_01)
}
};
let mut samples: Vec<(f64, f64)> = Vec::with_capacity(FUEL_CURVE_SAMPLES + 1);
let mut y_min = 0.0_f64;
let mut y_max = 0.0_f64;
for i in 0..=FUEL_CURVE_SAMPLES {
let mcr = i as f64 / FUEL_CURVE_SAMPLES as f64;
let y = fuel_rate(mcr);
y_min = y_min.min(y);
y_max = y_max.max(y);
samples.push((mcr, y));
}
let y_span = (y_max - y_min).max(1e-12);
let visuals = ui.visuals();
let painter = ui.painter_at(rect);
painter.rect(
rect,
2.0,
visuals.extreme_bg_color,
egui::Stroke::new(1.0, visuals.weak_text_color()),
egui::StrokeKind::Inside,
);
let to_screen = |mcr: f64, y: f64| -> egui::Pos2 {
let t_x = mcr.clamp(0.0, 1.0) as f32;
let t_y = ((y - y_min) / y_span) as f32;
egui::Pos2::new(
rect.left() + t_x * rect.width(),
rect.bottom() - t_y * rect.height(),
)
};
if y_min < 0.0 && y_max > 0.0 {
let zero_y = to_screen(0.0, 0.0).y;
painter.line_segment(
[
egui::Pos2::new(rect.left(), zero_y),
egui::Pos2::new(rect.right(), zero_y),
],
egui::Stroke::new(1.0, visuals.weak_text_color()),
);
}
let curve_points: Vec<egui::Pos2> = samples.iter().map(|&(mcr, y)| to_screen(mcr, y)).collect();
painter.add(egui::Shape::line(
curve_points,
egui::Stroke::new(1.5, visuals.text_color()),
));
}
pub(super) fn powered_by_egui_and_eframe(ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Powered by ");
ui.hyperlink_to("egui", "https://github.com/emilk/egui");
ui.label(" and ");
ui.hyperlink_to(
"eframe",
"https://github.com/emilk/egui/tree/master/crates/eframe",
);
ui.label(".");
});
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn sync_out_path_extension(path: &mut String, new_ext: &str) {
if path.is_empty() {
return;
}
let pb = std::path::PathBuf::from(&*path);
let updated = pb.with_extension(new_ext);
*path = updated.to_string_lossy().into_owned();
}