1use std::io::Cursor;
8use std::sync::Arc;
9use std::sync::atomic::Ordering;
10
11use axum::Json;
12use axum::extract::{Multipart, State};
13use bytes::Bytes;
14use chrono::Utc;
15use image::GenericImageView as _;
16use image::{ImageFormat, Rgba};
17use serde::{Deserialize, Serialize};
18use sl_map_apis::map_tiles::{Map, MapProgressEvent};
19use sl_map_apis::region::usb_notecard_to_grid_rectangle;
20use sl_types::map::{
21 GridCoordinates, GridRectangle, GridRectangleLike as _, RegionCoordinates, USBNotecard,
22 ZoomLevel,
23};
24use tokio_stream::wrappers::ReceiverStream;
25use uuid::Uuid;
26
27use crate::auth::CurrentUser;
28use crate::error::Error;
29use crate::jobs::{JobId, JobOutcome, JobState, Metadata, ProgressDto, record_event};
30use crate::library::{self, Destination};
31use crate::routes::notecards as notecard_routes;
32use crate::state::AppState;
33use crate::storage;
34
35const MAX_OUTPUT_DIMENSION: u32 = 0x8000;
42
43const MAX_OUTPUT_AREA: u64 = 0x4000 * 0x4000;
51
52const MAX_CONCURRENT_RENDERS_PER_USER: i64 = 3;
58
59const MIN_PIXELS_PER_REGION_FOR_REGION_LABELS: f32 = 64.0;
65
66const MAX_REGIONS_FOR_REGION_LABELS: usize = 1024;
72
73const REGION_LABEL_FONT_FACTOR: f32 = 0.12;
77
78const REGION_LABEL_FONT_MIN_PX: f32 = 8.0;
80
81const REGION_LABEL_FONT_MAX_PX: f32 = 22.0;
83
84const REGION_LABEL_PADDING: i32 = 3;
87
88const REGION_RECTANGLE_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]);
91
92#[derive(Debug, Clone, Copy, Default)]
95struct RegionOverlayOptions {
96 rectangles: bool,
98 names: bool,
100 coordinates: bool,
103}
104
105impl RegionOverlayOptions {
106 const fn any(self) -> bool {
108 self.rectangles || self.names || self.coordinates
109 }
110
111 const fn any_text(self) -> bool {
114 self.names || self.coordinates
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum OutputFormat {
122 #[default]
124 Png,
125 #[serde(alias = "jpg")]
127 Jpeg,
128}
129
130impl OutputFormat {
131 const fn image_format(self) -> ImageFormat {
133 match self {
134 Self::Png => ImageFormat::Png,
135 Self::Jpeg => ImageFormat::Jpeg,
136 }
137 }
138
139 const fn content_type(self) -> &'static str {
141 match self {
142 Self::Png => "image/png",
143 Self::Jpeg => "image/jpeg",
144 }
145 }
146}
147
148#[derive(Debug, Clone)]
150struct CommonParams {
151 max_width: u32,
153 max_height: u32,
155 missing_map_tile_color: Option<Rgba<u8>>,
157 missing_region_color: Option<Rgba<u8>>,
159 format: OutputFormat,
161 region_overlay: RegionOverlayOptions,
163 region_label_font_id: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum GlwSource {
171 SavedId {
173 glw_data_id: Uuid,
175 },
176 EventId {
179 event_id: u32,
181 },
182 EventKey {
185 event_key: String,
187 },
188 PastedJson {
191 payload: String,
194 },
195}
196
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct GlwStyleOverrides {
202 #[serde(default)]
204 pub margin_band: bool,
205 #[serde(default)]
207 pub area_outline_color: Option<String>,
208 #[serde(default)]
210 pub circle_outline_color: Option<String>,
211 #[serde(default)]
213 pub margin_outline_color: Option<String>,
214 #[serde(default)]
216 pub wind_color: Option<String>,
217 #[serde(default)]
219 pub current_color: Option<String>,
220 #[serde(default)]
222 pub wave_color: Option<String>,
223 #[serde(default)]
225 pub label_color: Option<String>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TextLabel {
232 pub slot: String,
234 pub lines: Vec<String>,
236 pub font_id: String,
239 pub font_px: f32,
241 pub color: String,
243 #[serde(default)]
246 pub h_align: Option<String>,
247 #[serde(default)]
250 pub v_align: Option<String>,
251 #[serde(default)]
256 pub slots: Vec<String>,
257}
258
259const fn default_logo_scale() -> u8 {
261 1
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct LogoPlacement {
269 pub slot: String,
271 pub logo_id: Uuid,
273 #[serde(default = "default_logo_scale")]
276 pub scale: u8,
277 #[serde(default)]
282 pub slots: Vec<String>,
283 #[serde(default)]
286 pub h_align: Option<String>,
287 #[serde(default)]
290 pub v_align: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct GlwRenderOptions {
296 pub source: GlwSource,
298 pub font_id: String,
301 #[serde(default)]
306 pub save_as: Option<String>,
307 #[serde(default)]
309 pub style: GlwStyleOverrides,
310 #[serde(default)]
314 pub legend_slot: Option<String>,
315}
316
317#[derive(Debug, Clone, Deserialize)]
319pub struct GridRectangleRequest {
320 pub lower_left_x: u32,
322 pub lower_left_y: u32,
324 pub upper_right_x: u32,
326 pub upper_right_y: u32,
328 pub max_width: u32,
330 pub max_height: u32,
332 #[serde(default)]
334 pub missing_map_tile_color: Option<String>,
335 #[serde(default)]
337 pub missing_region_color: Option<String>,
338 #[serde(default)]
340 pub format: OutputFormat,
341 #[serde(default)]
343 pub draw_region_rectangles: bool,
344 #[serde(default)]
346 pub draw_region_names: bool,
347 #[serde(default)]
349 pub draw_region_coordinates: bool,
350 #[serde(default)]
353 pub region_label_font_id: Option<String>,
354 #[serde(default)]
357 pub save_to: Option<String>,
358 #[serde(default)]
361 pub glw: Option<GlwRenderOptions>,
362 #[serde(default)]
365 pub labels: Vec<TextLabel>,
366 #[serde(default)]
368 pub logos: Vec<LogoPlacement>,
369 #[serde(default)]
374 pub groups: Vec<Vec<String>>,
375}
376
377#[derive(Debug, Clone, Serialize)]
379pub struct StartedResponse {
380 pub job_id: Uuid,
382 #[serde(skip_serializing_if = "Option::is_none")]
388 pub notecard: Option<NotecardSummary>,
389}
390
391#[derive(Debug, Clone, Serialize)]
395pub struct NotecardSummary {
396 pub notecard_id: Uuid,
399 pub name: String,
401 pub scope: String,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(tag = "kind", rename_all = "snake_case")]
409pub enum SavedRenderSettings {
410 GridRectangle(SavedGridRectangleSettings),
412 UsbNotecard(SavedUsbNotecardSettings),
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SavedGridRectangleSettings {
419 pub lower_left_x: u32,
421 pub lower_left_y: u32,
423 pub upper_right_x: u32,
425 pub upper_right_y: u32,
427 pub max_width: u32,
429 pub max_height: u32,
431 pub missing_map_tile_color: Option<String>,
433 pub missing_region_color: Option<String>,
435 pub format: String,
437 #[serde(default)]
439 pub draw_region_rectangles: bool,
440 #[serde(default)]
442 pub draw_region_names: bool,
443 #[serde(default)]
445 pub draw_region_coordinates: bool,
446 #[serde(default)]
448 pub region_label_font_id: Option<String>,
449 #[serde(default)]
453 pub glw: Option<GlwRenderOptions>,
454 #[serde(default)]
456 pub labels: Vec<TextLabel>,
457 #[serde(default)]
459 pub logos: Vec<LogoPlacement>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464#[expect(
465 clippy::struct_excessive_bools,
466 reason = "this is a flat persisted form-settings record; each bool maps directly to one independent checkbox in the render form"
467)]
468pub struct SavedUsbNotecardSettings {
469 pub notecard_id: Uuid,
471 pub border_north: u16,
474 pub border_south: u16,
477 pub border_east: u16,
480 pub border_west: u16,
483 pub color: String,
485 pub max_width: u32,
487 pub max_height: u32,
489 pub missing_map_tile_color: Option<String>,
491 pub missing_region_color: Option<String>,
493 pub format: String,
495 #[serde(default)]
497 pub draw_region_rectangles: bool,
498 #[serde(default)]
500 pub draw_region_names: bool,
501 #[serde(default)]
503 pub draw_region_coordinates: bool,
504 #[serde(default)]
506 pub region_label_font_id: Option<String>,
507 pub save_without_route: bool,
509 #[serde(default)]
511 pub glw: Option<GlwRenderOptions>,
512 #[serde(default)]
514 pub labels: Vec<TextLabel>,
515 #[serde(default)]
517 pub logos: Vec<LogoPlacement>,
518}
519
520pub async fn grid_rectangle(
527 user: CurrentUser,
528 State(state): State<AppState>,
529 Json(req): Json<GridRectangleRequest>,
530) -> Result<Json<StartedResponse>, Error> {
531 validate_dimensions(req.max_width, req.max_height)?;
532 let common = CommonParams {
533 max_width: req.max_width,
534 max_height: req.max_height,
535 missing_map_tile_color: req
536 .missing_map_tile_color
537 .as_deref()
538 .map(parse_color)
539 .transpose()?,
540 missing_region_color: req
541 .missing_region_color
542 .as_deref()
543 .map(parse_color)
544 .transpose()?,
545 format: req.format,
546 region_overlay: RegionOverlayOptions {
547 rectangles: req.draw_region_rectangles,
548 names: req.draw_region_names,
549 coordinates: req.draw_region_coordinates,
550 },
551 region_label_font_id: req.region_label_font_id.clone(),
552 };
553 assert_region_overlay_font(
554 &state,
555 common.region_overlay,
556 common.region_label_font_id.as_deref(),
557 )?;
558 let destination = Destination::parse(req.save_to.as_deref().unwrap_or("personal"))?;
559 library::assert_can_write(&state.db, user.user_id, destination).await?;
560 assert_under_concurrent_limit(&state.db, user.user_id).await?;
561 let logo_ids = validate_logos(&state, user.user_id, destination, &req.logos).await?;
562 let rect = GridRectangle::new(
563 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
564 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
565 );
566 let glw_ctx = req.glw.clone().map(|opts| GlwJobCtx {
567 options: opts,
568 destination,
569 created_by: user.user_id,
570 });
571 plan_placements(
574 &state,
575 rect.clone(),
576 &common,
577 glw_ctx.as_ref(),
578 None,
579 &req.labels,
580 &req.logos,
581 )
582 .await?;
583 let settings = SavedRenderSettings::GridRectangle(SavedGridRectangleSettings {
584 lower_left_x: req.lower_left_x,
585 lower_left_y: req.lower_left_y,
586 upper_right_x: req.upper_right_x,
587 upper_right_y: req.upper_right_y,
588 max_width: req.max_width,
589 max_height: req.max_height,
590 missing_map_tile_color: common.missing_map_tile_color.map(hex_from_rgba),
591 missing_region_color: common.missing_region_color.map(hex_from_rgba),
592 format: format_name(req.format).to_owned(),
593 draw_region_rectangles: req.draw_region_rectangles,
594 draw_region_names: req.draw_region_names,
595 draw_region_coordinates: req.draw_region_coordinates,
596 region_label_font_id: req.region_label_font_id.clone(),
597 glw: req.glw.clone(),
601 labels: req.labels.clone(),
602 logos: req.logos.clone(),
603 });
604 let render_id = Uuid::new_v4();
605 insert_render_row(
606 &state,
607 render_id,
608 destination,
609 user.user_id,
610 "grid_rectangle",
611 None,
612 &settings,
613 Some(&rect),
614 )
615 .await?;
616 link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
617 let (job_id, job) = state.jobs.create_with_id(render_id).await;
618 spawn_grid_rectangle_job(
619 state, job_id, job, rect, common, glw_ctx, req.labels, req.logos,
620 );
621 Ok(Json(StartedResponse {
622 job_id,
623 notecard: None,
624 }))
625}
626
627pub async fn usb_notecard(
635 user: CurrentUser,
636 State(state): State<AppState>,
637 multipart: Multipart,
638) -> Result<Json<StartedResponse>, Error> {
639 let parsed = parse_render_form(multipart).await?;
640 library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
641 assert_region_overlay_font(
642 &state,
643 parsed.common.region_overlay,
644 parsed.common.region_label_font_id.as_deref(),
645 )?;
646 assert_under_concurrent_limit(&state.db, user.user_id).await?;
647 let logo_ids = validate_logos(&state, user.user_id, parsed.destination, &parsed.logos).await?;
648
649 let (notecard, notecard_summary) = resolve_notecard(&state, &user, &parsed).await?;
654
655 let glw_ctx = parsed.glw.clone().map(|opts| GlwJobCtx {
656 options: opts,
657 destination: parsed.destination,
658 created_by: user.user_id,
659 });
660 let (_bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, parsed.borders).await?;
664 plan_placements(
665 &state,
666 rect,
667 &parsed.common,
668 glw_ctx.as_ref(),
669 Some((¬ecard, parsed.color)),
670 &parsed.labels,
671 &parsed.logos,
672 )
673 .await?;
674
675 let settings = SavedRenderSettings::UsbNotecard(SavedUsbNotecardSettings {
676 notecard_id: notecard_summary.notecard_id,
677 border_north: parsed.borders.0,
678 border_south: parsed.borders.1,
679 border_east: parsed.borders.2,
680 border_west: parsed.borders.3,
681 color: hex_from_rgba(parsed.color),
682 max_width: parsed.common.max_width,
683 max_height: parsed.common.max_height,
684 missing_map_tile_color: parsed.common.missing_map_tile_color.map(hex_from_rgba),
685 missing_region_color: parsed.common.missing_region_color.map(hex_from_rgba),
686 format: format_name(parsed.common.format).to_owned(),
687 draw_region_rectangles: parsed.common.region_overlay.rectangles,
688 draw_region_names: parsed.common.region_overlay.names,
689 draw_region_coordinates: parsed.common.region_overlay.coordinates,
690 region_label_font_id: parsed.common.region_label_font_id.clone(),
691 save_without_route: parsed.with_without_route,
692 glw: parsed.glw.clone(),
695 labels: parsed.labels.clone(),
696 logos: parsed.logos.clone(),
697 });
698
699 let render_id = Uuid::new_v4();
700 insert_render_row(
701 &state,
702 render_id,
703 parsed.destination,
704 user.user_id,
705 "usb_notecard",
706 Some(notecard_summary.notecard_id),
707 &settings,
708 None,
709 )
710 .await?;
711 link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
712
713 let (job_id, job) = state.jobs.create_with_id(render_id).await;
714 spawn_usb_notecard_job(
715 state,
716 job_id,
717 job,
718 notecard_summary.notecard_id,
719 notecard,
720 parsed.borders,
721 parsed.color,
722 parsed.common,
723 parsed.with_without_route,
724 glw_ctx,
725 parsed.labels,
726 parsed.logos,
727 );
728 Ok(Json(StartedResponse {
729 job_id,
730 notecard: Some(notecard_summary),
731 }))
732}
733
734#[derive(Debug, Clone, Copy, Serialize)]
747pub struct PixelRectDto {
748 pub x: u32,
750 pub y: u32,
752 pub width: u32,
754 pub height: u32,
756}
757
758#[derive(Debug, Clone, Serialize)]
760pub struct SlotDto {
761 pub slot: &'static str,
764 pub available: bool,
766 #[serde(skip_serializing_if = "Option::is_none")]
770 pub free_rect: Option<PixelRectDto>,
771 pub free_width: u32,
773 pub free_height: u32,
775 pub occupied_fraction: f32,
778 pub connected_neighbours: Vec<&'static str>,
781}
782
783#[derive(Debug, Clone, Serialize)]
786pub struct GroupDto {
787 pub slots: Vec<&'static str>,
790 pub available: bool,
792 #[serde(skip_serializing_if = "Option::is_none")]
795 pub free_rect: Option<PixelRectDto>,
796 pub free_width: u32,
798 pub free_height: u32,
800}
801
802#[derive(Debug, Clone, Serialize)]
804pub struct PlacementSlotsResponse {
805 pub image_width: u32,
807 pub image_height: u32,
809 pub slots: Vec<SlotDto>,
811 pub groups: Vec<GroupDto>,
814}
815
816fn compute_placement_slots(
819 map: &Map,
820 groups: &[Vec<sl_map_apis::coverage::PlacementSlot>],
821) -> PlacementSlotsResponse {
822 use sl_map_apis::coverage::PlacementSlot;
823 let image = sl_map_apis::map_tiles::MapLike::image(map);
824 let (image_width, image_height) = image.dimensions();
825 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
826 map,
827 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
828 );
829 let slots = grid
830 .evaluate_slots()
831 .into_iter()
832 .map(|info| SlotDto {
833 slot: info.slot.as_str(),
834 available: info.available,
835 free_rect: info.free_rect.map(|r| PixelRectDto {
836 x: r.x,
837 y: r.y,
838 width: r.width,
839 height: r.height,
840 }),
841 free_width: info.free_size.0,
842 free_height: info.free_size.1,
843 occupied_fraction: info.occupied_fraction,
844 connected_neighbours: info
845 .connected_neighbours
846 .into_iter()
847 .map(PlacementSlot::as_str)
848 .collect(),
849 })
850 .collect();
851 let group_dtos = groups
852 .iter()
853 .filter(|g| g.len() > 1)
854 .map(|g| {
855 let rect = grid.subset_rect(g);
856 GroupDto {
857 slots: g.iter().map(|s| s.as_str()).collect(),
858 available: rect.is_some(),
859 free_rect: rect.map(|r| PixelRectDto {
860 x: r.x,
861 y: r.y,
862 width: r.width,
863 height: r.height,
864 }),
865 free_width: rect.map_or(0, |r| r.width),
866 free_height: rect.map_or(0, |r| r.height),
867 }
868 })
869 .collect();
870 PlacementSlotsResponse {
871 image_width,
872 image_height,
873 slots,
874 groups: group_dtos,
875 }
876}
877
878fn parse_groups(
881 raw: &[Vec<String>],
882) -> Result<Vec<Vec<sl_map_apis::coverage::PlacementSlot>>, Error> {
883 raw.iter()
884 .map(|g| {
885 if g.is_empty() {
886 return Err(Error::BadRequest(
887 "a placement group must not be empty".to_owned(),
888 ));
889 }
890 let group = g
891 .iter()
892 .map(|name| {
893 name.parse::<sl_map_apis::coverage::PlacementSlot>()
894 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
895 Error::BadRequest(err.to_string())
896 })
897 })
898 .collect::<Result<Vec<_>, _>>()?;
899 if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
900 return Err(Error::BadRequest(format!(
901 "the combined slot group `{}` does not form a solid rectangle",
902 g.join("+")
903 )));
904 }
905 Ok(group)
906 })
907 .collect()
908}
909
910async fn resolve_glw_event_readonly(
915 state: &AppState,
916 user_id: Uuid,
917 source: &GlwSource,
918) -> Result<Option<sl_glw::GlwEvent>, Error> {
919 match source {
920 GlwSource::SavedId { glw_data_id } => {
921 let row =
922 crate::library::assert_can_read_glw_data(&state.db, user_id, *glw_data_id).await?;
923 Ok(Some(serde_json::from_str(&row.payload_json)?))
924 }
925 GlwSource::EventId { event_id } => {
926 let id = sl_glw::EventId::new(*event_id);
927 let mut cache = state.glw_event_cache.lock().await;
928 Ok(cache.get_event_by_id(id).await?)
929 }
930 GlwSource::EventKey { event_key } => {
931 let key = sl_glw::GlwEventKey::new(event_key);
932 let mut cache = state.glw_event_cache.lock().await;
933 Ok(cache.get_event_by_key(&key).await?)
934 }
935 GlwSource::PastedJson { payload } => Ok(Some(serde_json::from_str(payload)?)),
936 }
937}
938
939async fn apply_glw_overlay_readonly(
944 state: &AppState,
945 user_id: Uuid,
946 options: &GlwRenderOptions,
947 map: &mut Map,
948) -> Result<(), Error> {
949 use sl_glw::MapLikeGlwExt as _;
950 let font_path = state
951 .fonts
952 .path_for(&options.font_id)
953 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", options.font_id)))?;
954 let font = sl_map_apis::text::load_font(font_path)?;
955 let Some(event) = resolve_glw_event_readonly(state, user_id, &options.source).await? else {
956 tracing::warn!("GLW event not found; placement slots computed without overlay");
957 return Ok(());
958 };
959 let mut style = build_glw_style(&options.style, None)?;
962 style.legend_position = None;
963 map.draw_glw_event_with_font(&event, &style, &font);
964 Ok(())
965}
966
967pub async fn free_placement_slots_grid_rectangle(
976 user: CurrentUser,
977 State(state): State<AppState>,
978 Json(req): Json<GridRectangleRequest>,
979) -> Result<Json<PlacementSlotsResponse>, Error> {
980 validate_dimensions(req.max_width, req.max_height)?;
981 let rect = GridRectangle::new(
982 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
983 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
984 );
985 let groups = parse_groups(&req.groups)?;
986 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
987 if let Some(glw) = req.glw.as_ref() {
988 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
989 }
990 Ok(Json(compute_placement_slots(&map, &groups)))
991}
992
993pub async fn free_placement_slots_usb_notecard(
1003 user: CurrentUser,
1004 State(state): State<AppState>,
1005 multipart: Multipart,
1006) -> Result<Json<PlacementSlotsResponse>, Error> {
1007 let parsed = parse_render_form(multipart).await?;
1008 let notecard: USBNotecard = match &parsed.notecard_source {
1010 NotecardSource::Fresh { text, .. } => text.parse()?,
1011 NotecardSource::Existing { notecard_id } => {
1012 let row =
1013 crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1014 .await?;
1015 row.body.parse()?
1016 }
1017 };
1018 let (border_north, border_south, border_east, border_west) = parsed.borders;
1019 let rect = {
1020 let mut region = state.region_cache.lock().await;
1021 usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
1022 }
1023 .expanded_west(border_west)
1024 .expanded_east(border_east)
1025 .expanded_south(border_south)
1026 .expanded_north(border_north);
1027 let mut map = Map::blank_fit(rect, parsed.common.max_width, parsed.common.max_height)?;
1028 if let Some(glw) = parsed.glw.as_ref() {
1030 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
1031 }
1032 {
1033 let mut region = state.region_cache.lock().await;
1034 map.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
1035 .await?;
1036 }
1037 let groups = parse_groups(&parsed.groups)?;
1038 Ok(Json(compute_placement_slots(&map, &groups)))
1039}
1040
1041#[derive(Debug, Clone, Deserialize)]
1043pub struct GlwPreviewRequest {
1044 pub lower_left_x: u32,
1046 pub lower_left_y: u32,
1048 pub upper_right_x: u32,
1050 pub upper_right_y: u32,
1052 pub zoom: u8,
1057 pub glw: GlwRenderOptions,
1059}
1060
1061pub async fn glw_preview(
1079 user: CurrentUser,
1080 State(state): State<AppState>,
1081 Json(req): Json<GlwPreviewRequest>,
1082) -> Result<axum::response::Response, Error> {
1083 use axum::response::IntoResponse as _;
1084 use sl_glw::MapLikeGlwExt as _;
1085 let zoom = ZoomLevel::try_new(req.zoom)
1086 .map_err(|err| Error::BadRequest(format!("invalid zoom: {err}")))?;
1087 let rect = GridRectangle::new(
1088 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1089 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1090 );
1091 let pixels_per_region = u32::from(zoom.pixels_per_region());
1098 let width = pixels_per_region.saturating_mul(rect.size_x());
1099 let height = pixels_per_region.saturating_mul(rect.size_y());
1100 validate_dimensions(width, height)?;
1101 let mut map = Map::blank(rect, zoom);
1102 let font_path = state
1104 .fonts
1105 .path_for(&req.glw.font_id)
1106 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", req.glw.font_id)))?;
1107 let font = sl_map_apis::text::load_font(font_path)?;
1108 if let Some(event) = resolve_glw_event_readonly(&state, user.user_id, &req.glw.source).await? {
1110 let mut style = build_glw_style(&req.glw.style, None)?;
1116 style.legend_position = None;
1117 map.draw_glw_event_with_font(&event, &style, &font);
1118 } else {
1119 tracing::warn!("GLW event not found; preview overlay is fully transparent");
1120 }
1121 let image = encode_map(&map, OutputFormat::Png)?;
1124 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1125}
1126
1127pub async fn glw_legend_preview(
1148 user: CurrentUser,
1149 State(state): State<AppState>,
1150 Json(req): Json<GridRectangleRequest>,
1151) -> Result<axum::response::Response, Error> {
1152 use axum::response::IntoResponse as _;
1153 use sl_glw::MapLikeGlwExt as _;
1154 validate_dimensions(req.max_width, req.max_height)?;
1155 let rect = GridRectangle::new(
1156 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1157 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1158 );
1159 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1161 if let Some(glw) = req.glw.as_ref() {
1162 let font_path = state
1164 .fonts
1165 .path_for(&glw.font_id)
1166 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", glw.font_id)))?;
1167 let font = sl_map_apis::text::load_font(font_path)?;
1168 let style = build_glw_style(&glw.style, glw.legend_slot.as_deref())?;
1169 if style.legend_position.is_some() {
1172 if let Some(event) =
1173 resolve_glw_event_readonly(&state, user.user_id, &glw.source).await?
1174 {
1175 map.draw_glw_base_legend(&event.base, &style, &font);
1176 } else {
1177 tracing::warn!("GLW event not found; legend preview is fully transparent");
1178 }
1179 }
1180 }
1181 let image = encode_map(&map, OutputFormat::Png)?;
1184 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1185}
1186
1187#[derive(Debug, Clone, Deserialize)]
1194pub struct RoutePreviewWaypoint {
1195 pub region_x: u32,
1197 pub region_y: u32,
1199 pub x: f32,
1201 pub y: f32,
1203}
1204
1205#[derive(Debug, Clone, Deserialize)]
1207pub struct RoutePreviewRequest {
1208 pub lower_left_x: u32,
1210 pub lower_left_y: u32,
1212 pub upper_right_x: u32,
1214 pub upper_right_y: u32,
1216 pub max_width: u32,
1219 pub max_height: u32,
1221 pub color: String,
1223 pub waypoints: Vec<RoutePreviewWaypoint>,
1225}
1226
1227pub async fn route_preview(
1248 _user: CurrentUser,
1249 Json(req): Json<RoutePreviewRequest>,
1250) -> Result<axum::response::Response, Error> {
1251 use axum::response::IntoResponse as _;
1252 use sl_map_apis::map_tiles::MapLike as _;
1253 validate_dimensions(req.max_width, req.max_height)?;
1254 let color = parse_color(&req.color)?;
1255 let rect = GridRectangle::new(
1256 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1257 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1258 );
1259 let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1262 let mut pixel_waypoints = Vec::with_capacity(req.waypoints.len());
1263 for waypoint in &req.waypoints {
1264 let grid = GridCoordinates::new(waypoint.region_x, waypoint.region_y);
1265 let region_coordinates = RegionCoordinates::new(waypoint.x, waypoint.y, 0f32);
1266 let (x, y) = map
1267 .pixel_coordinates_for_coordinates(&grid, ®ion_coordinates)
1268 .ok_or_else(|| {
1269 Error::BadRequest("a route waypoint falls outside the rectangle".to_owned())
1270 })?;
1271 #[expect(
1272 clippy::as_conversions,
1273 clippy::cast_precision_loss,
1274 reason = "matches draw_route_with_progress: pixel coordinates never approach 2^23"
1275 )]
1276 pixel_waypoints.push((x as f32, y as f32));
1277 }
1278 map.draw_pixel_waypoint_route(&pixel_waypoints, color)
1279 .map_err(|err| Error::BadRequest(format!("route rasterisation failed: {err}")))?;
1280 let image = encode_map(&map, OutputFormat::Png)?;
1283 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1284}
1285
1286#[derive(Debug, Clone, Deserialize)]
1288pub struct RegionOverlayPreviewRequest {
1289 pub lower_left_x: u32,
1291 pub lower_left_y: u32,
1293 pub upper_right_x: u32,
1295 pub upper_right_y: u32,
1297 pub max_width: u32,
1301 pub max_height: u32,
1303 #[serde(default)]
1305 pub draw_region_rectangles: bool,
1306 #[serde(default)]
1308 pub draw_region_names: bool,
1309 #[serde(default)]
1311 pub draw_region_coordinates: bool,
1312 #[serde(default)]
1314 pub region_label_font_id: Option<String>,
1315 #[serde(default)]
1320 pub missing_region_color: Option<String>,
1321}
1322
1323pub async fn region_overlay_preview(
1345 _user: CurrentUser,
1346 State(state): State<AppState>,
1347 Json(req): Json<RegionOverlayPreviewRequest>,
1348) -> Result<axum::response::Response, Error> {
1349 use axum::response::IntoResponse as _;
1350 use futures::StreamExt as _;
1351 validate_dimensions(req.max_width, req.max_height)?;
1352 let rect = GridRectangle::new(
1353 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1354 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1355 );
1356 let opts = RegionOverlayOptions {
1357 rectangles: req.draw_region_rectangles,
1358 names: req.draw_region_names,
1359 coordinates: req.draw_region_coordinates,
1360 };
1361 assert_region_overlay_font(&state, opts, req.region_label_font_id.as_deref())?;
1364 let font_id = req.region_label_font_id.clone();
1365 let missing_region_color = req
1366 .missing_region_color
1367 .as_deref()
1368 .map(parse_color)
1369 .transpose()?;
1370 let (max_width, max_height) = (req.max_width, req.max_height);
1371
1372 let (line_tx, line_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
1376 drop(tokio::spawn(run_region_overlay_preview_stream(
1377 state,
1378 rect,
1379 opts,
1380 font_id,
1381 missing_region_color,
1382 max_width,
1383 max_height,
1384 line_tx,
1385 )));
1386 let stream = ReceiverStream::new(line_rx).map(Ok::<Bytes, std::convert::Infallible>);
1387 let body = axum::body::Body::from_stream(stream);
1388 Ok((
1389 [(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
1390 body,
1391 )
1392 .into_response())
1393}
1394
1395#[expect(
1400 clippy::too_many_arguments,
1401 reason = "a one-shot helper carrying the preview request fields into the streaming task"
1402)]
1403async fn run_region_overlay_preview_stream(
1404 state: AppState,
1405 rect: GridRectangle,
1406 opts: RegionOverlayOptions,
1407 font_id: Option<String>,
1408 missing_region_color: Option<Rgba<u8>>,
1409 max_width: u32,
1410 max_height: u32,
1411 line_tx: tokio::sync::mpsc::Sender<Bytes>,
1412) {
1413 let (ev_tx, ev_rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
1414 let forwarder = tokio::spawn(forward_region_overlay_progress(ev_rx, line_tx.clone()));
1415 let result = async {
1416 let mut map = Map::blank_fit(rect, max_width, max_height)?;
1417 apply_region_overlay(
1418 &state,
1419 opts,
1420 font_id.as_deref(),
1421 missing_region_color,
1422 &mut map,
1423 Some(&ev_tx),
1424 )
1425 .await?;
1426 encode_map(&map, OutputFormat::Png)
1427 }
1428 .await;
1429 drop(ev_tx);
1430 let _join = forwarder.await;
1431 let final_line = match result {
1432 Ok(png) => {
1433 use base64::Engine as _;
1434 let data = base64::engine::general_purpose::STANDARD.encode(&png);
1435 serde_json::json!({ "type": "image", "data": data }).to_string()
1436 }
1437 Err(err) => serde_json::to_string(&ProgressDto::Error {
1438 message: err.to_string(),
1439 })
1440 .unwrap_or_else(|_| {
1441 r#"{"type":"error","message":"region overlay preview failed"}"#.to_owned()
1442 }),
1443 };
1444 drop(line_tx.send(Bytes::from(format!("{final_line}\n"))).await);
1445}
1446
1447async fn forward_region_overlay_progress(
1451 mut ev_rx: tokio::sync::mpsc::Receiver<MapProgressEvent>,
1452 line_tx: tokio::sync::mpsc::Sender<Bytes>,
1453) {
1454 while let Some(event) = ev_rx.recv().await {
1455 let Ok(json) = serde_json::to_string(&ProgressDto::from(event)) else {
1456 continue;
1457 };
1458 if line_tx
1459 .send(Bytes::from(format!("{json}\n")))
1460 .await
1461 .is_err()
1462 {
1463 break;
1464 }
1465 }
1466}
1467
1468#[expect(
1474 clippy::too_many_arguments,
1475 reason = "a one-shot helper shared by the two placement-preview endpoints"
1476)]
1477async fn render_placement_elements_png(
1478 state: &AppState,
1479 occupancy: &Map,
1480 target_rect: GridRectangle,
1481 max_width: u32,
1482 max_height: u32,
1483 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
1484 labels: &[TextLabel],
1485 logos: &[LogoPlacement],
1486) -> Result<Bytes, Error> {
1487 let (label_draws, label_slots) =
1488 plan_labels(&state.fonts, labels, legend_slot, &[], occupancy)?;
1489 let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, occupancy).await?;
1490 let mut target = Map::blank_fit(target_rect, max_width, max_height)?;
1491 execute_labels(&label_draws, &mut target);
1492 execute_logos(&logo_draws, &mut target);
1493 encode_map(&target, OutputFormat::Png)
1494}
1495
1496pub async fn placement_preview_grid_rectangle(
1508 user: CurrentUser,
1509 State(state): State<AppState>,
1510 Json(req): Json<GridRectangleRequest>,
1511) -> Result<axum::response::Response, Error> {
1512 use axum::response::IntoResponse as _;
1513 validate_dimensions(req.max_width, req.max_height)?;
1514 assert_can_read_logos(&state, user.user_id, &req.logos).await?;
1518 let rect = GridRectangle::new(
1519 GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1520 GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1521 );
1522 let mut occ = Map::blank_fit(rect.clone(), req.max_width, req.max_height)?;
1523 let legend_slot = if let Some(glw) = req.glw.as_ref() {
1524 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1525 legend_position_from_slot(glw.legend_slot.as_deref())?
1526 } else {
1527 None
1528 };
1529 let png = render_placement_elements_png(
1530 &state,
1531 &occ,
1532 rect,
1533 req.max_width,
1534 req.max_height,
1535 legend_slot,
1536 &req.labels,
1537 &req.logos,
1538 )
1539 .await?;
1540 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1541}
1542
1543pub async fn placement_preview_usb_notecard(
1554 user: CurrentUser,
1555 State(state): State<AppState>,
1556 multipart: Multipart,
1557) -> Result<axum::response::Response, Error> {
1558 use axum::response::IntoResponse as _;
1559 let parsed = parse_render_form(multipart).await?;
1560 assert_can_read_logos(&state, user.user_id, &parsed.logos).await?;
1564 let notecard: USBNotecard = match &parsed.notecard_source {
1565 NotecardSource::Fresh { text, .. } => text.parse()?,
1566 NotecardSource::Existing { notecard_id } => {
1567 let row =
1568 crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1569 .await?;
1570 row.body.parse()?
1571 }
1572 };
1573 let (border_north, border_south, border_east, border_west) = parsed.borders;
1574 let rect = {
1575 let mut region = state.region_cache.lock().await;
1576 usb_notecard_to_grid_rectangle(&mut region, ¬ecard).await?
1577 }
1578 .expanded_west(border_west)
1579 .expanded_east(border_east)
1580 .expanded_south(border_south)
1581 .expanded_north(border_north);
1582 let mut occ = Map::blank_fit(
1585 rect.clone(),
1586 parsed.common.max_width,
1587 parsed.common.max_height,
1588 )?;
1589 let legend_slot = if let Some(glw) = parsed.glw.as_ref() {
1590 apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1591 legend_position_from_slot(glw.legend_slot.as_deref())?
1592 } else {
1593 None
1594 };
1595 {
1596 let mut region = state.region_cache.lock().await;
1597 occ.draw_route_with_progress(&mut region, ¬ecard, parsed.color, None)
1598 .await?;
1599 }
1600 let png = render_placement_elements_png(
1601 &state,
1602 &occ,
1603 rect,
1604 parsed.common.max_width,
1605 parsed.common.max_height,
1606 legend_slot,
1607 &parsed.labels,
1608 &parsed.logos,
1609 )
1610 .await?;
1611 Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1612}
1613
1614const fn format_name(format: OutputFormat) -> &'static str {
1617 match format {
1618 OutputFormat::Png => "png",
1619 OutputFormat::Jpeg => "jpeg",
1620 }
1621}
1622
1623fn hex_from_rgba(rgba: Rgba<u8>) -> String {
1626 let Rgba(parts) = rgba;
1627 let r = parts.first().copied().unwrap_or(0);
1628 let g = parts.get(1).copied().unwrap_or(0);
1629 let b = parts.get(2).copied().unwrap_or(0);
1630 format!("#{r:02x}{g:02x}{b:02x}")
1631}
1632
1633fn parse_color(s: &str) -> Result<Rgba<u8>, Error> {
1635 let parsed = hex_color::HexColor::parse(s)
1636 .map_err(|e| Error::BadRequest(format!("invalid colour `{s}`: {e}")))?;
1637 Ok(Rgba(parsed.to_be_bytes()))
1638}
1639
1640struct ParsedRenderForm {
1642 notecard_source: NotecardSource,
1645 destination: Destination,
1647 borders: (u16, u16, u16, u16),
1649 color: Rgba<u8>,
1651 common: CommonParams,
1653 with_without_route: bool,
1655 glw: Option<GlwRenderOptions>,
1660 labels: Vec<TextLabel>,
1663 logos: Vec<LogoPlacement>,
1666 groups: Vec<Vec<String>>,
1670}
1671
1672enum NotecardSource {
1674 Existing {
1676 notecard_id: Uuid,
1678 },
1679 Fresh {
1683 text: String,
1685 name: String,
1687 },
1688}
1689
1690async fn parse_render_form(multipart: Multipart) -> Result<ParsedRenderForm, Error> {
1692 let mut form = crate::usb_notecard::NotecardForm::default();
1693 let mut color: Rgba<u8> = Rgba([0xff, 0x00, 0x00, 0xff]);
1694 let mut max_width: Option<u32> = None;
1695 let mut max_height: Option<u32> = None;
1696 let mut missing_map_tile_color: Option<Rgba<u8>> = None;
1697 let mut missing_region_color: Option<Rgba<u8>> = None;
1698 let mut format = OutputFormat::default();
1699 let mut with_without_route = false;
1700 let mut draw_region_rectangles = false;
1701 let mut draw_region_names = false;
1702 let mut draw_region_coordinates = false;
1703 let mut region_label_font_id: Option<String> = None;
1704 let mut notecard_text: Option<String> = None;
1705 let mut notecard_file: Option<String> = None;
1706 let mut notecard_id: Option<Uuid> = None;
1707 let mut destination_raw: Option<String> = None;
1708 let mut notecard_name: Option<String> = None;
1709 let mut glw: Option<GlwRenderOptions> = None;
1710 let mut labels: Vec<TextLabel> = Vec::new();
1711 let mut logos: Vec<LogoPlacement> = Vec::new();
1712 let mut groups: Vec<Vec<String>> = Vec::new();
1713 let mut multipart = multipart;
1714 while let Some(field) = multipart.next_field().await? {
1715 let Some(name) = field.name().map(str::to_owned) else {
1716 continue;
1717 };
1718 match name.as_str() {
1719 "notecard" => {
1720 let bytes = field.bytes().await?;
1721 if !bytes.is_empty() {
1722 let text = String::from_utf8(bytes.to_vec())
1723 .map_err(|e| Error::BadRequest(format!("notecard is not UTF-8: {e}")))?;
1724 notecard_file = Some(text);
1725 }
1726 }
1727 "notecard_text" => {
1728 let text = field.text().await?;
1729 if !text.trim().is_empty() {
1730 notecard_text = Some(text);
1731 }
1732 }
1733 "notecard_id" => {
1734 let raw = field.text().await?;
1735 let trimmed = raw.trim();
1736 if !trimmed.is_empty() {
1737 notecard_id = Some(
1738 Uuid::parse_str(trimmed)
1739 .map_err(|e| Error::BadRequest(format!("invalid notecard_id: {e}")))?,
1740 );
1741 }
1742 }
1743 "border_regions" => form.border_regions = parse_optional_u16(&field.text().await?)?,
1744 "border_north" => form.border_north = parse_optional_u16(&field.text().await?)?,
1745 "border_south" => form.border_south = parse_optional_u16(&field.text().await?)?,
1746 "border_east" => form.border_east = parse_optional_u16(&field.text().await?)?,
1747 "border_west" => form.border_west = parse_optional_u16(&field.text().await?)?,
1748 "color" => {
1749 let raw = field.text().await?;
1750 color = parse_color(raw.trim())?;
1751 }
1752 "max_width" => max_width = Some(parse_u32(&field.text().await?)?),
1753 "max_height" => max_height = Some(parse_u32(&field.text().await?)?),
1754 "missing_map_tile_color" => {
1755 let raw = field.text().await?;
1756 let trimmed = raw.trim();
1757 if !trimmed.is_empty() {
1758 missing_map_tile_color = Some(parse_color(trimmed)?);
1759 }
1760 }
1761 "missing_region_color" => {
1762 let raw = field.text().await?;
1763 let trimmed = raw.trim();
1764 if !trimmed.is_empty() {
1765 missing_region_color = Some(parse_color(trimmed)?);
1766 }
1767 }
1768 "format" => {
1769 let raw = field.text().await?;
1770 format = match raw.trim().to_ascii_lowercase().as_str() {
1771 "png" => OutputFormat::Png,
1772 "jpeg" | "jpg" => OutputFormat::Jpeg,
1773 other => {
1774 return Err(Error::BadRequest(format!("unknown format `{other}`")));
1775 }
1776 };
1777 }
1778 "save_without_route" => {
1779 let raw = field.text().await?;
1780 with_without_route = parse_form_bool(&raw);
1781 }
1782 "draw_region_rectangles" => {
1783 let raw = field.text().await?;
1784 draw_region_rectangles = parse_form_bool(&raw);
1785 }
1786 "draw_region_names" => {
1787 let raw = field.text().await?;
1788 draw_region_names = parse_form_bool(&raw);
1789 }
1790 "draw_region_coordinates" => {
1791 let raw = field.text().await?;
1792 draw_region_coordinates = parse_form_bool(&raw);
1793 }
1794 "region_label_font_id" => {
1795 let raw = field.text().await?;
1796 let trimmed = raw.trim();
1797 if !trimmed.is_empty() {
1798 region_label_font_id = Some(trimmed.to_owned());
1799 }
1800 }
1801 "save_to" => destination_raw = Some(field.text().await?),
1802 "notecard_name" => {
1803 let t = field.text().await?;
1804 if !t.trim().is_empty() {
1805 notecard_name = Some(t);
1806 }
1807 }
1808 "glw_json" => {
1809 let raw = field.text().await?;
1810 if !raw.trim().is_empty() {
1811 glw = Some(
1812 serde_json::from_str::<GlwRenderOptions>(&raw)
1813 .map_err(|e| Error::BadRequest(format!("invalid glw_json: {e}")))?,
1814 );
1815 }
1816 }
1817 "labels_json" => {
1818 let raw = field.text().await?;
1819 if !raw.trim().is_empty() {
1820 labels = serde_json::from_str::<Vec<TextLabel>>(&raw)
1821 .map_err(|e| Error::BadRequest(format!("invalid labels_json: {e}")))?;
1822 }
1823 }
1824 "logos_json" => {
1825 let raw = field.text().await?;
1826 if !raw.trim().is_empty() {
1827 logos = serde_json::from_str::<Vec<LogoPlacement>>(&raw)
1828 .map_err(|e| Error::BadRequest(format!("invalid logos_json: {e}")))?;
1829 }
1830 }
1831 "groups_json" => {
1832 let raw = field.text().await?;
1833 if !raw.trim().is_empty() {
1834 groups = serde_json::from_str::<Vec<Vec<String>>>(&raw)
1835 .map_err(|e| Error::BadRequest(format!("invalid groups_json: {e}")))?;
1836 }
1837 }
1838 _ => {}
1839 }
1840 }
1841 let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
1842 let max_width = max_width.ok_or_else(|| Error::BadRequest("missing max_width".to_owned()))?;
1843 let max_height =
1844 max_height.ok_or_else(|| Error::BadRequest("missing max_height".to_owned()))?;
1845 validate_dimensions(max_width, max_height)?;
1846 let borders = form.borders();
1847 let common = CommonParams {
1848 max_width,
1849 max_height,
1850 missing_map_tile_color,
1851 missing_region_color,
1852 format,
1853 region_overlay: RegionOverlayOptions {
1854 rectangles: draw_region_rectangles,
1855 names: draw_region_names,
1856 coordinates: draw_region_coordinates,
1857 },
1858 region_label_font_id,
1859 };
1860 let notecard_source = if let Some(id) = notecard_id {
1861 if notecard_file.is_some() || notecard_text.is_some() {
1862 return Err(Error::BadRequest(
1863 "supply either `notecard_id` or notecard text/file, not both".to_owned(),
1864 ));
1865 }
1866 NotecardSource::Existing { notecard_id: id }
1867 } else {
1868 let raw = notecard_file.or(notecard_text).ok_or_else(|| {
1869 Error::BadRequest(
1870 "supply `notecard_id`, `notecard` file upload, or `notecard_text`".to_owned(),
1871 )
1872 })?;
1873 let name = notecard_name.map_or_else(default_notecard_name, |n| n.trim().to_owned());
1874 NotecardSource::Fresh { text: raw, name }
1875 };
1876 Ok(ParsedRenderForm {
1877 notecard_source,
1878 destination,
1879 borders,
1880 color,
1881 common,
1882 with_without_route,
1883 glw,
1884 labels,
1885 logos,
1886 groups,
1887 })
1888}
1889
1890fn default_notecard_name() -> String {
1892 let now = Utc::now();
1893 format!("Uploaded {}", now.format("%Y-%m-%d %H:%M:%S UTC"))
1894}
1895
1896async fn resolve_notecard(
1905 state: &AppState,
1906 user: &CurrentUser,
1907 parsed: &ParsedRenderForm,
1908) -> Result<(USBNotecard, NotecardSummary), Error> {
1909 match &parsed.notecard_source {
1910 NotecardSource::Existing { notecard_id } => {
1911 let row =
1912 library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id).await?;
1913 let parsed_notecard: USBNotecard = row.body.parse()?;
1914 let notecard_scope = library::destination_from_columns(
1915 row.owner_user_id.clone(),
1916 row.owner_group_id.clone(),
1917 )?;
1918 let effective_id = if notecard_scope == parsed.destination {
1919 *notecard_id
1920 } else {
1921 notecard_routes::insert_notecard_row(
1922 state,
1923 parsed.destination,
1924 user.user_id,
1925 &row.name,
1926 &row.body,
1927 )
1928 .await?
1929 };
1930 Ok((
1931 parsed_notecard,
1932 NotecardSummary {
1933 notecard_id: effective_id,
1934 name: row.name,
1935 scope: parsed.destination.render_string(),
1936 },
1937 ))
1938 }
1939 NotecardSource::Fresh { text, name, .. } => {
1940 let parsed_notecard: USBNotecard = text.parse()?;
1941 let notecard_id = notecard_routes::insert_notecard_row(
1942 state,
1943 parsed.destination,
1944 user.user_id,
1945 name,
1946 text,
1947 )
1948 .await?;
1949 Ok((
1950 parsed_notecard,
1951 NotecardSummary {
1952 notecard_id,
1953 name: name.clone(),
1954 scope: parsed.destination.render_string(),
1955 },
1956 ))
1957 }
1958 }
1959}
1960
1961#[expect(
1967 clippy::too_many_arguments,
1968 reason = "every argument is a distinct row column; bundling them into a struct would just be noise at the single call sites"
1969)]
1970async fn insert_render_row(
1971 state: &AppState,
1972 render_id: Uuid,
1973 destination: Destination,
1974 created_by: Uuid,
1975 kind: &str,
1976 notecard_id: Option<Uuid>,
1977 settings: &SavedRenderSettings,
1978 bounds: Option<&GridRectangle>,
1979) -> Result<(), Error> {
1980 let (owner_user, owner_group) = match destination {
1981 Destination::Personal => (Some(created_by.as_bytes().to_vec()), None),
1982 Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
1983 };
1984 let now = Utc::now();
1985 let settings_json = serde_json::to_string(settings)?;
1986 let (ll_x, ll_y, ur_x, ur_y) = match bounds {
1987 Some(rect) => (
1988 Some(i64::from(rect.lower_left_corner().x())),
1989 Some(i64::from(rect.lower_left_corner().y())),
1990 Some(i64::from(rect.upper_right_corner().x())),
1991 Some(i64::from(rect.upper_right_corner().y())),
1992 ),
1993 None => (None, None, None, None),
1994 };
1995 sqlx::query(
1996 "INSERT INTO saved_renders \
1997 (render_id, owner_user_id, owner_group_id, created_by, notecard_id, kind, \
1998 status, settings_json, created_at, \
1999 lower_left_x, lower_left_y, upper_right_x, upper_right_y) \
2000 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'in_progress', ?7, ?8, ?9, ?10, ?11, ?12)",
2001 )
2002 .bind(render_id.as_bytes().to_vec())
2003 .bind(owner_user)
2004 .bind(owner_group)
2005 .bind(created_by.as_bytes().to_vec())
2006 .bind(notecard_id.map(|id| id.as_bytes().to_vec()))
2007 .bind(kind)
2008 .bind(settings_json)
2009 .bind(now)
2010 .bind(ll_x)
2011 .bind(ll_y)
2012 .bind(ur_x)
2013 .bind(ur_y)
2014 .execute(&state.db)
2015 .await
2016 .map_err(|err| {
2017 tracing::error!("insert saved_renders failed: {err}");
2018 Error::Database
2019 })?;
2020 Ok(())
2021}
2022
2023async fn update_notecard_bounds(
2033 db: &sqlx::SqlitePool,
2034 notecard_id: Uuid,
2035 rect: &GridRectangle,
2036) -> Result<(), Error> {
2037 sqlx::query(
2038 "UPDATE saved_notecards \
2039 SET lower_left_x = ?1, lower_left_y = ?2, \
2040 upper_right_x = ?3, upper_right_y = ?4 \
2041 WHERE notecard_id = ?5",
2042 )
2043 .bind(i64::from(rect.lower_left_corner().x()))
2044 .bind(i64::from(rect.lower_left_corner().y()))
2045 .bind(i64::from(rect.upper_right_corner().x()))
2046 .bind(i64::from(rect.upper_right_corner().y()))
2047 .bind(notecard_id.as_bytes().to_vec())
2048 .execute(db)
2049 .await
2050 .map_err(|err| {
2051 tracing::error!("update saved_notecards bounds failed: {err}");
2052 Error::Database
2053 })?;
2054 Ok(())
2055}
2056
2057async fn update_render_bounds(
2063 db: &sqlx::SqlitePool,
2064 render_id: Uuid,
2065 rect: &GridRectangle,
2066) -> Result<(), Error> {
2067 sqlx::query(
2068 "UPDATE saved_renders \
2069 SET lower_left_x = ?1, lower_left_y = ?2, \
2070 upper_right_x = ?3, upper_right_y = ?4 \
2071 WHERE render_id = ?5",
2072 )
2073 .bind(i64::from(rect.lower_left_corner().x()))
2074 .bind(i64::from(rect.lower_left_corner().y()))
2075 .bind(i64::from(rect.upper_right_corner().x()))
2076 .bind(i64::from(rect.upper_right_corner().y()))
2077 .bind(render_id.as_bytes().to_vec())
2078 .execute(db)
2079 .await
2080 .map_err(|err| {
2081 tracing::error!("update saved_renders bounds failed: {err}");
2082 Error::Database
2083 })?;
2084 Ok(())
2085}
2086
2087async fn finalize_render_row(state: &AppState, render_id: Uuid, outcome: &JobOutcome) {
2091 let now = Utc::now();
2092 match outcome {
2093 JobOutcome::Ok {
2094 image,
2095 image_without_route,
2096 content_type,
2097 metadata,
2098 glw_data_id,
2099 } => {
2100 let ext = storage::ext_for_content_type(content_type);
2101 let image_filename = match storage::write_render_file(
2102 &state.config.storage_dir,
2103 render_id,
2104 storage::IMAGE_SUFFIX,
2105 ext,
2106 image.clone(),
2107 )
2108 .await
2109 {
2110 Ok(f) => Some(f),
2111 Err(err) => {
2112 tracing::error!("write primary render image failed: {err}");
2113 update_failed(state, render_id, &format!("write image failed: {err}"), now)
2114 .await;
2115 return;
2116 }
2117 };
2118 let without_filename = if let Some(bytes) = image_without_route {
2119 match storage::write_render_file(
2120 &state.config.storage_dir,
2121 render_id,
2122 storage::IMAGE_WITHOUT_ROUTE_SUFFIX,
2123 ext,
2124 bytes.clone(),
2125 )
2126 .await
2127 {
2128 Ok(f) => Some(f),
2129 Err(err) => {
2130 tracing::warn!("write without-route render image failed: {err}");
2131 None
2132 }
2133 }
2134 } else {
2135 None
2136 };
2137 let metadata_json = match serde_json::to_string(metadata) {
2138 Ok(s) => s,
2139 Err(err) => {
2140 tracing::error!("serialize metadata failed: {err}");
2141 update_failed(
2142 state,
2143 render_id,
2144 &format!("serialize metadata failed: {err}"),
2145 now,
2146 )
2147 .await;
2148 return;
2149 }
2150 };
2151 let rewritten_settings_json = match glw_data_id {
2157 Some(id) => match rewrite_settings_json_with_saved_glw(state, render_id, *id).await
2158 {
2159 Ok(updated) => updated,
2160 Err(err) => {
2161 tracing::error!("could not rewrite settings_json with glw_data_id: {err}");
2162 None
2163 }
2164 },
2165 None => None,
2166 };
2167 let result = sqlx::query(
2168 "UPDATE saved_renders SET status = 'done', finished_at = ?1, \
2169 metadata_json = ?2, content_type = ?3, \
2170 image_filename = ?4, image_without_route_filename = ?5, \
2171 glw_data_id = COALESCE(?7, glw_data_id), \
2172 settings_json = COALESCE(?8, settings_json) \
2173 WHERE render_id = ?6",
2174 )
2175 .bind(now)
2176 .bind(metadata_json)
2177 .bind(*content_type)
2178 .bind(image_filename)
2179 .bind(without_filename)
2180 .bind(render_id.as_bytes().to_vec())
2181 .bind(glw_data_id.map(|id| id.as_bytes().to_vec()))
2182 .bind(rewritten_settings_json)
2183 .execute(&state.db)
2184 .await;
2185 if let Err(err) = result {
2186 tracing::error!("update saved_renders to done failed: {err}");
2187 state.library_cleanup_dirty.store(true, Ordering::Release);
2188 }
2189 }
2190 JobOutcome::Err(msg) => {
2191 update_failed(state, render_id, msg, now).await;
2192 }
2193 }
2194}
2195
2196async fn update_failed(
2198 state: &AppState,
2199 render_id: Uuid,
2200 message: &str,
2201 now: chrono::DateTime<Utc>,
2202) {
2203 if let Err(err) = sqlx::query(
2204 "UPDATE saved_renders SET status = 'failed', finished_at = ?1, error_message = ?2 \
2205 WHERE render_id = ?3",
2206 )
2207 .bind(now)
2208 .bind(message)
2209 .bind(render_id.as_bytes().to_vec())
2210 .execute(&state.db)
2211 .await
2212 {
2213 tracing::error!("update saved_renders to failed failed: {err}");
2214 }
2215}
2216
2217#[expect(
2219 clippy::too_many_arguments,
2220 reason = "this is a one-shot helper that wires together every form field"
2221)]
2222fn spawn_grid_rectangle_job(
2223 state: AppState,
2224 job_id: JobId,
2225 job: Arc<JobState>,
2226 rect: GridRectangle,
2227 common: CommonParams,
2228 glw_ctx: Option<GlwJobCtx>,
2229 labels: Vec<TextLabel>,
2230 logos: Vec<LogoPlacement>,
2231) {
2232 drop(tokio::spawn(async move {
2233 let result = run_grid_rectangle_job(
2234 state.clone(),
2235 Arc::clone(&job),
2236 rect,
2237 common,
2238 glw_ctx,
2239 labels,
2240 logos,
2241 )
2242 .await;
2243 let outcome = finish_job(&job, result).await;
2244 finalize_render_row(&state, job_id, &outcome).await;
2245 tracing::info!("render job {job_id} finished");
2246 }));
2247}
2248
2249#[expect(
2251 clippy::too_many_arguments,
2252 reason = "this is a one-shot helper that wires together every form field"
2253)]
2254fn spawn_usb_notecard_job(
2255 state: AppState,
2256 job_id: JobId,
2257 job: Arc<JobState>,
2258 notecard_id: Uuid,
2259 notecard: USBNotecard,
2260 borders: (u16, u16, u16, u16),
2261 route_color: Rgba<u8>,
2262 common: CommonParams,
2263 with_without_route: bool,
2264 glw_ctx: Option<GlwJobCtx>,
2265 labels: Vec<TextLabel>,
2266 logos: Vec<LogoPlacement>,
2267) {
2268 drop(tokio::spawn(async move {
2269 let result = run_usb_notecard_job(
2270 state.clone(),
2271 Arc::clone(&job),
2272 job_id,
2273 notecard_id,
2274 notecard,
2275 borders,
2276 route_color,
2277 common,
2278 with_without_route,
2279 glw_ctx,
2280 labels,
2281 logos,
2282 )
2283 .await;
2284 let outcome = finish_job(&job, result).await;
2285 finalize_render_row(&state, job_id, &outcome).await;
2286 tracing::info!("render job {job_id} finished");
2287 }));
2288}
2289
2290fn assert_region_overlay_font(
2301 state: &AppState,
2302 opts: RegionOverlayOptions,
2303 font_id: Option<&str>,
2304) -> Result<(), Error> {
2305 if !opts.any_text() {
2306 return Ok(());
2307 }
2308 let font_id = font_id.ok_or_else(|| {
2309 Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2310 })?;
2311 if state.fonts.path_for(font_id).is_none() {
2312 return Err(Error::BadRequest(format!(
2313 "unknown region label font_id `{font_id}`"
2314 )));
2315 }
2316 Ok(())
2317}
2318
2319const fn region_text_overlay_allowed(pixels_per_region: f32, region_count: usize) -> bool {
2325 pixels_per_region >= MIN_PIXELS_PER_REGION_FOR_REGION_LABELS
2326 && region_count <= MAX_REGIONS_FOR_REGION_LABELS
2327}
2328
2329async fn apply_region_overlay(
2340 state: &AppState,
2341 opts: RegionOverlayOptions,
2342 font_id: Option<&str>,
2343 missing_region_color: Option<Rgba<u8>>,
2344 map: &mut Map,
2345 progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
2346) -> Result<(), Error> {
2347 use sl_map_apis::map_tiles::MapLike as _;
2348 use sl_types::map::GridRectangleLike as _;
2349 if !opts.any() {
2350 return Ok(());
2351 }
2352 let pixels_per_region = map.pixels_per_region();
2357 let region_count =
2358 usize::try_from(u64::from(map.size_x()).saturating_mul(u64::from(map.size_y())))
2359 .unwrap_or(usize::MAX);
2360 let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
2361 if !run_loop {
2362 if opts.any_text() {
2363 tracing::info!(
2364 pixels_per_region,
2365 region_count,
2366 "skipping region name/coordinate overlay (regions too small or too many)"
2367 );
2368 }
2369 if opts.rectangles {
2371 draw_region_rectangles(map);
2372 }
2373 return Ok(());
2374 }
2375 let font_id = font_id.ok_or_else(|| {
2376 Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2377 })?;
2378 let font_path = state
2379 .fonts
2380 .path_for(font_id)
2381 .ok_or_else(|| Error::BadRequest(format!("unknown region label font_id `{font_id}`")))?;
2382 let font = sl_map_apis::text::load_font(font_path)?;
2383 let scale = ab_glyph::PxScale::from(
2384 (pixels_per_region * REGION_LABEL_FONT_FACTOR)
2385 .clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
2386 );
2387 let style = sl_map_apis::text::LabelStyle {
2388 scale,
2389 fg: Rgba([255, 255, 255, 255]),
2390 shadow: Rgba([0, 0, 0, 180]),
2391 align: sl_map_apis::coverage::HAlign::Left,
2392 };
2393 let total_regions = u32::try_from(region_count).unwrap_or(u32::MAX);
2397 if let Some(tx) = progress.filter(|_| opts.names) {
2398 drop(tx.try_send(MapProgressEvent::RegionNamesPlanned { total_regions }));
2399 }
2400 let mut resolved: u32 = 0;
2401 for x in map.x_range() {
2402 for y in map.y_range() {
2403 let grid = GridCoordinates::new(x, y);
2404 let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
2405 continue;
2406 };
2407 let mut region_name: Option<String> = None;
2412 let mut missing = false;
2413 if opts.names {
2414 let lookup = {
2415 let mut region = state.region_cache.lock().await;
2416 region.get_region_name(&grid).await
2417 };
2418 match lookup {
2419 Ok(Some(name)) => region_name = Some(name.to_string()),
2420 Ok(None) => missing = true,
2421 Err(err) => {
2422 tracing::debug!("region name lookup failed for {grid:?}: {err}");
2423 }
2424 }
2425 if let Some(tx) = progress {
2426 drop(tx.try_send(MapProgressEvent::RegionNameResolved {
2427 index: resolved,
2428 total: total_regions,
2429 }));
2430 }
2431 resolved = resolved.saturating_add(1);
2432 }
2433 if let Some(color) = missing_region_color.filter(|_| missing) {
2436 map.draw_filled_rect(left, top, width, height, color);
2437 }
2438 if opts.rectangles {
2439 map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2440 }
2441 let mut lines: Vec<String> = Vec::new();
2442 if opts.coordinates {
2443 lines.push(format!("({x}, {y})"));
2444 }
2445 if let Some(name) = region_name {
2446 lines.push(name);
2447 }
2448 if lines.is_empty() {
2449 continue;
2450 }
2451 let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
2452 let bottom = top.saturating_add(height);
2455 let origin_x = i32::try_from(left)
2456 .unwrap_or(0)
2457 .saturating_add(REGION_LABEL_PADDING);
2458 let origin_y = i32::try_from(bottom)
2459 .unwrap_or(0)
2460 .saturating_sub(REGION_LABEL_PADDING)
2461 .saturating_sub(i32::try_from(text_h).unwrap_or(0));
2462 map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
2463 }
2464 }
2465 Ok(())
2466}
2467
2468fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
2473 use sl_map_apis::map_tiles::MapLike as _;
2474 let (x0, y0) =
2475 map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(0f32, 0f32, 0f32))?;
2476 let (x1, y1) =
2477 map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(256f32, 256f32, 0f32))?;
2478 Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
2479}
2480
2481fn draw_region_rectangles(map: &mut Map) {
2483 use sl_map_apis::map_tiles::MapLike as _;
2484 use sl_types::map::GridRectangleLike as _;
2485 for x in map.x_range() {
2486 for y in map.y_range() {
2487 let grid = GridCoordinates::new(x, y);
2488 if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
2489 map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2490 }
2491 }
2492 }
2493}
2494
2495async fn run_grid_rectangle_job(
2497 state: AppState,
2498 job: Arc<JobState>,
2499 rect: GridRectangle,
2500 common: CommonParams,
2501 glw_ctx: Option<GlwJobCtx>,
2502 labels: Vec<TextLabel>,
2503 logos: Vec<LogoPlacement>,
2504) -> Result<JobOutcome, Error> {
2505 let metadata = build_metadata(&rect);
2506 let occ_rect = rect.clone();
2509 let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2510 let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2511 let mut map = {
2512 let mut cache = state.map_tile_cache.lock().await;
2513 Map::new_with_progress(
2514 &mut cache,
2515 common.max_width,
2516 common.max_height,
2517 rect,
2518 common.missing_map_tile_color,
2519 common.missing_region_color,
2520 Some(&tx),
2521 )
2522 .await?
2523 };
2524 apply_region_overlay(
2530 &state,
2531 common.region_overlay,
2532 common.region_label_font_id.as_deref(),
2533 None,
2536 &mut map,
2537 Some(&tx),
2538 )
2539 .await?;
2540 drop(tx);
2541 let _join = forwarder.await;
2544 let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2545 let (label_draws, logo_draws) = plan_placements(
2549 &state,
2550 occ_rect,
2551 &common,
2552 glw_ctx.as_ref(),
2553 None,
2554 &labels,
2555 &logos,
2556 )
2557 .await?;
2558 execute_labels(&label_draws, &mut map);
2559 execute_logos(&logo_draws, &mut map);
2560 let image = encode_map(&map, common.format)?;
2561 Ok(JobOutcome::Ok {
2562 image,
2563 image_without_route: None,
2564 content_type: common.format.content_type(),
2565 metadata,
2566 glw_data_id,
2567 })
2568}
2569
2570#[expect(
2572 clippy::too_many_arguments,
2573 reason = "this is a one-shot helper invoked from a single spawn site"
2574)]
2575async fn run_usb_notecard_job(
2576 state: AppState,
2577 job: Arc<JobState>,
2578 render_id: Uuid,
2579 notecard_id: Uuid,
2580 notecard: USBNotecard,
2581 borders: (u16, u16, u16, u16),
2582 route_color: Rgba<u8>,
2583 common: CommonParams,
2584 with_without_route: bool,
2585 glw_ctx: Option<GlwJobCtx>,
2586 labels: Vec<TextLabel>,
2587 logos: Vec<LogoPlacement>,
2588) -> Result<JobOutcome, Error> {
2589 let (bare_rect, rect) = resolve_notecard_rect(&state, ¬ecard, borders).await?;
2590 update_notecard_bounds(&state.db, notecard_id, &bare_rect).await?;
2594 update_render_bounds(&state.db, render_id, &rect).await?;
2597 let metadata = build_metadata(&rect);
2598 let occ_rect = rect.clone();
2601 let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2602 let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2603 let (image_without_route, map, glw_data_id) = {
2604 let mut map = {
2605 let mut cache = state.map_tile_cache.lock().await;
2606 Map::new_with_progress(
2607 &mut cache,
2608 common.max_width,
2609 common.max_height,
2610 rect,
2611 common.missing_map_tile_color,
2612 common.missing_region_color,
2613 Some(&tx),
2614 )
2615 .await?
2616 };
2617 let without_route = if with_without_route {
2620 Some(encode_map(&map, common.format)?)
2621 } else {
2622 None
2623 };
2624 apply_region_overlay(
2628 &state,
2629 common.region_overlay,
2630 common.region_label_font_id.as_deref(),
2631 None,
2635 &mut map,
2636 Some(&tx),
2637 )
2638 .await?;
2639 let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2643 {
2644 let mut region = state.region_cache.lock().await;
2645 map.draw_route_with_progress(&mut region, ¬ecard, route_color, Some(&tx))
2646 .await?;
2647 }
2648 let (label_draws, logo_draws) = plan_placements(
2654 &state,
2655 occ_rect,
2656 &common,
2657 glw_ctx.as_ref(),
2658 Some((¬ecard, route_color)),
2659 &labels,
2660 &logos,
2661 )
2662 .await?;
2663 execute_labels(&label_draws, &mut map);
2664 execute_logos(&logo_draws, &mut map);
2665 (without_route, map, glw_data_id)
2666 };
2667 drop(tx);
2668 let _join = forwarder.await;
2669 let image = encode_map(&map, common.format)?;
2670 Ok(JobOutcome::Ok {
2671 image,
2672 image_without_route,
2673 content_type: common.format.content_type(),
2674 metadata,
2675 glw_data_id,
2676 })
2677}
2678
2679async fn forward_events(job: Arc<JobState>, mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
2682 while let Some(event) = rx.recv().await {
2683 record_event(&job, ProgressDto::from(event)).await;
2684 }
2685}
2686
2687fn build_metadata(rect: &GridRectangle) -> Metadata {
2690 let aspect_x = rect.size_x();
2691 let aspect_y = rect.size_y();
2692 let aspect_ratio = f64::from(aspect_x) / f64::from(aspect_y);
2693 Metadata {
2694 aspect_x,
2695 aspect_y,
2696 aspect_ratio,
2697 pps_hud_config: rect.pps_hud_config(),
2698 }
2699}
2700
2701fn encode_map(map: &Map, format: OutputFormat) -> Result<Bytes, Error> {
2703 let mut buf: Vec<u8> = Vec::new();
2704 let mut cursor = Cursor::new(&mut buf);
2705 sl_map_apis::map_tiles::MapLike::image(map).write_to(&mut cursor, format.image_format())?;
2706 Ok(Bytes::from(buf))
2707}
2708
2709async fn finish_job(job: &Arc<JobState>, result: Result<JobOutcome, Error>) -> JobOutcome {
2713 let outcome = match result {
2714 Ok(o) => o,
2715 Err(e) => {
2716 let message = format!("{e}");
2717 record_event(
2718 job,
2719 ProgressDto::Error {
2720 message: message.clone(),
2721 },
2722 )
2723 .await;
2724 JobOutcome::Err(message)
2725 }
2726 };
2727 if matches!(outcome, JobOutcome::Ok { .. }) {
2728 record_event(job, ProgressDto::Done).await;
2729 }
2730 let arc = Arc::new(outcome.clone());
2731 drop(job.outcome.send_replace(Some(arc)));
2732 outcome
2733}
2734
2735fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), Error> {
2738 if max_width == 0 || max_height == 0 {
2739 return Err(Error::BadRequest(
2740 "max_width and max_height must be greater than zero".to_owned(),
2741 ));
2742 }
2743 if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
2744 return Err(Error::BadRequest(format!(
2745 "max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
2746 )));
2747 }
2748 if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
2751 return Err(Error::BadRequest(format!(
2752 "max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
2753 )));
2754 }
2755 Ok(())
2756}
2757
2758async fn assert_under_concurrent_limit(db: &sqlx::SqlitePool, user_id: Uuid) -> Result<(), Error> {
2765 let count: i64 = sqlx::query_scalar(
2766 "SELECT COUNT(*) FROM saved_renders \
2767 WHERE created_by = ?1 AND status = 'in_progress'",
2768 )
2769 .bind(user_id.as_bytes().to_vec())
2770 .fetch_one(db)
2771 .await
2772 .map_err(|err| {
2773 tracing::error!("count in-progress renders failed: {err}");
2774 Error::Database
2775 })?;
2776 if count >= MAX_CONCURRENT_RENDERS_PER_USER {
2777 return Err(Error::Forbidden(format!(
2778 "at most {MAX_CONCURRENT_RENDERS_PER_USER} renders may be in progress per user; \
2779 wait for one to finish"
2780 )));
2781 }
2782 Ok(())
2783}
2784
2785fn parse_optional_u16(s: &str) -> Result<Option<u16>, Error> {
2787 let trimmed = s.trim();
2788 if trimmed.is_empty() {
2789 return Ok(None);
2790 }
2791 trimmed
2792 .parse::<u16>()
2793 .map(Some)
2794 .map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
2795}
2796
2797fn parse_u32(s: &str) -> Result<u32, Error> {
2799 s.trim()
2800 .parse::<u32>()
2801 .map_err(|e| Error::BadRequest(format!("invalid u32 `{s}`: {e}")))
2802}
2803
2804fn parse_form_bool(raw: &str) -> bool {
2808 matches!(
2809 raw.trim().to_ascii_lowercase().as_str(),
2810 "1" | "on" | "true" | "yes"
2811 )
2812}
2813
2814struct GlwJobCtx {
2820 options: GlwRenderOptions,
2822 destination: Destination,
2825 created_by: Uuid,
2827}
2828
2829struct ResolvedGlwEvent {
2832 event: sl_glw::GlwEvent,
2834 glw_data_id: Uuid,
2836}
2837
2838async fn resolve_glw_event(
2856 state: &AppState,
2857 ctx: &GlwJobCtx,
2858) -> Result<Option<ResolvedGlwEvent>, Error> {
2859 match &ctx.options.source {
2860 GlwSource::SavedId { glw_data_id } => {
2861 let row =
2862 crate::library::assert_can_read_glw_data(&state.db, ctx.created_by, *glw_data_id)
2863 .await?;
2864 let glw_dest = crate::library::destination_from_columns(
2869 row.owner_user_id.clone(),
2870 row.owner_group_id.clone(),
2871 )?;
2872 if glw_dest != ctx.destination {
2873 return Err(Error::BadRequest(format!(
2874 "GLW data {glw_data_id} is not in the same library as this render; \
2875 copy it into the render's scope first"
2876 )));
2877 }
2878 let event: sl_glw::GlwEvent = serde_json::from_str(&row.payload_json)?;
2879 Ok(Some(ResolvedGlwEvent {
2880 event,
2881 glw_data_id: *glw_data_id,
2882 }))
2883 }
2884 GlwSource::EventId { event_id } => {
2885 let id = sl_glw::EventId::new(*event_id);
2886 let event_opt = {
2887 let mut cache = state.glw_event_cache.lock().await;
2888 cache.get_event_by_id(id).await?
2889 };
2890 let Some(event) = event_opt else {
2891 return Ok(None);
2892 };
2893 let now = Utc::now();
2894 let name = default_or_user_name(
2895 ctx.options.save_as.as_deref(),
2896 &format!(
2897 "Event {event_id} fetched {ts}",
2898 ts = now.format("%Y-%m-%d %H:%M UTC")
2899 ),
2900 );
2901 let payload_json = serde_json::to_string(&event)?;
2902 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2903 state,
2904 &crate::routes::glw::InsertGlwData {
2905 destination: ctx.destination,
2906 created_by: ctx.created_by,
2907 name: &name,
2908 source_kind: crate::library::GlwDataSourceKind::EventId,
2909 source_event_id: Some(*event_id),
2910 source_event_key: None,
2911 payload_json: &payload_json,
2912 event_id: Some(event.event_id.get()),
2913 event_key: Some(event.event_key.as_str()),
2914 event_name: Some(event.event_name.as_str()),
2915 fetched_at: now,
2916 },
2917 )
2918 .await?;
2919 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2920 }
2921 GlwSource::EventKey { event_key } => {
2922 let key = sl_glw::GlwEventKey::new(event_key);
2923 let event_opt = {
2924 let mut cache = state.glw_event_cache.lock().await;
2925 cache.get_event_by_key(&key).await?
2926 };
2927 let Some(event) = event_opt else {
2928 return Ok(None);
2929 };
2930 let now = Utc::now();
2931 let name = default_or_user_name(
2932 ctx.options.save_as.as_deref(),
2933 &format!(
2934 "Key \"{event_key}\" fetched {ts}",
2935 ts = now.format("%Y-%m-%d %H:%M UTC")
2936 ),
2937 );
2938 let payload_json = serde_json::to_string(&event)?;
2939 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2940 state,
2941 &crate::routes::glw::InsertGlwData {
2942 destination: ctx.destination,
2943 created_by: ctx.created_by,
2944 name: &name,
2945 source_kind: crate::library::GlwDataSourceKind::EventKey,
2946 source_event_id: None,
2947 source_event_key: Some(event_key.as_str()),
2948 payload_json: &payload_json,
2949 event_id: Some(event.event_id.get()),
2950 event_key: Some(event.event_key.as_str()),
2951 event_name: Some(event.event_name.as_str()),
2952 fetched_at: now,
2953 },
2954 )
2955 .await?;
2956 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2957 }
2958 GlwSource::PastedJson { payload } => {
2959 let event: sl_glw::GlwEvent = serde_json::from_str(payload)?;
2960 let now = Utc::now();
2961 let name = default_or_user_name(
2962 ctx.options.save_as.as_deref(),
2963 &format!("Pasted JSON {}", now.format("%Y-%m-%d %H:%M UTC")),
2964 );
2965 let payload_json = serde_json::to_string(&event)?;
2966 let glw_data_id = crate::routes::glw::insert_glw_data_row(
2967 state,
2968 &crate::routes::glw::InsertGlwData {
2969 destination: ctx.destination,
2970 created_by: ctx.created_by,
2971 name: &name,
2972 source_kind: crate::library::GlwDataSourceKind::PastedJson,
2973 source_event_id: None,
2974 source_event_key: None,
2975 payload_json: &payload_json,
2976 event_id: Some(event.event_id.get()),
2977 event_key: Some(event.event_key.as_str()),
2978 event_name: Some(event.event_name.as_str()),
2979 fetched_at: now,
2980 },
2981 )
2982 .await?;
2983 Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2984 }
2985 }
2986}
2987
2988fn default_or_user_name(supplied: Option<&str>, default: &str) -> String {
2991 let trimmed = supplied.map(str::trim).filter(|s| !s.is_empty());
2992 match trimmed {
2993 Some(s) => s.to_owned(),
2994 None => default.to_owned(),
2995 }
2996}
2997
2998async fn apply_glw_overlay_to_map(
3003 state: &AppState,
3004 ctx: Option<&GlwJobCtx>,
3005 map: &mut Map,
3006) -> Result<Option<Uuid>, Error> {
3007 use sl_glw::MapLikeGlwExt as _;
3008 let Some(ctx) = ctx else {
3009 return Ok(None);
3010 };
3011 let font_path = state
3014 .fonts
3015 .path_for(&ctx.options.font_id)
3016 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", ctx.options.font_id)))?;
3017 let font = sl_map_apis::text::load_font(font_path)?;
3018
3019 let resolved = resolve_glw_event(state, ctx).await?;
3020 let Some(resolved) = resolved else {
3021 tracing::warn!("GLW event not found (server returned no event); rendering without overlay");
3022 return Ok(None);
3023 };
3024 let style = build_glw_style(&ctx.options.style, ctx.options.legend_slot.as_deref())?;
3025 map.draw_glw_event_with_font(&resolved.event, &style, &font);
3026 Ok(Some(resolved.glw_data_id))
3027}
3028
3029fn build_glw_style(
3033 overrides: &GlwStyleOverrides,
3034 legend_slot: Option<&str>,
3035) -> Result<sl_glw::GlwStyle, Error> {
3036 let mut style = sl_glw::GlwStyle {
3037 legend_position: legend_position_from_slot(legend_slot)?,
3038 draw_margin_band: overrides.margin_band,
3039 ..sl_glw::GlwStyle::default()
3040 };
3041 if let Some(c) = overrides.area_outline_color.as_deref() {
3042 style.palette.area_outline = parse_color(c.trim())?;
3043 }
3044 if let Some(c) = overrides.circle_outline_color.as_deref() {
3045 style.palette.circle_outline = parse_color(c.trim())?;
3046 }
3047 if let Some(c) = overrides.margin_outline_color.as_deref() {
3048 style.palette.margin_outline = parse_color(c.trim())?;
3049 }
3050 if let Some(c) = overrides.wind_color.as_deref() {
3051 style.palette.wind_arrow = parse_color(c.trim())?;
3052 }
3053 if let Some(c) = overrides.current_color.as_deref() {
3054 style.palette.current_arrow = parse_color(c.trim())?;
3055 }
3056 if let Some(c) = overrides.wave_color.as_deref() {
3057 style.palette.wave_glyph = parse_color(c.trim())?;
3058 }
3059 if let Some(c) = overrides.label_color.as_deref() {
3060 style.palette.label_fg = parse_color(c.trim())?;
3061 }
3062 Ok(style)
3063}
3064
3065fn legend_position_from_slot(
3069 slot: Option<&str>,
3070) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, Error> {
3071 use sl_map_apis::coverage::PlacementSlot;
3072 let name = match slot {
3073 None => return Ok(Some(PlacementSlot::TopLeft)),
3074 Some(s) => s.trim(),
3075 };
3076 match name {
3077 "" => Ok(Some(PlacementSlot::TopLeft)),
3078 "none" => Ok(None),
3079 other => other
3080 .parse::<PlacementSlot>()
3081 .map(Some)
3082 .map_err(|err| Error::BadRequest(err.to_string())),
3083 }
3084}
3085
3086fn legend_slot_of(ctx: Option<&GlwJobCtx>) -> Option<sl_map_apis::coverage::PlacementSlot> {
3090 let ctx = ctx?;
3091 legend_position_from_slot(ctx.options.legend_slot.as_deref())
3092 .ok()
3093 .flatten()
3094}
3095
3096fn parse_h_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::HAlign>, Error> {
3099 use sl_map_apis::coverage::HAlign;
3100 match value.map(str::trim).filter(|s| !s.is_empty()) {
3101 None => Ok(None),
3102 Some("left") => Ok(Some(HAlign::Left)),
3103 Some("center") => Ok(Some(HAlign::Center)),
3104 Some("right") => Ok(Some(HAlign::Right)),
3105 Some(other) => Err(Error::BadRequest(format!("invalid h_align `{other}`"))),
3106 }
3107}
3108
3109fn parse_v_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::VAlign>, Error> {
3112 use sl_map_apis::coverage::VAlign;
3113 match value.map(str::trim).filter(|s| !s.is_empty()) {
3114 None => Ok(None),
3115 Some("top") => Ok(Some(VAlign::Top)),
3116 Some("center") => Ok(Some(VAlign::Center)),
3117 Some("bottom") => Ok(Some(VAlign::Bottom)),
3118 Some(other) => Err(Error::BadRequest(format!("invalid v_align `{other}`"))),
3119 }
3120}
3121
3122fn parse_slot_group(
3127 anchor: sl_map_apis::coverage::PlacementSlot,
3128 names: &[String],
3129) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3130 if names.is_empty() {
3131 return Ok(vec![anchor]);
3132 }
3133 let mut group: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::with_capacity(names.len());
3134 for name in names {
3135 let slot = name
3136 .parse::<sl_map_apis::coverage::PlacementSlot>()
3137 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3138 Error::BadRequest(err.to_string())
3139 })?;
3140 if !group.contains(&slot) {
3141 group.push(slot);
3142 }
3143 }
3144 if !group.contains(&anchor) {
3145 group.push(anchor);
3146 }
3147 if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
3148 let joined = group
3149 .iter()
3150 .map(|s| s.as_str())
3151 .collect::<Vec<_>>()
3152 .join("+");
3153 return Err(Error::BadRequest(format!(
3154 "the combined slot group `{joined}` does not form a solid rectangle"
3155 )));
3156 }
3157 Ok(group)
3158}
3159
3160fn resolve_placement(
3166 anchor: sl_map_apis::coverage::PlacementSlot,
3167 group: &[sl_map_apis::coverage::PlacementSlot],
3168 slots: &[sl_map_apis::coverage::PlacementSlotInfo],
3169 grid: &sl_map_apis::coverage::OccupancyGrid,
3170) -> Result<
3171 (
3172 Vec<sl_map_apis::coverage::PlacementSlot>,
3173 sl_map_apis::coverage::PixelRect,
3174 ),
3175 Error,
3176> {
3177 if group.len() > 1 {
3178 let rect = grid.subset_rect(group).ok_or_else(|| {
3179 Error::BadRequest(format!(
3180 "the combined slot at `{anchor}` is fully covered; no room for content"
3181 ))
3182 })?;
3183 Ok((group.to_vec(), rect))
3184 } else {
3185 let info = slots
3186 .iter()
3187 .find(|info| info.slot == anchor)
3188 .ok_or_else(|| Error::BadRequest(format!("slot `{anchor}` not found")))?;
3189 let rect = info.free_rect.ok_or_else(|| {
3190 Error::BadRequest(format!(
3191 "slot `{anchor}` is fully covered; no room for content"
3192 ))
3193 })?;
3194 Ok((vec![anchor], rect))
3195 }
3196}
3197
3198fn reserve(
3203 reserved: &[sl_map_apis::coverage::PlacementSlot],
3204 used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
3205 others: &[sl_map_apis::coverage::PlacementSlot],
3206 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3207 what: &str,
3208) -> Result<(), Error> {
3209 for &slot in reserved {
3210 if legend_slot == Some(slot) {
3211 return Err(Error::BadRequest(format!(
3212 "a {what} uses slot `{slot}` which is occupied by the legend"
3213 )));
3214 }
3215 if used.contains(&slot) || others.contains(&slot) {
3216 return Err(Error::BadRequest(format!(
3217 "two placements target the same slot `{slot}`"
3218 )));
3219 }
3220 used.push(slot);
3221 }
3222 Ok(())
3223}
3224
3225struct LabelDraw {
3227 lines: Vec<String>,
3229 font: ab_glyph::FontVec,
3231 style: sl_map_apis::text::LabelStyle,
3233 origin: (i32, i32),
3235}
3236
3237struct LogoDraw {
3240 img: image::RgbaImage,
3242 x: i64,
3244 y: i64,
3246}
3247
3248fn plan_labels(
3258 fonts: &crate::fonts::FontDirectory,
3259 labels: &[TextLabel],
3260 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3261 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3262 occupancy: &Map,
3263) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3264 let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3265 let mut draws: Vec<LabelDraw> = Vec::new();
3266 if labels.is_empty() {
3267 return Ok((draws, used));
3268 }
3269 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3271 occupancy,
3272 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3273 );
3274 let slots = grid.evaluate_slots();
3275 for label in labels {
3278 let lines: Vec<String> = label.lines.clone();
3279 if lines.iter().all(|line| line.trim().is_empty()) {
3280 continue;
3282 }
3283 if !(label.font_px.is_finite() && label.font_px > 0f32) {
3284 return Err(Error::BadRequest(format!(
3285 "label font size must be a positive number of pixels, got {}",
3286 label.font_px
3287 )));
3288 }
3289 let anchor: sl_map_apis::coverage::PlacementSlot =
3290 label
3291 .slot
3292 .parse()
3293 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3294 Error::BadRequest(err.to_string())
3295 })?;
3296 let group = parse_slot_group(anchor, &label.slots)?;
3297 let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3298 reserve(
3299 &reserved,
3300 &mut used,
3301 reserved_by_others,
3302 legend_slot,
3303 "label",
3304 )?;
3305 let font_path = fonts
3306 .path_for(&label.font_id)
3307 .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", label.font_id)))?;
3308 let font = sl_map_apis::text::load_font(font_path)?;
3309 let color = parse_color(label.color.trim())?;
3310 let scale = ab_glyph::PxScale::from(label.font_px);
3311 let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
3312 if text_w > rect.width || text_h > rect.height {
3313 return Err(Error::BadRequest(format!(
3314 "label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
3315 rect.width, rect.height
3316 )));
3317 }
3318 let (default_h, default_v) = anchor.default_alignment();
3321 let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
3322 let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
3323 let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
3324 let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
3325 draws.push(LabelDraw {
3326 lines,
3327 font,
3328 style: sl_map_apis::text::LabelStyle {
3329 scale,
3330 fg: color,
3331 shadow: Rgba([0, 0, 0, 180]),
3332 align: h,
3335 },
3336 origin: (
3337 i32::try_from(origin_x).unwrap_or(0),
3338 i32::try_from(origin_y).unwrap_or(0),
3339 ),
3340 });
3341 }
3342 Ok((draws, used))
3343}
3344
3345fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
3347 use sl_map_apis::map_tiles::MapLike as _;
3348 for d in draws {
3349 map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
3350 }
3351}
3352
3353#[cfg(test)]
3359fn draw_labels_on_map(
3360 fonts: &crate::fonts::FontDirectory,
3361 labels: &[TextLabel],
3362 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3363 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3364 map: &mut Map,
3365) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3366 let (draws, used) = plan_labels(fonts, labels, legend_slot, reserved_by_others, map)?;
3367 execute_labels(&draws, map);
3368 Ok(used)
3369}
3370
3371async fn plan_logos(
3388 state: &AppState,
3389 logos: &[LogoPlacement],
3390 legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3391 reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3392 occupancy: &Map,
3393) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3394 let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3395 let mut draws: Vec<LogoDraw> = Vec::new();
3396 if logos.is_empty() {
3397 return Ok((draws, used));
3398 }
3399 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3400 occupancy,
3401 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3402 );
3403 let slots = grid.evaluate_slots();
3404 for logo in logos {
3407 if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
3408 return Err(Error::BadRequest(format!(
3409 "logo scale must be 1, 2 or 4, got {}",
3410 logo.scale
3411 )));
3412 }
3413 let anchor: sl_map_apis::coverage::PlacementSlot =
3414 logo.slot
3415 .parse()
3416 .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3417 Error::BadRequest(err.to_string())
3418 })?;
3419 let group = parse_slot_group(anchor, &logo.slots)?;
3420 let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3421 reserve(
3422 &reserved,
3423 &mut used,
3424 reserved_by_others,
3425 legend_slot,
3426 "logo",
3427 )?;
3428 let row: Option<(String,)> =
3431 sqlx::query_as("SELECT image_filename FROM saved_logos WHERE logo_id = ?1")
3432 .bind(logo.logo_id.as_bytes().to_vec())
3433 .fetch_optional(&state.db)
3434 .await
3435 .map_err(|err| {
3436 tracing::error!("logo file lookup failed: {err}");
3437 Error::Database
3438 })?;
3439 let (image_filename,) = row
3440 .ok_or_else(|| Error::BadRequest(format!("logo {} no longer exists", logo.logo_id)))?;
3441 let bytes = storage::read_logo_file(&state.config.storage_dir, &image_filename).await?;
3442 let decoded = image::load_from_memory(&bytes).map_err(|e| {
3443 Error::BadRequest(format!("could not decode logo {}: {e}", logo.logo_id))
3444 })?;
3445 let mut rgba = decoded.to_rgba8();
3446 if logo.scale != 1 {
3447 let factor = u32::from(logo.scale);
3448 let (w, h) = (rgba.width(), rgba.height());
3449 rgba = image::imageops::resize(
3450 &rgba,
3451 w.saturating_mul(factor),
3452 h.saturating_mul(factor),
3453 image::imageops::FilterType::Nearest,
3454 );
3455 }
3456 let (w, h) = (rgba.width(), rgba.height());
3457 if w > rect.width || h > rect.height {
3458 return Err(Error::BadRequest(format!(
3459 "logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
3460 rect.width, rect.height
3461 )));
3462 }
3463 let (default_h, default_v) = anchor.default_alignment();
3464 let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
3465 let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
3466 let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
3467 let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
3468 draws.push(LogoDraw {
3469 img: rgba,
3470 x: i64::from(origin_x),
3471 y: i64::from(origin_y),
3472 });
3473 }
3474 Ok((draws, used))
3475}
3476
3477fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
3480 use sl_map_apis::map_tiles::MapLike as _;
3481 for d in draws {
3482 image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
3483 }
3484}
3485
3486async fn resolve_notecard_rect(
3490 state: &AppState,
3491 notecard: &USBNotecard,
3492 borders: (u16, u16, u16, u16),
3493) -> Result<(GridRectangle, GridRectangle), Error> {
3494 let bare_rect = {
3495 let mut region = state.region_cache.lock().await;
3496 usb_notecard_to_grid_rectangle(&mut region, notecard).await?
3497 };
3498 let (border_north, border_south, border_east, border_west) = borders;
3499 let rect = bare_rect
3500 .expanded_west(border_west)
3501 .expanded_east(border_east)
3502 .expanded_south(border_south)
3503 .expanded_north(border_north);
3504 Ok((bare_rect, rect))
3505}
3506
3507async fn plan_placements(
3519 state: &AppState,
3520 occ_rect: GridRectangle,
3521 common: &CommonParams,
3522 glw_ctx: Option<&GlwJobCtx>,
3523 route: Option<(&USBNotecard, Rgba<u8>)>,
3524 labels: &[TextLabel],
3525 logos: &[LogoPlacement],
3526) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), Error> {
3527 let legend_slot = legend_slot_of(glw_ctx);
3528 let occ = {
3529 let mut occ = Map::blank_fit(occ_rect, common.max_width, common.max_height)?;
3530 if let Some(ctx) = glw_ctx {
3531 apply_glw_overlay_readonly(state, ctx.created_by, &ctx.options, &mut occ).await?;
3532 }
3533 if let Some((notecard, color)) = route {
3534 let mut region = state.region_cache.lock().await;
3535 occ.draw_route_with_progress(&mut region, notecard, color, None)
3536 .await?;
3537 }
3538 occ
3539 };
3540 let (label_draws, label_slots) = plan_labels(&state.fonts, labels, legend_slot, &[], &occ)?;
3541 let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, &occ).await?;
3542 Ok((label_draws, logo_draws))
3543}
3544
3545async fn validate_logos(
3550 state: &AppState,
3551 current_user: Uuid,
3552 destination: Destination,
3553 logos: &[LogoPlacement],
3554) -> Result<Vec<Uuid>, Error> {
3555 let mut ids: Vec<Uuid> = Vec::new();
3556 for logo in logos {
3557 let row = library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3558 let logo_dest = library::destination_from_columns(
3559 row.owner_user_id.clone(),
3560 row.owner_group_id.clone(),
3561 )?;
3562 if logo_dest != destination {
3563 return Err(Error::BadRequest(format!(
3564 "logo {} is not in the same library as this render; copy it into the render's \
3565 scope first",
3566 logo.logo_id
3567 )));
3568 }
3569 if !ids.contains(&logo.logo_id) {
3570 ids.push(logo.logo_id);
3571 }
3572 }
3573 Ok(ids)
3574}
3575
3576async fn assert_can_read_logos(
3584 state: &AppState,
3585 current_user: Uuid,
3586 logos: &[LogoPlacement],
3587) -> Result<(), Error> {
3588 for logo in logos {
3589 library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3590 }
3591 Ok(())
3592}
3593
3594async fn link_render_logos_or_fail(
3601 state: &AppState,
3602 render_id: Uuid,
3603 logo_ids: &[Uuid],
3604) -> Result<(), Error> {
3605 if let Err(err) = link_render_logos(state, render_id, logo_ids).await {
3606 update_failed(
3607 state,
3608 render_id,
3609 &format!("linking render logos failed: {err}"),
3610 Utc::now(),
3611 )
3612 .await;
3613 return Err(err);
3614 }
3615 Ok(())
3616}
3617
3618async fn link_render_logos(
3621 state: &AppState,
3622 render_id: Uuid,
3623 logo_ids: &[Uuid],
3624) -> Result<(), Error> {
3625 for id in logo_ids {
3626 sqlx::query(
3627 "INSERT OR IGNORE INTO saved_render_logos (render_id, logo_id) VALUES (?1, ?2)",
3628 )
3629 .bind(render_id.as_bytes().to_vec())
3630 .bind(id.as_bytes().to_vec())
3631 .execute(&state.db)
3632 .await
3633 .map_err(|err| {
3634 tracing::error!("link render logo failed: {err}");
3635 Error::Database
3636 })?;
3637 }
3638 Ok(())
3639}
3640
3641async fn rewrite_settings_json_with_saved_glw(
3646 state: &AppState,
3647 render_id: Uuid,
3648 glw_data_id: Uuid,
3649) -> Result<Option<String>, Error> {
3650 let row: Option<(String,)> =
3651 sqlx::query_as("SELECT settings_json FROM saved_renders WHERE render_id = ?1")
3652 .bind(render_id.as_bytes().to_vec())
3653 .fetch_optional(&state.db)
3654 .await
3655 .map_err(|err| {
3656 tracing::error!("settings_json fetch failed: {err}");
3657 Error::Database
3658 })?;
3659 let Some((settings_json,)) = row else {
3660 return Ok(None);
3661 };
3662 let mut parsed: SavedRenderSettings = serde_json::from_str(&settings_json)?;
3663 let glw_slot: &mut Option<GlwRenderOptions> = match &mut parsed {
3664 SavedRenderSettings::GridRectangle(s) => &mut s.glw,
3665 SavedRenderSettings::UsbNotecard(s) => &mut s.glw,
3666 };
3667 let Some(opts) = glw_slot.as_mut() else {
3668 return Ok(None);
3669 };
3670 if matches!(opts.source, GlwSource::SavedId { .. }) {
3673 return Ok(None);
3674 }
3675 opts.source = GlwSource::SavedId { glw_data_id };
3676 let json = serde_json::to_string(&parsed)?;
3677 Ok(Some(json))
3678}
3679
3680#[cfg(test)]
3681mod placement_slots_tests {
3682 use super::*;
3683 use pretty_assertions::assert_eq;
3684
3685 fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
3686 let rect = GridRectangle::new(
3688 GridCoordinates::new(1000, 1000),
3689 GridCoordinates::new(1003, 1003),
3690 );
3691 Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
3692 }
3693
3694 #[test]
3695 fn empty_map_reports_nine_free_slots() -> Result<(), Box<dyn std::error::Error>> {
3696 let map = blank_map()?;
3697 let resp = compute_placement_slots(&map, &[]);
3698 assert_eq!(resp.slots.len(), 9);
3699 assert_eq!((resp.image_width, resp.image_height), (128, 128));
3700 for s in &resp.slots {
3703 assert!(s.available, "{} should be free", s.slot);
3704 }
3705 let top_left = resp
3706 .slots
3707 .iter()
3708 .find(|s| s.slot == "top_left")
3709 .ok_or("missing top_left slot")?;
3710 assert!(top_left.free_rect.is_some());
3711 Ok(())
3712 }
3713
3714 #[test]
3715 fn route_blocks_centre_slot() -> Result<(), Box<dyn std::error::Error>> {
3716 let mut map = blank_map()?;
3717 map.draw_pixel_waypoint_route(
3718 &[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
3719 Rgba([255, 0, 0, 255]),
3720 )?;
3721 let resp = compute_placement_slots(&map, &[]);
3722 let center = resp
3723 .slots
3724 .iter()
3725 .find(|s| s.slot == "center")
3726 .ok_or("missing center slot")?;
3727 assert!(!center.available);
3728 let top_right = resp
3729 .slots
3730 .iter()
3731 .find(|s| s.slot == "top_right")
3732 .ok_or("missing top_right slot")?;
3733 assert!(top_right.available);
3734 Ok(())
3735 }
3736
3737 #[test]
3738 fn requested_group_reports_combined_rect() -> Result<(), Box<dyn std::error::Error>> {
3739 use sl_map_apis::coverage::PlacementSlot as P;
3740 let map = blank_map()?;
3741 let resp = compute_placement_slots(&map, &[vec![P::TopLeft, P::TopCenter]]);
3742 assert_eq!(resp.groups.len(), 1);
3743 let g = resp.groups.first().ok_or("missing group")?;
3744 assert_eq!(g.slots, vec!["top_left", "top_center"]);
3745 assert!(g.available && g.free_rect.is_some());
3746 let tl = resp
3748 .slots
3749 .iter()
3750 .find(|s| s.slot == "top_left")
3751 .ok_or("missing top_left")?;
3752 assert!(g.free_width > tl.free_width);
3753 Ok(())
3754 }
3755}
3756
3757#[cfg(test)]
3758mod glw_preview_tests {
3759 use super::*;
3765 use pretty_assertions::assert_eq;
3766 use sl_glw::{GlwEvent, MapLikeGlwExt as _};
3767
3768 const SAMPLE_JSON: &str = r#"{
3771 "eventId": 6910,
3772 "eventName": "test cruise",
3773 "eventKey": "key cruise",
3774 "directorName": "LaliaCasau Resident",
3775 "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
3776 "base": {
3777 "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
3778 "waves": {
3779 "height": 1.5, "speed": 3, "length": 35,
3780 "heightVar": 5, "lengthVar": 5,
3781 "effects": { "speed": 1, "steer": 1 }
3782 },
3783 "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
3784 },
3785 "areas": {
3786 "area1": {
3787 "coordSW": { "x": 1133, "y": 1048 },
3788 "coordNE": { "x": 1135, "y": 1049 },
3789 "margin": 25, "overlap": 0,
3790 "currents": { "speed": 1, "dir": 225, "waterDepth": 8 }
3791 }
3792 },
3793 "circles": {
3794 "circle1": {
3795 "centerSim": { "x": 1136, "y": 1051 },
3796 "centerPoint": { "x": 90, "y": 175 },
3797 "radius": 127, "margin": 25, "overlap": 0,
3798 "wind": { "speed": 15 },
3799 "currents": { "speed": 0.1, "dir": 225, "waterDepth": 6 }
3800 }
3801 }
3802 }"#;
3803
3804 fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
3807 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
3808 let fonts = crate::fonts::FontDirectory::scan(root)?;
3809 let path = fonts
3810 .path_for("DejaVuSans.ttf")
3811 .ok_or("bundled DejaVuSans.ttf font is missing")?;
3812 Ok(sl_map_apis::text::load_font(path)?)
3813 }
3814
3815 #[test]
3820 fn blank_overlay_dimensions_match_client_bounds() -> Result<(), Box<dyn std::error::Error>> {
3821 let rect = GridRectangle::new(
3822 GridCoordinates::new(1130, 1045),
3823 GridCoordinates::new(1140, 1055),
3824 );
3825 let zoom = ZoomLevel::try_new(4)?;
3826 let map = Map::blank(rect, zoom);
3827 let (w, h) = image::GenericImageView::dimensions(&map);
3828 assert_eq!((w, h), (11 * 32, 11 * 32));
3830 for y in 0..h {
3833 for x in 0..w {
3834 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
3835 assert_eq!(a, 0, "blank overlay must start fully transparent");
3836 }
3837 }
3838 Ok(())
3839 }
3840
3841 fn top_left_has_pixels(map: &Map, n: u32) -> bool {
3846 for y in 0..n {
3847 for x in 0..n {
3848 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
3849 if a != 0 {
3850 return true;
3851 }
3852 }
3853 }
3854 false
3855 }
3856
3857 #[test]
3861 fn preview_overlay_omits_the_legend() -> Result<(), Box<dyn std::error::Error>> {
3862 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
3863 let font = test_font()?;
3864 let rect = GridRectangle::new(
3865 GridCoordinates::new(1130, 1045),
3866 GridCoordinates::new(1140, 1055),
3867 );
3868 let zoom = ZoomLevel::try_new(4)?;
3869
3870 let mut with_legend = Map::blank(rect.clone(), zoom);
3873 let style_with = build_glw_style(&GlwStyleOverrides::default(), Some("top_left"))?;
3874 with_legend.draw_glw_event_with_font(&event, &style_with, &font);
3875 assert!(
3876 top_left_has_pixels(&with_legend, 40),
3877 "the legend should fill the top-left corner when enabled"
3878 );
3879
3880 let mut without_legend = Map::blank(rect, zoom);
3883 let mut style_without = build_glw_style(&GlwStyleOverrides::default(), None)?;
3884 style_without.legend_position = None;
3885 without_legend.draw_glw_event_with_font(&event, &style_without, &font);
3886 assert!(
3887 !top_left_has_pixels(&without_legend, 40),
3888 "the preview overlay must not draw the legend"
3889 );
3890
3891 let (w, h) = image::GenericImageView::dimensions(&without_legend);
3893 let mut any = false;
3894 'outer: for y in 0..h {
3895 for x in 0..w {
3896 let image::Rgba([_, _, _, a]) =
3897 image::GenericImageView::get_pixel(&without_legend, x, y);
3898 if a != 0 {
3899 any = true;
3900 break 'outer;
3901 }
3902 }
3903 }
3904 assert!(any, "the GLW shapes should still be drawn");
3905 Ok(())
3906 }
3907}
3908
3909#[cfg(test)]
3910mod route_preview_tests {
3911 use super::*;
3918 use pretty_assertions::assert_eq;
3919 use sl_map_apis::map_tiles::MapLike as _;
3920
3921 const ROUTE_COLOR: Rgba<u8> = Rgba([255, 0, 0, 255]);
3925
3926 #[expect(
3930 clippy::as_conversions,
3931 clippy::cast_precision_loss,
3932 reason = "pixel coordinates are bounded by the output dimensions and never approach 2^23"
3933 )]
3934 fn widen(v: u32) -> f32 {
3935 v as f32
3936 }
3937
3938 fn pixel_for(map: &Map, rx: u32, ry: u32, x: f32, y: f32) -> Option<(f32, f32)> {
3941 let (px, py) = map.pixel_coordinates_for_coordinates(
3942 &GridCoordinates::new(rx, ry),
3943 &RegionCoordinates::new(x, y, 0f32),
3944 )?;
3945 Some((widen(px), widen(py)))
3946 }
3947
3948 fn distance_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
3950 let (px, py) = p;
3951 let (ax, ay) = a;
3952 let (bx, by) = b;
3953 let (dx, dy) = (bx - ax, by - ay);
3954 let len_sq = (dx * dx) + (dy * dy);
3955 let t = if len_sq <= f32::EPSILON {
3956 0f32
3957 } else {
3958 ((((px - ax) * dx) + ((py - ay) * dy)) / len_sq).clamp(0f32, 1f32)
3959 };
3960 let (cx, cy) = (ax + (t * dx), ay + (t * dy));
3961 ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
3962 }
3963
3964 #[test]
3968 fn route_drawn_in_colour_on_transparent_background() -> Result<(), Box<dyn std::error::Error>> {
3969 let rect = GridRectangle::new(
3970 GridCoordinates::new(1000, 1000),
3971 GridCoordinates::new(1010, 1010),
3972 );
3973 let mut map = Map::blank_fit(rect, 512, 512)?;
3974 let pixel_waypoints = vec![
3975 pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?,
3976 pixel_for(&map, 1009, 1009, 128f32, 128f32).ok_or("waypoint outside rect")?,
3977 ];
3978 map.draw_pixel_waypoint_route(&pixel_waypoints, ROUTE_COLOR)?;
3979
3980 let (w, h) = image::GenericImageView::dimensions(&map);
3981 let mut coloured = 0u32;
3982 let mut foreign = 0u32;
3983 for y in 0..h {
3984 for x in 0..w {
3985 let image::Rgba([r, g, b, a]) = image::GenericImageView::get_pixel(&map, x, y);
3986 if image::Rgba([r, g, b, a]) == ROUTE_COLOR {
3987 coloured += 1;
3988 } else if a != 0 {
3989 foreign += 1;
3991 }
3992 }
3993 }
3994 assert!(
3995 coloured > 0,
3996 "the route must be drawn in the requested colour"
3997 );
3998 assert_eq!(
3999 foreign, 0,
4000 "only the route colour and full transparency may appear"
4001 );
4002 Ok(())
4003 }
4004
4005 #[test]
4010 fn route_curves_between_waypoints() -> Result<(), Box<dyn std::error::Error>> {
4011 let rect = GridRectangle::new(
4012 GridCoordinates::new(1000, 1000),
4013 GridCoordinates::new(1012, 1012),
4014 );
4015 let mut map = Map::blank_fit(rect, 512, 512)?;
4016 let p0 = pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4019 let p1 = pixel_for(&map, 1006, 1006, 128f32, 128f32).ok_or("waypoint outside rect")?;
4020 let p2 = pixel_for(&map, 1011, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4021 map.draw_pixel_waypoint_route(&[p0, p1, p2], ROUTE_COLOR)?;
4022
4023 let (w, h) = image::GenericImageView::dimensions(&map);
4024 let mut max_off = 0f32;
4025 for y in 0..h {
4026 for x in 0..w {
4027 if image::GenericImageView::get_pixel(&map, x, y) == ROUTE_COLOR {
4028 let p = (widen(x), widen(y));
4029 let off = distance_to_segment(p, p0, p1).min(distance_to_segment(p, p1, p2));
4030 max_off = max_off.max(off);
4031 }
4032 }
4033 }
4034 assert!(
4035 max_off > 4f32,
4036 "the route should curve away from the straight chords (max off-chord \
4037 distance was {max_off:.1} px); a straight polyline would stay on them"
4038 );
4039 Ok(())
4040 }
4041}
4042
4043#[cfg(test)]
4044mod glw_legend_preview_tests {
4045 use super::*;
4051 use pretty_assertions::assert_eq;
4052 use sl_glw::{GlwEvent, MapLikeGlwExt as _};
4053
4054 const SAMPLE_JSON: &str = r#"{
4057 "eventId": 6910,
4058 "eventName": "test cruise",
4059 "eventKey": "key cruise",
4060 "directorName": "LaliaCasau Resident",
4061 "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
4062 "base": {
4063 "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
4064 "waves": {
4065 "height": 1.5, "speed": 3, "length": 35,
4066 "heightVar": 5, "lengthVar": 5,
4067 "effects": { "speed": 1, "steer": 1 }
4068 },
4069 "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
4070 },
4071 "areas": {},
4072 "circles": {}
4073 }"#;
4074
4075 fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
4077 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4078 let fonts = crate::fonts::FontDirectory::scan(root)?;
4079 let path = fonts
4080 .path_for("DejaVuSans.ttf")
4081 .ok_or("bundled DejaVuSans.ttf font is missing")?;
4082 Ok(sl_map_apis::text::load_font(path)?)
4083 }
4084
4085 fn corner_has_pixels(map: &Map, ox: u32, oy: u32, w: u32, h: u32) -> bool {
4088 for y in oy..oy.saturating_add(h) {
4089 for x in ox..ox.saturating_add(w) {
4090 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4091 if a != 0 {
4092 return true;
4093 }
4094 }
4095 }
4096 false
4097 }
4098
4099 #[test]
4103 fn legend_drawn_only_in_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
4104 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4105 let font = test_font()?;
4106 let rect = GridRectangle::new(
4107 GridCoordinates::new(1130, 1045),
4108 GridCoordinates::new(1140, 1055),
4109 );
4110 let mut map = Map::blank_fit(rect, 512, 512)?;
4111 let style = build_glw_style(&GlwStyleOverrides::default(), Some("bottom_right"))?;
4112 map.draw_glw_base_legend(&event.base, &style, &font);
4114 let (w, h) = image::GenericImageView::dimensions(&map);
4115 assert!(
4116 corner_has_pixels(&map, w.saturating_sub(80), h.saturating_sub(80), 80, 80),
4117 "the bottom_right legend should fill the bottom-right corner"
4118 );
4119 assert!(
4120 !corner_has_pixels(&map, 0, 0, 80, 80),
4121 "no legend should appear in the unselected top-left corner"
4122 );
4123 Ok(())
4124 }
4125
4126 #[test]
4129 fn legend_slot_none_draws_nothing() -> Result<(), Box<dyn std::error::Error>> {
4130 let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4131 let font = test_font()?;
4132 let rect = GridRectangle::new(
4133 GridCoordinates::new(1130, 1045),
4134 GridCoordinates::new(1140, 1055),
4135 );
4136 let mut map = Map::blank_fit(rect, 512, 512)?;
4137 let style = build_glw_style(&GlwStyleOverrides::default(), Some("none"))?;
4138 assert!(
4139 style.legend_position.is_none(),
4140 "the `none` slot must disable the legend"
4141 );
4142 if style.legend_position.is_some() {
4145 map.draw_glw_base_legend(&event.base, &style, &font);
4146 }
4147 let (w, h) = image::GenericImageView::dimensions(&map);
4148 for y in 0..h {
4149 for x in 0..w {
4150 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
4151 assert_eq!(a, 0, "a `none` legend slot must leave the map transparent");
4152 }
4153 }
4154 Ok(())
4155 }
4156}
4157
4158#[cfg(test)]
4159mod label_tests {
4160 use super::*;
4161 use pretty_assertions::assert_eq;
4162 use pretty_assertions::assert_matches;
4163 use sl_map_apis::coverage::{HAlign, PlacementSlot, VAlign};
4164
4165 fn test_fonts() -> Result<crate::fonts::FontDirectory, Box<dyn std::error::Error>> {
4168 let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4169 Ok(crate::fonts::FontDirectory::scan(root)?)
4170 }
4171
4172 fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
4174 let rect = GridRectangle::new(
4175 GridCoordinates::new(1130, 1130),
4176 GridCoordinates::new(1140, 1140),
4177 );
4178 Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
4179 }
4180
4181 fn label(slot: &str, font_px: f32, lines: &[&str]) -> TextLabel {
4182 TextLabel {
4183 slot: slot.to_owned(),
4184 lines: lines.iter().map(|s| (*s).to_owned()).collect(),
4185 font_id: "DejaVuSans.ttf".to_owned(),
4186 font_px,
4187 color: "#ffffff".to_owned(),
4188 h_align: None,
4189 v_align: None,
4190 slots: Vec::new(),
4191 }
4192 }
4193
4194 fn any_pixel_drawn(map: &Map) -> bool {
4195 let (w, h) = image::GenericImageView::dimensions(map);
4196 for y in 0..h {
4197 for x in 0..w {
4198 let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4199 if a != 0 {
4200 return true;
4201 }
4202 }
4203 }
4204 false
4205 }
4206
4207 fn opaque_map() -> Result<Map, Box<dyn std::error::Error>> {
4210 use image::GenericImage as _;
4211 use sl_map_apis::map_tiles::MapLike as _;
4212 let mut map = blank_map()?;
4213 let (w, h) = image::GenericImageView::dimensions(&map);
4214 for y in 0..h {
4215 for x in 0..w {
4216 map.image_mut().put_pixel(x, y, image::Rgba([5, 5, 5, 255]));
4217 }
4218 }
4219 Ok(map)
4220 }
4221
4222 #[test]
4227 fn labels_plan_on_overlay_then_draw_onto_opaque_target()
4228 -> Result<(), Box<dyn std::error::Error>> {
4229 let fonts = test_fonts()?;
4230 let lbls = [label("top_left", 18f32, &["Hi"])];
4231
4232 let occ = blank_map()?;
4235 let (draws, used) = plan_labels(&fonts, &lbls, None, &[], &occ)?;
4236 assert!(!draws.is_empty(), "the label should be planned");
4237 assert!(used.contains(&PlacementSlot::TopLeft));
4238 let mut target = opaque_map()?;
4239 execute_labels(&draws, &mut target);
4240 let (w, h) = image::GenericImageView::dimensions(&target);
4241 let mut changed = false;
4242 'outer: for y in 0..h {
4243 for x in 0..w {
4244 let image::Rgba([r, g, b, _]) = image::GenericImageView::get_pixel(&target, x, y);
4245 if (r, g, b) != (5, 5, 5) {
4246 changed = true;
4247 break 'outer;
4248 }
4249 }
4250 }
4251 assert!(changed, "the label must draw onto the opaque target");
4252
4253 let opaque_occ = opaque_map()?;
4255 assert!(
4256 plan_labels(&fonts, &lbls, None, &[], &opaque_occ).is_err(),
4257 "an opaque occupancy map leaves no room — the regression the split fixes"
4258 );
4259 Ok(())
4260 }
4261
4262 #[test]
4263 fn legend_position_from_slot_maps_names() -> Result<(), Box<dyn std::error::Error>> {
4264 use sl_map_apis::coverage::PlacementSlot as P;
4265 assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
4266 assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
4267 assert_eq!(legend_position_from_slot(Some("none"))?, None);
4268 assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
4269 assert_eq!(
4270 legend_position_from_slot(Some("bottom_right"))?,
4271 Some(P::BottomRight)
4272 );
4273 assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
4274 Ok(())
4275 }
4276
4277 #[test]
4278 fn slot_parsers_and_alignment() -> Result<(), Box<dyn std::error::Error>> {
4279 assert_eq!(
4280 "middle_left".parse::<PlacementSlot>()?,
4281 PlacementSlot::MiddleLeft
4282 );
4283 assert_matches!("nope".parse::<PlacementSlot>(), Err(_));
4284 assert_eq!(parse_h_align(None)?, None);
4285 assert_eq!(parse_h_align(Some("right"))?, Some(HAlign::Right));
4286 assert_eq!(parse_v_align(Some("bottom"))?, Some(VAlign::Bottom));
4287 assert_matches!(parse_h_align(Some("sideways")), Err(_));
4288 Ok(())
4289 }
4290
4291 #[test]
4292 fn small_label_draws_and_oversized_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
4293 let fonts = test_fonts()?;
4294
4295 let mut map = blank_map()?;
4296 draw_labels_on_map(
4297 &fonts,
4298 &[label("top_left", 18f32, &["Hi"])],
4299 None,
4300 &[],
4301 &mut map,
4302 )?;
4303 assert!(any_pixel_drawn(&map), "a fitting label should draw pixels");
4304
4305 let mut map2 = blank_map()?;
4307 let err = draw_labels_on_map(
4308 &fonts,
4309 &[label("center", 4000f32, &["BIG"])],
4310 None,
4311 &[],
4312 &mut map2,
4313 );
4314 assert_matches!(
4315 err,
4316 Err(Error::BadRequest(_)),
4317 "oversized label must be rejected"
4318 );
4319 Ok(())
4320 }
4321
4322 #[test]
4323 fn slot_collisions_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
4324 let fonts = test_fonts()?;
4325
4326 let mut map = blank_map()?;
4328 let dup = draw_labels_on_map(
4329 &fonts,
4330 &[
4331 label("top_right", 16f32, &["a"]),
4332 label("top_right", 16f32, &["b"]),
4333 ],
4334 None,
4335 &[],
4336 &mut map,
4337 );
4338 assert_matches!(
4339 dup,
4340 Err(Error::BadRequest(_)),
4341 "duplicate slot must be rejected"
4342 );
4343
4344 let mut map2 = blank_map()?;
4346 let clash = draw_labels_on_map(
4347 &fonts,
4348 &[label("top_left", 16f32, &["x"])],
4349 Some(PlacementSlot::TopLeft),
4350 &[],
4351 &mut map2,
4352 );
4353 assert_matches!(
4354 clash,
4355 Err(Error::BadRequest(_)),
4356 "legend collision must be rejected"
4357 );
4358 Ok(())
4359 }
4360
4361 #[test]
4362 fn blank_label_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
4363 let fonts = test_fonts()?;
4364 let mut map = blank_map()?;
4365 draw_labels_on_map(
4366 &fonts,
4367 &[label("center", 16f32, &["", " "])],
4368 None,
4369 &[],
4370 &mut map,
4371 )?;
4372 assert!(!any_pixel_drawn(&map), "an all-blank label draws nothing");
4373 Ok(())
4374 }
4375
4376 #[test]
4377 fn reserve_rejects_legend_duplicate_and_other_pool() -> Result<(), Box<dyn std::error::Error>> {
4378 let mut used = Vec::new();
4380 assert_matches!(
4381 reserve(
4382 &[PlacementSlot::TopLeft],
4383 &mut used,
4384 &[],
4385 Some(PlacementSlot::TopLeft),
4386 "label",
4387 ),
4388 Err(Error::BadRequest(_))
4389 );
4390
4391 let mut used = Vec::new();
4393 reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "label")?;
4394 assert_matches!(
4395 reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "logo"),
4396 Err(Error::BadRequest(_))
4397 );
4398
4399 let mut used = Vec::new();
4401 assert_matches!(
4402 reserve(
4403 &[PlacementSlot::Center],
4404 &mut used,
4405 &[PlacementSlot::Center],
4406 None,
4407 "logo",
4408 ),
4409 Err(Error::BadRequest(_))
4410 );
4411 Ok(())
4412 }
4413
4414 #[test]
4415 fn resolve_placement_single_vs_combined() -> Result<(), Box<dyn std::error::Error>> {
4416 let map = blank_map()?;
4417 let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
4418 &map,
4419 sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
4420 );
4421 let slots = grid.evaluate_slots();
4422
4423 let (reserved, rect) = resolve_placement(
4425 PlacementSlot::TopLeft,
4426 &[PlacementSlot::TopLeft],
4427 &slots,
4428 &grid,
4429 )?;
4430 assert_eq!(reserved, vec![PlacementSlot::TopLeft]);
4431 assert!(rect.width > 0 && rect.height > 0);
4432
4433 let group = vec![PlacementSlot::TopLeft, PlacementSlot::TopCenter];
4436 let (all, combined) = resolve_placement(PlacementSlot::TopLeft, &group, &slots, &grid)?;
4437 assert_eq!(all, group);
4438 assert!(combined.width > rect.width);
4439 Ok(())
4440 }
4441
4442 #[test]
4443 fn parse_slot_group_defaults_and_includes_anchor() -> Result<(), Box<dyn std::error::Error>> {
4444 use sl_map_apis::coverage::PlacementSlot as P;
4445 assert_eq!(parse_slot_group(P::TopLeft, &[])?, vec![P::TopLeft]);
4447 let g = parse_slot_group(
4449 P::TopLeft,
4450 &["top_center".to_owned(), "top_center".to_owned()],
4451 )?;
4452 assert_eq!(g, vec![P::TopCenter, P::TopLeft]);
4453 assert_matches!(parse_slot_group(P::TopLeft, &["nope".to_owned()]), Err(_));
4454 Ok(())
4455 }
4456
4457 #[test]
4458 fn parse_slot_group_rejects_non_rectangular_groups() {
4459 use sl_map_apis::coverage::PlacementSlot as P;
4460 assert_matches!(
4462 parse_slot_group(P::TopLeft, &["top_center".to_owned()]),
4463 Ok(_)
4464 );
4465 assert_matches!(
4467 parse_slot_group(P::TopLeft, &["bottom_right".to_owned()]),
4468 Err(_)
4469 );
4470 assert_matches!(
4472 parse_slot_group(
4473 P::TopLeft,
4474 &["top_center".to_owned(), "middle_left".to_owned()],
4475 ),
4476 Err(_)
4477 );
4478 }
4479
4480 #[test]
4481 fn parse_groups_rejects_non_rectangular_groups() {
4482 assert_matches!(
4484 parse_groups(&[vec!["top_left".to_owned(), "top_center".to_owned()]]),
4485 Ok(_)
4486 );
4487 assert_matches!(
4489 parse_groups(&[vec!["top_left".to_owned(), "bottom_left".to_owned()]]),
4490 Err(_)
4491 );
4492 }
4493}
4494
4495#[cfg(test)]
4496mod region_overlay_tests {
4497 use super::*;
4498 use pretty_assertions::assert_eq;
4499
4500 #[test]
4501 fn text_overlay_gate_requires_both_size_and_count() {
4502 assert!(region_text_overlay_allowed(
4504 MIN_PIXELS_PER_REGION_FOR_REGION_LABELS,
4505 MAX_REGIONS_FOR_REGION_LABELS
4506 ));
4507 assert!(!region_text_overlay_allowed(
4509 MIN_PIXELS_PER_REGION_FOR_REGION_LABELS - 1.0,
4510 1
4511 ));
4512 assert!(!region_text_overlay_allowed(
4514 256.0,
4515 MAX_REGIONS_FOR_REGION_LABELS + 1
4516 ));
4517 assert!(region_text_overlay_allowed(256.0, 4));
4519 }
4520
4521 #[test]
4522 fn rectangles_write_visible_pixels() -> Result<(), Box<dyn std::error::Error>> {
4523 let rect = GridRectangle::new(
4525 GridCoordinates::new(1000, 1000),
4526 GridCoordinates::new(1003, 1003),
4527 );
4528 let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4529 let before = (0..map.height())
4531 .flat_map(|y| (0..map.width()).map(move |x| (x, y)))
4532 .any(|(x, y)| {
4533 let image::Rgba([_, _, _, a]) = map.get_pixel(x, y);
4534 a != 0
4535 });
4536 assert!(!before, "blank map should start fully transparent");
4537
4538 draw_region_rectangles(&mut map);
4539
4540 let mut drawn = false;
4542 for y in 0..map.height() {
4543 for x in 0..map.width() {
4544 if map.get_pixel(x, y) == image::Rgba([255, 255, 255, 255]) {
4545 drawn = true;
4546 }
4547 }
4548 }
4549 assert!(
4550 drawn,
4551 "draw_region_rectangles should write opaque white pixels"
4552 );
4553 Ok(())
4554 }
4555
4556 #[test]
4557 fn missing_region_fill_paints_the_region_rect() -> Result<(), Box<dyn std::error::Error>> {
4558 use sl_map_apis::map_tiles::MapLike as _;
4559 let rect = GridRectangle::new(
4561 GridCoordinates::new(1000, 1000),
4562 GridCoordinates::new(1001, 1001),
4563 );
4564 let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4565 let grid = GridCoordinates::new(1000, 1000);
4568 let (left, top, width, height) =
4569 region_pixel_rect(&map, &grid).ok_or("region not in map")?;
4570 let fill = Rgba([0x19, 0x48, 0x5a, 0xff]);
4571 map.draw_filled_rect(left, top, width, height, fill);
4572
4573 assert_eq!(
4575 map.get_pixel(left + 1, top + 1),
4576 fill,
4577 "fill colour painted"
4578 );
4579 let other = region_pixel_rect(&map, &GridCoordinates::new(1001, 1001))
4581 .ok_or("other region not in map")?;
4582 let image::Rgba([_, _, _, a]) = map.get_pixel(other.0 + 1, other.1 + 1);
4583 assert_eq!(a, 0, "unfilled region stays transparent");
4584 Ok(())
4585 }
4586}