Skip to main content

oxigdal_server/handlers/
tiles.rs

1//! XYZ tile handlers
2//!
3//! Simple tile serving compatible with Leaflet, MapLibre, and other web mapping libraries.
4//! Provides a standard {z}/{x}/{y} endpoint for tile requests.
5
6use crate::cache::{CacheKey, TileCache};
7use crate::config::ImageFormat;
8use crate::dataset_registry::DatasetRegistry;
9use axum::{
10    extract::{Path, State},
11    http::{StatusCode, header},
12    response::{IntoResponse, Response},
13};
14use bytes::Bytes;
15use std::sync::Arc;
16use thiserror::Error;
17use tracing::{debug, trace};
18
19/// XYZ tile errors
20#[derive(Debug, Error)]
21pub enum TileError {
22    /// Layer not found
23    #[error("Layer not found: {0}")]
24    LayerNotFound(String),
25
26    /// Invalid coordinates
27    #[error("Invalid tile coordinates")]
28    InvalidCoordinates,
29
30    /// Tile out of bounds
31    #[error("Tile coordinates out of bounds")]
32    TileOutOfBounds,
33
34    /// Rendering error
35    #[error("Rendering error: {0}")]
36    Rendering(String),
37
38    /// Registry error
39    #[error("Registry error: {0}")]
40    Registry(#[from] crate::dataset_registry::RegistryError),
41
42    /// Unsupported format
43    #[error("Unsupported format: {0}")]
44    UnsupportedFormat(String),
45}
46
47impl IntoResponse for TileError {
48    fn into_response(self) -> Response {
49        let (status, message) = match self {
50            TileError::LayerNotFound(_) | TileError::TileOutOfBounds => {
51                (StatusCode::NOT_FOUND, self.to_string())
52            }
53            TileError::InvalidCoordinates => (StatusCode::BAD_REQUEST, self.to_string()),
54            _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
55        };
56
57        (status, [(header::CONTENT_TYPE, "text/plain")], message).into_response()
58    }
59}
60
61/// Shared tile server state
62#[derive(Clone)]
63pub struct TileState {
64    /// Dataset registry
65    pub registry: DatasetRegistry,
66
67    /// Tile cache
68    pub cache: TileCache,
69}
70
71/// Tile path parameters
72#[derive(Debug)]
73pub struct TilePath {
74    /// Layer name
75    pub layer: String,
76
77    /// Zoom level
78    pub z: u8,
79
80    /// Tile X coordinate
81    pub x: u32,
82
83    /// Tile Y coordinate
84    pub y: u32,
85
86    /// Image format (extension)
87    pub format: String,
88}
89
90/// Web Mercator tile bounds calculator
91pub struct WebMercatorBounds {
92    /// Zoom level
93    pub z: u8,
94
95    /// Tile X coordinate
96    pub x: u32,
97
98    /// Tile Y coordinate
99    pub y: u32,
100}
101
102impl WebMercatorBounds {
103    /// Create new bounds calculator
104    pub fn new(z: u8, x: u32, y: u32) -> Self {
105        Self { z, x, y }
106    }
107
108    /// Get the number of tiles at this zoom level
109    pub fn num_tiles(&self) -> u32 {
110        1 << self.z
111    }
112
113    /// Calculate the bounding box in Web Mercator coordinates
114    pub fn bbox(&self) -> (f64, f64, f64, f64) {
115        let n = self.num_tiles() as f64;
116        let size = 20037508.34278925 * 2.0;
117
118        let min_x = -20037508.34278925 + (self.x as f64 / n) * size;
119        let max_x = -20037508.34278925 + ((self.x + 1) as f64 / n) * size;
120        let min_y = 20037508.34278925 - ((self.y + 1) as f64 / n) * size;
121        let max_y = 20037508.34278925 - (self.y as f64 / n) * size;
122
123        (min_x, min_y, max_x, max_y)
124    }
125
126    /// Calculate bounding box in WGS84 (lon/lat)
127    pub fn bbox_wgs84(&self) -> (f64, f64, f64, f64) {
128        let (min_x, min_y, max_x, max_y) = self.bbox();
129
130        // Convert from Web Mercator to WGS84
131        let min_lon = (min_x / 20037508.34278925) * 180.0;
132        let max_lon = (max_x / 20037508.34278925) * 180.0;
133
134        let min_lat = (min_y / 20037508.34278925) * 180.0;
135        let min_lat =
136            (2.0 * min_lat.to_radians().exp().atan() - std::f64::consts::PI / 2.0).to_degrees();
137
138        let max_lat = (max_y / 20037508.34278925) * 180.0;
139        let max_lat =
140            (2.0 * max_lat.to_radians().exp().atan() - std::f64::consts::PI / 2.0).to_degrees();
141
142        (min_lon, min_lat, max_lon, max_lat)
143    }
144
145    /// Check if tile coordinates are valid for this zoom level
146    pub fn is_valid(&self) -> bool {
147        let max_tile = self.num_tiles();
148        self.x < max_tile && self.y < max_tile && self.z <= 30
149    }
150}
151
152/// Handle XYZ tile request
153pub async fn get_tile(
154    State(state): State<Arc<TileState>>,
155    Path((layer, z, x, y_with_ext)): Path<(String, u8, u32, String)>,
156) -> Result<Response, TileError> {
157    // Parse y coordinate and format from "y.ext"
158    let (y, format) = parse_y_and_format(&y_with_ext)?;
159
160    debug!("XYZ tile request: {}/{}/{}/{}.{}", layer, z, x, y, format);
161
162    // Validate coordinates
163    let bounds = WebMercatorBounds::new(z, x, y);
164    if !bounds.is_valid() {
165        return Err(TileError::InvalidCoordinates);
166    }
167
168    // Check cache first
169    let cache_key = CacheKey::new(layer.clone(), z, x, y, format.clone());
170
171    if let Some(cached_tile) = state.cache.get(&cache_key) {
172        trace!("Cache hit for tile: {}", cache_key.to_string());
173        let image_format = parse_format(&format)?;
174        return Ok((
175            StatusCode::OK,
176            [(header::CONTENT_TYPE, image_format.mime_type())],
177            cached_tile,
178        )
179            .into_response());
180    }
181
182    // Get layer
183    let layer_info = state.registry.get_layer(&layer)?;
184
185    // Validate zoom level
186    if z < layer_info.config.min_zoom || z > layer_info.config.max_zoom {
187        return Err(TileError::TileOutOfBounds);
188    }
189
190    // Parse image format
191    let image_format = parse_format(&format)?;
192
193    // Check if format is supported by this layer
194    if !layer_info.config.formats.contains(&image_format) {
195        return Err(TileError::UnsupportedFormat(format.clone()));
196    }
197
198    // Get dataset
199    let dataset = state.registry.get_dataset(&layer)?;
200
201    // Render tile
202    let tile_data = render_tile(&dataset, &bounds, layer_info.config.tile_size, image_format)?;
203
204    // Cache the tile
205    let _ = state.cache.put(cache_key, tile_data.clone());
206
207    Ok((
208        StatusCode::OK,
209        [(header::CONTENT_TYPE, image_format.mime_type())],
210        tile_data,
211    )
212        .into_response())
213}
214
215/// Parse y coordinate and format from string like "123.png"
216fn parse_y_and_format(y_with_ext: &str) -> Result<(u32, String), TileError> {
217    let parts: Vec<&str> = y_with_ext.rsplitn(2, '.').collect();
218
219    if parts.len() != 2 {
220        return Err(TileError::InvalidCoordinates);
221    }
222
223    let format = parts[0].to_string();
224    let y = parts[1]
225        .parse::<u32>()
226        .map_err(|_| TileError::InvalidCoordinates)?;
227
228    Ok((y, format))
229}
230
231/// Parse image format from file extension
232fn parse_format(ext: &str) -> Result<ImageFormat, TileError> {
233    ext.parse::<ImageFormat>()
234        .map_err(|_| TileError::UnsupportedFormat(ext.to_string()))
235}
236
237/// Render a tile from the dataset
238fn render_tile(
239    dataset: &Arc<crate::dataset_registry::Dataset>,
240    bounds: &WebMercatorBounds,
241    tile_size: u32,
242    format: ImageFormat,
243) -> Result<Bytes, TileError> {
244    debug!(
245        "Rendering tile: z={}, x={}, y={}, size={}x{}, format={:?}",
246        bounds.z, bounds.x, bounds.y, tile_size, tile_size, format
247    );
248
249    // Get dataset metadata
250    let (raster_width, raster_height) = dataset.raster_size();
251    let _band_count = dataset.raster_count();
252
253    // Calculate geographic bounds from Web Mercator
254    let tile_bounds = calculate_tile_geographic_bounds(bounds);
255
256    // Get dataset geotransform to map geographic to pixel coordinates
257    let geotransform = dataset
258        .geotransform()
259        .map_err(|e| TileError::Rendering(e.to_string()))?;
260
261    // Calculate pixel window in source dataset
262    let (src_x, src_y, src_width, src_height) =
263        calculate_pixel_window(&tile_bounds, &geotransform, raster_width, raster_height);
264
265    debug!(
266        "Pixel window: x={}, y={}, w={}, h={}",
267        src_x, src_y, src_width, src_height
268    );
269
270    // Create image buffer (RGBA)
271    let mut img_buffer = vec![0u8; (tile_size * tile_size * 4) as usize];
272
273    // Create a checkerboard pattern based on coordinates
274    let checker_size = tile_size / 8;
275    for y in 0..tile_size {
276        for x in 0..tile_size {
277            let idx = ((y * tile_size + x) * 4) as usize;
278
279            let checker_x = (x / checker_size) % 2;
280            let checker_y = (y / checker_size) % 2;
281            let is_dark = (checker_x + checker_y) % 2 == 0;
282
283            let base_color: u8 = if is_dark { 100 } else { 200 };
284
285            // Add some variation based on tile coordinates (using saturating_add to avoid overflow)
286            let r = base_color.saturating_add((bounds.x % 50) as u8);
287            let g = base_color.saturating_add((bounds.y % 50) as u8);
288            let b = base_color.saturating_add(bounds.z.saturating_mul(10));
289
290            img_buffer[idx] = r;
291            img_buffer[idx + 1] = g;
292            img_buffer[idx + 2] = b;
293            img_buffer[idx + 3] = 255;
294        }
295    }
296
297    // Encode based on format
298    let encoded = match format {
299        ImageFormat::Png => encode_png(&img_buffer, tile_size, tile_size)?,
300        ImageFormat::Jpeg => encode_jpeg(&img_buffer, tile_size, tile_size)?,
301        ImageFormat::Webp => {
302            return Err(TileError::UnsupportedFormat(
303                "WebP not yet supported".to_string(),
304            ));
305        }
306        ImageFormat::Geotiff => {
307            return Err(TileError::UnsupportedFormat(
308                "GeoTIFF not supported for tiles".to_string(),
309            ));
310        }
311    };
312
313    Ok(Bytes::from(encoded))
314}
315
316/// Encode image as PNG
317fn encode_png(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, TileError> {
318    let mut output = Vec::new();
319    {
320        let mut encoder = png::Encoder::new(&mut output, width, height);
321        encoder.set_color(png::ColorType::Rgba);
322        encoder.set_depth(png::BitDepth::Eight);
323
324        let mut writer = encoder
325            .write_header()
326            .map_err(|e| TileError::Rendering(e.to_string()))?;
327
328        writer
329            .write_image_data(data)
330            .map_err(|e| TileError::Rendering(e.to_string()))?;
331    }
332
333    Ok(output)
334}
335
336/// Encode image as JPEG
337fn encode_jpeg(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, TileError> {
338    // Convert RGBA to RGB
339    let rgb_data: Vec<u8> = data
340        .chunks(4)
341        .flat_map(|rgba| &rgba[0..3])
342        .copied()
343        .collect();
344
345    let mut jpeg_buffer = Vec::new();
346    let mut encoder = jpeg_encoder::Encoder::new(&mut jpeg_buffer, 90);
347    encoder.set_progressive(true);
348    encoder
349        .encode(
350            &rgb_data,
351            width as u16,
352            height as u16,
353            jpeg_encoder::ColorType::Rgb,
354        )
355        .map_err(|e| TileError::Rendering(e.to_string()))?;
356
357    Ok(jpeg_buffer)
358}
359
360/// Handle tile metadata request (TileJSON format)
361pub async fn get_tilejson(
362    State(state): State<Arc<TileState>>,
363    Path(layer): Path<String>,
364) -> Result<Response, TileError> {
365    debug!("TileJSON request for layer: {}", layer);
366
367    // Get layer info
368    let layer_info = state.registry.get_layer(&layer)?;
369
370    // Generate TileJSON
371    let tilejson = serde_json::json!({
372        "tilejson": "2.2.0",
373        "name": layer_info.title,
374        "description": layer_info.abstract_,
375        "version": "1.0.0",
376        "scheme": "xyz",
377        "tiles": [
378            format!("/tiles/{}/{{z}}/{{x}}/{{y}}.png", layer)
379        ],
380        "minzoom": layer_info.config.min_zoom,
381        "maxzoom": layer_info.config.max_zoom,
382        "bounds": layer_info.metadata.bbox.map(|(min_x, min_y, max_x, max_y)| {
383            vec![min_x, min_y, max_x, max_y]
384        }).unwrap_or_else(|| vec![-180.0, -85.0511, 180.0, 85.0511]),
385        "center": layer_info.metadata.bbox.map(|(min_x, min_y, max_x, max_y)| {
386            let center_lon = (min_x + max_x) / 2.0;
387            let center_lat = (min_y + max_y) / 2.0;
388            let zoom = layer_info.config.min_zoom +
389                       ((layer_info.config.max_zoom - layer_info.config.min_zoom) / 2);
390            vec![center_lon, center_lat, zoom as f64]
391        }),
392    });
393
394    Ok((
395        StatusCode::OK,
396        [(header::CONTENT_TYPE, "application/json")],
397        serde_json::to_string_pretty(&tilejson)
398            .map_err(|e: serde_json::Error| TileError::Rendering(e.to_string()))?,
399    )
400        .into_response())
401}
402
403/// Calculate geographic bounds from Web Mercator tile coordinates
404fn calculate_tile_geographic_bounds(bounds: &WebMercatorBounds) -> GeographicBounds {
405    const EARTH_RADIUS: f64 = 6378137.0; // WGS84 Earth radius in meters
406
407    // Calculate Web Mercator bounds
408    let tile_size_meters = 2.0 * std::f64::consts::PI * EARTH_RADIUS / (1 << bounds.z) as f64;
409    let min_x_meters = bounds.x as f64 * tile_size_meters - std::f64::consts::PI * EARTH_RADIUS;
410    let max_y_meters = std::f64::consts::PI * EARTH_RADIUS - bounds.y as f64 * tile_size_meters;
411
412    let max_x_meters = min_x_meters + tile_size_meters;
413    let min_y_meters = max_y_meters - tile_size_meters;
414
415    // Convert Web Mercator to WGS84 latitude/longitude
416    let min_lon = (min_x_meters / EARTH_RADIUS).to_degrees();
417    let max_lon = (max_x_meters / EARTH_RADIUS).to_degrees();
418
419    let min_lat = ((std::f64::consts::PI / 2.0)
420        - 2.0 * ((-min_y_meters / EARTH_RADIUS).exp()).atan())
421    .to_degrees();
422    let max_lat = ((std::f64::consts::PI / 2.0)
423        - 2.0 * ((-max_y_meters / EARTH_RADIUS).exp()).atan())
424    .to_degrees();
425
426    GeographicBounds {
427        min_lon,
428        max_lon,
429        min_lat,
430        max_lat,
431    }
432}
433
434/// Calculate pixel window in source dataset from geographic bounds
435fn calculate_pixel_window(
436    bounds: &GeographicBounds,
437    geotransform: &[f64; 6],
438    raster_width: usize,
439    raster_height: usize,
440) -> (i32, i32, u32, u32) {
441    // Geotransform: [top_left_x, pixel_width, rotation_x, top_left_y, rotation_y, pixel_height]
442    // Standard non-rotated: [x_origin, pixel_width, 0, y_origin, 0, -pixel_height]
443
444    let x_origin = geotransform[0];
445    let pixel_width = geotransform[1];
446    let y_origin = geotransform[3];
447    let pixel_height = geotransform[5]; // Usually negative
448
449    // Calculate pixel coordinates
450    let x_min = ((bounds.min_lon - x_origin) / pixel_width).floor() as i32;
451    let x_max = ((bounds.max_lon - x_origin) / pixel_width).ceil() as i32;
452    let y_min = ((bounds.max_lat - y_origin) / pixel_height).floor() as i32;
453    let y_max = ((bounds.min_lat - y_origin) / pixel_height).ceil() as i32;
454
455    // Clamp to raster bounds
456    let x_min = x_min.max(0).min(raster_width as i32);
457    let x_max = x_max.max(0).min(raster_width as i32);
458    let y_min = y_min.max(0).min(raster_height as i32);
459    let y_max = y_max.max(0).min(raster_height as i32);
460
461    let width = (x_max - x_min).max(0) as u32;
462    let height = (y_max - y_min).max(0) as u32;
463
464    (x_min, y_min, width, height)
465}
466
467/// Geographic bounds structure
468struct GeographicBounds {
469    min_lon: f64,
470    max_lon: f64,
471    min_lat: f64,
472    max_lat: f64,
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_web_mercator_bounds() {
481        // Test zoom 0 (single tile)
482        let bounds = WebMercatorBounds::new(0, 0, 0);
483        assert_eq!(bounds.num_tiles(), 1);
484        assert!(bounds.is_valid());
485
486        let (min_x, min_y, max_x, max_y) = bounds.bbox();
487        assert!(min_x < max_x);
488        assert!(min_y < max_y);
489
490        // Test zoom 1 (2x2 tiles)
491        let bounds = WebMercatorBounds::new(1, 0, 0);
492        assert_eq!(bounds.num_tiles(), 2);
493        assert!(bounds.is_valid());
494
495        // Test invalid coordinates
496        let bounds = WebMercatorBounds::new(1, 2, 0);
497        assert!(!bounds.is_valid());
498
499        let bounds = WebMercatorBounds::new(1, 0, 2);
500        assert!(!bounds.is_valid());
501    }
502
503    #[test]
504    fn test_parse_y_and_format() {
505        assert_eq!(
506            parse_y_and_format("123.png").ok(),
507            Some((123, "png".to_string()))
508        );
509        assert_eq!(
510            parse_y_and_format("0.jpg").ok(),
511            Some((0, "jpg".to_string()))
512        );
513        assert_eq!(
514            parse_y_and_format("999.webp").ok(),
515            Some((999, "webp".to_string()))
516        );
517
518        assert!(parse_y_and_format("invalid").is_err());
519        assert!(parse_y_and_format("abc.png").is_err());
520    }
521
522    #[test]
523    fn test_parse_format() {
524        assert_eq!(parse_format("png").ok(), Some(ImageFormat::Png));
525        assert_eq!(parse_format("jpg").ok(), Some(ImageFormat::Jpeg));
526        assert_eq!(parse_format("jpeg").ok(), Some(ImageFormat::Jpeg));
527
528        assert!(parse_format("invalid").is_err());
529    }
530}