Skip to content

Commit

Permalink
backend, frontend: polished server-side generation of map pins
Browse files Browse the repository at this point in the history
  • Loading branch information
ElysaSrc committed Jun 22, 2024
1 parent 3941aa0 commit 6e789dc
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 94 deletions.
2 changes: 2 additions & 0 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ pub enum AppError {
Validation(String),
Database(sqlx::Error),
InvalidPagination,
Internal(Option<String>),
}

#[derive(FromRequest)]
Expand Down Expand Up @@ -256,6 +257,7 @@ impl IntoResponse for AppError {
},
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", None),
AppError::InvalidPagination => (StatusCode::BAD_REQUEST, "invalid_pagination", None),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", e),
};

let resp = (
Expand Down
144 changes: 98 additions & 46 deletions backend/src/api/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ use std::sync::Arc;

use crate::{
api::AppState,
models::{
category::{self, Category},
icon::Icon,
},
models::{category::Category, icon::Icon},
};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, Router},
Json,
};
use resvg::tiny_skia;
use serde_json::json;
use resvg::{tiny_skia, usvg, usvg::ImageHrefResolver};
use serde::Deserialize;
use usvg::ImageKind::{JPEG, PNG, SVG};

use sqlx::{pool::PoolConnection, Postgres};
use tiny_skia::{Pixmap, Transform};
use usvg::{Options, Tree};
use uuid::Uuid;

use super::{AppError, DbConn};
Expand Down Expand Up @@ -54,44 +54,66 @@ async fn get_icon(
) -> Result<Response, AppError> {
let (data, mime_type) = get_icon_internal(&state, hash, &mut conn).await?;

return Ok((
Ok((
StatusCode::OK,
[
("Content-Type", mime_type),
("Cache-Control", "public, max-age=31536000".to_owned()),
],
data,
)
.into_response());
.into_response())
}

#[derive(Deserialize, Debug)]
pub struct RenderQuery {
pub h: Option<u32>,
pub w: Option<u32>,
}

async fn get_pin(
State(state): State<AppState>,
DbConn(mut conn): DbConn,
Path(category_id): Path<Uuid>,
Query(query): Query<RenderQuery>,
Path(category_id): Path<String>,
) -> Result<Response, AppError> {
let category = Category::get(category_id, &mut conn).await?;
let border_color = category.border_color;
let fill_color = category.fill_color;
let icon_hash = category.icon_hash;
let (border_color, fill_color, icon_hash) = match category_id.as_str() {
"default" => ("#222222".to_owned(), "#9999FF".to_owned(), None),
category_id => {
// Parse category_id as Uuid
let category_id = Uuid::parse_str(category_id)
.map_err(|_| AppError::Validation("invalid_category_id".to_string()))?;

let category = Category::get(category_id, &mut conn).await?;
(
category.border_color,
category.fill_color,
category.icon_hash,
)
}
};

let icon = match icon_hash {
None => None,
Some(icon_hash) => Some(get_icon_internal(&state, icon_hash, &mut conn).await?),
};

if query.h > Some(100) || query.w > Some(100) {
return Err(AppError::Validation("invalid_size".to_string()));
}

let icon_svg = match icon.as_ref() {
None => "".to_owned(),
Some(_) => format!(
r#"
<image
x="9"
y="9"
width="26"
height="26"
xlink:href="icon"
/>
"#
),
Some(_) => r#"
<image
x="9"
y="9"
width="26"
height="26"
xlink:href="icon"
/>
"#
.to_owned(),
};
let pin_svg = format!(
r#"
Expand All @@ -116,31 +138,31 @@ async fn get_pin(
"#
);

use resvg::usvg;

let mut opt = usvg::Options::default();
let mut opt = Options::default();

// We know that our SVG won't have DataUrl hrefs, just return None for such case.
let resolve_data = Box::new(|_: &str, _: std::sync::Arc<Vec<u8>>, _: &usvg::Options| None);

let parsed_image = if let Some((data, mime)) = icon {
match mime.as_str() {
"image/png" => Some(usvg::ImageKind::PNG(Arc::new(data.clone()))),
"image/jpeg" => Some(usvg::ImageKind::JPEG(Arc::new(data.clone()))),
"image/png" => Some(PNG(Arc::new(data.clone()))),
"image/jpeg" => Some(JPEG(Arc::new(data.clone()))),
"image/svg+xml" => {
let tree: usvg::Tree =
usvg::Tree::from_data(&data, &usvg::Options::default()).unwrap();
Some(usvg::ImageKind::SVG(tree))
let tree: Tree = match Tree::from_data(&data, &Options::default()) {
Ok(tree) => tree,
Err(_) => {
return Err(AppError::Internal(Some("svg_renderer_failed".to_string())))
}
};
Some(SVG(tree))
}
_ => None,
}
} else {
None
};

// Here we handle xlink:href attribute as string,
// let's use already loaded Ferris image to match that string.
let resolve_string = Box::new(move |href: &str, _: &usvg::Options| {
let resolve_string = Box::new(move |href: &str, _: &Options| {
if let Some(parsed_image) = parsed_image.as_ref() {
match href {
"icon" => Some(parsed_image.clone()),
Expand All @@ -152,20 +174,50 @@ async fn get_pin(
});

// Assign new ImageHrefResolver option using our closures.
opt.image_href_resolver = usvg::ImageHrefResolver {
opt.image_href_resolver = ImageHrefResolver {
resolve_data,
resolve_string,
};

let tree = usvg::Tree::from_str(&pin_svg, &opt).unwrap();
let tree = match Tree::from_str(&pin_svg, &opt) {
Ok(tree) => tree,
Err(_) => return Err(AppError::Internal(Some("svg_renderer_failed".to_string()))),
};

let pixmap_size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
resvg::render(
&tree,
tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
let image_data = pixmap.encode_png().unwrap();

let (height, width, scale) = match (query.h, query.w) {
(Some(h), Some(w)) => (h, w, None),
(Some(h), None) => {
let scale = h as f32 / pixmap_size.height() as f32;
(h, (pixmap_size.width() as f32 * scale) as u32, Some(scale))
}
(None, Some(w)) => {
let scale = w as f32 / pixmap_size.width() as f32;
((pixmap_size.height() as f32 * scale) as u32, w, Some(scale))
}
_ => (38, 24, None),
};

let scale_x = scale.unwrap_or(width as f32 / pixmap_size.width() as f32);
let scale_y = scale.unwrap_or(height as f32 / pixmap_size.height() as f32);
let transform = Transform::from_scale(scale_x, scale_y);

let mut pixmap = match Pixmap::new(width, height) {
Some(pixmap) => pixmap,
None => {
return Err(AppError::Internal(Some(
"pixmap_creation_failed".to_string(),
)))
}
};

resvg::render(&tree, transform, &mut pixmap.as_mut());
let image_data = match pixmap.encode_png() {
Ok(image_data) => image_data,
Err(_) => return Err(AppError::Internal(Some("png_encoding_failed".to_string()))),
};

Ok((
StatusCode::OK,
[
Expand Down
4 changes: 1 addition & 3 deletions frontend/components/NominatimPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@
class="!h-80"
:coordinates="transformedCoordinates"
:locked="false"
fill-color="#9999FF"
border-color="#222222"
:icon-hash="null"
category-id="default"
:zoom="10"
/>
<small class="text-secondary flex ">Addresse : {{ results[0].display_name }}</small>
Expand Down
12 changes: 2 additions & 10 deletions frontend/components/SingleEntityMap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@
:callback-item="null"
:width="24"
:height="38"
:fill="props.fillColor"
:stroke="props.borderColor"
:category-id="categoryId"
:highlighted="false"
:icon-url="iconUrl"
/>
</ol-overlay>
</ol-map>
Expand All @@ -43,16 +41,10 @@ import type { Coordinate } from 'ol/coordinate'
const props = defineProps<{
coordinates: Coordinate
fillColor: string
borderColor: string
iconHash: string | null | undefined
categoryId: string
zoom: number
locked: boolean
}>()
const iconUrl = computed(() => {
return props.iconHash ? `/api/icons/${props.iconHash}` : null
})
</script>

<style scoped lang="scss">
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/viewer/FullResult.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
:coordinates="[props.entity.web_mercator_x, props.entity.web_mercator_y]"
:fill-color="state.getCategory(props.entity.category_id).fill_color"
:border-color="state.getCategory(props.entity.category_id).fill_color"
:icon-hash="state.getCategory(props.entity.category_id).icon_hash"
:category-id="props.entity.category_id"
:zoom="13"
:locked="true"
/>
Expand Down
4 changes: 1 addition & 3 deletions frontend/components/viewer/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@
:callback-item="entity"
:width="24"
:height="38"
:fill="entity.category.fill_color"
:stroke="entity.category.border_color"
:category-id="entity.category_id"
:highlighted="isEntityHighlighted(entity)"
:icon-url="`/api/icons/${entity.category.icon_hash}`"
@click="handleEntityClick"
/>
</ol-overlay>
Expand Down
34 changes: 3 additions & 31 deletions frontend/components/viewer/map/Marker.vue
Original file line number Diff line number Diff line change
@@ -1,49 +1,21 @@
<template>
<svg
<img
:class="{ highlighted: props.highlighted }"
class="map-marker"
:width="props.width"
:height="props.height"
:style="{
marginTop: `-${props.height}px`,
marginLeft: `-${props.width / 2}px`,
}"
version="1.1"
viewBox="0 0 43.921 66.94"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:src="`/api/icons/pin/${props.categoryId}?h=${props.height}&w=${props.width}`"
style="cursor: pointer"
@click="handleClick"
>
<path
d="m21.905 1.2688c-11.397-1.86e-5 -20.637 9.5307-20.636
21.287 0.00476 3.5178 0.85467 6.9796 2.4736 10.076 5.9268 10.527 12.063 21.068 18.111
31.572 5.8042-10.829 13.224-21.769 18.766-32.581
1.4143-2.9374 1.9205-5.7872 1.9231-9.0669 6.2e-5 -11.757-9.2392-21.287-20.636-21.287z"
:fill="props.fill"
:stroke="props.stroke"
stroke-width="2.5"
/>
<image
v-if="props.iconUrl && props.iconUrl.length > 0"
x="9"
y="9"
width="26"
height="26"
:href="props.iconUrl"
/>
</svg>
</template>

<script setup lang="ts" generic="T">
const props = defineProps<{
width: number
height: number
fill: string
stroke: string
categoryId: string
highlighted: boolean
iconUrl: string | null | undefined
callbackItem: T
}>()
Expand Down

0 comments on commit 6e789dc

Please sign in to comment.