Skip to content

Commit 1be7cdb

Browse files
committed
Heatmap
1 parent 6de3109 commit 1be7cdb

8 files changed

Lines changed: 177 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Heatmap rendering: `mapcat -H data.geojson` or `mapcat data.geojson:heatmap` renders point data as a colour-gradient heatmap (blue → cyan → green → yellow → red).
6+
- 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`).
7+
58
## [0.2.10] - 2026-04-18
69

710
- Headless rendering: `mapcat -o file.png` renders maps directly to PNG (file or stdin input, configurable dimensions, auto-detects format).

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ curl 'https://api.example.com/geo' | mapcat # From API
5050

5151
Or just drag and drop files onto the map window.
5252

53+
## Heatmap
54+
55+
Render point data as a colour-gradient heatmap instead of individual shapes:
56+
57+
```bash
58+
# All inputs as heatmap
59+
mapcat -H points.geojson
60+
61+
# Mix: one file as heatmap, another normal
62+
mapcat routes.geojson points.geojson:heatmap
63+
64+
# Headless heatmap
65+
mapcat -o map.png -H points.geojson
66+
```
67+
5368
## Headless Rendering
5469

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

76+
# Multiple files
77+
mapcat -o map.png routes.geojson points.geojson
78+
6179
# Pipe data
6280
cat data.geojson | mapcat -o map.png
6381

6482
# Custom image size
6583
mapcat -o map.png data.geojson --width 3200 --height 2400
84+
85+
# Black background, no map tiles
86+
mapcat -o map.png --no-map data.geojson
6687
```
6788

6889
## Basic Controls

src/bin/mapcat/main.rs

Lines changed: 75 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::Path;
33
use std::str::FromStr;
44

55
use clap::Parser as CliParser;
6+
use egui::Color32;
67
use log::error;
78
use mapvas::map::coordinates::PixelCoordinate;
89
use mapvas::map::geometry_collection::{Geometry, Metadata};
@@ -16,6 +17,29 @@ use std::io::{BufRead, BufReader, Read};
1617

1718
mod sender;
1819

20+
#[derive(Debug, Clone)]
21+
struct FileArg {
22+
path: std::path::PathBuf,
23+
heatmap: bool,
24+
}
25+
26+
impl std::str::FromStr for FileArg {
27+
type Err = std::convert::Infallible;
28+
fn from_str(s: &str) -> Result<Self, Self::Err> {
29+
if let Some(path) = s.strip_suffix(":heatmap") {
30+
Ok(Self {
31+
path: path.into(),
32+
heatmap: true,
33+
})
34+
} else {
35+
Ok(Self {
36+
path: s.into(),
37+
heatmap: false,
38+
})
39+
}
40+
}
41+
}
42+
1943
/// Walk a geometry tree and append every coordinate it contains to `out`.
2044
fn collect_coords(geometry: &Geometry<PixelCoordinate>, out: &mut Vec<PixelCoordinate>) {
2145
match geometry {
@@ -107,6 +131,10 @@ struct Args {
107131
#[arg(short, long)]
108132
output: Option<std::path::PathBuf>,
109133

134+
/// Render without map background (black background, only geometries). Only with --output.
135+
#[arg(long)]
136+
no_map: bool,
137+
110138
/// Image width in pixels (only with --output)
111139
#[arg(long, default_value_t = 1980)]
112140
width: u32,
@@ -116,7 +144,8 @@ struct Args {
116144
height: u32,
117145

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

122151
fn render_output(args: &Args) {
@@ -128,7 +157,13 @@ fn render_output(args: &Args) {
128157
let config = Config::new();
129158
init_style_config(config.vector_style_file.as_deref());
130159

131-
let renderer = HeadlessRenderer::with_config(config);
160+
let renderer = if args.no_map {
161+
HeadlessRenderer::with_config(config)
162+
.without_map()
163+
.with_background_color(Color32::BLACK)
164+
} else {
165+
HeadlessRenderer::with_config(config)
166+
};
132167

133168
let mut events: Vec<MapEvent> = Vec::new();
134169
if args.files.is_empty() {
@@ -143,15 +178,20 @@ fn render_output(args: &Args) {
143178
.with_invert_coordinates(args.invert_coordinates);
144179
events.extend(content_parser.parse());
145180
} else {
146-
for path in &args.files {
147-
let mut parser = AutoFileParser::new(path)
181+
for file_arg in &args.files {
182+
let mut parser = AutoFileParser::new(&file_arg.path)
148183
.with_label_pattern(&args.label_pattern)
149184
.with_invert_coordinates(args.invert_coordinates);
150-
events.extend(parser.parse());
185+
let file_events: Vec<MapEvent> = parser.parse().collect();
186+
if args.heatmap || file_arg.heatmap {
187+
events.extend(convert_to_heatmap(file_events));
188+
} else {
189+
events.extend(file_events);
190+
}
151191
}
152192
}
153193

154-
if args.heatmap {
194+
if args.files.is_empty() && args.heatmap {
155195
events = convert_to_heatmap(events);
156196
}
157197

@@ -173,16 +213,16 @@ fn render_output(args: &Args) {
173213
);
174214
}
175215

176-
fn readers(paths: &[std::path::PathBuf]) -> Vec<Box<dyn BufRead>> {
177-
let mut res: Vec<Box<dyn BufRead>> = Vec::new();
178-
if paths.is_empty() {
179-
res.push(Box::new(std::io::stdin().lock()));
216+
fn readers(files: &[FileArg]) -> Vec<(Box<dyn BufRead>, bool)> {
217+
let mut res: Vec<(Box<dyn BufRead>, bool)> = Vec::new();
218+
if files.is_empty() {
219+
res.push((Box::new(std::io::stdin().lock()), false));
180220
} else {
181-
for f in paths {
182-
match File::open(f) {
183-
Ok(file) => res.push(Box::new(BufReader::new(file))),
221+
for f in files {
222+
match File::open(&f.path) {
223+
Ok(file) => res.push((Box::new(BufReader::new(file)), f.heatmap)),
184224
Err(e) => {
185-
eprintln!("Error: Failed to open file '{}': {}", f.display(), e);
225+
eprintln!("Error: Failed to open file '{}': {}", f.path.display(), e);
186226
std::process::exit(1);
187227
}
188228
}
@@ -215,15 +255,8 @@ async fn main() {
215255
let (sender, _) = sender::MapSender::new().await;
216256

217257
let send_events = |events: Box<dyn Iterator<Item = MapEvent>>| {
218-
if args.heatmap {
219-
let collected: Vec<MapEvent> = events.collect();
220-
for event in convert_to_heatmap(collected) {
221-
sender.send_event(event);
222-
}
223-
} else {
224-
for event in events {
225-
sender.send_event(event);
226-
}
258+
for event in events {
259+
sender.send_event(event);
227260
}
228261
};
229262

@@ -240,15 +273,24 @@ async fn main() {
240273
let content_parser = mapvas::parser::ContentAutoParser::new(content)
241274
.with_label_pattern(&args.label_pattern)
242275
.with_invert_coordinates(args.invert_coordinates);
243-
send_events(Box::new(content_parser.parse().into_iter()));
276+
let events: Vec<MapEvent> = content_parser.parse();
277+
if args.heatmap {
278+
send_events(Box::new(convert_to_heatmap(events).into_iter()));
279+
} else {
280+
send_events(Box::new(events.into_iter()));
281+
}
244282
} else {
245283
// Use file-based auto-parser for each file
246-
for file_path in &args.files {
247-
let mut auto_parser = AutoFileParser::new(file_path)
284+
for file_arg in &args.files {
285+
let mut auto_parser = AutoFileParser::new(&file_arg.path)
248286
.with_label_pattern(&args.label_pattern)
249287
.with_invert_coordinates(args.invert_coordinates);
250288
let events: Vec<MapEvent> = auto_parser.parse().collect();
251-
send_events(Box::new(events.into_iter()));
289+
if args.heatmap || file_arg.heatmap {
290+
send_events(Box::new(convert_to_heatmap(events).into_iter()));
291+
} else {
292+
send_events(Box::new(events.into_iter()));
293+
}
252294
}
253295
}
254296
} else {
@@ -273,10 +315,14 @@ async fn main() {
273315
};
274316

275317
let readers = readers(&args.files);
276-
for reader in readers {
318+
for (reader, file_heatmap) in readers {
277319
let mut parser = parser();
278320
let events: Vec<MapEvent> = parser.parse(reader).collect();
279-
send_events(Box::new(events.into_iter()));
321+
if args.heatmap || file_heatmap {
322+
send_events(Box::new(convert_to_heatmap(events).into_iter()));
323+
} else {
324+
send_events(Box::new(events.into_iter()));
325+
}
280326
}
281327
}
282328

src/headless.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use crate::{
1919
pub struct HeadlessRenderer {
2020
config: Config,
2121
wait_for_tiles: bool,
22+
no_map: bool,
23+
background_color: Option<egui::Color32>,
2224
}
2325

2426
impl HeadlessRenderer {
@@ -28,6 +30,8 @@ impl HeadlessRenderer {
2830
Self {
2931
config: Config::new(),
3032
wait_for_tiles: true,
33+
no_map: false,
34+
background_color: None,
3135
}
3236
}
3337

@@ -37,6 +41,8 @@ impl HeadlessRenderer {
3741
Self {
3842
config,
3943
wait_for_tiles: true,
44+
no_map: false,
45+
background_color: None,
4046
}
4147
}
4248

@@ -46,6 +52,19 @@ impl HeadlessRenderer {
4652
self
4753
}
4854

55+
#[must_use]
56+
pub fn without_map(mut self) -> Self {
57+
self.no_map = true;
58+
self.wait_for_tiles = false;
59+
self
60+
}
61+
62+
#[must_use]
63+
pub fn with_background_color(mut self, color: egui::Color32) -> Self {
64+
self.background_color = Some(color);
65+
self
66+
}
67+
4968
/// Render map with the given events to an image.
5069
///
5170
/// Loads geometries from the provided `MapEvent`s, focuses the view,
@@ -61,7 +80,17 @@ impl HeadlessRenderer {
6180
#[must_use]
6281
pub fn render(&self, events: &[MapEvent], width: u32, height: u32) -> image::RgbaImage {
6382
let ctx = egui::Context::default();
64-
let (mut map, remote, data_holder) = Map::new(ctx, self.config.clone());
83+
if let Some(color) = self.background_color {
84+
ctx.global_style_mut(|s| {
85+
s.visuals.panel_fill = color;
86+
s.visuals.window_fill = color;
87+
});
88+
}
89+
let (mut map, remote, data_holder) = if self.no_map {
90+
Map::new_without_tiles(ctx, self.config.clone())
91+
} else {
92+
Map::new(ctx, self.config.clone())
93+
};
6594
map.set_headless();
6695

6796
for event in events {

src/map/geometry_collection.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,10 @@ mod tests {
762762
);
763763
let cloned = geom.clone();
764764
if let (Geometry::Heatmap(a, _), Geometry::Heatmap(b, _)) = (&geom, &cloned) {
765-
assert!(Arc::ptr_eq(a, b), "cloning Heatmap must share the Arc buffer");
765+
assert!(
766+
Arc::ptr_eq(a, b),
767+
"cloning Heatmap must share the Arc buffer"
768+
);
766769
} else {
767770
panic!("expected Heatmap variants");
768771
}

src/map/mapvas_egui.rs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,27 @@ impl Map {
6060
ctx: egui::Context,
6161
cfg: crate::config::Config,
6262
) -> (Self, Remote, Rc<dyn MapLayerHolder>) {
63-
let tile_layer = layer::TileLayer::from_config(ctx.clone(), &cfg);
63+
Self::new_impl(ctx, cfg, false)
64+
}
65+
66+
#[must_use]
67+
pub fn new_without_tiles(
68+
ctx: egui::Context,
69+
cfg: crate::config::Config,
70+
) -> (Self, Remote, Rc<dyn MapLayerHolder>) {
71+
Self::new_impl(ctx, cfg, true)
72+
}
73+
74+
fn new_impl(
75+
ctx: egui::Context,
76+
cfg: crate::config::Config,
77+
no_tile_layer: bool,
78+
) -> (Self, Remote, Rc<dyn MapLayerHolder>) {
79+
let tile_layer = if no_tile_layer {
80+
None
81+
} else {
82+
Some(layer::TileLayer::from_config(ctx.clone(), &cfg))
83+
};
6484
let shape_info = std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
6585
let shape_layer = layer::ShapeLayer::new(cfg.clone(), ctx.clone(), shape_info.clone());
6686
let shape_layer_sender = shape_layer.get_sender();
@@ -84,12 +104,14 @@ impl Map {
84104
acc
85105
});
86106

87-
let layers: Rc<Mutex<Vec<Box<dyn Layer>>>> = Rc::new(Mutex::new(vec![
88-
Box::new(tile_layer),
89-
Box::new(shape_layer),
90-
Box::new(command),
91-
Box::new(screenshot_layer),
92-
]));
107+
let mut layer_vec: Vec<Box<dyn Layer>> = Vec::new();
108+
if let Some(tl) = tile_layer {
109+
layer_vec.push(Box::new(tl));
110+
}
111+
layer_vec.push(Box::new(shape_layer));
112+
layer_vec.push(Box::new(command));
113+
layer_vec.push(Box::new(screenshot_layer));
114+
let layers: Rc<Mutex<Vec<Box<dyn Layer>>>> = Rc::new(Mutex::new(layer_vec));
93115

94116
let map_data_holder = Rc::new(MapLayerHolderImpl::new(layers.clone(), timeline.clone()));
95117

0 commit comments

Comments
 (0)