perfect-postcode/server-rs/src/routes/tiles.rs

266 lines
8.7 KiB
Rust

use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use pmtiles::{AsyncPmTilesReader, MmapBackend, TileCoord};
use serde::Deserialize;
use tracing::warn;
pub type TileReader = AsyncPmTilesReader<MmapBackend>;
pub async fn get_tile(
State(reader): State<Arc<TileReader>>,
Path((zoom, col, row)): Path<(u8, u32, u32)>,
) -> Response {
let tile_coord = match TileCoord::new(zoom, col, row) {
Ok(tile_coord) => tile_coord,
Err(err) => {
warn!(zoom, col, row, error = %err, "Invalid tile coordinate");
return StatusCode::BAD_REQUEST.into_response();
}
};
match reader.get_tile(tile_coord).await {
Ok(Some(tile_bytes)) => (
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/x-protobuf"),
(header::CONTENT_ENCODING, "gzip"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
tile_bytes.to_vec(),
)
.into_response(),
Ok(None) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
warn!(zoom, col, row, error = %err, "Failed to get tile");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[derive(Deserialize)]
pub struct StyleParams {
theme: Option<String>,
}
pub async fn get_style(
State(reader): State<Arc<TileReader>>,
public_url: String,
Query(params): Query<StyleParams>,
) -> Result<Response, (StatusCode, String)> {
let is_dark = params.theme.as_deref() == Some("dark");
// Metadata is returned as a JSON string
let metadata_str = reader.get_metadata().await.map_err(|err| {
warn!(error = %err, "Failed to get PMTiles metadata");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to read tile metadata".to_string(),
)
})?;
// Parse the JSON string
let metadata: serde_json::Value = serde_json::from_str(&metadata_str).map_err(|err| {
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to parse tile metadata".to_string(),
)
})?;
// Extract tilestats for layer info if available
let layers: Vec<serde_json::Value> = metadata
.get("vector_layers")
.and_then(|vl| vl.as_array())
.cloned()
.unwrap_or_default();
// Build absolute tile URL using the configured public URL (not the Host header)
let tile_url = format!(
"{}/api/tiles/{{z}}/{{x}}/{{y}}",
public_url.trim_end_matches('/')
);
let style = build_style(is_dark, &layers, &tile_url);
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
serde_json::to_string(&style).unwrap(),
)
.into_response())
}
fn build_style(is_dark: bool, layers: &[serde_json::Value], tile_url: &str) -> serde_json::Value {
let (bg_color, water_color, land_color, road_color, text_color, text_halo) = if is_dark {
(
"#1a1a1a", "#193447", "#1a1a1a", "#2a2a2a", "#888888", "#000000",
)
} else {
(
"#f8f4f0", "#aad3df", "#f8f4f0", "#ffffff", "#333333", "#ffffff",
)
};
// Build layer list from metadata
let layer_ids: Vec<&str> = layers
.iter()
.filter_map(|ly| ly.get("id").and_then(|id| id.as_str()))
.collect();
let mut style_layers = vec![serde_json::json!({
"id": "background",
"type": "background",
"paint": { "background-color": bg_color }
})];
// Land/earth layer (must come before water so rivers render on top)
if layer_ids.contains(&"earth") {
style_layers.push(serde_json::json!({
"id": "earth",
"type": "fill",
"source": "protomaps",
"source-layer": "earth",
"paint": { "fill-color": land_color }
}));
}
// Landuse (parks, forests) - render before water
if layer_ids.contains(&"landuse") {
let park_color = if is_dark { "#2d4a2d" } else { "#c8e6c8" };
style_layers.push(serde_json::json!({
"id": "landuse-park",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": ["any",
["==", ["get", "pmap:kind"], "park"],
["==", ["get", "pmap:kind"], "nature_reserve"],
["==", ["get", "pmap:kind"], "forest"]
],
"paint": { "fill-color": park_color, "fill-opacity": 0.7 }
}));
}
// Water layer (after earth so rivers show on top of land)
if layer_ids.contains(&"water") {
style_layers.push(serde_json::json!({
"id": "water",
"type": "fill",
"source": "protomaps",
"source-layer": "water",
"paint": { "fill-color": water_color }
}));
}
// Roads
if layer_ids.contains(&"roads") {
let road_casing = if is_dark { "#111111" } else { "#cccccc" };
style_layers.extend(vec![
serde_json::json!({
"id": "roads-casing",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": ["!=", ["get", "pmap:kind"], "path"],
"paint": {
"line-color": road_casing,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 1, 18, 12]
}
}),
serde_json::json!({
"id": "roads",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": ["!=", ["get", "pmap:kind"], "path"],
"paint": {
"line-color": road_color,
"line-width": ["interpolate", ["linear"], ["zoom"], 10, 0.5, 18, 8]
}
}),
]);
}
// Buildings
if layer_ids.contains(&"buildings") {
let building_color = if is_dark { "#252525" } else { "#e8e4e0" };
style_layers.push(serde_json::json!({
"id": "buildings",
"type": "fill",
"source": "protomaps",
"source-layer": "buildings",
"minzoom": 14,
"paint": { "fill-color": building_color, "fill-opacity": 0.8 }
}));
}
// Waterway labels - this layer ID is used by deck.gl as an insertion point
// for interleaved layers (beforeId: 'waterway_label')
if layer_ids.contains(&"water") {
style_layers.push(serde_json::json!({
"id": "waterway_label",
"type": "symbol",
"source": "protomaps",
"source-layer": "water",
"filter": ["all", ["has", "name"], ["==", ["geometry-type"], "LineString"]],
"layout": {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": 10,
"symbol-placement": "line"
},
"paint": {
"text-color": water_color,
"text-halo-color": text_halo,
"text-halo-width": 1
}
}));
}
// Place labels
if layer_ids.contains(&"places") {
style_layers.push(serde_json::json!({
"id": "place-labels",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": ["has", "name"],
"layout": {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"],
6, ["match", ["get", "pmap:kind"], "city", 12, "town", 10, 8],
14, ["match", ["get", "pmap:kind"], "city", 24, "town", 18, 14]
],
"text-max-width": 8
},
"paint": {
"text-color": text_color,
"text-halo-color": text_halo,
"text-halo-width": 1.5
}
}));
}
serde_json::json!({
"version": 8,
"name": if is_dark { "Dark" } else { "Light" },
"glyphs": "/assets/fonts/{fontstack}/{range}.pbf",
"sources": {
"protomaps": {
"type": "vector",
"tiles": [tile_url],
"maxzoom": 15
}
},
"layers": style_layers
})
}
pub async fn init_tile_reader(path: &std::path::Path) -> anyhow::Result<TileReader> {
let backend = MmapBackend::try_from(path).await?;
let reader = AsyncPmTilesReader::try_from_source(backend).await?;
Ok(reader)
}