hittekaart 0.2.0

Generates OSM heatmap tiles from GPX tracks
Documentation
//! Abstractions over different storage backends.
//!
//! The main trait to use here is [`Storage`], which provides the necessary interface to store
//! tiles. Usually you want to have a `dyn Storage`, and then instantiate it with a concrete
//! implementation (either [`Folder`] or [`Sqlite`]), depending on the command line flags or
//! similar.
use rusqlite::{params, Connection};
use std::{
    fs,
    io::ErrorKind,
    path::{Path, PathBuf},
};

use super::error::{Error, Result};

/// The trait that provides the interface for storing tiles.
pub trait Storage {
    /// Prepare the storage.
    ///
    /// This can be used to e.g. ensure the directory exists, or to create the database.
    fn prepare(&mut self) -> Result<()>;
    /// Prepare for a given zoom level.
    ///
    /// This function is called once per zoom, and can be used e.g. to set up the inner folder for
    /// the level. This can avoid unnecesary syscalls if this setup would be done in
    /// [`Storage::store`] instead.
    fn prepare_zoom(&mut self, zoom: u32) -> Result<()>;
    /// Store the given data for the tile.
    fn store(&mut self, zoom: u32, x: u64, y: u64, data: &[u8]) -> Result<()>;
    /// Finish the storing operation.
    ///
    /// This can flush any buffers, commit database changes, and so on.
    fn finish(&mut self) -> Result<()>;
}

/// Folder-based storage.
///
/// This stores the tiles according to the [slippy map
/// tilenames](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames).
#[derive(Debug)]
pub struct Folder {
    base_dir: PathBuf,
}

impl Folder {
    /// Create a new folder based storage.
    ///
    /// The given directory is the "root" directory, so a tile would be saved as
    /// `base_dir/{zoom}/{x}/{y}.png`.
    pub fn new(base_dir: PathBuf) -> Self {
        Folder { base_dir }
    }
}

impl Storage for Folder {
    fn prepare(&mut self) -> Result<()> {
        let path = &self.base_dir;
        let metadata = fs::metadata(path);
        match metadata {
            Err(e) if e.kind() == ErrorKind::NotFound => {
                fs::create_dir(path)
                    .map_err(|e| Error::Io("creating the output directory", e))?
            }
            Err(e) => Err(Error::Io("checking the output direectory", e))?,
            Ok(m) if m.is_dir() => (),
            Ok(_) => Err(Error::InvalidOutputDirectory)?,
        }
        Ok(())
    }

    fn prepare_zoom(&mut self, zoom: u32) -> Result<()> {
        let target = [&self.base_dir, &zoom.to_string().into()]
            .iter()
            .collect::<PathBuf>();
        fs::create_dir(target)
            .map_err(|e| Error::Io("creating the zoom directory", e))?;
        Ok(())
    }

    fn store(&mut self, zoom: u32, x: u64, y: u64, data: &[u8]) -> Result<()> {
        let folder = self.base_dir.join(zoom.to_string()).join(x.to_string());
        let metadata = folder.metadata();
        match metadata {
            Err(_) => fs::create_dir(&folder)
                .map_err(|e| Error::Io("creating the output directory", e))?,
            Ok(m) if !m.is_dir() => Err(Error::InvalidOutputDirectory)?,
            _ => {}
        }
        let file = folder.join(format!("{y}.png"));
        fs::write(file, data)
            .map_err(|e| Error::Io("writing the output file", e))?;
        Ok(())
    }

    fn finish(&mut self) -> Result<()> {
        Ok(())
    }
}

/// OsmAnd compatible storage (SQLite backed).
///
/// This stores tiles into a SQLite database with the following tables:
///
/// ```sql
/// CREATE TABLE tiles (
///     x INTEGER,
///     y INTEGER,
///     z INTEGER,
///     image BLOB,
///     time INTEGER,
///     PRIMARY KEY (x, y, z)
/// );
/// CREATE TABLE info (
///     url TEXT,
///     randoms TEXT,
///     referer TEXT,
///     rule TEXT,
///     useragent TEXT,
///     minzoom INTEGER,
///     maxzoom INTEGER,
///     ellipsoid INTEGER,
///     inverted_y INTEGER,
///     timecolumn TEXT,
///     expireminutes INTEGER,
///     tilenumbering TEXT,
///     tilesize INTEGER
/// );
/// ```
///
/// The tile data lands in the `tiles` table, with `x`, `y` and `z` being the x/y/zoom coordinates.
/// Note that the coordinates and zoom are not inverted, the same values as with the folder storage
/// will be used. The `time` column is set to `NULL`.
///
/// The `info` table contains metadata that is required by OsmAnd to properly load the tiles.
///
/// To use the resulting file, give it an `.sqlitedb` extension and place it under
/// `Android/data/net.osmand.plus/files/tiles/` on your phone.
///
/// See <https://osmand.net/docs/technical/osmand-file-formats/osmand-sqlite/> for the technical
/// reference.
#[derive(Debug)]
pub struct OsmAnd {
    connection: Connection,
    min_zoom: u32,
    max_zoom: u32,
}

impl OsmAnd {
    /// Create a new OsmAnd tile store.
    ///
    /// The database will be saved at the given location. Note that the database must not yet
    /// exist.
    pub fn open<P: AsRef<Path>>(file: P) -> Result<Self> {
        let path = file.as_ref();
        if fs::metadata(path).is_ok() {
            return Err(Error::OutputAlreadyExists(path.to_path_buf()));
        }
        let connection = Connection::open(path)?;
        Ok(OsmAnd {
            connection,
            min_zoom: u32::MAX,
            max_zoom: u32::MIN,
        })
    }
}

impl Storage for OsmAnd {
    fn prepare(&mut self) -> Result<()> {
        self.connection.execute(
            "CREATE TABLE info (
                url TEXT,
                randoms TEXT,
                referer TEXT,
                rule TEXT,
                useragent TEXT,
                minzoom INTEGER,
                maxzoom INTEGER,
                ellipsoid INTEGER,
                inverted_y INTEGER,
                timecolumn TEXT,
                expireminutes INTEGER,
                tilenumbering TEXT,
                tilesize INTEGER
            );",
            (),
        )?;
        self.connection.execute(
            "CREATE TABLE tiles (
                x INTEGER,
                y INTEGER,
                z INTEGER,
                image BLOB,
                time INTEGER,
                PRIMARY KEY (x, y, z)
            );",
            (),
        )?;
        self.connection.execute("BEGIN;", ())?;
        Ok(())
    }

    fn prepare_zoom(&mut self, _zoom: u32) -> Result<()> {
        Ok(())
    }

    fn store(&mut self, zoom: u32, x: u64, y: u64, data: &[u8]) -> Result<()> {
        // SQLite can store all i64 values, but no values between i64::MAX and u64::MAX
        // See https://github.com/rusqlite/rusqlite/issues/1722
        let x: i64 = x.try_into().expect("x coordinate too large for SQLite");
        let y: i64 = y.try_into().expect("y coordinate too large for SQLite");
        self.connection.execute(
            "INSERT INTO tiles (z, x, y, image) VALUES (?, ?, ?, ?)",
            params![zoom, x, y, data],
        )?;
        self.min_zoom = self.min_zoom.min(zoom);
        self.max_zoom = self.max_zoom.max(zoom);
        Ok(())
    }

    fn finish(&mut self) -> Result<()> {
        self.connection.execute(
            "INSERT INTO info (inverted_y, tilenumbering, minzoom, maxzoom) VALUES (?, ?, ?, ?);",
            params![0, "", self.min_zoom, self.max_zoom],
        )?;
        self.connection.execute("COMMIT;", ())?;
        Ok(())
    }
}

/// MBTiles storage (SQLite backed).
///
/// This stores tiles into a SQLite database with the following tables:
///
/// ```sql
/// CREATE TABLE metadata (name TEXT, value TEXT);
/// CREATE TABLE tiles (
///     zoom_level INTEGER,
///     tile_column INTEGER,
///     tile_row INTEGER,
///     tile_data BLOB,
///     PRIMARY KEY (zoom_level, tile_column, tile_row)
/// );
/// ```
///
/// The tiles end up in the `tiles` table. Note that the `y` coordinate (`tile_row`) is inverted,
/// meaning that `tile_row = 2^zoom - 1 - y`.
///
/// The metadata table will contain two rows, one with `name = "name"` and one with `name =
/// "format"`. You can set a custom name/title of the map with the following SQL statement:
///
/// ```sql
/// UPDATE metadata SET value = "My cool heatmap!" WHERE name = "name";
/// ```
#[derive(Debug)]
pub struct MbTiles {
    connection: Connection,
}

impl MbTiles {
    /// Create a new MBTiles tile store.
    ///
    /// The database will be saved at the given location. Note that the database must not yet
    /// exist.
    pub fn open<P: AsRef<Path>>(file: P) -> Result<Self> {
        let path = file.as_ref();
        if fs::metadata(path).is_ok() {
            return Err(Error::OutputAlreadyExists(path.to_path_buf()));
        }
        let connection = Connection::open(path)?;
        Ok(MbTiles { connection })
    }
}

impl Storage for MbTiles {
    fn prepare(&mut self) -> Result<()> {
        self.connection.execute(
            "PRAGMA application_id = 0x4d504258;",
            (),
        )?;
        self.connection.execute(
            "CREATE TABLE metadata (name TEXT, value TEXT);",
            (),
        )?;
        self.connection.execute(
            "CREATE TABLE tiles (
                zoom_level INTEGER,
                tile_column INTEGER,
                tile_row INTEGER,
                tile_data BLOB,
                PRIMARY KEY (zoom_level, tile_column, tile_row)
            );",
            (),
        )?;
        self.connection.execute("BEGIN;", ())?;
        Ok(())
    }

    fn prepare_zoom(&mut self, _zoom: u32) -> Result<()> {
        Ok(())
    }

    fn store(&mut self, zoom: u32, x: u64, y: u64, data: &[u8]) -> Result<()> {
        let inverted_y = 2u64.pow(zoom) - 1 - y;
        // SQLite can store all i64 values, but no values between i64::MAX and u64::MAX
        // See https://github.com/rusqlite/rusqlite/issues/1722
        let x: i64 = x.try_into().expect("x coordinate too large for SQLite");
        let inverted_y: i64 = inverted_y.try_into().expect("inverted_y coordinate too large for SQLite");
        self.connection.execute(
            "INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)",
            params![zoom, x, inverted_y, data],
        )?;
        Ok(())
    }

    fn finish(&mut self) -> Result<()> {
        self.connection.execute(
            "INSERT INTO metadata (name, value) VALUES (?, ?);",
            params!["name", "Heatmap"],
        )?;
        self.connection.execute(
            "INSERT INTO metadata (name, value) VALUES (?, ?);",
            params!["format", "png"],
        )?;
        self.connection.execute("COMMIT;", ())?;
        Ok(())
    }
}