Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Heatmap rendering: `mapcat -H data.geojson` or `mapcat data.geojson:heatmap` renders point data as a colour-gradient heatmap (blue → cyan → green → yellow → red).
- Per-file heatmap suffix: append `:heatmap` to any file argument to render that file as a heatmap while other files render normally (e.g. `mapcat routes.geojson points.geojson:heatmap`).

## [0.2.10] - 2026-04-18

- Headless rendering: `mapcat -o file.png` renders maps directly to PNG (file or stdin input, configurable dimensions, auto-detects format).
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ curl 'https://api.example.com/geo' | mapcat # From API

Or just drag and drop files onto the map window.

## Heatmap

Render point data as a colour-gradient heatmap instead of individual shapes:

```bash
# All inputs as heatmap
mapcat -H points.geojson

# Mix: one file as heatmap, another normal
mapcat routes.geojson points.geojson:heatmap

# Headless heatmap
mapcat -o map.png -H points.geojson
```

## Headless Rendering

Render maps directly to PNG without opening a window:
Expand All @@ -58,11 +73,17 @@ Render maps directly to PNG without opening a window:
# Render a file
mapcat -o map.png data.geojson

# Multiple files
mapcat -o map.png routes.geojson points.geojson

# Pipe data
cat data.geojson | mapcat -o map.png

# Custom image size
mapcat -o map.png data.geojson --width 3200 --height 2400

# Black background, no map tiles
mapcat -o map.png --no-map data.geojson
```

## Basic Controls
Expand Down
163 changes: 142 additions & 21 deletions src/bin/mapcat/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;

use clap::Parser as CliParser;
use egui::Color32;
use log::error;
use mapvas::map::map_event::{Color, MapEvent};
use mapvas::map::coordinates::PixelCoordinate;
use mapvas::map::geometry_collection::{Geometry, Metadata};
use mapvas::map::map_event::{Color, Layer, MapEvent};
use mapvas::parser::{
AutoFileParser, FileParser, GeoJsonParser, GpxParser, GrepParser, JsonParser, KmlParser,
TTJsonParser,
Expand All @@ -13,8 +17,82 @@ use std::io::{BufRead, BufReader, Read};

mod sender;

#[derive(Debug, Clone)]
struct FileArg {
path: std::path::PathBuf,
heatmap: bool,
}

impl std::str::FromStr for FileArg {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(path) = s.strip_suffix(":heatmap") {
Ok(Self {
path: path.into(),
heatmap: true,
})
} else {
Ok(Self {
path: s.into(),
heatmap: false,
})
}
}
}

/// Walk a geometry tree and append every coordinate it contains to `out`.
fn collect_coords(geometry: &Geometry<PixelCoordinate>, out: &mut Vec<PixelCoordinate>) {
match geometry {
Geometry::Point(c, _) => out.push(*c),
Geometry::LineString(coords, _) | Geometry::Polygon(coords, _) => {
out.extend(coords.iter().copied());
}
Geometry::Heatmap(coords, _) => out.extend(coords.iter().copied()),
Geometry::GeometryCollection(geometries, _) => {
for g in geometries {
collect_coords(g, out);
}
}
}
}

/// Replace every `MapEvent::Layer` in `events` with a single `Geometry::Heatmap`
/// per layer id, accumulating all the coordinates from the original geometries.
fn convert_to_heatmap(events: Vec<MapEvent>) -> Vec<MapEvent> {
let mut heatmap_coords: BTreeMap<String, Vec<PixelCoordinate>> = BTreeMap::new();
let mut other = Vec::new();

for event in events {
if let MapEvent::Layer(Layer { id, geometries }) = event {
let entry = heatmap_coords.entry(id).or_default();
for g in &geometries {
collect_coords(g, entry);
}
} else {
other.push(event);
}
}

let mut result = Vec::with_capacity(other.len() + heatmap_coords.len());
for (id, coords) in heatmap_coords {
if coords.is_empty() {
continue;
}
result.push(MapEvent::Layer(Layer {
id,
geometries: vec![Geometry::Heatmap(
std::sync::Arc::new(coords),
Metadata::default(),
)],
}));
}
result.extend(other);
result
}

#[derive(clap::Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[allow(clippy::struct_excessive_bools)]
struct Args {
/// Which parser to use. Values: auto (file extension based with fallbacks), grep, ttjson, json, geojson, gpx, kml.
#[arg(short, long, default_value = "auto")]
Expand Down Expand Up @@ -45,10 +123,18 @@ struct Args {
#[arg(short, long, default_value = "")]
screenshot: String,

/// Render all input coordinates as a heatmap instead of individual shapes.
#[arg(short = 'H', long)]
heatmap: bool,

/// Render directly to an image file instead of sending to mapvas
#[arg(short, long)]
output: Option<std::path::PathBuf>,

/// Render without map background (black background, only geometries). Only with --output.
#[arg(long)]
no_map: bool,

/// Image width in pixels (only with --output)
#[arg(long, default_value_t = 1980)]
width: u32,
Expand All @@ -58,7 +144,8 @@ struct Args {
height: u32,

/// Files to parse. stdin is used if not provided.
files: Vec<std::path::PathBuf>,
/// Append `:heatmap` to a file to render it as a heatmap (e.g. `points.geojson:heatmap`).
files: Vec<FileArg>,
}

fn render_output(args: &Args) {
Expand All @@ -70,7 +157,13 @@ fn render_output(args: &Args) {
let config = Config::new();
init_style_config(config.vector_style_file.as_deref());

let renderer = HeadlessRenderer::with_config(config);
let renderer = if args.no_map {
HeadlessRenderer::with_config(config)
.without_map()
.with_background_color(Color32::BLACK)
} else {
HeadlessRenderer::with_config(config)
};

let mut events: Vec<MapEvent> = Vec::new();
if args.files.is_empty() {
Expand All @@ -85,14 +178,23 @@ fn render_output(args: &Args) {
.with_invert_coordinates(args.invert_coordinates);
events.extend(content_parser.parse());
} else {
for path in &args.files {
let mut parser = AutoFileParser::new(path)
for file_arg in &args.files {
let mut parser = AutoFileParser::new(&file_arg.path)
.with_label_pattern(&args.label_pattern)
.with_invert_coordinates(args.invert_coordinates);
events.extend(parser.parse());
let file_events: Vec<MapEvent> = parser.parse().collect();
if args.heatmap || file_arg.heatmap {
events.extend(convert_to_heatmap(file_events));
} else {
events.extend(file_events);
}
}
}

if args.files.is_empty() && args.heatmap {
events = convert_to_heatmap(events);
}

// Always focus on data in output mode
events.push(MapEvent::Focus);

Expand All @@ -111,16 +213,16 @@ fn render_output(args: &Args) {
);
}

fn readers(paths: &[std::path::PathBuf]) -> Vec<Box<dyn BufRead>> {
let mut res: Vec<Box<dyn BufRead>> = Vec::new();
if paths.is_empty() {
res.push(Box::new(std::io::stdin().lock()));
fn readers(files: &[FileArg]) -> Vec<(Box<dyn BufRead>, bool)> {
let mut res: Vec<(Box<dyn BufRead>, bool)> = Vec::new();
if files.is_empty() {
res.push((Box::new(std::io::stdin().lock()), false));
} else {
for f in paths {
match File::open(f) {
Ok(file) => res.push(Box::new(BufReader::new(file))),
for f in files {
match File::open(&f.path) {
Ok(file) => res.push((Box::new(BufReader::new(file)), f.heatmap)),
Err(e) => {
eprintln!("Error: Failed to open file '{}': {}", f.display(), e);
eprintln!("Error: Failed to open file '{}': {}", f.path.display(), e);
std::process::exit(1);
}
}
Expand Down Expand Up @@ -152,6 +254,12 @@ async fn main() {

let (sender, _) = sender::MapSender::new().await;

let send_events = |events: Box<dyn Iterator<Item = MapEvent>>| {
for event in events {
sender.send_event(event);
}
};

if args.parser == "auto" {
if args.files.is_empty() {
// Use content-based auto-parser for stdin
Expand All @@ -165,16 +273,24 @@ async fn main() {
let content_parser = mapvas::parser::ContentAutoParser::new(content)
.with_label_pattern(&args.label_pattern)
.with_invert_coordinates(args.invert_coordinates);
for event in content_parser.parse() {
sender.send_event(event);
let events: Vec<MapEvent> = content_parser.parse();
if args.heatmap {
send_events(Box::new(convert_to_heatmap(events).into_iter()));
} else {
send_events(Box::new(events.into_iter()));
}
} else {
// Use file-based auto-parser for each file
for file_path in &args.files {
let mut auto_parser = AutoFileParser::new(file_path)
for file_arg in &args.files {
let mut auto_parser = AutoFileParser::new(&file_arg.path)
.with_label_pattern(&args.label_pattern)
.with_invert_coordinates(args.invert_coordinates);
auto_parser.parse().for_each(|e| sender.send_event(e));
let events: Vec<MapEvent> = auto_parser.parse().collect();
if args.heatmap || file_arg.heatmap {
send_events(Box::new(convert_to_heatmap(events).into_iter()));
} else {
send_events(Box::new(events.into_iter()));
}
}
}
} else {
Expand All @@ -199,9 +315,14 @@ async fn main() {
};

let readers = readers(&args.files);
for reader in readers {
for (reader, file_heatmap) in readers {
let mut parser = parser();
parser.parse(reader).for_each(|e| sender.send_event(e));
let events: Vec<MapEvent> = parser.parse(reader).collect();
if args.heatmap || file_heatmap {
send_events(Box::new(convert_to_heatmap(events).into_iter()));
} else {
send_events(Box::new(events.into_iter()));
}
}
}

Expand Down
35 changes: 32 additions & 3 deletions src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use crate::{
pub struct HeadlessRenderer {
config: Config,
wait_for_tiles: bool,
no_map: bool,
background_color: Option<egui::Color32>,
}

impl HeadlessRenderer {
Expand All @@ -28,6 +30,8 @@ impl HeadlessRenderer {
Self {
config: Config::new(),
wait_for_tiles: true,
no_map: false,
background_color: None,
}
}

Expand All @@ -37,6 +41,8 @@ impl HeadlessRenderer {
Self {
config,
wait_for_tiles: true,
no_map: false,
background_color: None,
}
}

Expand All @@ -46,6 +52,19 @@ impl HeadlessRenderer {
self
}

#[must_use]
pub fn without_map(mut self) -> Self {
self.no_map = true;
self.wait_for_tiles = false;
self
}

#[must_use]
pub fn with_background_color(mut self, color: egui::Color32) -> Self {
self.background_color = Some(color);
self
}

/// Render map with the given events to an image.
///
/// Loads geometries from the provided `MapEvent`s, focuses the view,
Expand All @@ -61,7 +80,17 @@ impl HeadlessRenderer {
#[must_use]
pub fn render(&self, events: &[MapEvent], width: u32, height: u32) -> image::RgbaImage {
let ctx = egui::Context::default();
let (mut map, remote, data_holder) = Map::new(ctx, self.config.clone());
if let Some(color) = self.background_color {
ctx.global_style_mut(|s| {
s.visuals.panel_fill = color;
s.visuals.window_fill = color;
});
}
let (mut map, remote, data_holder) = if self.no_map {
Map::new_without_tiles(ctx, self.config.clone())
} else {
Map::new(ctx, self.config.clone())
};
map.set_headless();

for event in events {
Expand Down Expand Up @@ -106,8 +135,8 @@ impl HeadlessRenderer {
break;
}
}
// One final run to render any last tiles that arrived
let _ = harness.try_run_realtime();
// One final frame to paint the tiles that arrived in the last loop iteration.
harness.step();
}

harness.render().expect("Failed to render headless image")
Expand Down
3 changes: 3 additions & 0 deletions src/map/distance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub fn distance_to_geometry(
.iter()
.filter_map(|geom| distance_to_geometry(geom, click_coord))
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)),
// Heatmaps are not individually hover-selectable — walking every point on
// every mouse move freezes the UI for large datasets.
Geometry::Heatmap(_, _) => None,
}
}

Expand Down
Loading
Loading