use ab_glyph::Font;
use sl_map_apis::map_tiles::MapLike;
use sl_types::map::{GridRectangleLike as _, RegionCoordinates};
use sl_map_apis::text::{LabelStyle, measure_text};
use crate::geometry;
use crate::style::GlwStyle;
use crate::types::{
Area, Base, Circle, CurrentsOverride, GlwEvent, KnotSpeed, WaveHeight, WavesOverride,
WindDirection, WindOverride,
};
pub trait MapLikeGlwExt: MapLike {
fn draw_glw_event(&mut self, event: &GlwEvent, style: &GlwStyle) {
for area in event.areas.as_slice() {
self.draw_glw_area(area, &event.base, style);
}
for circle in event.circles.as_slice() {
self.draw_glw_circle(circle, &event.base, style);
}
}
fn draw_glw_area(&mut self, area: &Area, base: &Base, style: &GlwStyle) {
draw_area_default(self, area, base, style);
}
fn draw_glw_circle(&mut self, circle: &Circle, base: &Base, style: &GlwStyle) {
draw_circle_default(self, circle, base, style);
}
fn draw_glw_event_with_font<F: Font>(&mut self, event: &GlwEvent, style: &GlwStyle, font: &F) {
self.draw_glw_event(event, style);
if style.label_overrides {
for area in event.areas.as_slice() {
self.draw_glw_area_label(area, &event.base, style, font);
}
for circle in event.circles.as_slice() {
self.draw_glw_circle_label(circle, &event.base, style, font);
}
}
if style.legend_position.is_some() {
self.draw_glw_base_legend(&event.base, style, font);
}
}
fn draw_glw_area_label<F: Font>(
&mut self,
area: &Area,
base: &Base,
style: &GlwStyle,
font: &F,
) {
draw_area_label_default(self, area, base, style, font);
}
fn draw_glw_circle_label<F: Font>(
&mut self,
circle: &Circle,
base: &Base,
style: &GlwStyle,
font: &F,
) {
draw_circle_label_default(self, circle, base, style, font);
}
fn draw_glw_base_legend<F: Font>(&mut self, base: &Base, style: &GlwStyle, font: &F) {
draw_base_legend_default(self, base, style, font);
}
}
impl<M: MapLike + ?Sized> MapLikeGlwExt for M {}
pub fn draw_glw_event<M: MapLike + ?Sized>(map: &mut M, event: &GlwEvent, style: &GlwStyle) {
map.draw_glw_event(event, style);
}
fn draw_area_default<M: MapLike + ?Sized>(map: &mut M, area: &Area, base: &Base, style: &GlwStyle) {
let Some((sw_x, sw_y)) = map.pixel_coordinates_for_coordinates(
&area.grid_rectangle.lower_left_corner(),
&RegionCoordinates::new(0.0, 0.0, 0.0),
) else {
tracing::debug!(area = %area.name, "area outside map; skipping");
return;
};
let Some((ne_x, ne_y)) = map.pixel_coordinates_for_coordinates(
&area.grid_rectangle.upper_right_corner(),
&RegionCoordinates::new(256.0, 256.0, 0.0),
) else {
tracing::debug!(area = %area.name, "area corner outside map; skipping");
return;
};
let (x0, x1) = sort_pair(sw_x, ne_x);
let (y0, y1) = sort_pair(sw_y, ne_y);
let width = x1.saturating_sub(x0).max(1);
let height = y1.saturating_sub(y0).max(1);
let rect = imageproc::rect::Rect::at(
i32::try_from(x0).unwrap_or(0),
i32::try_from(y0).unwrap_or(0),
)
.of_size(width, height);
if let Some(fill) = style.palette.area_fill {
imageproc::drawing::draw_filled_rect_mut(map.image_mut(), rect, fill);
}
imageproc::drawing::draw_hollow_rect_mut(map.image_mut(), rect, style.palette.area_outline);
if style.draw_margin_band {
let margin_m = f32::from(area.margin.meters());
if margin_m > 0.0 {
let margin_px = margin_m * map.pixels_per_meter();
geometry::dashed_rect(
map,
(x0 as f32 - margin_px, y0 as f32 - margin_px),
(x1 as f32 + margin_px, y1 as f32 + margin_px),
style.margin_dash_pixels,
style.margin_gap_pixels,
style.palette.margin_outline,
);
}
}
let wind_dir = area
.wind
.and_then(|w| w.direction)
.unwrap_or(base.wind.direction);
let wind_speed = area.wind.and_then(|w| w.speed).unwrap_or(base.wind.speed);
tracing::debug!(
area = %area.name,
wind = geometry::degrees_to_compass8(wind_dir),
wind_knots = wind_speed.knots(),
"drawing area"
);
let density = u32::from(style.area_arrow_density.max(1));
let fx0 = x0 as f32;
let fy0 = y0 as f32;
let fw = (x1 - x0) as f32;
let fh = (y1 - y0) as f32;
for i_x in 0..density {
for i_y in 0..density {
let u = (i_x as f32 + 0.5) / density as f32;
let v = (i_y as f32 + 0.5) / density as f32;
let anchor = (fx0 + u * fw, fy0 + v * fh);
draw_wind_glyph(map, anchor, wind_dir, wind_speed, style);
}
}
let inset = (fw.min(fh) * 0.18).clamp(14.0, 40.0);
if let (true, Some(currents)) = (style.draw_currents, area.currents) {
let speed = currents.speed.unwrap_or(base.currents.speed);
let dir = currents.direction.unwrap_or(base.currents.direction);
let anchor = (fx0 + fw - inset, fy0 + inset);
draw_current_glyph(map, anchor, dir, speed, style);
}
if let (true, Some(waves)) = (style.draw_waves, area.waves) {
let height_m = waves.height.unwrap_or(base.waves.height);
let anchor = (fx0 + inset, fy0 + fh - inset);
draw_wave_glyph(map, anchor, height_m.meters(), style);
}
}
fn draw_circle_default<M: MapLike + ?Sized>(
map: &mut M,
circle: &Circle,
base: &Base,
style: &GlwStyle,
) {
let Some(center) = geometry::circle_center_pixel(map, &circle.center_sim, &circle.center_point)
else {
tracing::debug!(circle = %circle.name, "circle centre outside map; skipping");
return;
};
let radius_px = (circle.radius.meters() * map.pixels_per_meter()).max(1.0);
let cx_i = i32::try_from(center.0 as i64).unwrap_or(0);
let cy_i = i32::try_from(center.1 as i64).unwrap_or(0);
imageproc::drawing::draw_hollow_circle_mut(
map.image_mut(),
(cx_i, cy_i),
radius_px as i32,
style.palette.circle_outline,
);
if style.draw_margin_band {
let margin_m = f32::from(circle.margin.meters());
if margin_m > 0.0 {
let margin_px = margin_m * map.pixels_per_meter();
geometry::dashed_circle(
map,
center,
radius_px + margin_px,
style.margin_dash_pixels,
style.margin_gap_pixels,
style.palette.margin_outline,
);
}
}
let wind_dir = circle
.wind
.and_then(|w| w.direction)
.unwrap_or(base.wind.direction);
let wind_speed = circle.wind.and_then(|w| w.speed).unwrap_or(base.wind.speed);
draw_wind_glyph(map, center, wind_dir, wind_speed, style);
let offset = (radius_px * 0.55).clamp(18.0, 60.0);
if let (true, Some(currents)) = (style.draw_currents, circle.currents) {
let speed = currents.speed.unwrap_or(base.currents.speed);
let dir = currents.direction.unwrap_or(base.currents.direction);
let anchor = (center.0 + offset, center.1 - offset);
draw_current_glyph(map, anchor, dir, speed, style);
}
if let (true, Some(waves)) = (style.draw_waves, circle.waves) {
let height_m = waves.height.unwrap_or(base.waves.height);
let anchor = (center.0 - offset, center.1 + offset);
draw_wave_glyph(map, anchor, height_m.meters(), style);
}
}
const fn sort_pair(a: u32, b: u32) -> (u32, u32) {
if a <= b { (a, b) } else { (b, a) }
}
fn arrow_length_px(speed: KnotSpeed, style: &GlwStyle) -> f32 {
(speed.knots() * style.arrow_pixels_per_knot).max(style.min_arrow_pixels)
}
fn stroke_line<M: MapLike + ?Sized>(
map: &mut M,
a: (f32, f32),
b: (f32, f32),
color: image::Rgba<u8>,
) {
imageproc::drawing::draw_line_segment_mut(map.image_mut(), a, b, color);
}
fn draw_wind_glyph<M: MapLike + ?Sized>(
map: &mut M,
center: (f32, f32),
dir: WindDirection,
speed: KnotSpeed,
style: &GlwStyle,
) {
let length = arrow_length_px(speed, style);
let (dx, dy) = geometry::blowing_toward_unit_vec(dir);
let (px, py) = (-dy, dx);
let half = length * 0.5;
let tail = (center.0 - dx * half, center.1 - dy * half);
let tip = (center.0 + dx * half, center.1 + dy * half);
let color = style.palette.wind_arrow;
stroke_line(map, tail, tip, color);
let tail_fin_len = (length * 0.22).clamp(4.0, 10.0);
let tail_fin_end = (tail.0 - px * tail_fin_len, tail.1 - py * tail_fin_len);
stroke_line(map, tail, tail_fin_end, color);
let head_back = (length * 0.36).clamp(6.0, 14.0);
let head_notch = (length * 0.20).clamp(3.0, 8.0);
let head_side = (length * 0.22).clamp(4.0, 10.0);
let back_right = (
tip.0 - dx * head_back - px * head_side,
tip.1 - dy * head_back - py * head_side,
);
let back_left = (
tip.0 - dx * head_back + px * head_side,
tip.1 - dy * head_back + py * head_side,
);
let notch = (tip.0 - dx * head_notch, tip.1 - dy * head_notch);
let p_tip = imageproc::point::Point::<i32>::new(tip.0 as i32, tip.1 as i32);
let p_back_right =
imageproc::point::Point::<i32>::new(back_right.0 as i32, back_right.1 as i32);
let p_notch = imageproc::point::Point::<i32>::new(notch.0 as i32, notch.1 as i32);
let p_back_left = imageproc::point::Point::<i32>::new(back_left.0 as i32, back_left.1 as i32);
if p_tip != p_back_right
&& p_back_right != p_notch
&& p_notch != p_back_left
&& p_back_left != p_tip
{
imageproc::drawing::draw_polygon_mut(
map.image_mut(),
&[p_tip, p_back_right, p_notch, p_back_left],
color,
);
}
}
fn draw_current_glyph<M: MapLike + ?Sized>(
map: &mut M,
center: (f32, f32),
dir: WindDirection,
speed: KnotSpeed,
style: &GlwStyle,
) {
let length = arrow_length_px(speed, style) * 2.0;
let (dx, dy) = geometry::blowing_toward_unit_vec(dir);
let (px, py) = (-dy, dx);
let half = length * 0.5;
let tail = (center.0 - dx * half, center.1 - dy * half);
let tip = (center.0 + dx * half, center.1 + dy * half);
let color = style.palette.current_arrow;
let head_len = (length * 0.30).clamp(8.0, 18.0);
let head_half = head_len * 0.55;
let head_base = (tip.0 - dx * head_len, tip.1 - dy * head_len);
stroke_line(map, tail, head_base, color);
let p_tip = imageproc::point::Point::<i32>::new(tip.0 as i32, tip.1 as i32);
let p_left = imageproc::point::Point::<i32>::new(
(head_base.0 + px * head_half) as i32,
(head_base.1 + py * head_half) as i32,
);
let p_right = imageproc::point::Point::<i32>::new(
(head_base.0 - px * head_half) as i32,
(head_base.1 - py * head_half) as i32,
);
if p_tip != p_left && p_tip != p_right && p_left != p_right {
imageproc::drawing::draw_polygon_mut(map.image_mut(), &[p_tip, p_left, p_right], color);
}
}
fn format_speed(speed: KnotSpeed) -> String {
let k = speed.knots();
if (k - k.round()).abs() < 0.05 {
format!("{k:.0}")
} else {
format!("{k:.1}")
}
}
fn format_height(h: WaveHeight) -> String {
format!("{:.1}m", h.meters())
}
fn build_label_lines(
base: &Base,
wind: Option<WindOverride>,
currents: Option<CurrentsOverride>,
waves: Option<WavesOverride>,
) -> Vec<String> {
let mut lines = Vec::new();
if let Some(w) = wind.filter(|w| !w.is_empty()) {
let dir = w.direction.unwrap_or(base.wind.direction);
let speed = w.speed.unwrap_or(base.wind.speed);
lines.push(format!(
"Wind: {} {}Kt",
geometry::degrees_to_compass8(dir),
format_speed(speed),
));
}
if let Some(c) = currents.filter(|c| !c.is_empty()) {
let dir = c.direction.unwrap_or(base.currents.direction);
let speed = c.speed.unwrap_or(base.currents.speed);
lines.push(format!(
"Current: {} {}Kt",
geometry::degrees_to_compass8(dir),
format_speed(speed),
));
}
if let Some(wv) = waves.filter(|w| !w.is_empty()) {
let height = wv.height.unwrap_or(base.waves.height);
lines.push(format!("Waves: {}", format_height(height)));
if let Some(effects) = wv.effects {
let label = match (effects.speed, effects.steer) {
(true, true) => "spd+steer",
(true, false) => "spd",
(false, true) => "steer",
(false, false) => "",
};
if !label.is_empty() {
lines.push(label.to_owned());
}
}
}
lines
}
fn build_base_legend_lines(base: &Base) -> Vec<String> {
vec![
format!(
"Wind: {} {}Kt",
geometry::degrees_to_compass8(base.wind.direction),
format_speed(base.wind.speed),
),
format!(
"Current: {} {}Kt",
geometry::degrees_to_compass8(base.currents.direction),
format_speed(base.currents.speed),
),
format!("Waves: {}", format_height(base.waves.height)),
]
}
fn draw_area_label_default<M, F>(map: &mut M, area: &Area, base: &Base, style: &GlwStyle, font: &F)
where
M: MapLike + ?Sized,
F: Font,
{
let lines = build_label_lines(base, area.wind, area.currents, area.waves);
if lines.is_empty() {
return;
}
let Some((sw_x, sw_y)) = map.pixel_coordinates_for_coordinates(
&area.grid_rectangle.lower_left_corner(),
&RegionCoordinates::new(0.0, 0.0, 0.0),
) else {
return;
};
let Some((ne_x, ne_y)) = map.pixel_coordinates_for_coordinates(
&area.grid_rectangle.upper_right_corner(),
&RegionCoordinates::new(256.0, 256.0, 0.0),
) else {
return;
};
let (x0, x1) = sort_pair(sw_x, ne_x);
let (y0, y1) = sort_pair(sw_y, ne_y);
let scale = ab_glyph::PxScale::from(style.label_font_px);
let (text_w, text_h) = measure_text(scale, font, &lines);
let inset_y = 6_i32;
let centre_x = x0.midpoint(x1);
let x = i32::try_from(centre_x).unwrap_or(0) - i32::try_from(text_w / 2).unwrap_or(0);
let y = i32::try_from(y1).unwrap_or(0) - i32::try_from(text_h).unwrap_or(0) - inset_y;
let y = y.max(i32::try_from(y0).unwrap_or(0) + inset_y);
let label_style = LabelStyle {
scale,
fg: style.palette.label_fg,
shadow: style.palette.label_shadow,
align: sl_map_apis::coverage::HAlign::Left,
};
map.draw_text_label((x, y), &lines, &label_style, font);
}
fn draw_circle_label_default<M, F>(
map: &mut M,
circle: &Circle,
base: &Base,
style: &GlwStyle,
font: &F,
) where
M: MapLike + ?Sized,
F: Font,
{
let lines = build_label_lines(base, circle.wind, circle.currents, circle.waves);
if lines.is_empty() {
return;
}
let Some(center) = geometry::circle_center_pixel(map, &circle.center_sim, &circle.center_point)
else {
return;
};
let radius_px = (circle.radius.meters() * map.pixels_per_meter()).max(1.0);
let scale = ab_glyph::PxScale::from(style.label_font_px);
let (text_w, text_h) = measure_text(scale, font, &lines);
let diameter = 2.0 * radius_px;
let inset = 4.0_f32;
let fits_inside = text_w as f32 <= diameter * 0.8 && text_h as f32 <= diameter * 0.6;
let x = (center.0 - text_w as f32 * 0.5) as i32;
let y = if fits_inside {
(center.1 + radius_px - text_h as f32 - inset) as i32
} else {
(center.1 + radius_px + inset) as i32
};
let label_style = LabelStyle {
scale,
fg: style.palette.label_fg,
shadow: style.palette.label_shadow,
align: sl_map_apis::coverage::HAlign::Left,
};
map.draw_text_label((x, y), &lines, &label_style, font);
}
fn draw_base_legend_default<M, F>(map: &mut M, base: &Base, style: &GlwStyle, font: &F)
where
M: MapLike + ?Sized,
F: Font,
{
let Some(anchor) = style.legend_position else {
return;
};
let lines = build_base_legend_lines(base);
let scale = ab_glyph::PxScale::from(style.label_font_px);
let (text_w, text_h) = measure_text(scale, font, &lines);
let pad: u32 = 6;
let panel_w = text_w + pad * 2;
let panel_h = text_h + pad * 2;
let (img_w, img_h) = image::GenericImageView::dimensions(map.image());
let edge_gap: u32 = 8;
let (px_origin, py_origin) = anchor.anchored_origin(panel_w, panel_h, img_w, img_h, edge_gap);
let rect = imageproc::rect::Rect::at(
i32::try_from(px_origin).unwrap_or(0),
i32::try_from(py_origin).unwrap_or(0),
)
.of_size(panel_w.max(1), panel_h.max(1));
imageproc::drawing::draw_filled_rect_mut(map.image_mut(), rect, style.palette.legend_bg);
let label_style = LabelStyle {
scale,
fg: style.palette.legend_fg,
shadow: style.palette.label_shadow,
align: sl_map_apis::coverage::HAlign::Left,
};
map.draw_text_label(
(
i32::try_from(px_origin + pad).unwrap_or(0),
i32::try_from(py_origin + pad).unwrap_or(0),
),
&lines,
&label_style,
font,
);
}
fn draw_wave_glyph<M: MapLike + ?Sized>(
map: &mut M,
center: (f32, f32),
height_m: f32,
style: &GlwStyle,
) {
let amplitude = (1.5 + height_m * 0.8).clamp(2.5, 5.0);
let half_width = (10.0 + height_m * 1.5).clamp(12.0, 22.0);
let row_spacing = amplitude * 2.0 + 3.0;
let color = style.palette.wave_glyph;
for row in [-1.0_f32, 1.0] {
let row_y = center.1 + row * row_spacing * 0.5;
let segments: u32 = 24;
let mut prev: Option<(f32, f32)> = None;
for i in 0..=segments {
let t = i as f32 / segments as f32;
let x = center.0 - half_width + 2.0 * half_width * t;
let phase = 4.0 * std::f32::consts::PI * t;
let y = row_y - amplitude * phase.cos();
if let Some(p) = prev {
stroke_line(map, p, (x, y), color);
}
prev = Some((x, y));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use sl_map_apis::coverage::PlacementSlot;
use sl_map_apis::map_tiles::Map;
use sl_types::map::{GridCoordinates, GridRectangle, ZoomLevel};
fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
Ok(ab_glyph::FontVec::try_from_vec(fs_err::read(path)?)?)
}
fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
let grid = GridRectangle::new(
GridCoordinates::new(1130, 1130),
GridCoordinates::new(1140, 1140),
);
Ok(Map::blank(grid, ZoomLevel::try_new(4)?))
}
fn alpha_at(map: &Map, x: u32, y: u32) -> u8 {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
a
}
const BASE_ONLY_JSON: &str = r#"{
"eventId": 1, "eventName": "t", "eventKey": "k",
"directorName": "d", "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
"base": {
"wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
"waves": { "height": 1.5, "speed": 3, "length": 35, "heightVar": 5, "lengthVar": 5,
"effects": { "speed": 1, "steer": 1 } },
"currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
},
"areas": {}, "circles": {}
}"#;
#[test]
fn legend_follows_the_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
let font = test_font()?;
let event: crate::types::GlwEvent = serde_json::from_str(BASE_ONLY_JSON)?;
let (w, h) = {
let map = blank_map()?;
image::GenericImageView::dimensions(&map)
};
let mut tl = blank_map()?;
let style_tl = GlwStyle {
legend_position: Some(PlacementSlot::TopLeft),
..GlwStyle::default()
};
tl.draw_glw_event_with_font(&event, &style_tl, &font);
assert!(
alpha_at(&tl, 12, 12) != 0,
"TopLeft legend should fill its corner"
);
assert_eq!(alpha_at(&tl, w - 2, h - 2), 0, "far corner stays clear");
let mut br = blank_map()?;
let style_br = GlwStyle {
legend_position: Some(PlacementSlot::BottomRight),
..GlwStyle::default()
};
br.draw_glw_event_with_font(&event, &style_br, &font);
assert!(
alpha_at(&br, w - 12, h - 12) != 0,
"BottomRight legend fills its corner"
);
assert_eq!(alpha_at(&br, 1, 1), 0, "top-left stays clear");
for position in PlacementSlot::ALL {
let mut map = blank_map()?;
let style = GlwStyle {
legend_position: Some(position),
..GlwStyle::default()
};
map.draw_glw_event_with_font(&event, &style, &font);
let mut any = false;
for y in 0..h {
for x in 0..w {
if alpha_at(&map, x, y) != 0 {
any = true;
}
}
}
assert!(any, "legend at {position:?} should draw pixels");
}
Ok(())
}
}