aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gpx.rs138
-rw-r--r--src/layer.rs169
-rw-r--r--src/lib.rs11
-rw-r--r--src/main.rs164
-rw-r--r--src/renderer/heatmap.rs214
-rw-r--r--src/renderer/marktile.rs65
-rw-r--r--src/renderer/mod.rs99
-rw-r--r--src/renderer/tile-marked.pngbin116 -> 0 bytes
-rw-r--r--src/renderer/tilehunt.rs153
-rw-r--r--src/storage.rs165
10 files changed, 0 insertions, 1178 deletions
diff --git a/src/gpx.rs b/src/gpx.rs
deleted file mode 100644
index fb9e00e..0000000
--- a/src/gpx.rs
+++ /dev/null
@@ -1,138 +0,0 @@
-//! GPX data extraction functions.
-//!
-//! We *could* use the [gpx](https://github.com/georust/gpx) crate, but we don't care about much
-//! other than the coordinates of the tracks. By implementing the little functionality ourselves,
-//! we can use a fast XML parser ([roxmltree](https://github.com/RazrFalcon/roxmltree)).
-//!
-//! Note that we throw away all information that we don't care about. Since we need only the
-//! coordinates of a track, we simply use a `Vec<Coordinates>` to represent a track.
-use std::{
- f64::consts::PI,
- ffi::OsStr,
- fs::{self, File},
- io::{BufReader, Read},
- path::Path,
-};
-
-use color_eyre::eyre::{eyre, Result};
-use flate2::bufread::GzDecoder;
-use roxmltree::{Document, Node, NodeType};
-
-/// World coordinates.
-#[derive(Debug, Copy, Clone, PartialEq)]
-pub struct Coordinates {
- longitude: f64,
- latitude: f64,
-}
-
-impl Coordinates {
- /// Calculates the [Web Mercator
- /// projection](https://en.wikipedia.org/wiki/Web_Mercator_projection) of the coordinates.
- ///
- /// Returns the `(x, y)` projection, where both are in the range `[0, 256 * 2^zoom)`.
- pub fn web_mercator(self, zoom: u32) -> (u64, u64) {
- const WIDTH: f64 = super::layer::TILE_WIDTH as f64;
- const HEIGHT: f64 = super::layer::TILE_HEIGHT as f64;
-
- let lambda = self.longitude.to_radians();
- let phi = self.latitude.to_radians();
- let x = 2u64.pow(zoom) as f64 / (2.0 * PI) * WIDTH * (lambda + PI);
- let y =
- 2u64.pow(zoom) as f64 / (2.0 * PI) * HEIGHT * (PI - (PI / 4.0 + phi / 2.0).tan().ln());
- (x.floor() as u64, y.floor() as u64)
- }
-}
-
-fn is_track_node(node: &Node) -> bool {
- node.node_type() == NodeType::Element && node.tag_name().name() == "trk"
-}
-
-fn is_track_segment(node: &Node) -> bool {
- node.node_type() == NodeType::Element && node.tag_name().name() == "trkseg"
-}
-
-fn is_track_point(node: &Node) -> bool {
- node.node_type() == NodeType::Element && node.tag_name().name() == "trkpt"
-}
-
-/// Extracts a track from the given string.
-pub fn extract_from_str(input: &str) -> Result<Vec<Coordinates>> {
- let mut result = Vec::new();
- let document = Document::parse(input)?;
- for node in document.root_element().children().filter(is_track_node) {
- for segment in node.children().filter(is_track_segment) {
- for point in segment.children().filter(is_track_point) {
- let latitude = point
- .attribute("lat")
- .and_then(|l| l.parse::<f64>().ok())
- .ok_or_else(|| eyre!("Invalid latitude"))?;
- let longitude = point
- .attribute("lon")
- .and_then(|l| l.parse::<f64>().ok())
- .ok_or_else(|| eyre!("Invalid longitude"))?;
- result.push(Coordinates {
- latitude,
- longitude,
- });
- }
- }
- }
- Ok(result)
-}
-
-/// Compression format of the data.
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
-pub enum Compression {
- /// Indicates that no compression is applied, and the file is plain GPX.
- None,
- /// Indicates that the file is gzip compressed.
- Gzip,
- /// Indicates that the file is brotli compressed.
- Brotli,
-}
-
-impl Compression {
- /// Suggests a [`Compression`] from the given path name.
- ///
- /// This will suggest [`Compression::Brotli`] for files ending in `.br`, [`Compression::Gzip`]
- /// for files ending with `.gz` or `.gzip`, and [`Compression::None`] for files ending with
- /// `.gpx`.
- ///
- /// If the file does not end with any of the aforementioned extensions, an error is returned
- /// instead.
- pub fn suggest_from_path<P: AsRef<Path>>(path: P) -> Option<Compression> {
- let Some(ext) = path.as_ref().extension() else { return None };
- if OsStr::new("br") == ext {
- Some(Compression::Brotli)
- } else if [OsStr::new("gz"), OsStr::new("gzip")].contains(&ext) {
- Some(Compression::Gzip)
- } else if OsStr::new("gpx") == ext {
- Some(Compression::None)
- } else {
- None
- }
- }
-}
-
-/// Extracts the relevant GPX data from the given file.
-///
-/// Note that the content must be valid UTF-8, as that is what our parser expects.
-pub fn extract_from_file<P: AsRef<Path>>(
- path: P,
- compression: Compression,
-) -> Result<Vec<Coordinates>> {
- let content = match compression {
- Compression::None => fs::read_to_string(path)?,
- Compression::Gzip => {
- let mut result = String::new();
- GzDecoder::new(BufReader::new(File::open(path)?)).read_to_string(&mut result)?;
- result
- }
- Compression::Brotli => {
- let mut result = Vec::new();
- brotli::BrotliDecompress(&mut BufReader::new(File::open(path)?), &mut result)?;
- String::from_utf8(result)?
- }
- };
- extract_from_str(&content)
-}
diff --git a/src/layer.rs b/src/layer.rs
deleted file mode 100644
index dec2419..0000000
--- a/src/layer.rs
+++ /dev/null
@@ -1,169 +0,0 @@
-//! Lazy tiled image.
-//!
-//! This supports OSM-style "tiled" images, but not all of the tiles have to be present. If a tile
-//! is not present, a default pixel is returned. The tile is allocated with the first call to a
-//! mutating operation.
-use std::{
- fs::File,
- io::{BufWriter, Write},
- path::Path,
-};
-
-use color_eyre::eyre::Result;
-use fnv::FnvHashMap;
-use image::{
- codecs::png::{CompressionType, FilterType, PngEncoder},
- ColorType, ImageBuffer, ImageEncoder, Pixel, RgbaImage,
-};
-use num_traits::Zero;
-use rayon::iter::{IntoParallelIterator, ParallelIterator};
-
-/// Height of a single tile.
-pub const TILE_HEIGHT: u64 = 256;
-/// Width of a single tile.
-pub const TILE_WIDTH: u64 = 256;
-
-type TileIndex = (u64, u64);
-
-/// Main "lazy image buffer" struct.
-///
-/// This lazily allocates a new tile (of size [`TILE_WIDTH`] × [`TILE_HEIGHT`]) for each mutable
-/// pixel access. Each tile is pre-filled with the given default pixel.
-#[derive(Debug, Clone)]
-pub struct TileLayer<P: Pixel> {
- tiles: FnvHashMap<TileIndex, ImageBuffer<P, Vec<P::Subpixel>>>,
- default_pixel: P,
-}
-
-impl<P: Pixel> TileLayer<P> {
- /// Construct a new lazy buffer with the given default (background) pixel.
- ///
- /// Note that this does not yet allocate any image tiles.
- pub fn from_pixel(pixel: P) -> Self {
- TileLayer {
- tiles: Default::default(),
- default_pixel: pixel,
- }
- }
-
- /// Iterates over all tiles, together with their indices.
- pub fn enumerate_tiles(
- &self,
- ) -> impl Iterator<Item = (u64, u64, &ImageBuffer<P, Vec<P::Subpixel>>)> {
- self.tiles.iter().map(|((x, y), t)| (*x, *y, t))
- }
-
- /// Returns a mutable reference to the given tile.
- ///
- /// This allocates a new tile if the requested tile does not yet exist.
- pub fn tile_mut(&mut self, tile_x: u64, tile_y: u64) -> &mut ImageBuffer<P, Vec<P::Subpixel>> {
- self.tiles.entry((tile_x, tile_y)).or_insert_with(|| {
- ImageBuffer::from_pixel(TILE_WIDTH as u32, TILE_HEIGHT as u32, self.default_pixel)
- })
- }
-
- /// Enumerate all pixels that are allocated.
- ///
- /// This provides access to the pixel and its coordinates.
- pub fn enumerate_pixels(&self) -> impl Iterator<Item = (u64, u64, &P)> {
- self.tiles.iter().flat_map(|((tx, ty), tile)| {
- tile.enumerate_pixels().map(move |(x, y, p)| {
- (
- u64::from(x) + tx * TILE_WIDTH,
- u64::from(y) + ty * TILE_HEIGHT,
- p,
- )
- })
- })
- }
-
- /// Iterate over all pixels that are allocated.
- pub fn pixels(&self) -> impl Iterator<Item = &P> {
- self.enumerate_pixels().map(|x| x.2)
- }
-
- /// Returns the number of allocated tiles.
- pub fn tile_count(&self) -> usize {
- self.tiles.len()
- }
-
- /// Copies the non-zero pixels from `source` to `self`.
- ///
- /// A zero-pixel is identified by comparing all its channels' values with `Zero::zero()`. If
- /// any channel is non-zero, the pixel is considered non-zero and is copied.
- ///
- /// The top-left pixel of `source` is copied to `(x, y)`.
- ///
- /// This method is more efficient than copying pixels one by one, as it groups them by tile and
- /// only does one tile lookup then.
- pub fn blit_nonzero(&mut self, x: u64, y: u64, source: &ImageBuffer<P, Vec<P::Subpixel>>) {
- let zero = zero_pixel::<P>();
- let source_width = u64::from(source.width());
- let source_height = u64::from(source.height());
- for tx in x / TILE_WIDTH..=(x + source_width) / TILE_WIDTH {
- for ty in y / TILE_HEIGHT..=(y + source_height) / TILE_HEIGHT {
- let tile = self.tile_mut(tx, ty);
- let offset_x = (tx * TILE_WIDTH).saturating_sub(x);
- let offset_y = (ty * TILE_HEIGHT).saturating_sub(y);
- let local_min_x = x.saturating_sub(tx * TILE_WIDTH);
- let local_min_y = y.saturating_sub(ty * TILE_HEIGHT);
- let local_max_x = TILE_WIDTH.min(x + source_width - tx * TILE_WIDTH);
- let local_max_y = TILE_HEIGHT.min(y + source_height - ty * TILE_HEIGHT);
- // Keep x in the inner loop for better cache locality!
- for (y, source_y) in (local_min_y..local_max_y).zip(offset_y..) {
- for (x, source_x) in (local_min_x..local_max_x).zip(offset_x..) {
- let pixel = source
- .get_pixel(source_x.try_into().unwrap(), source_y.try_into().unwrap());
- if pixel.channels() != zero.channels() {
- *tile.get_pixel_mut(x.try_into().unwrap(), y.try_into().unwrap()) =
- *pixel;
- }
- }
- }
- }
- }
- }
-}
-
-impl<P> TileLayer<P>
-where
- P: Pixel + Send,
- P::Subpixel: Send,
-{
- /// Turns this lazy tile layer into a parallelized iterator.
- pub fn into_parallel_tiles(
- self,
- ) -> impl ParallelIterator<Item = (u64, u64, ImageBuffer<P, Vec<P::Subpixel>>)> {
- IntoParallelIterator::into_par_iter(self.tiles).map(|((x, y), t)| (x, y, t))
- }
-}
-
-/// Saves the given image buffer to the given path.
-pub fn compress_png<P: AsRef<Path>>(image: &RgbaImage, path: P) -> Result<()> {
- let outstream = BufWriter::new(File::create(path)?);
- compress_png_stream(image, outstream)
-}
-
-/// Saves the given image buffer to the given stream.
-///
-/// Note that this uses the best compression available.
-pub fn compress_png_stream<W: Write>(image: &RgbaImage, outstream: W) -> Result<()> {
- let encoder =
- PngEncoder::new_with_quality(outstream, CompressionType::Best, FilterType::Adaptive);
-
- encoder.write_image(image, image.width(), image.height(), ColorType::Rgba8)?;
-
- Ok(())
-}
-
-/// Encodes the given image buffer and returns its data as a vector.
-pub fn compress_png_as_bytes(image: &RgbaImage) -> Result<Vec<u8>> {
- let mut buffer = Vec::new();
- compress_png_stream(image, &mut buffer)?;
- Ok(buffer)
-}
-
-fn zero_pixel<P: Pixel>() -> P {
- let zeroes = vec![Zero::zero(); P::CHANNEL_COUNT as usize];
- *P::from_slice(&zeroes)
-}
diff --git a/src/lib.rs b/src/lib.rs
deleted file mode 100644
index d6a6a46..0000000
--- a/src/lib.rs
+++ /dev/null
@@ -1,11 +0,0 @@
-//! `hittekaart` is a program to generate heatmaps from GPX tracks.
-//!
-//! Note that this crate is not meant to be used as a library. Instead, use the command line
-//! program that is included in this crate.
-//!
-//! This library therefore contains an API that is tailored to the use case of the `hittekaart`
-//! binary.
-pub mod gpx;
-pub mod layer;
-pub mod renderer;
-pub mod storage;
diff --git a/src/main.rs b/src/main.rs
deleted file mode 100644
index 11133af..0000000
--- a/src/main.rs
+++ /dev/null
@@ -1,164 +0,0 @@
-use std::{io, path::PathBuf};
-
-use clap::{Parser, ValueEnum};
-use color_eyre::{
- eyre::{bail, eyre, Result},
- Report,
-};
-use hittekaart::{
- gpx::{self, Compression},
- renderer::{self, heatmap, marktile, tilehunt, Renderer},
- storage::{Folder, Sqlite, Storage},
-};
-use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
-use is_terminal::IsTerminal;
-use rayon::{
- iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator},
- ThreadPoolBuilder,
-};
-
-/// Tile generation mode.
-#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Hash)]
-enum Mode {
- Heatmap,
- Marktile,
- Tilehunter,
-}
-
-#[derive(Parser, Debug, Clone)]
-#[command(author, version, about)]
-struct Args {
- /// The GPX files to parse.
- #[arg(required = true)]
- files: Vec<PathBuf>,
-
- /// Minimum zoom level to generate tiles for.
- #[arg(long, default_value_t = 0)]
- min_zoom: u32,
-
- /// Maximum zoom level to generate tiles for.
- #[arg(long, default_value_t = 19)]
- max_zoom: u32,
-
- /// Number of threads to use. Set to 0 to use all available CPU cores.
- #[arg(long, short, default_value_t = 0)]
- threads: usize,
-
- /// The output directory. Will be created if it does not exist. Defaults to "tiles" for the
- /// folder-based storage, and "tiles.sqlite" for the SQLite-based storage.
- #[arg(long, short)]
- output: Option<PathBuf>,
-
- /// Store the tiles in a SQLite database. If given, `--output` will determine the SQLite
- /// filename.
- #[arg(long)]
- sqlite: bool,
-
- /// Generation mode.
- #[arg(value_enum, long, short, default_value_t = Mode::Heatmap)]
- mode: Mode,
-
- /// Zoom level for the tilehunter mode.
- #[arg(long, default_value_t = 14)]
- tilehunter_zoom: u32,
-}
-
-fn main() -> Result<()> {
- color_eyre::install()?;
-
- let args = Args::parse();
-
- if args.max_zoom < args.min_zoom {
- bail!("Max zoom cannot be smaller than min zoom!");
- }
-
- ThreadPoolBuilder::new()
- .num_threads(args.threads)
- .build_global()?;
-
- match args.mode {
- Mode::Heatmap => run(heatmap::Renderer, args),
- Mode::Marktile => run(marktile::Renderer, args),
- Mode::Tilehunter => run(tilehunt::Renderer::new(args.tilehunter_zoom), args),
- }
-}
-
-fn run<R: Renderer>(renderer: R, args: Args) -> Result<()> {
- let progress_style =
- ProgressStyle::with_template("[{elapsed}] {prefix:.cyan} {wide_bar} {pos:.green}/{len}")?;
- let zoom_style =
- ProgressStyle::with_template("[{elapsed}] {prefix:.yellow} {wide_bar} {pos:.green}/{len}")?;
-
- let use_progress_bars = io::stdout().is_terminal();
- let make_bar = |len| {
- if use_progress_bars {
- ProgressBar::new(len)
- } else {
- ProgressBar::hidden()
- }
- };
-
- let bar = make_bar(args.files.len().try_into().unwrap()).with_style(progress_style.clone());
- bar.set_prefix("Reading GPX files");
-
- let mut tracks = Vec::new();
- args.files
- .par_iter()
- .map(|file| {
- let compression = Compression::suggest_from_path(file)
- .ok_or_else(|| eyre!("Could not determine format for {file:?}"))?;
- let data = gpx::extract_from_file(file, compression)?;
- bar.inc(1);
- Ok::<_, Report>(data)
- })
- .collect_into_vec(&mut tracks);
- let tracks = tracks.into_iter().collect::<Result<Vec<_>>>()?;
- bar.finish();
-
- let mut storage: Box<dyn Storage + Send> = if args.sqlite {
- let output = args.output.unwrap_or_else(|| "tiles.sqlite".into());
- Box::new(Sqlite::connect(output)?)
- } else {
- let output = args.output.unwrap_or_else(|| "tiles".into());
- Box::new(Folder::new(output))
- };
- storage.prepare()?;
-
- let multibar = MultiProgress::new();
- if !use_progress_bars {
- multibar.set_draw_target(ProgressDrawTarget::hidden())
- }
- let zoom_bar = make_bar((args.max_zoom - args.min_zoom + 1).into()).with_style(zoom_style);
- multibar.add(zoom_bar.clone());
- zoom_bar.set_prefix("Zoom levels");
-
- for zoom in args.min_zoom..=args.max_zoom {
- let bar = make_bar(tracks.len().try_into().unwrap()).with_style(progress_style.clone());
- multibar.insert_from_back(1, bar.clone());
- bar.set_prefix("Rendering heat zones");
- let counter = renderer::prepare(&renderer, zoom, &tracks, || {
- bar.inc(1);
- Ok(())
- })?;
- bar.finish();
- multibar.remove(&bar);
-
- storage.prepare_zoom(zoom)?;
-
- let bar = make_bar(renderer.tile_count(&counter)?).with_style(progress_style.clone());
- multibar.insert_from_back(1, bar.clone());
- bar.set_prefix("Saving heat tiles");
- renderer::colorize(&renderer, counter, |rendered_tile| {
- storage.store(zoom, rendered_tile.x, rendered_tile.y, &rendered_tile.data)?;
- bar.inc(1);
- Ok(())
- })?;
- bar.finish();
- multibar.remove(&bar);
- zoom_bar.inc(1);
- }
- storage.finish()?;
- zoom_bar.finish();
-
- Ok(())
-}
diff --git a/src/renderer/heatmap.rs b/src/renderer/heatmap.rs
deleted file mode 100644
index 0c4f93f..0000000
--- a/src/renderer/heatmap.rs
+++ /dev/null
@@ -1,214 +0,0 @@
-//! Actual rendering functions for heatmaps.
-//!
-//! We begin the rendering by using [`render_heatcounter`] to turn a list of GPX tracks into a
-//! [`HeatCounter`], which is basically a grayscale heatmap, where each pixel represents the number
-//! of tracks that goes through this pixel.
-//!
-//! We then render the colored heatmap tiles using [`lazy_colorization`], which provides us with
-//! colorful PNG data.
-use color_eyre::{eyre::Result, Report};
-use crossbeam_channel::Sender;
-use image::{ImageBuffer, Luma, Pixel, RgbaImage};
-use nalgebra::{vector, Vector2};
-use rayon::iter::ParallelIterator;
-
-use super::{
- super::{
- gpx::Coordinates,
- layer::{self, TileLayer},
- },
- RenderedTile,
-};
-
-/// Type for the intermediate heat counters.
-pub type HeatCounter = TileLayer<Luma<u8>>;
-
-fn render_circle<P: Pixel>(layer: &mut TileLayer<P>, center: (u64, u64), radius: u64, pixel: P) {
- let topleft = (center.0 - radius, center.1 - radius);
- let rad_32: u32 = radius.try_into().unwrap();
- let mut circle = ImageBuffer::<P, Vec<P::Subpixel>>::new(rad_32 * 2 + 1, rad_32 * 2 + 1);
- imageproc::drawing::draw_filled_circle_mut(
- &mut circle,
- (
- i32::try_from(radius).unwrap(),
- i32::try_from(radius).unwrap(),
- ),
- radius.try_into().unwrap(),
- pixel,
- );
- layer.blit_nonzero(topleft.0, topleft.1, &circle);
-}
-
-fn direction_vector(a: (u64, u64), b: (u64, u64)) -> Vector2<f64> {
- let dx = if b.0 > a.0 {
- (b.0 - a.0) as f64
- } else {
- -((a.0 - b.0) as f64)
- };
- let dy = if b.1 > a.1 {
- (b.1 - a.1) as f64
- } else {
- -((a.1 - b.1) as f64)
- };
- vector![dx, dy]
-}
-
-fn render_line<P: Pixel>(
- layer: &mut TileLayer<P>,
- start: (u64, u64),
- end: (u64, u64),
- thickness: u64,
- pixel: P,
-) {
- use imageproc::point::Point;
-
- if start == end {
- return;
- }
-
- fn unsigned_add(a: Vector2<u64>, b: Vector2<i32>) -> Vector2<u64> {
- let x = if b[0] < 0 {
- a[0] - u64::from(b[0].unsigned_abs())
- } else {
- a[0] + u64::try_from(b[0]).unwrap()
- };
- let y = if b[1] < 0 {
- a[1] - u64::from(b[1].unsigned_abs())
- } else {
- a[1] + u64::try_from(b[1]).unwrap()
- };
- vector![x, y]
- }
-
- let r = direction_vector(start, end);
- let normal = vector![r[1], -r[0]].normalize();
-
- let start = vector![start.0, start.1];
- let end = vector![end.0, end.1];
-
- let displacement = normal * thickness as f64;
- let displacement = displacement.map(|x| x as i32);
- if displacement == vector![0, 0] {
- return;
- }
- let polygon = [
- unsigned_add(start, displacement),
- unsigned_add(end, displacement),
- unsigned_add(end, -displacement),
- unsigned_add(start, -displacement),
- ];
- let min_x = polygon.iter().map(|p| p[0]).min().unwrap();
- let min_y = polygon.iter().map(|p| p[1]).min().unwrap();
- let max_x = polygon.iter().map(|p| p[0]).max().unwrap();
- let max_y = polygon.iter().map(|p| p[1]).max().unwrap();
-
- let mut overlay = ImageBuffer::<P, Vec<P::Subpixel>>::new(
- (max_x - min_x).try_into().unwrap(),
- (max_y - min_y).try_into().unwrap(),
- );
- let adjusted_poly = polygon
- .into_iter()
- .map(|p| Point::new((p[0] - min_x) as i32, (p[1] - min_y) as i32))
- .collect::<Vec<_>>();
- imageproc::drawing::draw_polygon_mut(&mut overlay, &adjusted_poly, pixel);
-
- layer.blit_nonzero(min_x, min_y, &overlay);
-}
-
-fn merge_heat_counter(base: &mut HeatCounter, overlay: &HeatCounter) {
- for (tx, ty, source) in overlay.enumerate_tiles() {
- let target = base.tile_mut(tx, ty);
- for (x, y, source) in source.enumerate_pixels() {
- let target = target.get_pixel_mut(x, y);
- target[0] += source[0];
- }
- }
-}
-
-fn colorize_tile(tile: &ImageBuffer<Luma<u8>, Vec<u8>>, max: u32) -> RgbaImage {
- let gradient = colorgrad::yl_or_rd();
- let mut result = ImageBuffer::from_pixel(tile.width(), tile.height(), [0, 0, 0, 0].into());
- for (x, y, pixel) in tile.enumerate_pixels() {
- if pixel[0] > 0 {
- let alpha = pixel[0] as f64 / max as f64;
- let color = gradient.at(1.0 - alpha);
- let target = result.get_pixel_mut(x, y);
- *target = color.to_rgba8().into();
- }
- }
- result
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub struct Renderer;
-
-impl super::Renderer for Renderer {
- type Prepared = HeatCounter;
-
- /// Renders the heat counter for the given zoom level and track points.
- ///
- /// The given callback will be called when a track has been rendered and merged into the
- /// accumulator, to allow for UI feedback. The passed parameter is the number of tracks that have
- /// been rendered since the last call.
- fn prepare(
- &self,
- zoom: u32,
- tracks: &[Vec<Coordinates>],
- tick: Sender<()>,
- ) -> Result<HeatCounter> {
- let mut heatcounter = TileLayer::from_pixel([0].into());
-
- for track in tracks {
- let mut layer = TileLayer::from_pixel([0].into());
-
- let points = track
- .iter()
- .map(|coords| coords.web_mercator(zoom))
- .collect::<Vec<_>>();
-
- for point in points.iter() {
- render_circle(&mut layer, *point, (zoom as u64 / 4).max(2) - 1, [1].into());
- }
-
- for (a, b) in points.iter().zip(points.iter().skip(1)) {
- render_line(&mut layer, *a, *b, (zoom as u64 / 4).max(1), [1].into());
- }
-
- merge_heat_counter(&mut heatcounter, &layer);
- tick.send(()).unwrap();
- }
- Ok(heatcounter)
- }
-
- /// Lazily colorizes a [`HeatCounter`] by colorizing it tile-by-tile and saving a tile before
- /// rendering the next one.
- ///
- /// This function calls the given callback with each rendered tile, and the function is responsible
- /// for saving it. If the callback returns an `Err(...)`, the error is passed through.
- ///
- /// Note that this function internally uses `rayon` for parallization. If you want to limit the
- /// number of threads used, set up the global [`rayon::ThreadPool`] first.
- fn colorize(&self, layer: HeatCounter, tx: Sender<RenderedTile>) -> Result<()> {
- let max = layer.pixels().map(|l| l.0[0]).max().unwrap_or_default();
- if max == 0 {
- return Ok(());
- }
-
- layer
- .into_parallel_tiles()
- .try_for_each_with(tx, |tx, (tile_x, tile_y, tile)| {
- let colorized = colorize_tile(&tile, max.into());
- let data = layer::compress_png_as_bytes(&colorized)?;
- tx.send(RenderedTile {
- x: tile_x,
- y: tile_y,
- data,
- })?;
- Ok::<(), Report>(())
- })
- }
-
- fn tile_count(&self, layer: &HeatCounter) -> Result<u64> {
- Ok(layer.tile_count().try_into().unwrap())
- }
-}
diff --git a/src/renderer/marktile.rs b/src/renderer/marktile.rs
deleted file mode 100644
index 1e3020f..0000000
--- a/src/renderer/marktile.rs
+++ /dev/null
@@ -1,65 +0,0 @@
-//! Actual rendering functions for tile hunts.
-//!
-//! This renders a tile as "transparent green" if any track passes through it.
-//!
-//! Note that is version of "tile hunt" is a bit silly, as the tile size changes with the zoom
-//! level. For a better version, the "tile hunt size" should be fixed to a given zoom.
-use color_eyre::eyre::Result;
-use crossbeam_channel::Sender;
-use fnv::FnvHashSet;
-
-use super::{
- super::{
- gpx::Coordinates,
- layer::{TILE_HEIGHT, TILE_WIDTH},
- },
- RenderedTile,
-};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub struct Renderer;
-
-impl super::Renderer for Renderer {
- type Prepared = FnvHashSet<(u64, u64)>;
-
- fn prepare(
- &self,
- zoom: u32,
- tracks: &[Vec<Coordinates>],
- tick: Sender<()>,
- ) -> Result<Self::Prepared> {
- let mut marked = FnvHashSet::default();
-
- for track in tracks {
- for point in track {
- let merc = point.web_mercator(zoom);
- let tile_x = merc.0 / TILE_WIDTH;
- let tile_y = merc.1 / TILE_HEIGHT;
- marked.insert((tile_x, tile_y));
- }
-
- tick.send(()).unwrap();
- }
-
- Ok(marked)
- }
-
- fn colorize(&self, layer: Self::Prepared, tx: Sender<RenderedTile>) -> Result<()> {
- // The tile is hand-crafted to be very small. See
- // <https://www.mjt.me.uk/posts/smallest-png/> for a reference, and of course the actual
- // PNG specification <http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html>.
- static IMAGE_DATA: &[u8] = include_bytes!("tile-marked.png");
- for (tile_x, tile_y) in layer {
- tx.send(RenderedTile {
- x: tile_x,
- y: tile_y,
- data: IMAGE_DATA.to_vec(),
- })?;
- }
- Ok(())
- }
-
- fn tile_count(&self, layer: &Self::Prepared) -> Result<u64> {
- Ok(layer.len().try_into().unwrap())
- }
-}
diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs
deleted file mode 100644
index 73c2e87..0000000
--- a/src/renderer/mod.rs
+++ /dev/null
@@ -1,99 +0,0 @@
-//! Generic "tile rendering" methods.
-use std::thread;
-
-use color_eyre::Result;
-use crossbeam_channel::Sender;
-
-use super::gpx::Coordinates;
-
-pub mod heatmap;
-pub mod marktile;
-pub mod tilehunt;
-
-const CHANNEL_SIZE: usize = 30;
-
-/// Represents a fully rendered tile.
-#[derive(Debug, Clone)]
-pub struct RenderedTile {
- /// The `x` coordinate of the tile.
- pub x: u64,
- /// The `y` coordinate of the tile.
- pub y: u64,
- /// The encoded (PNG) image data, ready to be saved to disk.
- pub data: Vec<u8>,
-}
-
-/// An object that is responsible for turning raw GPX tracks into a representation.
-///
-/// This is done in two steps, preparation and actual rendering. This allows different feedback for
-/// the user.
-pub trait Renderer: Send + Sync {
- type Prepared: Send;
-
- /// Prepare the rendered data.
- ///
- /// The `tick` channel is used to provide user-feedback, for every finished track a tick should
- /// be sent.
- fn prepare(
- &self,
- zoom: u32,
- tracks: &[Vec<Coordinates>],
- tick: Sender<()>,
- ) -> Result<Self::Prepared>;
-
- /// Actually produce the colored tiles, using the previously prepared data.
- ///
- /// The `saver` channel is used to send the finished tiles to a thread that is responsible for
- /// saving them.
- fn colorize(&self, prepared: Self::Prepared, saver: Sender<RenderedTile>) -> Result<()>;
-
- /// Returns the tile count of the prepared data.
- ///
- /// This is used for the user interface, to scale progress bars appropriately.
- fn tile_count(&self, prepared: &Self::Prepared) -> Result<u64>;
-}
-
-/// A convenience wrapper to call [`Renderer::prepare`].
-///
-/// This function takes the same arguments, but provides the ability to use a callback closure
-/// instead of having to set up a channel. The callback is always called on the same thread.
-pub fn prepare<R: Renderer, F: FnMut() -> Result<()>>(
- renderer: &R,
- zoom: u32,
- tracks: &[Vec<Coordinates>],
- mut tick: F,
-) -> Result<R::Prepared> {
- thread::scope(|s| {
- let (sender, receiver) = crossbeam_channel::bounded(CHANNEL_SIZE);
-
- let preparer = s.spawn(|| renderer.prepare(zoom, tracks, sender));
-
- for _ in receiver {
- tick()?;
- }
-
- preparer.join().unwrap()
- })
-}
-
-/// A convenience wrapper to call [`Renderer::colorize`].
-///
-/// This function takes the same arguments, but provides the ability to use a callback closure
-/// instead of having to set up a channel. The callback is always called on the same thread.
-pub fn colorize<R: Renderer, F: FnMut(RenderedTile) -> Result<()>>(
- renderer: &R,
- prepared: R::Prepared,
- mut saver: F,
-) -> Result<()> {
- thread::scope(|s| {
- let (sender, receiver) = crossbeam_channel::bounded(CHANNEL_SIZE);
-
- let colorizer = s.spawn(|| renderer.colorize(prepared, sender));
-
- for tile in receiver {
- saver(tile)?;
- }
-
- colorizer.join().unwrap()
- })
-}
diff --git a/src/renderer/tile-marked.png b/src/renderer/tile-marked.png
deleted file mode 100644
index 0b05ce2..0000000
--- a/src/renderer/tile-marked.png
+++ /dev/null
Binary files differ
diff --git a/src/renderer/tilehunt.rs b/src/renderer/tilehunt.rs
deleted file mode 100644
index 9081523..0000000
--- a/src/renderer/tilehunt.rs
+++ /dev/null
@@ -1,153 +0,0 @@
-//! Actual rendering functions for tile hunts.
-//!
-//! This renders a tile as "transparent green" if any track passes through it.
-//!
-//! Note that is version of "tile hunt" is a bit silly, as the tile size changes with the zoom
-//! level. For a better version, the "tile hunt size" should be fixed to a given zoom.
-use std::cmp::Ordering;
-
-use color_eyre::eyre::Result;
-use crossbeam_channel::Sender;
-use fnv::{FnvHashMap, FnvHashSet};
-use image::RgbaImage;
-use imageproc::{drawing::draw_filled_rect_mut, rect::Rect};
-use rayon::iter::{IntoParallelIterator, ParallelIterator};
-
-use super::{
- super::{
- gpx::Coordinates,
- layer::{self, TILE_HEIGHT, TILE_WIDTH},
- },
- RenderedTile,
-};
-
-fn render_squares(grid: u32, inner: Vec<(u8, u8)>) -> Result<Vec<u8>> {
- // We re-use the tiny PNG if possible
- static FULL_TILE: &[u8] = include_bytes!("tile-marked.png");
- if grid == 1 && !inner.is_empty() {
- return Ok(FULL_TILE.to_vec());
- }
- let mut base =
- RgbaImage::from_pixel(TILE_WIDTH as u32, TILE_HEIGHT as u32, [0, 0, 0, 0].into());
- let patch_size = TILE_WIDTH as u32 / grid;
-
- for (patch_x, patch_y) in inner {
- draw_filled_rect_mut(
- &mut base,
- Rect::at(
- patch_x as i32 * patch_size as i32,
- patch_y as i32 * patch_size as i32,
- )
- .of_size(patch_size, patch_size),
- [0, 255, 0, 128].into(),
- );
- }
-
- layer::compress_png_as_bytes(&base)
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-pub struct Renderer(u32);
-
-impl Renderer {
- pub fn new(hunter_zoom: u32) -> Self {
- Renderer(hunter_zoom)
- }
-
- #[inline]
- pub fn hunter_zoom(&self) -> u32 {
- self.0
- }
-}
-
-impl super::Renderer for Renderer {
- type Prepared = (u32, FnvHashMap<(u64, u64), Vec<(u8, u8)>>);
-
- fn prepare(
- &self,
- zoom: u32,
- tracks: &[Vec<Coordinates>],
- tick: Sender<()>,
- ) -> Result<Self::Prepared> {
- let mut marked = FnvHashSet::default();
-
- for track in tracks {
- for point in track {
- let merc = point.web_mercator(self.hunter_zoom());
- let tile_x = merc.0 / TILE_WIDTH;
- let tile_y = merc.1 / TILE_HEIGHT;
- marked.insert((tile_x, tile_y));
- }
-
- tick.send(()).unwrap();
- }
-
- let scale = i32::try_from(zoom).unwrap() - i32::try_from(self.hunter_zoom()).unwrap();
- let grid = if scale >= 0 {
- 1
- } else {
- 2u64.pow(scale.abs().min(8) as u32)
- };
-
- let mut result = FnvHashMap::<(u64, u64), Vec<(u8, u8)>>::default();
-
- for (tile_x, tile_y) in marked {
- match scale.cmp(&0) {
- Ordering::Equal =>
- // The current zoom level is the same as the hunter level, so the tiles have a 1:1
- // mapping
- {
- result.entry((tile_x, tile_y)).or_default().push((0u8, 0u8))
- }
- Ordering::Less =>
- // In this case we are "zoomed out" further than the hunter level, so a marked tile
- // has to be scaled down and we need to figure out where in the "big tile" our
- // marked tile is
- {
- result
- .entry((tile_x / grid, tile_y / grid))
- .or_default()
- .push((
- (tile_x % grid).try_into().unwrap(),
- (tile_y % grid).try_into().unwrap(),
- ))
- }
- Ordering::Greater => {
- // In this case, we are zoomed in more than the hunter level. Each marked tile
- // expands to multiple tiles.
- let multiplier = 2u64.pow(scale as u32);
- for dx in 0..multiplier {
- for dy in 0..multiplier {
- result
- .entry((tile_x * multiplier + dx, tile_y * multiplier + dy))
- .or_default()
- .push((0u8, 0u8));
- }
- }
- }
- }
- }
-
- Ok((grid.try_into().unwrap(), result))
- }
-
- fn colorize(&self, layer: Self::Prepared, tx: Sender<RenderedTile>) -> Result<()> {
- let grid = layer.0;
- layer
- .1
- .into_par_iter()
- .try_for_each_with(tx, |tx, ((tile_x, tile_y), inner)| {
- let data = render_squares(grid, inner)?;
- tx.send(RenderedTile {
- x: tile_x,
- y: tile_y,
- data,
- })?;
- Ok(())
- })
- }
-
- fn tile_count(&self, layer: &Self::Prepared) -> Result<u64> {
- Ok(layer.1.len().try_into().unwrap())
- }
-}
diff --git a/src/storage.rs b/src/storage.rs
deleted file mode 100644
index 9e6b270..0000000
--- a/src/storage.rs
+++ /dev/null
@@ -1,165 +0,0 @@
-//! 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 color_eyre::{
- eyre::{bail, WrapErr},
- Result,
-};
-use rusqlite::{params, Connection};
-use std::{
- fs,
- io::ErrorKind,
- path::{Path, PathBuf},
-};
-
-/// 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 => {
- let parent = path.parent().unwrap_or_else(|| Path::new("/"));
- fs::create_dir(path)
- .context(format!("Could not create output directory at {parent:?}"))?
- }
- Err(e) => Err(e).context("Error while checking output directory")?,
- Ok(m) if m.is_dir() => (),
- Ok(_) => bail!("Output directory is not a directory"),
- }
- 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)?;
- 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)?,
- Ok(m) if !m.is_dir() => bail!("Output path is not a directory"),
- _ => {}
- }
- let file = folder.join(format!("{y}.png"));
- fs::write(file, data)?;
- Ok(())
- }
-
- fn finish(&mut self) -> Result<()> {
- Ok(())
- }
-}
-
-/// SQLite based storage.
-///
-/// This stores tiles in a SQLite database. The database will have a single table:
-///
-/// ```sql
-/// CREATE TABLE tiles (
-/// zoom INTEGER,
-/// x INTEGER,
-/// y INTEGER,
-/// data BLOB,
-/// PRIMARY KEY (zoom, x, y)
-/// );
-/// ```
-#[derive(Debug)]
-pub struct Sqlite {
- connection: Connection,
-}
-
-impl Sqlite {
- /// Create a new SQLite backed tile store.
- ///
- /// The database will be saved at the given location. Note that the database must not yet
- /// exist.
- pub fn connect<P: AsRef<Path>>(file: P) -> Result<Self> {
- let path = file.as_ref();
- if fs::metadata(path).is_ok() {
- bail!("Path {path:?} already exists, refusing to open")
- }
- let connection = Connection::open(path)?;
- Ok(Sqlite { connection })
- }
-}
-
-impl Storage for Sqlite {
- fn prepare(&mut self) -> Result<()> {
- self.connection.execute(
- "CREATE TABLE tiles (
- zoom INTEGER,
- x INTEGER,
- y INTEGER,
- data BLOB,
- PRIMARY KEY (zoom, x, y)
- );",
- (),
- )?;
- 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<()> {
- self.connection.execute(
- "INSERT INTO tiles (zoom, x, y, data) VALUES (?, ?, ?, ?)",
- params![zoom, x, y, data],
- )?;
- Ok(())
- }
-
- fn finish(&mut self) -> Result<()> {
- self.connection.execute("COMMIT;", ())?;
- Ok(())
- }
-}