hittekaart 0.2.0

Generates OSM heatmap tiles from GPX tracks
Documentation
//! 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 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::{
        error::Result,
        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())
    }
}