use std::io::Cursor;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use axum::Json;
use axum::extract::{Multipart, State};
use bytes::Bytes;
use chrono::Utc;
use image::GenericImageView as _;
use image::{ImageFormat, Rgba};
use serde::{Deserialize, Serialize};
use sl_map_apis::map_tiles::{Map, MapProgressEvent};
use sl_map_apis::region::usb_notecard_to_grid_rectangle;
use sl_types::map::{
GridCoordinates, GridRectangle, GridRectangleLike as _, RegionCoordinates, USBNotecard,
ZoomLevel,
};
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
use crate::auth::CurrentUser;
use crate::error::Error;
use crate::jobs::{JobId, JobOutcome, JobState, Metadata, ProgressDto, record_event};
use crate::library::{self, Destination};
use crate::routes::notecards as notecard_routes;
use crate::state::AppState;
use crate::storage;
const MAX_OUTPUT_DIMENSION: u32 = 0x8000;
const MAX_OUTPUT_AREA: u64 = 0x4000 * 0x4000;
const MAX_CONCURRENT_RENDERS_PER_USER: i64 = 3;
const MIN_PIXELS_PER_REGION_FOR_REGION_LABELS: f32 = 64.0;
const MAX_REGIONS_FOR_REGION_LABELS: usize = 1024;
const REGION_LABEL_FONT_FACTOR: f32 = 0.12;
const REGION_LABEL_FONT_MIN_PX: f32 = 8.0;
const REGION_LABEL_FONT_MAX_PX: f32 = 22.0;
const REGION_LABEL_PADDING: i32 = 3;
const REGION_RECTANGLE_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]);
#[derive(Debug, Clone, Copy, Default)]
struct RegionOverlayOptions {
rectangles: bool,
names: bool,
coordinates: bool,
}
impl RegionOverlayOptions {
const fn any(self) -> bool {
self.rectangles || self.names || self.coordinates
}
const fn any_text(self) -> bool {
self.names || self.coordinates
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Png,
#[serde(alias = "jpg")]
Jpeg,
}
impl OutputFormat {
const fn image_format(self) -> ImageFormat {
match self {
Self::Png => ImageFormat::Png,
Self::Jpeg => ImageFormat::Jpeg,
}
}
const fn content_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
}
}
}
#[derive(Debug, Clone)]
struct CommonParams {
max_width: u32,
max_height: u32,
missing_map_tile_color: Option<Rgba<u8>>,
missing_region_color: Option<Rgba<u8>>,
format: OutputFormat,
region_overlay: RegionOverlayOptions,
region_label_font_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GlwSource {
SavedId {
glw_data_id: Uuid,
},
EventId {
event_id: u32,
},
EventKey {
event_key: String,
},
PastedJson {
payload: String,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GlwStyleOverrides {
#[serde(default)]
pub margin_band: bool,
#[serde(default)]
pub area_outline_color: Option<String>,
#[serde(default)]
pub circle_outline_color: Option<String>,
#[serde(default)]
pub margin_outline_color: Option<String>,
#[serde(default)]
pub wind_color: Option<String>,
#[serde(default)]
pub current_color: Option<String>,
#[serde(default)]
pub wave_color: Option<String>,
#[serde(default)]
pub label_color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextLabel {
pub slot: String,
pub lines: Vec<String>,
pub font_id: String,
pub font_px: f32,
pub color: String,
#[serde(default)]
pub h_align: Option<String>,
#[serde(default)]
pub v_align: Option<String>,
#[serde(default)]
pub slots: Vec<String>,
}
const fn default_logo_scale() -> u8 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoPlacement {
pub slot: String,
pub logo_id: Uuid,
#[serde(default = "default_logo_scale")]
pub scale: u8,
#[serde(default)]
pub slots: Vec<String>,
#[serde(default)]
pub h_align: Option<String>,
#[serde(default)]
pub v_align: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlwRenderOptions {
pub source: GlwSource,
pub font_id: String,
#[serde(default)]
pub save_as: Option<String>,
#[serde(default)]
pub style: GlwStyleOverrides,
#[serde(default)]
pub legend_slot: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GridRectangleRequest {
pub lower_left_x: u16,
pub lower_left_y: u16,
pub upper_right_x: u16,
pub upper_right_y: u16,
pub max_width: u32,
pub max_height: u32,
#[serde(default)]
pub missing_map_tile_color: Option<String>,
#[serde(default)]
pub missing_region_color: Option<String>,
#[serde(default)]
pub format: OutputFormat,
#[serde(default)]
pub draw_region_rectangles: bool,
#[serde(default)]
pub draw_region_names: bool,
#[serde(default)]
pub draw_region_coordinates: bool,
#[serde(default)]
pub region_label_font_id: Option<String>,
#[serde(default)]
pub save_to: Option<String>,
#[serde(default)]
pub glw: Option<GlwRenderOptions>,
#[serde(default)]
pub labels: Vec<TextLabel>,
#[serde(default)]
pub logos: Vec<LogoPlacement>,
#[serde(default)]
pub groups: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StartedResponse {
pub job_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub notecard: Option<NotecardSummary>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NotecardSummary {
pub notecard_id: Uuid,
pub name: String,
pub scope: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SavedRenderSettings {
GridRectangle(SavedGridRectangleSettings),
UsbNotecard(SavedUsbNotecardSettings),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedGridRectangleSettings {
pub lower_left_x: u16,
pub lower_left_y: u16,
pub upper_right_x: u16,
pub upper_right_y: u16,
pub max_width: u32,
pub max_height: u32,
pub missing_map_tile_color: Option<String>,
pub missing_region_color: Option<String>,
pub format: String,
#[serde(default)]
pub draw_region_rectangles: bool,
#[serde(default)]
pub draw_region_names: bool,
#[serde(default)]
pub draw_region_coordinates: bool,
#[serde(default)]
pub region_label_font_id: Option<String>,
#[serde(default)]
pub glw: Option<GlwRenderOptions>,
#[serde(default)]
pub labels: Vec<TextLabel>,
#[serde(default)]
pub logos: Vec<LogoPlacement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[expect(
clippy::struct_excessive_bools,
reason = "this is a flat persisted form-settings record; each bool maps directly to one independent checkbox in the render form"
)]
pub struct SavedUsbNotecardSettings {
pub notecard_id: Uuid,
pub border_north: u16,
pub border_south: u16,
pub border_east: u16,
pub border_west: u16,
pub color: String,
pub max_width: u32,
pub max_height: u32,
pub missing_map_tile_color: Option<String>,
pub missing_region_color: Option<String>,
pub format: String,
#[serde(default)]
pub draw_region_rectangles: bool,
#[serde(default)]
pub draw_region_names: bool,
#[serde(default)]
pub draw_region_coordinates: bool,
#[serde(default)]
pub region_label_font_id: Option<String>,
pub save_without_route: bool,
#[serde(default)]
pub glw: Option<GlwRenderOptions>,
#[serde(default)]
pub labels: Vec<TextLabel>,
#[serde(default)]
pub logos: Vec<LogoPlacement>,
}
pub async fn grid_rectangle(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<GridRectangleRequest>,
) -> Result<Json<StartedResponse>, Error> {
validate_dimensions(req.max_width, req.max_height)?;
let common = CommonParams {
max_width: req.max_width,
max_height: req.max_height,
missing_map_tile_color: req
.missing_map_tile_color
.as_deref()
.map(parse_color)
.transpose()?,
missing_region_color: req
.missing_region_color
.as_deref()
.map(parse_color)
.transpose()?,
format: req.format,
region_overlay: RegionOverlayOptions {
rectangles: req.draw_region_rectangles,
names: req.draw_region_names,
coordinates: req.draw_region_coordinates,
},
region_label_font_id: req.region_label_font_id.clone(),
};
assert_region_overlay_font(
&state,
common.region_overlay,
common.region_label_font_id.as_deref(),
)?;
let destination = Destination::parse(req.save_to.as_deref().unwrap_or("personal"))?;
library::assert_can_write(&state.db, user.user_id, destination).await?;
assert_under_concurrent_limit(&state.db, user.user_id).await?;
let logo_ids = validate_logos(&state, user.user_id, destination, &req.logos).await?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let glw_ctx = req.glw.clone().map(|opts| GlwJobCtx {
options: opts,
destination,
created_by: user.user_id,
});
plan_placements(
&state,
rect.clone(),
&common,
glw_ctx.as_ref(),
None,
&req.labels,
&req.logos,
)
.await?;
let settings = SavedRenderSettings::GridRectangle(SavedGridRectangleSettings {
lower_left_x: req.lower_left_x,
lower_left_y: req.lower_left_y,
upper_right_x: req.upper_right_x,
upper_right_y: req.upper_right_y,
max_width: req.max_width,
max_height: req.max_height,
missing_map_tile_color: common.missing_map_tile_color.map(hex_from_rgba),
missing_region_color: common.missing_region_color.map(hex_from_rgba),
format: format_name(req.format).to_owned(),
draw_region_rectangles: req.draw_region_rectangles,
draw_region_names: req.draw_region_names,
draw_region_coordinates: req.draw_region_coordinates,
region_label_font_id: req.region_label_font_id.clone(),
glw: req.glw.clone(),
labels: req.labels.clone(),
logos: req.logos.clone(),
});
let render_id = Uuid::new_v4();
insert_render_row(
&state,
render_id,
destination,
user.user_id,
"grid_rectangle",
None,
&settings,
Some(&rect),
)
.await?;
link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
let (job_id, job) = state.jobs.create_with_id(render_id).await;
spawn_grid_rectangle_job(
state, job_id, job, rect, common, glw_ctx, req.labels, req.logos,
);
Ok(Json(StartedResponse {
job_id,
notecard: None,
}))
}
pub async fn usb_notecard(
user: CurrentUser,
State(state): State<AppState>,
multipart: Multipart,
) -> Result<Json<StartedResponse>, Error> {
let parsed = parse_render_form(multipart).await?;
library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
assert_region_overlay_font(
&state,
parsed.common.region_overlay,
parsed.common.region_label_font_id.as_deref(),
)?;
assert_under_concurrent_limit(&state.db, user.user_id).await?;
let logo_ids = validate_logos(&state, user.user_id, parsed.destination, &parsed.logos).await?;
let (notecard, notecard_summary) = resolve_notecard(&state, &user, &parsed).await?;
let glw_ctx = parsed.glw.clone().map(|opts| GlwJobCtx {
options: opts,
destination: parsed.destination,
created_by: user.user_id,
});
let (_bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, parsed.borders).await?;
plan_placements(
&state,
rect,
&parsed.common,
glw_ctx.as_ref(),
Some((¬ecard, parsed.color)),
&parsed.labels,
&parsed.logos,
)
.await?;
let settings = SavedRenderSettings::UsbNotecard(SavedUsbNotecardSettings {
notecard_id: notecard_summary.notecard_id,
border_north: parsed.borders.0,
border_south: parsed.borders.1,
border_east: parsed.borders.2,
border_west: parsed.borders.3,
color: hex_from_rgba(parsed.color),
max_width: parsed.common.max_width,
max_height: parsed.common.max_height,
missing_map_tile_color: parsed.common.missing_map_tile_color.map(hex_from_rgba),
missing_region_color: parsed.common.missing_region_color.map(hex_from_rgba),
format: format_name(parsed.common.format).to_owned(),
draw_region_rectangles: parsed.common.region_overlay.rectangles,
draw_region_names: parsed.common.region_overlay.names,
draw_region_coordinates: parsed.common.region_overlay.coordinates,
region_label_font_id: parsed.common.region_label_font_id.clone(),
save_without_route: parsed.with_without_route,
glw: parsed.glw.clone(),
labels: parsed.labels.clone(),
logos: parsed.logos.clone(),
});
let render_id = Uuid::new_v4();
insert_render_row(
&state,
render_id,
parsed.destination,
user.user_id,
"usb_notecard",
Some(notecard_summary.notecard_id),
&settings,
None,
)
.await?;
link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
let (job_id, job) = state.jobs.create_with_id(render_id).await;
spawn_usb_notecard_job(
state,
job_id,
job,
notecard_summary.notecard_id,
notecard,
parsed.borders,
parsed.color,
parsed.common,
parsed.with_without_route,
glw_ctx,
parsed.labels,
parsed.logos,
);
Ok(Json(StartedResponse {
job_id,
notecard: Some(notecard_summary),
}))
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct PixelRectDto {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlotDto {
pub slot: &'static str,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub free_rect: Option<PixelRectDto>,
pub free_width: u32,
pub free_height: u32,
pub occupied_fraction: f32,
pub connected_neighbours: Vec<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GroupDto {
pub slots: Vec<&'static str>,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub free_rect: Option<PixelRectDto>,
pub free_width: u32,
pub free_height: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlacementSlotsResponse {
pub image_width: u32,
pub image_height: u32,
pub slots: Vec<SlotDto>,
pub groups: Vec<GroupDto>,
}
fn compute_placement_slots(
map: &Map,
groups: &[Vec<sl_map_apis::coverage::PlacementSlot>],
) -> PlacementSlotsResponse {
use sl_map_apis::coverage::PlacementSlot;
let image = sl_map_apis::map_tiles::MapLike::image(map);
let (image_width, image_height) = image.dimensions();
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
map,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid
.evaluate_slots()
.into_iter()
.map(|info| SlotDto {
slot: info.slot.as_str(),
available: info.available,
free_rect: info.free_rect.map(|r| PixelRectDto {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
}),
free_width: info.free_size.0,
free_height: info.free_size.1,
occupied_fraction: info.occupied_fraction,
connected_neighbours: info
.connected_neighbours
.into_iter()
.map(PlacementSlot::as_str)
.collect(),
})
.collect();
let group_dtos = groups
.iter()
.filter(|g| g.len() > 1)
.map(|g| {
let rect = grid.subset_rect(g);
GroupDto {
slots: g.iter().map(|s| s.as_str()).collect(),
available: rect.is_some(),
free_rect: rect.map(|r| PixelRectDto {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
}),
free_width: rect.map_or(0, |r| r.width),
free_height: rect.map_or(0, |r| r.height),
}
})
.collect();
PlacementSlotsResponse {
image_width,
image_height,
slots,
groups: group_dtos,
}
}
fn parse_groups(
raw: &[Vec<String>],
) -> Result<Vec<Vec<sl_map_apis::coverage::PlacementSlot>>, Error> {
raw.iter()
.map(|g| {
if g.is_empty() {
return Err(Error::BadRequest(
"a placement group must not be empty".to_owned(),
));
}
let group = g
.iter()
.map(|name| {
name.parse::<sl_map_apis::coverage::PlacementSlot>()
.map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
Error::BadRequest(err.to_string())
})
})
.collect::<Result<Vec<_>, _>>()?;
if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
return Err(Error::BadRequest(format!(
"the combined slot group `{}` does not form a solid rectangle",
g.join("+")
)));
}
Ok(group)
})
.collect()
}
async fn resolve_glw_event_readonly(
state: &AppState,
user_id: Uuid,
source: &GlwSource,
) -> Result<Option<sl_glw::GlwEvent>, Error> {
match source {
GlwSource::SavedId { glw_data_id } => {
let row =
crate::library::assert_can_read_glw_data(&state.db, user_id, *glw_data_id).await?;
Ok(Some(serde_json::from_str(&row.payload_json)?))
}
GlwSource::EventId { event_id } => {
let id = sl_glw::EventId::new(*event_id);
let mut cache = state.glw_event_cache.lock().await;
Ok(cache.get_event_by_id(id).await?)
}
GlwSource::EventKey { event_key } => {
let key = sl_glw::GlwEventKey::new(event_key);
let mut cache = state.glw_event_cache.lock().await;
Ok(cache.get_event_by_key(&key).await?)
}
GlwSource::PastedJson { payload } => Ok(Some(serde_json::from_str(payload)?)),
}
}
async fn apply_glw_overlay_readonly(
state: &AppState,
user_id: Uuid,
options: &GlwRenderOptions,
map: &mut Map,
) -> Result<(), Error> {
use sl_glw::MapLikeGlwExt as _;
let font_path = state
.fonts
.path_for(&options.font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", options.font_id)))?;
let font = sl_map_apis::text::load_font(font_path)?;
let Some(event) = resolve_glw_event_readonly(state, user_id, &options.source).await? else {
tracing::warn!("GLW event not found; placement slots computed without overlay");
return Ok(());
};
let mut style = build_glw_style(&options.style, None)?;
style.legend_position = None;
map.draw_glw_event_with_font(&event, &style, &font);
Ok(())
}
pub async fn free_placement_slots_grid_rectangle(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<GridRectangleRequest>,
) -> Result<Json<PlacementSlotsResponse>, Error> {
validate_dimensions(req.max_width, req.max_height)?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let groups = parse_groups(&req.groups)?;
let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
if let Some(glw) = req.glw.as_ref() {
apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
}
Ok(Json(compute_placement_slots(&map, &groups)))
}
pub async fn free_placement_slots_usb_notecard(
user: CurrentUser,
State(state): State<AppState>,
multipart: Multipart,
) -> Result<Json<PlacementSlotsResponse>, Error> {
let parsed = parse_render_form(multipart).await?;
let notecard: USBNotecard = match &parsed.notecard_source {
NotecardSource::Fresh { text, .. } => text.parse()?,
NotecardSource::Existing { notecard_id } => {
let row =
crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
.await?;
row.body.parse()?
}
};
let (border_north, border_south, border_east, border_west) = parsed.borders;
let rect = {
let mut region = state.region_cache.lock().await;
usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
}
.expanded_west(border_west)
.expanded_east(border_east)
.expanded_south(border_south)
.expanded_north(border_north);
let mut map = Map::blank_fit(rect, parsed.common.max_width, parsed.common.max_height)?;
if let Some(glw) = parsed.glw.as_ref() {
apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
}
{
let mut region = state.region_cache.lock().await;
map.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
.await?;
}
let groups = parse_groups(&parsed.groups)?;
Ok(Json(compute_placement_slots(&map, &groups)))
}
#[derive(Debug, Clone, Deserialize)]
pub struct GlwPreviewRequest {
pub lower_left_x: u16,
pub lower_left_y: u16,
pub upper_right_x: u16,
pub upper_right_y: u16,
pub zoom: u8,
pub glw: GlwRenderOptions,
}
pub async fn glw_preview(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<GlwPreviewRequest>,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
use sl_glw::MapLikeGlwExt as _;
let zoom = ZoomLevel::try_new(req.zoom)
.map_err(|err| Error::BadRequest(format!("invalid zoom: {err}")))?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let pixels_per_region = u32::from(zoom.pixels_per_region());
let width = pixels_per_region.saturating_mul(u32::from(rect.size_x()));
let height = pixels_per_region.saturating_mul(u32::from(rect.size_y()));
validate_dimensions(width, height)?;
let mut map = Map::blank(rect, zoom);
let font_path = state
.fonts
.path_for(&req.glw.font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", req.glw.font_id)))?;
let font = sl_map_apis::text::load_font(font_path)?;
if let Some(event) = resolve_glw_event_readonly(&state, user.user_id, &req.glw.source).await? {
let mut style = build_glw_style(&req.glw.style, None)?;
style.legend_position = None;
map.draw_glw_event_with_font(&event, &style, &font);
} else {
tracing::warn!("GLW event not found; preview overlay is fully transparent");
}
let image = encode_map(&map, OutputFormat::Png)?;
Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
}
pub async fn glw_legend_preview(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<GridRectangleRequest>,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
use sl_glw::MapLikeGlwExt as _;
validate_dimensions(req.max_width, req.max_height)?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
if let Some(glw) = req.glw.as_ref() {
let font_path = state
.fonts
.path_for(&glw.font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", glw.font_id)))?;
let font = sl_map_apis::text::load_font(font_path)?;
let style = build_glw_style(&glw.style, glw.legend_slot.as_deref())?;
if style.legend_position.is_some() {
if let Some(event) =
resolve_glw_event_readonly(&state, user.user_id, &glw.source).await?
{
map.draw_glw_base_legend(&event.base, &style, &font);
} else {
tracing::warn!("GLW event not found; legend preview is fully transparent");
}
}
}
let image = encode_map(&map, OutputFormat::Png)?;
Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
}
#[derive(Debug, Clone, Deserialize)]
pub struct RoutePreviewWaypoint {
pub region_x: u16,
pub region_y: u16,
pub x: f32,
pub y: f32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RoutePreviewRequest {
pub lower_left_x: u16,
pub lower_left_y: u16,
pub upper_right_x: u16,
pub upper_right_y: u16,
pub max_width: u32,
pub max_height: u32,
pub color: String,
pub waypoints: Vec<RoutePreviewWaypoint>,
}
pub async fn route_preview(
_user: CurrentUser,
Json(req): Json<RoutePreviewRequest>,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
use sl_map_apis::map_tiles::MapLike as _;
validate_dimensions(req.max_width, req.max_height)?;
let color = parse_color(&req.color)?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
let mut pixel_waypoints = Vec::with_capacity(req.waypoints.len());
for waypoint in &req.waypoints {
let grid = GridCoordinates::new(waypoint.region_x, waypoint.region_y);
let region_coordinates = RegionCoordinates::new(waypoint.x, waypoint.y, 0f32);
let (x, y) = map
.pixel_coordinates_for_coordinates(&grid, ®ion_coordinates)
.ok_or_else(|| {
Error::BadRequest("a route waypoint falls outside the rectangle".to_owned())
})?;
#[expect(
clippy::as_conversions,
clippy::cast_precision_loss,
reason = "matches draw_route_with_progress: pixel coordinates never approach 2^23"
)]
pixel_waypoints.push((x as f32, y as f32));
}
map.draw_pixel_waypoint_route(&pixel_waypoints, color)
.map_err(|err| Error::BadRequest(format!("route rasterisation failed: {err}")))?;
let image = encode_map(&map, OutputFormat::Png)?;
Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegionOverlayPreviewRequest {
pub lower_left_x: u16,
pub lower_left_y: u16,
pub upper_right_x: u16,
pub upper_right_y: u16,
pub max_width: u32,
pub max_height: u32,
#[serde(default)]
pub draw_region_rectangles: bool,
#[serde(default)]
pub draw_region_names: bool,
#[serde(default)]
pub draw_region_coordinates: bool,
#[serde(default)]
pub region_label_font_id: Option<String>,
#[serde(default)]
pub missing_region_color: Option<String>,
}
pub async fn region_overlay_preview(
_user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<RegionOverlayPreviewRequest>,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
use futures::StreamExt as _;
validate_dimensions(req.max_width, req.max_height)?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let opts = RegionOverlayOptions {
rectangles: req.draw_region_rectangles,
names: req.draw_region_names,
coordinates: req.draw_region_coordinates,
};
assert_region_overlay_font(&state, opts, req.region_label_font_id.as_deref())?;
let font_id = req.region_label_font_id.clone();
let missing_region_color = req
.missing_region_color
.as_deref()
.map(parse_color)
.transpose()?;
let (max_width, max_height) = (req.max_width, req.max_height);
let (line_tx, line_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
drop(tokio::spawn(run_region_overlay_preview_stream(
state,
rect,
opts,
font_id,
missing_region_color,
max_width,
max_height,
line_tx,
)));
let stream = ReceiverStream::new(line_rx).map(Ok::<Bytes, std::convert::Infallible>);
let body = axum::body::Body::from_stream(stream);
Ok((
[(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
body,
)
.into_response())
}
#[expect(
clippy::too_many_arguments,
reason = "a one-shot helper carrying the preview request fields into the streaming task"
)]
async fn run_region_overlay_preview_stream(
state: AppState,
rect: GridRectangle,
opts: RegionOverlayOptions,
font_id: Option<String>,
missing_region_color: Option<Rgba<u8>>,
max_width: u32,
max_height: u32,
line_tx: tokio::sync::mpsc::Sender<Bytes>,
) {
let (ev_tx, ev_rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
let forwarder = tokio::spawn(forward_region_overlay_progress(ev_rx, line_tx.clone()));
let result = async {
let mut map = Map::blank_fit(rect, max_width, max_height)?;
apply_region_overlay(
&state,
opts,
font_id.as_deref(),
missing_region_color,
&mut map,
Some(&ev_tx),
)
.await?;
encode_map(&map, OutputFormat::Png)
}
.await;
drop(ev_tx);
let _join = forwarder.await;
let final_line = match result {
Ok(png) => {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(&png);
serde_json::json!({ "type": "image", "data": data }).to_string()
}
Err(err) => serde_json::to_string(&ProgressDto::Error {
message: err.to_string(),
})
.unwrap_or_else(|_| {
r#"{"type":"error","message":"region overlay preview failed"}"#.to_owned()
}),
};
drop(line_tx.send(Bytes::from(format!("{final_line}\n"))).await);
}
async fn forward_region_overlay_progress(
mut ev_rx: tokio::sync::mpsc::Receiver<MapProgressEvent>,
line_tx: tokio::sync::mpsc::Sender<Bytes>,
) {
while let Some(event) = ev_rx.recv().await {
let Ok(json) = serde_json::to_string(&ProgressDto::from(event)) else {
continue;
};
if line_tx
.send(Bytes::from(format!("{json}\n")))
.await
.is_err()
{
break;
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "a one-shot helper shared by the two placement-preview endpoints"
)]
async fn render_placement_elements_png(
state: &AppState,
occupancy: &Map,
target_rect: GridRectangle,
max_width: u32,
max_height: u32,
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
labels: &[TextLabel],
logos: &[LogoPlacement],
) -> Result<Bytes, Error> {
let (label_draws, label_slots) =
plan_labels(&state.fonts, labels, legend_slot, &[], occupancy)?;
let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, occupancy).await?;
let mut target = Map::blank_fit(target_rect, max_width, max_height)?;
execute_labels(&label_draws, &mut target);
execute_logos(&logo_draws, &mut target);
encode_map(&target, OutputFormat::Png)
}
pub async fn placement_preview_grid_rectangle(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<GridRectangleRequest>,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
validate_dimensions(req.max_width, req.max_height)?;
assert_can_read_logos(&state, user.user_id, &req.logos).await?;
let rect = GridRectangle::new(
GridCoordinates::new(req.lower_left_x, req.lower_left_y),
GridCoordinates::new(req.upper_right_x, req.upper_right_y),
);
let mut occ = Map::blank_fit(rect.clone(), req.max_width, req.max_height)?;
let legend_slot = if let Some(glw) = req.glw.as_ref() {
apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
legend_position_from_slot(glw.legend_slot.as_deref())?
} else {
None
};
let png = render_placement_elements_png(
&state,
&occ,
rect,
req.max_width,
req.max_height,
legend_slot,
&req.labels,
&req.logos,
)
.await?;
Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
}
pub async fn placement_preview_usb_notecard(
user: CurrentUser,
State(state): State<AppState>,
multipart: Multipart,
) -> Result<axum::response::Response, Error> {
use axum::response::IntoResponse as _;
let parsed = parse_render_form(multipart).await?;
assert_can_read_logos(&state, user.user_id, &parsed.logos).await?;
let notecard: USBNotecard = match &parsed.notecard_source {
NotecardSource::Fresh { text, .. } => text.parse()?,
NotecardSource::Existing { notecard_id } => {
let row =
crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
.await?;
row.body.parse()?
}
};
let (border_north, border_south, border_east, border_west) = parsed.borders;
let rect = {
let mut region = state.region_cache.lock().await;
usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
}
.expanded_west(border_west)
.expanded_east(border_east)
.expanded_south(border_south)
.expanded_north(border_north);
let mut occ = Map::blank_fit(
rect.clone(),
parsed.common.max_width,
parsed.common.max_height,
)?;
let legend_slot = if let Some(glw) = parsed.glw.as_ref() {
apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
legend_position_from_slot(glw.legend_slot.as_deref())?
} else {
None
};
{
let mut region = state.region_cache.lock().await;
occ.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
.await?;
}
let png = render_placement_elements_png(
&state,
&occ,
rect,
parsed.common.max_width,
parsed.common.max_height,
legend_slot,
&parsed.labels,
&parsed.logos,
)
.await?;
Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
}
const fn format_name(format: OutputFormat) -> &'static str {
match format {
OutputFormat::Png => "png",
OutputFormat::Jpeg => "jpeg",
}
}
fn hex_from_rgba(rgba: Rgba<u8>) -> String {
let Rgba(parts) = rgba;
let r = parts.first().copied().unwrap_or(0);
let g = parts.get(1).copied().unwrap_or(0);
let b = parts.get(2).copied().unwrap_or(0);
format!("#{r:02x}{g:02x}{b:02x}")
}
fn parse_color(s: &str) -> Result<Rgba<u8>, Error> {
let parsed = hex_color::HexColor::parse(s)
.map_err(|e| Error::BadRequest(format!("invalid colour `{s}`: {e}")))?;
Ok(Rgba(parsed.to_be_bytes()))
}
struct ParsedRenderForm {
notecard_source: NotecardSource,
destination: Destination,
borders: (u16, u16, u16, u16),
color: Rgba<u8>,
common: CommonParams,
with_without_route: bool,
glw: Option<GlwRenderOptions>,
labels: Vec<TextLabel>,
logos: Vec<LogoPlacement>,
groups: Vec<Vec<String>>,
}
enum NotecardSource {
Existing {
notecard_id: Uuid,
},
Fresh {
text: String,
name: String,
},
}
async fn parse_render_form(multipart: Multipart) -> Result<ParsedRenderForm, Error> {
let mut form = crate::usb_notecard::NotecardForm::default();
let mut color: Rgba<u8> = Rgba([0xff, 0x00, 0x00, 0xff]);
let mut max_width: Option<u32> = None;
let mut max_height: Option<u32> = None;
let mut missing_map_tile_color: Option<Rgba<u8>> = None;
let mut missing_region_color: Option<Rgba<u8>> = None;
let mut format = OutputFormat::default();
let mut with_without_route = false;
let mut draw_region_rectangles = false;
let mut draw_region_names = false;
let mut draw_region_coordinates = false;
let mut region_label_font_id: Option<String> = None;
let mut notecard_text: Option<String> = None;
let mut notecard_file: Option<String> = None;
let mut notecard_id: Option<Uuid> = None;
let mut destination_raw: Option<String> = None;
let mut notecard_name: Option<String> = None;
let mut glw: Option<GlwRenderOptions> = None;
let mut labels: Vec<TextLabel> = Vec::new();
let mut logos: Vec<LogoPlacement> = Vec::new();
let mut groups: Vec<Vec<String>> = Vec::new();
let mut multipart = multipart;
while let Some(field) = multipart.next_field().await? {
let Some(name) = field.name().map(str::to_owned) else {
continue;
};
match name.as_str() {
"notecard" => {
let bytes = field.bytes().await?;
if !bytes.is_empty() {
let text = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::BadRequest(format!("notecard is not UTF-8: {e}")))?;
notecard_file = Some(text);
}
}
"notecard_text" => {
let text = field.text().await?;
if !text.trim().is_empty() {
notecard_text = Some(text);
}
}
"notecard_id" => {
let raw = field.text().await?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
notecard_id = Some(
Uuid::parse_str(trimmed)
.map_err(|e| Error::BadRequest(format!("invalid notecard_id: {e}")))?,
);
}
}
"border_regions" => form.border_regions = parse_optional_u16(&field.text().await?)?,
"border_north" => form.border_north = parse_optional_u16(&field.text().await?)?,
"border_south" => form.border_south = parse_optional_u16(&field.text().await?)?,
"border_east" => form.border_east = parse_optional_u16(&field.text().await?)?,
"border_west" => form.border_west = parse_optional_u16(&field.text().await?)?,
"color" => {
let raw = field.text().await?;
color = parse_color(raw.trim())?;
}
"max_width" => max_width = Some(parse_u32(&field.text().await?)?),
"max_height" => max_height = Some(parse_u32(&field.text().await?)?),
"missing_map_tile_color" => {
let raw = field.text().await?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
missing_map_tile_color = Some(parse_color(trimmed)?);
}
}
"missing_region_color" => {
let raw = field.text().await?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
missing_region_color = Some(parse_color(trimmed)?);
}
}
"format" => {
let raw = field.text().await?;
format = match raw.trim().to_ascii_lowercase().as_str() {
"png" => OutputFormat::Png,
"jpeg" | "jpg" => OutputFormat::Jpeg,
other => {
return Err(Error::BadRequest(format!("unknown format `{other}`")));
}
};
}
"save_without_route" => {
let raw = field.text().await?;
with_without_route = parse_form_bool(&raw);
}
"draw_region_rectangles" => {
let raw = field.text().await?;
draw_region_rectangles = parse_form_bool(&raw);
}
"draw_region_names" => {
let raw = field.text().await?;
draw_region_names = parse_form_bool(&raw);
}
"draw_region_coordinates" => {
let raw = field.text().await?;
draw_region_coordinates = parse_form_bool(&raw);
}
"region_label_font_id" => {
let raw = field.text().await?;
let trimmed = raw.trim();
if !trimmed.is_empty() {
region_label_font_id = Some(trimmed.to_owned());
}
}
"save_to" => destination_raw = Some(field.text().await?),
"notecard_name" => {
let t = field.text().await?;
if !t.trim().is_empty() {
notecard_name = Some(t);
}
}
"glw_json" => {
let raw = field.text().await?;
if !raw.trim().is_empty() {
glw = Some(
serde_json::from_str::<GlwRenderOptions>(&raw)
.map_err(|e| Error::BadRequest(format!("invalid glw_json: {e}")))?,
);
}
}
"labels_json" => {
let raw = field.text().await?;
if !raw.trim().is_empty() {
labels = serde_json::from_str::<Vec<TextLabel>>(&raw)
.map_err(|e| Error::BadRequest(format!("invalid labels_json: {e}")))?;
}
}
"logos_json" => {
let raw = field.text().await?;
if !raw.trim().is_empty() {
logos = serde_json::from_str::<Vec<LogoPlacement>>(&raw)
.map_err(|e| Error::BadRequest(format!("invalid logos_json: {e}")))?;
}
}
"groups_json" => {
let raw = field.text().await?;
if !raw.trim().is_empty() {
groups = serde_json::from_str::<Vec<Vec<String>>>(&raw)
.map_err(|e| Error::BadRequest(format!("invalid groups_json: {e}")))?;
}
}
_ => {}
}
}
let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
let max_width = max_width.ok_or_else(|| Error::BadRequest("missing max_width".to_owned()))?;
let max_height =
max_height.ok_or_else(|| Error::BadRequest("missing max_height".to_owned()))?;
validate_dimensions(max_width, max_height)?;
let borders = form.borders();
let common = CommonParams {
max_width,
max_height,
missing_map_tile_color,
missing_region_color,
format,
region_overlay: RegionOverlayOptions {
rectangles: draw_region_rectangles,
names: draw_region_names,
coordinates: draw_region_coordinates,
},
region_label_font_id,
};
let notecard_source = if let Some(id) = notecard_id {
if notecard_file.is_some() || notecard_text.is_some() {
return Err(Error::BadRequest(
"supply either `notecard_id` or notecard text/file, not both".to_owned(),
));
}
NotecardSource::Existing { notecard_id: id }
} else {
let raw = notecard_file.or(notecard_text).ok_or_else(|| {
Error::BadRequest(
"supply `notecard_id`, `notecard` file upload, or `notecard_text`".to_owned(),
)
})?;
let name = notecard_name.map_or_else(default_notecard_name, |n| n.trim().to_owned());
NotecardSource::Fresh { text: raw, name }
};
Ok(ParsedRenderForm {
notecard_source,
destination,
borders,
color,
common,
with_without_route,
glw,
labels,
logos,
groups,
})
}
fn default_notecard_name() -> String {
let now = Utc::now();
format!("Uploaded {}", now.format("%Y-%m-%d %H:%M:%S UTC"))
}
async fn resolve_notecard(
state: &AppState,
user: &CurrentUser,
parsed: &ParsedRenderForm,
) -> Result<(USBNotecard, NotecardSummary), Error> {
match &parsed.notecard_source {
NotecardSource::Existing { notecard_id } => {
let row =
library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id).await?;
let parsed_notecard: USBNotecard = row.body.parse()?;
let notecard_scope = library::destination_from_columns(
row.owner_user_id.clone(),
row.owner_group_id.clone(),
)?;
let effective_id = if notecard_scope == parsed.destination {
*notecard_id
} else {
notecard_routes::insert_notecard_row(
state,
parsed.destination,
user.user_id,
&row.name,
&row.body,
)
.await?
};
Ok((
parsed_notecard,
NotecardSummary {
notecard_id: effective_id,
name: row.name,
scope: parsed.destination.render_string(),
},
))
}
NotecardSource::Fresh { text, name, .. } => {
let parsed_notecard: USBNotecard = text.parse()?;
let notecard_id = notecard_routes::insert_notecard_row(
state,
parsed.destination,
user.user_id,
name,
text,
)
.await?;
Ok((
parsed_notecard,
NotecardSummary {
notecard_id,
name: name.clone(),
scope: parsed.destination.render_string(),
},
))
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "every argument is a distinct row column; bundling them into a struct would just be noise at the single call sites"
)]
async fn insert_render_row(
state: &AppState,
render_id: Uuid,
destination: Destination,
created_by: Uuid,
kind: &str,
notecard_id: Option<Uuid>,
settings: &SavedRenderSettings,
bounds: Option<&GridRectangle>,
) -> Result<(), Error> {
let (owner_user, owner_group) = match destination {
Destination::Personal => (Some(created_by.as_bytes().to_vec()), None),
Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
};
let now = Utc::now();
let settings_json = serde_json::to_string(settings)?;
let (ll_x, ll_y, ur_x, ur_y) = match bounds {
Some(rect) => (
Some(i64::from(rect.lower_left_corner().x())),
Some(i64::from(rect.lower_left_corner().y())),
Some(i64::from(rect.upper_right_corner().x())),
Some(i64::from(rect.upper_right_corner().y())),
),
None => (None, None, None, None),
};
sqlx::query(
"INSERT INTO saved_renders \
(render_id, owner_user_id, owner_group_id, created_by, notecard_id, kind, \
status, settings_json, created_at, \
lower_left_x, lower_left_y, upper_right_x, upper_right_y) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'in_progress', ?7, ?8, ?9, ?10, ?11, ?12)",
)
.bind(render_id.as_bytes().to_vec())
.bind(owner_user)
.bind(owner_group)
.bind(created_by.as_bytes().to_vec())
.bind(notecard_id.map(|id| id.as_bytes().to_vec()))
.bind(kind)
.bind(settings_json)
.bind(now)
.bind(ll_x)
.bind(ll_y)
.bind(ur_x)
.bind(ur_y)
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("insert saved_renders failed: {err}");
Error::Database
})?;
Ok(())
}
async fn update_notecard_bounds(
db: &sqlx::SqlitePool,
notecard_id: Uuid,
rect: &GridRectangle,
) -> Result<(), Error> {
sqlx::query(
"UPDATE saved_notecards \
SET lower_left_x = ?1, lower_left_y = ?2, \
upper_right_x = ?3, upper_right_y = ?4 \
WHERE notecard_id = ?5",
)
.bind(i64::from(rect.lower_left_corner().x()))
.bind(i64::from(rect.lower_left_corner().y()))
.bind(i64::from(rect.upper_right_corner().x()))
.bind(i64::from(rect.upper_right_corner().y()))
.bind(notecard_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("update saved_notecards bounds failed: {err}");
Error::Database
})?;
Ok(())
}
async fn update_render_bounds(
db: &sqlx::SqlitePool,
render_id: Uuid,
rect: &GridRectangle,
) -> Result<(), Error> {
sqlx::query(
"UPDATE saved_renders \
SET lower_left_x = ?1, lower_left_y = ?2, \
upper_right_x = ?3, upper_right_y = ?4 \
WHERE render_id = ?5",
)
.bind(i64::from(rect.lower_left_corner().x()))
.bind(i64::from(rect.lower_left_corner().y()))
.bind(i64::from(rect.upper_right_corner().x()))
.bind(i64::from(rect.upper_right_corner().y()))
.bind(render_id.as_bytes().to_vec())
.execute(db)
.await
.map_err(|err| {
tracing::error!("update saved_renders bounds failed: {err}");
Error::Database
})?;
Ok(())
}
async fn finalize_render_row(state: &AppState, render_id: Uuid, outcome: &JobOutcome) {
let now = Utc::now();
match outcome {
JobOutcome::Ok {
image,
image_without_route,
content_type,
metadata,
glw_data_id,
} => {
let ext = storage::ext_for_content_type(content_type);
let image_filename = match storage::write_render_file(
&state.config.storage_dir,
render_id,
storage::IMAGE_SUFFIX,
ext,
image.clone(),
)
.await
{
Ok(f) => Some(f),
Err(err) => {
tracing::error!("write primary render image failed: {err}");
update_failed(state, render_id, &format!("write image failed: {err}"), now)
.await;
return;
}
};
let without_filename = if let Some(bytes) = image_without_route {
match storage::write_render_file(
&state.config.storage_dir,
render_id,
storage::IMAGE_WITHOUT_ROUTE_SUFFIX,
ext,
bytes.clone(),
)
.await
{
Ok(f) => Some(f),
Err(err) => {
tracing::warn!("write without-route render image failed: {err}");
None
}
}
} else {
None
};
let metadata_json = match serde_json::to_string(metadata) {
Ok(s) => s,
Err(err) => {
tracing::error!("serialize metadata failed: {err}");
update_failed(
state,
render_id,
&format!("serialize metadata failed: {err}"),
now,
)
.await;
return;
}
};
let rewritten_settings_json = match glw_data_id {
Some(id) => match rewrite_settings_json_with_saved_glw(state, render_id, *id).await
{
Ok(updated) => updated,
Err(err) => {
tracing::error!("could not rewrite settings_json with glw_data_id: {err}");
None
}
},
None => None,
};
let result = sqlx::query(
"UPDATE saved_renders SET status = 'done', finished_at = ?1, \
metadata_json = ?2, content_type = ?3, \
image_filename = ?4, image_without_route_filename = ?5, \
glw_data_id = COALESCE(?7, glw_data_id), \
settings_json = COALESCE(?8, settings_json) \
WHERE render_id = ?6",
)
.bind(now)
.bind(metadata_json)
.bind(*content_type)
.bind(image_filename)
.bind(without_filename)
.bind(render_id.as_bytes().to_vec())
.bind(glw_data_id.map(|id| id.as_bytes().to_vec()))
.bind(rewritten_settings_json)
.execute(&state.db)
.await;
if let Err(err) = result {
tracing::error!("update saved_renders to done failed: {err}");
state.library_cleanup_dirty.store(true, Ordering::Release);
}
}
JobOutcome::Err(msg) => {
update_failed(state, render_id, msg, now).await;
}
}
}
async fn update_failed(
state: &AppState,
render_id: Uuid,
message: &str,
now: chrono::DateTime<Utc>,
) {
if let Err(err) = sqlx::query(
"UPDATE saved_renders SET status = 'failed', finished_at = ?1, error_message = ?2 \
WHERE render_id = ?3",
)
.bind(now)
.bind(message)
.bind(render_id.as_bytes().to_vec())
.execute(&state.db)
.await
{
tracing::error!("update saved_renders to failed failed: {err}");
}
}
#[expect(
clippy::too_many_arguments,
reason = "this is a one-shot helper that wires together every form field"
)]
fn spawn_grid_rectangle_job(
state: AppState,
job_id: JobId,
job: Arc<JobState>,
rect: GridRectangle,
common: CommonParams,
glw_ctx: Option<GlwJobCtx>,
labels: Vec<TextLabel>,
logos: Vec<LogoPlacement>,
) {
drop(tokio::spawn(async move {
let result = run_grid_rectangle_job(
state.clone(),
Arc::clone(&job),
rect,
common,
glw_ctx,
labels,
logos,
)
.await;
let outcome = finish_job(&job, result).await;
finalize_render_row(&state, job_id, &outcome).await;
tracing::info!("render job {job_id} finished");
}));
}
#[expect(
clippy::too_many_arguments,
reason = "this is a one-shot helper that wires together every form field"
)]
fn spawn_usb_notecard_job(
state: AppState,
job_id: JobId,
job: Arc<JobState>,
notecard_id: Uuid,
notecard: USBNotecard,
borders: (u16, u16, u16, u16),
route_color: Rgba<u8>,
common: CommonParams,
with_without_route: bool,
glw_ctx: Option<GlwJobCtx>,
labels: Vec<TextLabel>,
logos: Vec<LogoPlacement>,
) {
drop(tokio::spawn(async move {
let result = run_usb_notecard_job(
state.clone(),
Arc::clone(&job),
job_id,
notecard_id,
notecard,
borders,
route_color,
common,
with_without_route,
glw_ctx,
labels,
logos,
)
.await;
let outcome = finish_job(&job, result).await;
finalize_render_row(&state, job_id, &outcome).await;
tracing::info!("render job {job_id} finished");
}));
}
fn assert_region_overlay_font(
state: &AppState,
opts: RegionOverlayOptions,
font_id: Option<&str>,
) -> Result<(), Error> {
if !opts.any_text() {
return Ok(());
}
let font_id = font_id.ok_or_else(|| {
Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
})?;
if state.fonts.path_for(font_id).is_none() {
return Err(Error::BadRequest(format!(
"unknown region label font_id `{font_id}`"
)));
}
Ok(())
}
const fn region_text_overlay_allowed(pixels_per_region: f32, region_count: usize) -> bool {
pixels_per_region >= MIN_PIXELS_PER_REGION_FOR_REGION_LABELS
&& region_count <= MAX_REGIONS_FOR_REGION_LABELS
}
async fn apply_region_overlay(
state: &AppState,
opts: RegionOverlayOptions,
font_id: Option<&str>,
missing_region_color: Option<Rgba<u8>>,
map: &mut Map,
progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
) -> Result<(), Error> {
use sl_map_apis::map_tiles::MapLike as _;
use sl_types::map::GridRectangleLike as _;
if !opts.any() {
return Ok(());
}
let pixels_per_region = map.pixels_per_region();
let region_count = usize::from(map.size_x()).saturating_mul(usize::from(map.size_y()));
let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
if !run_loop {
if opts.any_text() {
tracing::info!(
pixels_per_region,
region_count,
"skipping region name/coordinate overlay (regions too small or too many)"
);
}
if opts.rectangles {
draw_region_rectangles(map);
}
return Ok(());
}
let font_id = font_id.ok_or_else(|| {
Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
})?;
let font_path = state
.fonts
.path_for(font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown region label font_id `{font_id}`")))?;
let font = sl_map_apis::text::load_font(font_path)?;
let scale = ab_glyph::PxScale::from(
(pixels_per_region * REGION_LABEL_FONT_FACTOR)
.clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
);
let style = sl_map_apis::text::LabelStyle {
scale,
fg: Rgba([255, 255, 255, 255]),
shadow: Rgba([0, 0, 0, 180]),
align: sl_map_apis::coverage::HAlign::Left,
};
let total_regions = u32::try_from(region_count).unwrap_or(u32::MAX);
if let Some(tx) = progress.filter(|_| opts.names) {
drop(tx.try_send(MapProgressEvent::RegionNamesPlanned { total_regions }));
}
let mut resolved: u32 = 0;
for x in map.x_range() {
for y in map.y_range() {
let grid = GridCoordinates::new(x, y);
let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
continue;
};
let mut region_name: Option<String> = None;
let mut missing = false;
if opts.names {
let lookup = {
let mut region = state.region_cache.lock().await;
region.get_region_name(&grid).await
};
match lookup {
Ok(Some(name)) => region_name = Some(name.to_string()),
Ok(None) => missing = true,
Err(err) => {
tracing::debug!("region name lookup failed for {grid:?}: {err}");
}
}
if let Some(tx) = progress {
drop(tx.try_send(MapProgressEvent::RegionNameResolved {
index: resolved,
total: total_regions,
}));
}
resolved = resolved.saturating_add(1);
}
if let Some(color) = missing_region_color.filter(|_| missing) {
map.draw_filled_rect(left, top, width, height, color);
}
if opts.rectangles {
map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
}
let mut lines: Vec<String> = Vec::new();
if opts.coordinates {
lines.push(format!("({x}, {y})"));
}
if let Some(name) = region_name {
lines.push(name);
}
if lines.is_empty() {
continue;
}
let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
let bottom = top.saturating_add(height);
let origin_x = i32::try_from(left)
.unwrap_or(0)
.saturating_add(REGION_LABEL_PADDING);
let origin_y = i32::try_from(bottom)
.unwrap_or(0)
.saturating_sub(REGION_LABEL_PADDING)
.saturating_sub(i32::try_from(text_h).unwrap_or(0));
map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
}
}
Ok(())
}
fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
use sl_map_apis::map_tiles::MapLike as _;
let (x0, y0) =
map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(0f32, 0f32, 0f32))?;
let (x1, y1) =
map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(256f32, 256f32, 0f32))?;
Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
}
fn draw_region_rectangles(map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
use sl_types::map::GridRectangleLike as _;
for x in map.x_range() {
for y in map.y_range() {
let grid = GridCoordinates::new(x, y);
if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
}
}
}
}
async fn run_grid_rectangle_job(
state: AppState,
job: Arc<JobState>,
rect: GridRectangle,
common: CommonParams,
glw_ctx: Option<GlwJobCtx>,
labels: Vec<TextLabel>,
logos: Vec<LogoPlacement>,
) -> Result<JobOutcome, Error> {
let metadata = build_metadata(&rect);
let occ_rect = rect.clone();
let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
let mut map = {
let mut cache = state.map_tile_cache.lock().await;
Map::new_with_progress(
&mut cache,
common.max_width,
common.max_height,
rect,
common.missing_map_tile_color,
common.missing_region_color,
Some(&tx),
)
.await?
};
apply_region_overlay(
&state,
common.region_overlay,
common.region_label_font_id.as_deref(),
None,
&mut map,
Some(&tx),
)
.await?;
drop(tx);
let _join = forwarder.await;
let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
let (label_draws, logo_draws) = plan_placements(
&state,
occ_rect,
&common,
glw_ctx.as_ref(),
None,
&labels,
&logos,
)
.await?;
execute_labels(&label_draws, &mut map);
execute_logos(&logo_draws, &mut map);
let image = encode_map(&map, common.format)?;
Ok(JobOutcome::Ok {
image,
image_without_route: None,
content_type: common.format.content_type(),
metadata,
glw_data_id,
})
}
#[expect(
clippy::too_many_arguments,
reason = "this is a one-shot helper invoked from a single spawn site"
)]
async fn run_usb_notecard_job(
state: AppState,
job: Arc<JobState>,
render_id: Uuid,
notecard_id: Uuid,
notecard: USBNotecard,
borders: (u16, u16, u16, u16),
route_color: Rgba<u8>,
common: CommonParams,
with_without_route: bool,
glw_ctx: Option<GlwJobCtx>,
labels: Vec<TextLabel>,
logos: Vec<LogoPlacement>,
) -> Result<JobOutcome, Error> {
let (bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, borders).await?;
update_notecard_bounds(&state.db, notecard_id, &bare_rect).await?;
update_render_bounds(&state.db, render_id, &rect).await?;
let metadata = build_metadata(&rect);
let occ_rect = rect.clone();
let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
let (image_without_route, map, glw_data_id) = {
let mut map = {
let mut cache = state.map_tile_cache.lock().await;
Map::new_with_progress(
&mut cache,
common.max_width,
common.max_height,
rect,
common.missing_map_tile_color,
common.missing_region_color,
Some(&tx),
)
.await?
};
let without_route = if with_without_route {
Some(encode_map(&map, common.format)?)
} else {
None
};
apply_region_overlay(
&state,
common.region_overlay,
common.region_label_font_id.as_deref(),
None,
&mut map,
Some(&tx),
)
.await?;
let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
{
let mut region = state.region_cache.lock().await;
map.draw_route_with_progress(&mut region, ¬ecard, route_color, Some(&tx))
.await?;
}
let (label_draws, logo_draws) = plan_placements(
&state,
occ_rect,
&common,
glw_ctx.as_ref(),
Some((¬ecard, route_color)),
&labels,
&logos,
)
.await?;
execute_labels(&label_draws, &mut map);
execute_logos(&logo_draws, &mut map);
(without_route, map, glw_data_id)
};
drop(tx);
let _join = forwarder.await;
let image = encode_map(&map, common.format)?;
Ok(JobOutcome::Ok {
image,
image_without_route,
content_type: common.format.content_type(),
metadata,
glw_data_id,
})
}
async fn forward_events(job: Arc<JobState>, mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
while let Some(event) = rx.recv().await {
record_event(&job, ProgressDto::from(event)).await;
}
}
fn build_metadata(rect: &GridRectangle) -> Metadata {
let aspect_x = rect.size_x();
let aspect_y = rect.size_y();
let aspect_ratio = f32::from(aspect_x) / f32::from(aspect_y);
Metadata {
aspect_x,
aspect_y,
aspect_ratio,
pps_hud_config: rect.pps_hud_config(),
}
}
fn encode_map(map: &Map, format: OutputFormat) -> Result<Bytes, Error> {
let mut buf: Vec<u8> = Vec::new();
let mut cursor = Cursor::new(&mut buf);
sl_map_apis::map_tiles::MapLike::image(map).write_to(&mut cursor, format.image_format())?;
Ok(Bytes::from(buf))
}
async fn finish_job(job: &Arc<JobState>, result: Result<JobOutcome, Error>) -> JobOutcome {
let outcome = match result {
Ok(o) => o,
Err(e) => {
let message = format!("{e}");
record_event(
job,
ProgressDto::Error {
message: message.clone(),
},
)
.await;
JobOutcome::Err(message)
}
};
if matches!(outcome, JobOutcome::Ok { .. }) {
record_event(job, ProgressDto::Done).await;
}
let arc = Arc::new(outcome.clone());
drop(job.outcome.send_replace(Some(arc)));
outcome
}
fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), Error> {
if max_width == 0 || max_height == 0 {
return Err(Error::BadRequest(
"max_width and max_height must be greater than zero".to_owned(),
));
}
if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
return Err(Error::BadRequest(format!(
"max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
)));
}
if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
return Err(Error::BadRequest(format!(
"max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
)));
}
Ok(())
}
async fn assert_under_concurrent_limit(db: &sqlx::SqlitePool, user_id: Uuid) -> Result<(), Error> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM saved_renders \
WHERE created_by = ?1 AND status = 'in_progress'",
)
.bind(user_id.as_bytes().to_vec())
.fetch_one(db)
.await
.map_err(|err| {
tracing::error!("count in-progress renders failed: {err}");
Error::Database
})?;
if count >= MAX_CONCURRENT_RENDERS_PER_USER {
return Err(Error::Forbidden(format!(
"at most {MAX_CONCURRENT_RENDERS_PER_USER} renders may be in progress per user; \
wait for one to finish"
)));
}
Ok(())
}
fn parse_optional_u16(s: &str) -> Result<Option<u16>, Error> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(None);
}
trimmed
.parse::<u16>()
.map(Some)
.map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
}
fn parse_u32(s: &str) -> Result<u32, Error> {
s.trim()
.parse::<u32>()
.map_err(|e| Error::BadRequest(format!("invalid u32 `{s}`: {e}")))
}
fn parse_form_bool(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "on" | "true" | "yes"
)
}
struct GlwJobCtx {
options: GlwRenderOptions,
destination: Destination,
created_by: Uuid,
}
struct ResolvedGlwEvent {
event: sl_glw::GlwEvent,
glw_data_id: Uuid,
}
async fn resolve_glw_event(
state: &AppState,
ctx: &GlwJobCtx,
) -> Result<Option<ResolvedGlwEvent>, Error> {
match &ctx.options.source {
GlwSource::SavedId { glw_data_id } => {
let row =
crate::library::assert_can_read_glw_data(&state.db, ctx.created_by, *glw_data_id)
.await?;
let glw_dest = crate::library::destination_from_columns(
row.owner_user_id.clone(),
row.owner_group_id.clone(),
)?;
if glw_dest != ctx.destination {
return Err(Error::BadRequest(format!(
"GLW data {glw_data_id} is not in the same library as this render; \
copy it into the render's scope first"
)));
}
let event: sl_glw::GlwEvent = serde_json::from_str(&row.payload_json)?;
Ok(Some(ResolvedGlwEvent {
event,
glw_data_id: *glw_data_id,
}))
}
GlwSource::EventId { event_id } => {
let id = sl_glw::EventId::new(*event_id);
let event_opt = {
let mut cache = state.glw_event_cache.lock().await;
cache.get_event_by_id(id).await?
};
let Some(event) = event_opt else {
return Ok(None);
};
let now = Utc::now();
let name = default_or_user_name(
ctx.options.save_as.as_deref(),
&format!(
"Event {event_id} fetched {ts}",
ts = now.format("%Y-%m-%d %H:%M UTC")
),
);
let payload_json = serde_json::to_string(&event)?;
let glw_data_id = crate::routes::glw::insert_glw_data_row(
state,
&crate::routes::glw::InsertGlwData {
destination: ctx.destination,
created_by: ctx.created_by,
name: &name,
source_kind: crate::library::GlwDataSourceKind::EventId,
source_event_id: Some(*event_id),
source_event_key: None,
payload_json: &payload_json,
event_id: Some(event.event_id.get()),
event_key: Some(event.event_key.as_str()),
event_name: Some(event.event_name.as_str()),
fetched_at: now,
},
)
.await?;
Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
}
GlwSource::EventKey { event_key } => {
let key = sl_glw::GlwEventKey::new(event_key);
let event_opt = {
let mut cache = state.glw_event_cache.lock().await;
cache.get_event_by_key(&key).await?
};
let Some(event) = event_opt else {
return Ok(None);
};
let now = Utc::now();
let name = default_or_user_name(
ctx.options.save_as.as_deref(),
&format!(
"Key \"{event_key}\" fetched {ts}",
ts = now.format("%Y-%m-%d %H:%M UTC")
),
);
let payload_json = serde_json::to_string(&event)?;
let glw_data_id = crate::routes::glw::insert_glw_data_row(
state,
&crate::routes::glw::InsertGlwData {
destination: ctx.destination,
created_by: ctx.created_by,
name: &name,
source_kind: crate::library::GlwDataSourceKind::EventKey,
source_event_id: None,
source_event_key: Some(event_key.as_str()),
payload_json: &payload_json,
event_id: Some(event.event_id.get()),
event_key: Some(event.event_key.as_str()),
event_name: Some(event.event_name.as_str()),
fetched_at: now,
},
)
.await?;
Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
}
GlwSource::PastedJson { payload } => {
let event: sl_glw::GlwEvent = serde_json::from_str(payload)?;
let now = Utc::now();
let name = default_or_user_name(
ctx.options.save_as.as_deref(),
&format!("Pasted JSON {}", now.format("%Y-%m-%d %H:%M UTC")),
);
let payload_json = serde_json::to_string(&event)?;
let glw_data_id = crate::routes::glw::insert_glw_data_row(
state,
&crate::routes::glw::InsertGlwData {
destination: ctx.destination,
created_by: ctx.created_by,
name: &name,
source_kind: crate::library::GlwDataSourceKind::PastedJson,
source_event_id: None,
source_event_key: None,
payload_json: &payload_json,
event_id: Some(event.event_id.get()),
event_key: Some(event.event_key.as_str()),
event_name: Some(event.event_name.as_str()),
fetched_at: now,
},
)
.await?;
Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
}
}
}
fn default_or_user_name(supplied: Option<&str>, default: &str) -> String {
let trimmed = supplied.map(str::trim).filter(|s| !s.is_empty());
match trimmed {
Some(s) => s.to_owned(),
None => default.to_owned(),
}
}
async fn apply_glw_overlay_to_map(
state: &AppState,
ctx: Option<&GlwJobCtx>,
map: &mut Map,
) -> Result<Option<Uuid>, Error> {
use sl_glw::MapLikeGlwExt as _;
let Some(ctx) = ctx else {
return Ok(None);
};
let font_path = state
.fonts
.path_for(&ctx.options.font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", ctx.options.font_id)))?;
let font = sl_map_apis::text::load_font(font_path)?;
let resolved = resolve_glw_event(state, ctx).await?;
let Some(resolved) = resolved else {
tracing::warn!("GLW event not found (server returned no event); rendering without overlay");
return Ok(None);
};
let style = build_glw_style(&ctx.options.style, ctx.options.legend_slot.as_deref())?;
map.draw_glw_event_with_font(&resolved.event, &style, &font);
Ok(Some(resolved.glw_data_id))
}
fn build_glw_style(
overrides: &GlwStyleOverrides,
legend_slot: Option<&str>,
) -> Result<sl_glw::GlwStyle, Error> {
let mut style = sl_glw::GlwStyle {
legend_position: legend_position_from_slot(legend_slot)?,
draw_margin_band: overrides.margin_band,
..sl_glw::GlwStyle::default()
};
if let Some(c) = overrides.area_outline_color.as_deref() {
style.palette.area_outline = parse_color(c.trim())?;
}
if let Some(c) = overrides.circle_outline_color.as_deref() {
style.palette.circle_outline = parse_color(c.trim())?;
}
if let Some(c) = overrides.margin_outline_color.as_deref() {
style.palette.margin_outline = parse_color(c.trim())?;
}
if let Some(c) = overrides.wind_color.as_deref() {
style.palette.wind_arrow = parse_color(c.trim())?;
}
if let Some(c) = overrides.current_color.as_deref() {
style.palette.current_arrow = parse_color(c.trim())?;
}
if let Some(c) = overrides.wave_color.as_deref() {
style.palette.wave_glyph = parse_color(c.trim())?;
}
if let Some(c) = overrides.label_color.as_deref() {
style.palette.label_fg = parse_color(c.trim())?;
}
Ok(style)
}
fn legend_position_from_slot(
slot: Option<&str>,
) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, Error> {
use sl_map_apis::coverage::PlacementSlot;
let name = match slot {
None => return Ok(Some(PlacementSlot::TopLeft)),
Some(s) => s.trim(),
};
match name {
"" => Ok(Some(PlacementSlot::TopLeft)),
"none" => Ok(None),
other => other
.parse::<PlacementSlot>()
.map(Some)
.map_err(|err| Error::BadRequest(err.to_string())),
}
}
fn legend_slot_of(ctx: Option<&GlwJobCtx>) -> Option<sl_map_apis::coverage::PlacementSlot> {
let ctx = ctx?;
legend_position_from_slot(ctx.options.legend_slot.as_deref())
.ok()
.flatten()
}
fn parse_h_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::HAlign>, Error> {
use sl_map_apis::coverage::HAlign;
match value.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some("left") => Ok(Some(HAlign::Left)),
Some("center") => Ok(Some(HAlign::Center)),
Some("right") => Ok(Some(HAlign::Right)),
Some(other) => Err(Error::BadRequest(format!("invalid h_align `{other}`"))),
}
}
fn parse_v_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::VAlign>, Error> {
use sl_map_apis::coverage::VAlign;
match value.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some("top") => Ok(Some(VAlign::Top)),
Some("center") => Ok(Some(VAlign::Center)),
Some("bottom") => Ok(Some(VAlign::Bottom)),
Some(other) => Err(Error::BadRequest(format!("invalid v_align `{other}`"))),
}
}
fn parse_slot_group(
anchor: sl_map_apis::coverage::PlacementSlot,
names: &[String],
) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
if names.is_empty() {
return Ok(vec![anchor]);
}
let mut group: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::with_capacity(names.len());
for name in names {
let slot = name
.parse::<sl_map_apis::coverage::PlacementSlot>()
.map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
Error::BadRequest(err.to_string())
})?;
if !group.contains(&slot) {
group.push(slot);
}
}
if !group.contains(&anchor) {
group.push(anchor);
}
if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
let joined = group
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("+");
return Err(Error::BadRequest(format!(
"the combined slot group `{joined}` does not form a solid rectangle"
)));
}
Ok(group)
}
fn resolve_placement(
anchor: sl_map_apis::coverage::PlacementSlot,
group: &[sl_map_apis::coverage::PlacementSlot],
slots: &[sl_map_apis::coverage::PlacementSlotInfo],
grid: &sl_map_apis::coverage::OccupancyGrid,
) -> Result<
(
Vec<sl_map_apis::coverage::PlacementSlot>,
sl_map_apis::coverage::PixelRect,
),
Error,
> {
if group.len() > 1 {
let rect = grid.subset_rect(group).ok_or_else(|| {
Error::BadRequest(format!(
"the combined slot at `{anchor}` is fully covered; no room for content"
))
})?;
Ok((group.to_vec(), rect))
} else {
let info = slots
.iter()
.find(|info| info.slot == anchor)
.ok_or_else(|| Error::BadRequest(format!("slot `{anchor}` not found")))?;
let rect = info.free_rect.ok_or_else(|| {
Error::BadRequest(format!(
"slot `{anchor}` is fully covered; no room for content"
))
})?;
Ok((vec![anchor], rect))
}
}
fn reserve(
reserved: &[sl_map_apis::coverage::PlacementSlot],
used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
others: &[sl_map_apis::coverage::PlacementSlot],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
what: &str,
) -> Result<(), Error> {
for &slot in reserved {
if legend_slot == Some(slot) {
return Err(Error::BadRequest(format!(
"a {what} uses slot `{slot}` which is occupied by the legend"
)));
}
if used.contains(&slot) || others.contains(&slot) {
return Err(Error::BadRequest(format!(
"two placements target the same slot `{slot}`"
)));
}
used.push(slot);
}
Ok(())
}
struct LabelDraw {
lines: Vec<String>,
font: ab_glyph::FontVec,
style: sl_map_apis::text::LabelStyle,
origin: (i32, i32),
}
struct LogoDraw {
img: image::RgbaImage,
x: i64,
y: i64,
}
fn plan_labels(
fonts: &crate::fonts::FontDirectory,
labels: &[TextLabel],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
occupancy: &Map,
) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
let mut draws: Vec<LabelDraw> = Vec::new();
if labels.is_empty() {
return Ok((draws, used));
}
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
occupancy,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid.evaluate_slots();
for label in labels {
let lines: Vec<String> = label.lines.clone();
if lines.iter().all(|line| line.trim().is_empty()) {
continue;
}
if !(label.font_px.is_finite() && label.font_px > 0f32) {
return Err(Error::BadRequest(format!(
"label font size must be a positive number of pixels, got {}",
label.font_px
)));
}
let anchor: sl_map_apis::coverage::PlacementSlot =
label
.slot
.parse()
.map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
Error::BadRequest(err.to_string())
})?;
let group = parse_slot_group(anchor, &label.slots)?;
let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
reserve(
&reserved,
&mut used,
reserved_by_others,
legend_slot,
"label",
)?;
let font_path = fonts
.path_for(&label.font_id)
.ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", label.font_id)))?;
let font = sl_map_apis::text::load_font(font_path)?;
let color = parse_color(label.color.trim())?;
let scale = ab_glyph::PxScale::from(label.font_px);
let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
if text_w > rect.width || text_h > rect.height {
return Err(Error::BadRequest(format!(
"label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
rect.width, rect.height
)));
}
let (default_h, default_v) = anchor.default_alignment();
let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
draws.push(LabelDraw {
lines,
font,
style: sl_map_apis::text::LabelStyle {
scale,
fg: color,
shadow: Rgba([0, 0, 0, 180]),
align: h,
},
origin: (
i32::try_from(origin_x).unwrap_or(0),
i32::try_from(origin_y).unwrap_or(0),
),
});
}
Ok((draws, used))
}
fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
for d in draws {
map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
}
}
#[cfg(test)]
fn draw_labels_on_map(
fonts: &crate::fonts::FontDirectory,
labels: &[TextLabel],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
map: &mut Map,
) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
let (draws, used) = plan_labels(fonts, labels, legend_slot, reserved_by_others, map)?;
execute_labels(&draws, map);
Ok(used)
}
async fn plan_logos(
state: &AppState,
logos: &[LogoPlacement],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
occupancy: &Map,
) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
let mut draws: Vec<LogoDraw> = Vec::new();
if logos.is_empty() {
return Ok((draws, used));
}
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
occupancy,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid.evaluate_slots();
for logo in logos {
if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
return Err(Error::BadRequest(format!(
"logo scale must be 1, 2 or 4, got {}",
logo.scale
)));
}
let anchor: sl_map_apis::coverage::PlacementSlot =
logo.slot
.parse()
.map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
Error::BadRequest(err.to_string())
})?;
let group = parse_slot_group(anchor, &logo.slots)?;
let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
reserve(
&reserved,
&mut used,
reserved_by_others,
legend_slot,
"logo",
)?;
let row: Option<(String,)> =
sqlx::query_as("SELECT image_filename FROM saved_logos WHERE logo_id = ?1")
.bind(logo.logo_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("logo file lookup failed: {err}");
Error::Database
})?;
let (image_filename,) = row
.ok_or_else(|| Error::BadRequest(format!("logo {} no longer exists", logo.logo_id)))?;
let bytes = storage::read_logo_file(&state.config.storage_dir, &image_filename).await?;
let decoded = image::load_from_memory(&bytes).map_err(|e| {
Error::BadRequest(format!("could not decode logo {}: {e}", logo.logo_id))
})?;
let mut rgba = decoded.to_rgba8();
if logo.scale != 1 {
let factor = u32::from(logo.scale);
let (w, h) = (rgba.width(), rgba.height());
rgba = image::imageops::resize(
&rgba,
w.saturating_mul(factor),
h.saturating_mul(factor),
image::imageops::FilterType::Nearest,
);
}
let (w, h) = (rgba.width(), rgba.height());
if w > rect.width || h > rect.height {
return Err(Error::BadRequest(format!(
"logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
rect.width, rect.height
)));
}
let (default_h, default_v) = anchor.default_alignment();
let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
draws.push(LogoDraw {
img: rgba,
x: i64::from(origin_x),
y: i64::from(origin_y),
});
}
Ok((draws, used))
}
fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
for d in draws {
image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
}
}
async fn resolve_notecard_rect(
state: &AppState,
notecard: &USBNotecard,
borders: (u16, u16, u16, u16),
) -> Result<(GridRectangle, GridRectangle), Error> {
let bare_rect = {
let mut region = state.region_cache.lock().await;
usb_notecard_to_grid_rectangle(&mut region, notecard).await?
};
let (border_north, border_south, border_east, border_west) = borders;
let rect = bare_rect
.expanded_west(border_west)
.expanded_east(border_east)
.expanded_south(border_south)
.expanded_north(border_north);
Ok((bare_rect, rect))
}
async fn plan_placements(
state: &AppState,
occ_rect: GridRectangle,
common: &CommonParams,
glw_ctx: Option<&GlwJobCtx>,
route: Option<(&USBNotecard, Rgba<u8>)>,
labels: &[TextLabel],
logos: &[LogoPlacement],
) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), Error> {
let legend_slot = legend_slot_of(glw_ctx);
let occ = {
let mut occ = Map::blank_fit(occ_rect, common.max_width, common.max_height)?;
if let Some(ctx) = glw_ctx {
apply_glw_overlay_readonly(state, ctx.created_by, &ctx.options, &mut occ).await?;
}
if let Some((notecard, color)) = route {
let mut region = state.region_cache.lock().await;
occ.draw_route_with_progress(&mut region, notecard, color, None)
.await?;
}
occ
};
let (label_draws, label_slots) = plan_labels(&state.fonts, labels, legend_slot, &[], &occ)?;
let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, &occ).await?;
Ok((label_draws, logo_draws))
}
async fn validate_logos(
state: &AppState,
current_user: Uuid,
destination: Destination,
logos: &[LogoPlacement],
) -> Result<Vec<Uuid>, Error> {
let mut ids: Vec<Uuid> = Vec::new();
for logo in logos {
let row = library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
let logo_dest = library::destination_from_columns(
row.owner_user_id.clone(),
row.owner_group_id.clone(),
)?;
if logo_dest != destination {
return Err(Error::BadRequest(format!(
"logo {} is not in the same library as this render; copy it into the render's \
scope first",
logo.logo_id
)));
}
if !ids.contains(&logo.logo_id) {
ids.push(logo.logo_id);
}
}
Ok(ids)
}
async fn assert_can_read_logos(
state: &AppState,
current_user: Uuid,
logos: &[LogoPlacement],
) -> Result<(), Error> {
for logo in logos {
library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
}
Ok(())
}
async fn link_render_logos_or_fail(
state: &AppState,
render_id: Uuid,
logo_ids: &[Uuid],
) -> Result<(), Error> {
if let Err(err) = link_render_logos(state, render_id, logo_ids).await {
update_failed(
state,
render_id,
&format!("linking render logos failed: {err}"),
Utc::now(),
)
.await;
return Err(err);
}
Ok(())
}
async fn link_render_logos(
state: &AppState,
render_id: Uuid,
logo_ids: &[Uuid],
) -> Result<(), Error> {
for id in logo_ids {
sqlx::query(
"INSERT OR IGNORE INTO saved_render_logos (render_id, logo_id) VALUES (?1, ?2)",
)
.bind(render_id.as_bytes().to_vec())
.bind(id.as_bytes().to_vec())
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("link render logo failed: {err}");
Error::Database
})?;
}
Ok(())
}
async fn rewrite_settings_json_with_saved_glw(
state: &AppState,
render_id: Uuid,
glw_data_id: Uuid,
) -> Result<Option<String>, Error> {
let row: Option<(String,)> =
sqlx::query_as("SELECT settings_json FROM saved_renders WHERE render_id = ?1")
.bind(render_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("settings_json fetch failed: {err}");
Error::Database
})?;
let Some((settings_json,)) = row else {
return Ok(None);
};
let mut parsed: SavedRenderSettings = serde_json::from_str(&settings_json)?;
let glw_slot: &mut Option<GlwRenderOptions> = match &mut parsed {
SavedRenderSettings::GridRectangle(s) => &mut s.glw,
SavedRenderSettings::UsbNotecard(s) => &mut s.glw,
};
let Some(opts) = glw_slot.as_mut() else {
return Ok(None);
};
if matches!(opts.source, GlwSource::SavedId { .. }) {
return Ok(None);
}
opts.source = GlwSource::SavedId { glw_data_id };
let json = serde_json::to_string(&parsed)?;
Ok(Some(json))
}
#[cfg(test)]
mod placement_slots_tests {
use super::*;
use pretty_assertions::assert_eq;
fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1003, 1003),
);
Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
}
#[test]
fn empty_map_reports_nine_free_slots() -> Result<(), Box<dyn std::error::Error>> {
let map = blank_map()?;
let resp = compute_placement_slots(&map, &[]);
assert_eq!(resp.slots.len(), 9);
assert_eq!((resp.image_width, resp.image_height), (128, 128));
for s in &resp.slots {
assert!(s.available, "{} should be free", s.slot);
}
let top_left = resp
.slots
.iter()
.find(|s| s.slot == "top_left")
.ok_or("missing top_left slot")?;
assert!(top_left.free_rect.is_some());
Ok(())
}
#[test]
fn route_blocks_centre_slot() -> Result<(), Box<dyn std::error::Error>> {
let mut map = blank_map()?;
map.draw_pixel_waypoint_route(
&[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
Rgba([255, 0, 0, 255]),
)?;
let resp = compute_placement_slots(&map, &[]);
let center = resp
.slots
.iter()
.find(|s| s.slot == "center")
.ok_or("missing center slot")?;
assert!(!center.available);
let top_right = resp
.slots
.iter()
.find(|s| s.slot == "top_right")
.ok_or("missing top_right slot")?;
assert!(top_right.available);
Ok(())
}
#[test]
fn requested_group_reports_combined_rect() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
let map = blank_map()?;
let resp = compute_placement_slots(&map, &[vec![P::TopLeft, P::TopCenter]]);
assert_eq!(resp.groups.len(), 1);
let g = resp.groups.first().ok_or("missing group")?;
assert_eq!(g.slots, vec!["top_left", "top_center"]);
assert!(g.available && g.free_rect.is_some());
let tl = resp
.slots
.iter()
.find(|s| s.slot == "top_left")
.ok_or("missing top_left")?;
assert!(g.free_width > tl.free_width);
Ok(())
}
}
#[cfg(test)]
mod glw_preview_tests {
use super::*;
use pretty_assertions::assert_eq;
use sl_glw::{GlwEvent, MapLikeGlwExt as _};
const SAMPLE_JSON: &str = r#"{
"eventId": 6910,
"eventName": "test cruise",
"eventKey": "key cruise",
"directorName": "LaliaCasau Resident",
"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": {
"area1": {
"coordSW": { "x": 1133, "y": 1048 },
"coordNE": { "x": 1135, "y": 1049 },
"margin": 25, "overlap": 0,
"currents": { "speed": 1, "dir": 225, "waterDepth": 8 }
}
},
"circles": {
"circle1": {
"centerSim": { "x": 1136, "y": 1051 },
"centerPoint": { "x": 90, "y": 175 },
"radius": 127, "margin": 25, "overlap": 0,
"wind": { "speed": 15 },
"currents": { "speed": 0.1, "dir": 225, "waterDepth": 6 }
}
}
}"#;
fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
let fonts = crate::fonts::FontDirectory::scan(root)?;
let path = fonts
.path_for("DejaVuSans.ttf")
.ok_or("bundled DejaVuSans.ttf font is missing")?;
Ok(sl_map_apis::text::load_font(path)?)
}
#[test]
fn blank_overlay_dimensions_match_client_bounds() -> Result<(), Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1130, 1045),
GridCoordinates::new(1140, 1055),
);
let zoom = ZoomLevel::try_new(4)?;
let map = Map::blank(rect, zoom);
let (w, h) = image::GenericImageView::dimensions(&map);
assert_eq!((w, h), (11 * 32, 11 * 32));
for y in 0..h {
for x in 0..w {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
assert_eq!(a, 0, "blank overlay must start fully transparent");
}
}
Ok(())
}
fn top_left_has_pixels(map: &Map, n: u32) -> bool {
for y in 0..n {
for x in 0..n {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
if a != 0 {
return true;
}
}
}
false
}
#[test]
fn preview_overlay_omits_the_legend() -> Result<(), Box<dyn std::error::Error>> {
let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
let font = test_font()?;
let rect = GridRectangle::new(
GridCoordinates::new(1130, 1045),
GridCoordinates::new(1140, 1055),
);
let zoom = ZoomLevel::try_new(4)?;
let mut with_legend = Map::blank(rect.clone(), zoom);
let style_with = build_glw_style(&GlwStyleOverrides::default(), Some("top_left"))?;
with_legend.draw_glw_event_with_font(&event, &style_with, &font);
assert!(
top_left_has_pixels(&with_legend, 40),
"the legend should fill the top-left corner when enabled"
);
let mut without_legend = Map::blank(rect, zoom);
let mut style_without = build_glw_style(&GlwStyleOverrides::default(), None)?;
style_without.legend_position = None;
without_legend.draw_glw_event_with_font(&event, &style_without, &font);
assert!(
!top_left_has_pixels(&without_legend, 40),
"the preview overlay must not draw the legend"
);
let (w, h) = image::GenericImageView::dimensions(&without_legend);
let mut any = false;
'outer: for y in 0..h {
for x in 0..w {
let image::Rgba([_, _, _, a]) =
image::GenericImageView::get_pixel(&without_legend, x, y);
if a != 0 {
any = true;
break 'outer;
}
}
}
assert!(any, "the GLW shapes should still be drawn");
Ok(())
}
}
#[cfg(test)]
mod route_preview_tests {
use super::*;
use pretty_assertions::assert_eq;
use sl_map_apis::map_tiles::MapLike as _;
const ROUTE_COLOR: Rgba<u8> = Rgba([255, 0, 0, 255]);
#[expect(
clippy::as_conversions,
clippy::cast_precision_loss,
reason = "pixel coordinates are bounded by the output dimensions and never approach 2^23"
)]
fn widen(v: u32) -> f32 {
v as f32
}
fn pixel_for(map: &Map, rx: u16, ry: u16, x: f32, y: f32) -> Option<(f32, f32)> {
let (px, py) = map.pixel_coordinates_for_coordinates(
&GridCoordinates::new(rx, ry),
&RegionCoordinates::new(x, y, 0f32),
)?;
Some((widen(px), widen(py)))
}
fn distance_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
let (px, py) = p;
let (ax, ay) = a;
let (bx, by) = b;
let (dx, dy) = (bx - ax, by - ay);
let len_sq = (dx * dx) + (dy * dy);
let t = if len_sq <= f32::EPSILON {
0f32
} else {
((((px - ax) * dx) + ((py - ay) * dy)) / len_sq).clamp(0f32, 1f32)
};
let (cx, cy) = (ax + (t * dx), ay + (t * dy));
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
}
#[test]
fn route_drawn_in_colour_on_transparent_background() -> Result<(), Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1010, 1010),
);
let mut map = Map::blank_fit(rect, 512, 512)?;
let pixel_waypoints = vec![
pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?,
pixel_for(&map, 1009, 1009, 128f32, 128f32).ok_or("waypoint outside rect")?,
];
map.draw_pixel_waypoint_route(&pixel_waypoints, ROUTE_COLOR)?;
let (w, h) = image::GenericImageView::dimensions(&map);
let mut coloured = 0u32;
let mut foreign = 0u32;
for y in 0..h {
for x in 0..w {
let image::Rgba([r, g, b, a]) = image::GenericImageView::get_pixel(&map, x, y);
if image::Rgba([r, g, b, a]) == ROUTE_COLOR {
coloured += 1;
} else if a != 0 {
foreign += 1;
}
}
}
assert!(
coloured > 0,
"the route must be drawn in the requested colour"
);
assert_eq!(
foreign, 0,
"only the route colour and full transparency may appear"
);
Ok(())
}
#[test]
fn route_curves_between_waypoints() -> Result<(), Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1012, 1012),
);
let mut map = Map::blank_fit(rect, 512, 512)?;
let p0 = pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
let p1 = pixel_for(&map, 1006, 1006, 128f32, 128f32).ok_or("waypoint outside rect")?;
let p2 = pixel_for(&map, 1011, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
map.draw_pixel_waypoint_route(&[p0, p1, p2], ROUTE_COLOR)?;
let (w, h) = image::GenericImageView::dimensions(&map);
let mut max_off = 0f32;
for y in 0..h {
for x in 0..w {
if image::GenericImageView::get_pixel(&map, x, y) == ROUTE_COLOR {
let p = (widen(x), widen(y));
let off = distance_to_segment(p, p0, p1).min(distance_to_segment(p, p1, p2));
max_off = max_off.max(off);
}
}
}
assert!(
max_off > 4f32,
"the route should curve away from the straight chords (max off-chord \
distance was {max_off:.1} px); a straight polyline would stay on them"
);
Ok(())
}
}
#[cfg(test)]
mod glw_legend_preview_tests {
use super::*;
use pretty_assertions::assert_eq;
use sl_glw::{GlwEvent, MapLikeGlwExt as _};
const SAMPLE_JSON: &str = r#"{
"eventId": 6910,
"eventName": "test cruise",
"eventKey": "key cruise",
"directorName": "LaliaCasau Resident",
"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": {}
}"#;
fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
let fonts = crate::fonts::FontDirectory::scan(root)?;
let path = fonts
.path_for("DejaVuSans.ttf")
.ok_or("bundled DejaVuSans.ttf font is missing")?;
Ok(sl_map_apis::text::load_font(path)?)
}
fn corner_has_pixels(map: &Map, ox: u32, oy: u32, w: u32, h: u32) -> bool {
for y in oy..oy.saturating_add(h) {
for x in ox..ox.saturating_add(w) {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
if a != 0 {
return true;
}
}
}
false
}
#[test]
fn legend_drawn_only_in_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
let font = test_font()?;
let rect = GridRectangle::new(
GridCoordinates::new(1130, 1045),
GridCoordinates::new(1140, 1055),
);
let mut map = Map::blank_fit(rect, 512, 512)?;
let style = build_glw_style(&GlwStyleOverrides::default(), Some("bottom_right"))?;
map.draw_glw_base_legend(&event.base, &style, &font);
let (w, h) = image::GenericImageView::dimensions(&map);
assert!(
corner_has_pixels(&map, w.saturating_sub(80), h.saturating_sub(80), 80, 80),
"the bottom_right legend should fill the bottom-right corner"
);
assert!(
!corner_has_pixels(&map, 0, 0, 80, 80),
"no legend should appear in the unselected top-left corner"
);
Ok(())
}
#[test]
fn legend_slot_none_draws_nothing() -> Result<(), Box<dyn std::error::Error>> {
let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
let font = test_font()?;
let rect = GridRectangle::new(
GridCoordinates::new(1130, 1045),
GridCoordinates::new(1140, 1055),
);
let mut map = Map::blank_fit(rect, 512, 512)?;
let style = build_glw_style(&GlwStyleOverrides::default(), Some("none"))?;
assert!(
style.legend_position.is_none(),
"the `none` slot must disable the legend"
);
if style.legend_position.is_some() {
map.draw_glw_base_legend(&event.base, &style, &font);
}
let (w, h) = image::GenericImageView::dimensions(&map);
for y in 0..h {
for x in 0..w {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
assert_eq!(a, 0, "a `none` legend slot must leave the map transparent");
}
}
Ok(())
}
}
#[cfg(test)]
mod label_tests {
use super::*;
use pretty_assertions::assert_eq;
use pretty_assertions::assert_matches;
use sl_map_apis::coverage::{HAlign, PlacementSlot, VAlign};
fn test_fonts() -> Result<crate::fonts::FontDirectory, Box<dyn std::error::Error>> {
let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
Ok(crate::fonts::FontDirectory::scan(root)?)
}
fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1130, 1130),
GridCoordinates::new(1140, 1140),
);
Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
}
fn label(slot: &str, font_px: f32, lines: &[&str]) -> TextLabel {
TextLabel {
slot: slot.to_owned(),
lines: lines.iter().map(|s| (*s).to_owned()).collect(),
font_id: "DejaVuSans.ttf".to_owned(),
font_px,
color: "#ffffff".to_owned(),
h_align: None,
v_align: None,
slots: Vec::new(),
}
}
fn any_pixel_drawn(map: &Map) -> bool {
let (w, h) = image::GenericImageView::dimensions(map);
for y in 0..h {
for x in 0..w {
let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
if a != 0 {
return true;
}
}
}
false
}
fn opaque_map() -> Result<Map, Box<dyn std::error::Error>> {
use image::GenericImage as _;
use sl_map_apis::map_tiles::MapLike as _;
let mut map = blank_map()?;
let (w, h) = image::GenericImageView::dimensions(&map);
for y in 0..h {
for x in 0..w {
map.image_mut().put_pixel(x, y, image::Rgba([5, 5, 5, 255]));
}
}
Ok(map)
}
#[test]
fn labels_plan_on_overlay_then_draw_onto_opaque_target()
-> Result<(), Box<dyn std::error::Error>> {
let fonts = test_fonts()?;
let lbls = [label("top_left", 18f32, &["Hi"])];
let occ = blank_map()?;
let (draws, used) = plan_labels(&fonts, &lbls, None, &[], &occ)?;
assert!(!draws.is_empty(), "the label should be planned");
assert!(used.contains(&PlacementSlot::TopLeft));
let mut target = opaque_map()?;
execute_labels(&draws, &mut target);
let (w, h) = image::GenericImageView::dimensions(&target);
let mut changed = false;
'outer: for y in 0..h {
for x in 0..w {
let image::Rgba([r, g, b, _]) = image::GenericImageView::get_pixel(&target, x, y);
if (r, g, b) != (5, 5, 5) {
changed = true;
break 'outer;
}
}
}
assert!(changed, "the label must draw onto the opaque target");
let opaque_occ = opaque_map()?;
assert!(
plan_labels(&fonts, &lbls, None, &[], &opaque_occ).is_err(),
"an opaque occupancy map leaves no room — the regression the split fixes"
);
Ok(())
}
#[test]
fn legend_position_from_slot_maps_names() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
assert_eq!(legend_position_from_slot(Some("none"))?, None);
assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
assert_eq!(
legend_position_from_slot(Some("bottom_right"))?,
Some(P::BottomRight)
);
assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
Ok(())
}
#[test]
fn slot_parsers_and_alignment() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
"middle_left".parse::<PlacementSlot>()?,
PlacementSlot::MiddleLeft
);
assert_matches!("nope".parse::<PlacementSlot>(), Err(_));
assert_eq!(parse_h_align(None)?, None);
assert_eq!(parse_h_align(Some("right"))?, Some(HAlign::Right));
assert_eq!(parse_v_align(Some("bottom"))?, Some(VAlign::Bottom));
assert_matches!(parse_h_align(Some("sideways")), Err(_));
Ok(())
}
#[test]
fn small_label_draws_and_oversized_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
let fonts = test_fonts()?;
let mut map = blank_map()?;
draw_labels_on_map(
&fonts,
&[label("top_left", 18f32, &["Hi"])],
None,
&[],
&mut map,
)?;
assert!(any_pixel_drawn(&map), "a fitting label should draw pixels");
let mut map2 = blank_map()?;
let err = draw_labels_on_map(
&fonts,
&[label("center", 4000f32, &["BIG"])],
None,
&[],
&mut map2,
);
assert_matches!(
err,
Err(Error::BadRequest(_)),
"oversized label must be rejected"
);
Ok(())
}
#[test]
fn slot_collisions_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
let fonts = test_fonts()?;
let mut map = blank_map()?;
let dup = draw_labels_on_map(
&fonts,
&[
label("top_right", 16f32, &["a"]),
label("top_right", 16f32, &["b"]),
],
None,
&[],
&mut map,
);
assert_matches!(
dup,
Err(Error::BadRequest(_)),
"duplicate slot must be rejected"
);
let mut map2 = blank_map()?;
let clash = draw_labels_on_map(
&fonts,
&[label("top_left", 16f32, &["x"])],
Some(PlacementSlot::TopLeft),
&[],
&mut map2,
);
assert_matches!(
clash,
Err(Error::BadRequest(_)),
"legend collision must be rejected"
);
Ok(())
}
#[test]
fn blank_label_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
let fonts = test_fonts()?;
let mut map = blank_map()?;
draw_labels_on_map(
&fonts,
&[label("center", 16f32, &["", " "])],
None,
&[],
&mut map,
)?;
assert!(!any_pixel_drawn(&map), "an all-blank label draws nothing");
Ok(())
}
#[test]
fn reserve_rejects_legend_duplicate_and_other_pool() -> Result<(), Box<dyn std::error::Error>> {
let mut used = Vec::new();
assert_matches!(
reserve(
&[PlacementSlot::TopLeft],
&mut used,
&[],
Some(PlacementSlot::TopLeft),
"label",
),
Err(Error::BadRequest(_))
);
let mut used = Vec::new();
reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "label")?;
assert_matches!(
reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "logo"),
Err(Error::BadRequest(_))
);
let mut used = Vec::new();
assert_matches!(
reserve(
&[PlacementSlot::Center],
&mut used,
&[PlacementSlot::Center],
None,
"logo",
),
Err(Error::BadRequest(_))
);
Ok(())
}
#[test]
fn resolve_placement_single_vs_combined() -> Result<(), Box<dyn std::error::Error>> {
let map = blank_map()?;
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
&map,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid.evaluate_slots();
let (reserved, rect) = resolve_placement(
PlacementSlot::TopLeft,
&[PlacementSlot::TopLeft],
&slots,
&grid,
)?;
assert_eq!(reserved, vec![PlacementSlot::TopLeft]);
assert!(rect.width > 0 && rect.height > 0);
let group = vec![PlacementSlot::TopLeft, PlacementSlot::TopCenter];
let (all, combined) = resolve_placement(PlacementSlot::TopLeft, &group, &slots, &grid)?;
assert_eq!(all, group);
assert!(combined.width > rect.width);
Ok(())
}
#[test]
fn parse_slot_group_defaults_and_includes_anchor() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
assert_eq!(parse_slot_group(P::TopLeft, &[])?, vec![P::TopLeft]);
let g = parse_slot_group(
P::TopLeft,
&["top_center".to_owned(), "top_center".to_owned()],
)?;
assert_eq!(g, vec![P::TopCenter, P::TopLeft]);
assert_matches!(parse_slot_group(P::TopLeft, &["nope".to_owned()]), Err(_));
Ok(())
}
#[test]
fn parse_slot_group_rejects_non_rectangular_groups() {
use sl_map_apis::coverage::PlacementSlot as P;
assert_matches!(
parse_slot_group(P::TopLeft, &["top_center".to_owned()]),
Ok(_)
);
assert_matches!(
parse_slot_group(P::TopLeft, &["bottom_right".to_owned()]),
Err(_)
);
assert_matches!(
parse_slot_group(
P::TopLeft,
&["top_center".to_owned(), "middle_left".to_owned()],
),
Err(_)
);
}
#[test]
fn parse_groups_rejects_non_rectangular_groups() {
assert_matches!(
parse_groups(&[vec!["top_left".to_owned(), "top_center".to_owned()]]),
Ok(_)
);
assert_matches!(
parse_groups(&[vec!["top_left".to_owned(), "bottom_left".to_owned()]]),
Err(_)
);
}
}
#[cfg(test)]
mod region_overlay_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn text_overlay_gate_requires_both_size_and_count() {
assert!(region_text_overlay_allowed(
MIN_PIXELS_PER_REGION_FOR_REGION_LABELS,
MAX_REGIONS_FOR_REGION_LABELS
));
assert!(!region_text_overlay_allowed(
MIN_PIXELS_PER_REGION_FOR_REGION_LABELS - 1.0,
1
));
assert!(!region_text_overlay_allowed(
256.0,
MAX_REGIONS_FOR_REGION_LABELS + 1
));
assert!(region_text_overlay_allowed(256.0, 4));
}
#[test]
fn rectangles_write_visible_pixels() -> Result<(), Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1003, 1003),
);
let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
let before = (0..map.height())
.flat_map(|y| (0..map.width()).map(move |x| (x, y)))
.any(|(x, y)| {
let image::Rgba([_, _, _, a]) = map.get_pixel(x, y);
a != 0
});
assert!(!before, "blank map should start fully transparent");
draw_region_rectangles(&mut map);
let mut drawn = false;
for y in 0..map.height() {
for x in 0..map.width() {
if map.get_pixel(x, y) == image::Rgba([255, 255, 255, 255]) {
drawn = true;
}
}
}
assert!(
drawn,
"draw_region_rectangles should write opaque white pixels"
);
Ok(())
}
#[test]
fn missing_region_fill_paints_the_region_rect() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::map_tiles::MapLike as _;
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1001, 1001),
);
let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
let grid = GridCoordinates::new(1000, 1000);
let (left, top, width, height) =
region_pixel_rect(&map, &grid).ok_or("region not in map")?;
let fill = Rgba([0x19, 0x48, 0x5a, 0xff]);
map.draw_filled_rect(left, top, width, height, fill);
assert_eq!(
map.get_pixel(left + 1, top + 1),
fill,
"fill colour painted"
);
let other = region_pixel_rect(&map, &GridCoordinates::new(1001, 1001))
.ok_or("other region not in map")?;
let image::Rgba([_, _, _, a]) = map.get_pixel(other.0 + 1, other.1 + 1);
assert_eq!(a, 0, "unfilled region stays transparent");
Ok(())
}
}