Skip to content

Commit ad81d81

Browse files
committed
Heatmap rendering
1 parent a4120e3 commit ad81d81

11 files changed

Lines changed: 430 additions & 29 deletions

File tree

src/bin/mapcat/main.rs

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
use std::collections::BTreeMap;
12
use std::path::Path;
23
use std::str::FromStr;
34

45
use clap::Parser as CliParser;
56
use log::error;
6-
use mapvas::map::map_event::{Color, MapEvent};
7+
use mapvas::map::coordinates::PixelCoordinate;
8+
use mapvas::map::geometry_collection::{Geometry, Metadata};
9+
use mapvas::map::map_event::{Color, Layer, MapEvent};
710
use mapvas::parser::{
811
AutoFileParser, FileParser, GeoJsonParser, GpxParser, GrepParser, JsonParser, KmlParser,
912
TTJsonParser,
@@ -13,8 +16,59 @@ use std::io::{BufRead, BufReader, Read};
1316

1417
mod sender;
1518

19+
/// Walk a geometry tree and append every coordinate it contains to `out`.
20+
fn collect_coords(geometry: &Geometry<PixelCoordinate>, out: &mut Vec<PixelCoordinate>) {
21+
match geometry {
22+
Geometry::Point(c, _) => out.push(*c),
23+
Geometry::LineString(coords, _) | Geometry::Polygon(coords, _) => {
24+
out.extend(coords.iter().copied());
25+
}
26+
Geometry::Heatmap(coords, _) => out.extend(coords.iter().copied()),
27+
Geometry::GeometryCollection(geometries, _) => {
28+
for g in geometries {
29+
collect_coords(g, out);
30+
}
31+
}
32+
}
33+
}
34+
35+
/// Replace every `MapEvent::Layer` in `events` with a single `Geometry::Heatmap`
36+
/// per layer id, accumulating all the coordinates from the original geometries.
37+
fn convert_to_heatmap(events: Vec<MapEvent>) -> Vec<MapEvent> {
38+
let mut heatmap_coords: BTreeMap<String, Vec<PixelCoordinate>> = BTreeMap::new();
39+
let mut other = Vec::new();
40+
41+
for event in events {
42+
if let MapEvent::Layer(Layer { id, geometries }) = event {
43+
let entry = heatmap_coords.entry(id).or_default();
44+
for g in &geometries {
45+
collect_coords(g, entry);
46+
}
47+
} else {
48+
other.push(event);
49+
}
50+
}
51+
52+
let mut result = Vec::with_capacity(other.len() + heatmap_coords.len());
53+
for (id, coords) in heatmap_coords {
54+
if coords.is_empty() {
55+
continue;
56+
}
57+
result.push(MapEvent::Layer(Layer {
58+
id,
59+
geometries: vec![Geometry::Heatmap(
60+
std::sync::Arc::new(coords),
61+
Metadata::default(),
62+
)],
63+
}));
64+
}
65+
result.extend(other);
66+
result
67+
}
68+
1669
#[derive(clap::Parser, Debug)]
1770
#[command(author, version, about, long_about = None)]
71+
#[allow(clippy::struct_excessive_bools)]
1872
struct Args {
1973
/// Which parser to use. Values: auto (file extension based with fallbacks), grep, ttjson, json, geojson, gpx, kml.
2074
#[arg(short, long, default_value = "auto")]
@@ -45,6 +99,10 @@ struct Args {
4599
#[arg(short, long, default_value = "")]
46100
screenshot: String,
47101

102+
/// Render all input coordinates as a heatmap instead of individual shapes.
103+
#[arg(short = 'H', long)]
104+
heatmap: bool,
105+
48106
/// Render directly to an image file instead of sending to mapvas
49107
#[arg(short, long)]
50108
output: Option<std::path::PathBuf>,
@@ -93,6 +151,10 @@ fn render_output(args: &Args) {
93151
}
94152
}
95153

154+
if args.heatmap {
155+
events = convert_to_heatmap(events);
156+
}
157+
96158
// Always focus on data in output mode
97159
events.push(MapEvent::Focus);
98160

@@ -152,6 +214,19 @@ async fn main() {
152214

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

217+
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+
}
227+
}
228+
};
229+
155230
if args.parser == "auto" {
156231
if args.files.is_empty() {
157232
// Use content-based auto-parser for stdin
@@ -165,16 +240,15 @@ async fn main() {
165240
let content_parser = mapvas::parser::ContentAutoParser::new(content)
166241
.with_label_pattern(&args.label_pattern)
167242
.with_invert_coordinates(args.invert_coordinates);
168-
for event in content_parser.parse() {
169-
sender.send_event(event);
170-
}
243+
send_events(Box::new(content_parser.parse().into_iter()));
171244
} else {
172245
// Use file-based auto-parser for each file
173246
for file_path in &args.files {
174247
let mut auto_parser = AutoFileParser::new(file_path)
175248
.with_label_pattern(&args.label_pattern)
176249
.with_invert_coordinates(args.invert_coordinates);
177-
auto_parser.parse().for_each(|e| sender.send_event(e));
250+
let events: Vec<MapEvent> = auto_parser.parse().collect();
251+
send_events(Box::new(events.into_iter()));
178252
}
179253
}
180254
} else {
@@ -201,7 +275,8 @@ async fn main() {
201275
let readers = readers(&args.files);
202276
for reader in readers {
203277
let mut parser = parser();
204-
parser.parse(reader).for_each(|e| sender.send_event(e));
278+
let events: Vec<MapEvent> = parser.parse(reader).collect();
279+
send_events(Box::new(events.into_iter()));
205280
}
206281
}
207282

src/map/distance.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub fn distance_to_geometry(
1414
.iter()
1515
.filter_map(|geom| distance_to_geometry(geom, click_coord))
1616
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)),
17+
// Heatmaps are not individually hover-selectable — walking every point on
18+
// every mouse move freezes the UI for large datasets.
19+
Geometry::Heatmap(_, _) => None,
1720
}
1821
}
1922

src/map/geometry_collection.rs

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use std::iter::once;
2+
use std::sync::Arc;
23

34
use chrono::{DateTime, Utc};
45
use egui::Color32;
56
use itertools::Either;
6-
use serde::{Deserialize, Serialize};
7+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
78

89
use super::coordinates::{BoundingBox, Coordinate, PixelCoordinate, WGS84Coordinate};
910

@@ -285,6 +286,30 @@ pub enum Geometry<C: Coordinate> {
285286
Point(C, Metadata),
286287
LineString(Vec<C>, Metadata),
287288
Polygon(Vec<C>, Metadata),
289+
/// Heatmap density layer. Wrapped in `Arc` so the (potentially huge)
290+
/// coordinate buffer is shared across tile-render clones rather than copied.
291+
Heatmap(
292+
#[serde(serialize_with = "serialize_arc_vec")]
293+
#[serde(deserialize_with = "deserialize_arc_vec")]
294+
Arc<Vec<C>>,
295+
Metadata,
296+
),
297+
}
298+
299+
fn serialize_arc_vec<S, T>(arc: &Arc<Vec<T>>, ser: S) -> Result<S::Ok, S::Error>
300+
where
301+
S: Serializer,
302+
T: Serialize,
303+
{
304+
arc.as_ref().serialize(ser)
305+
}
306+
307+
fn deserialize_arc_vec<'de, D, T>(de: D) -> Result<Arc<Vec<T>>, D::Error>
308+
where
309+
D: Deserializer<'de>,
310+
T: Deserialize<'de>,
311+
{
312+
Ok(Arc::new(Vec::<T>::deserialize(de)?))
288313
}
289314

290315
impl From<Geometry<WGS84Coordinate>> for Geometry<PixelCoordinate> {
@@ -309,6 +334,15 @@ impl From<Geometry<WGS84Coordinate>> for Geometry<PixelCoordinate> {
309334
.collect(),
310335
metadata,
311336
),
337+
Geometry::Heatmap(coords, metadata) => Geometry::Heatmap(
338+
Arc::new(
339+
coords
340+
.iter()
341+
.map(super::coordinates::Coordinate::as_pixel_coordinate)
342+
.collect::<Vec<_>>(),
343+
),
344+
metadata,
345+
),
312346
}
313347
}
314348
}
@@ -338,6 +372,10 @@ impl<C: Coordinate> Geometry<C> {
338372
.iter()
339373
.map(|c| BoundingBox::from_iterator(once(*c)))
340374
.fold(BoundingBox::default(), |acc, b| acc.extend(&b)),
375+
Geometry::Heatmap(coords, _) => coords
376+
.iter()
377+
.map(|c| BoundingBox::from_iterator(once(*c)))
378+
.fold(BoundingBox::default(), |acc, b| acc.extend(&b)),
341379
}
342380
}
343381

@@ -346,7 +384,8 @@ impl<C: Coordinate> Geometry<C> {
346384
Geometry::GeometryCollection(_, metadata)
347385
| Geometry::Point(_, metadata)
348386
| Geometry::Polygon(_, metadata)
349-
| Geometry::LineString(_, metadata) => metadata.style.as_ref().is_none_or(|s| s.visible),
387+
| Geometry::LineString(_, metadata)
388+
| Geometry::Heatmap(_, metadata) => metadata.style.as_ref().is_none_or(|s| s.visible),
350389
}
351390
}
352391

@@ -362,7 +401,8 @@ impl<C: Coordinate> Geometry<C> {
362401
}
363402
Geometry::Point(_, metadata)
364403
| Geometry::LineString(_, metadata)
365-
| Geometry::Polygon(_, metadata) => {
404+
| Geometry::Polygon(_, metadata)
405+
| Geometry::Heatmap(_, metadata) => {
366406
self.is_visible() && metadata.is_visible_at_time(current_time)
367407
}
368408
}
@@ -392,7 +432,8 @@ impl<C: Coordinate> Geometry<C> {
392432
Geometry::GeometryCollection(_, metadata)
393433
| Geometry::Point(_, metadata)
394434
| Geometry::Polygon(_, metadata)
395-
| Geometry::LineString(_, metadata) => {
435+
| Geometry::LineString(_, metadata)
436+
| Geometry::Heatmap(_, metadata) => {
396437
metadata.style = Some(style.clone());
397438
}
398439
}
@@ -405,7 +446,8 @@ impl<C: Coordinate> Geometry<C> {
405446
Geometry::GeometryCollection(_, metadata)
406447
| Geometry::Point(_, metadata)
407448
| Geometry::Polygon(_, metadata)
408-
| Geometry::LineString(_, metadata) => &metadata.style,
449+
| Geometry::LineString(_, metadata)
450+
| Geometry::Heatmap(_, metadata) => &metadata.style,
409451
}
410452
}
411453
}
@@ -675,6 +717,57 @@ mod tests {
675717
assert!(geom.is_visible());
676718
}
677719

720+
#[test]
721+
fn heatmap_bounding_box_unions_all_points() {
722+
let geom = Geometry::Heatmap(
723+
Arc::new(vec![
724+
PixelCoordinate::new(1.0, 2.0),
725+
PixelCoordinate::new(5.0, 8.0),
726+
PixelCoordinate::new(-3.0, 4.0),
727+
]),
728+
Metadata::default(),
729+
);
730+
let bbox = geom.bounding_box();
731+
assert!(bbox.is_valid());
732+
assert!((bbox.min_x() - -3.0).abs() < 1e-6);
733+
assert!((bbox.max_x() - 5.0).abs() < 1e-6);
734+
assert!((bbox.min_y() - 2.0).abs() < 1e-6);
735+
assert!((bbox.max_y() - 8.0).abs() < 1e-6);
736+
}
737+
738+
#[test]
739+
fn heatmap_visibility_respects_style() {
740+
let geom = Geometry::Heatmap(
741+
Arc::new(vec![PixelCoordinate::new(0.0, 0.0)]),
742+
Metadata::default().with_style(Style::default().with_visible(false)),
743+
);
744+
assert!(!geom.is_visible());
745+
}
746+
747+
#[test]
748+
fn heatmap_wgs84_to_pixel_conversion() {
749+
let geom: Geometry<WGS84Coordinate> = Geometry::Heatmap(
750+
Arc::new(vec![WGS84Coordinate { lat: 0.0, lon: 0.0 }]),
751+
Metadata::default(),
752+
);
753+
let pixel: Geometry<PixelCoordinate> = geom.into();
754+
assert!(matches!(pixel, Geometry::Heatmap(_, _)));
755+
}
756+
757+
#[test]
758+
fn heatmap_clone_shares_buffer() {
759+
let geom = Geometry::Heatmap(
760+
Arc::new(vec![PixelCoordinate::new(0.0, 0.0); 1024]),
761+
Metadata::default(),
762+
);
763+
let cloned = geom.clone();
764+
if let (Geometry::Heatmap(a, _), Geometry::Heatmap(b, _)) = (&geom, &cloned) {
765+
assert!(Arc::ptr_eq(a, b), "cloning Heatmap must share the Arc buffer");
766+
} else {
767+
panic!("expected Heatmap variants");
768+
}
769+
}
770+
678771
#[test]
679772
fn geometry_is_visible_with_hidden_style() {
680773
let geom = Geometry::Point(

src/map/mapvas_egui/layer/commands/external_cmd.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ impl ExternalCommand {
599599
lines += l;
600600
polygons += poly;
601601
}
602+
Geometry::Heatmap(_, _) => {}
602603
}
603604
}
604605

@@ -927,6 +928,18 @@ impl ExternalCommand {
927928
}
928929
}
929930
}
931+
Geometry::Heatmap(coords, metadata) => {
932+
let heatmap_text = format!("🔥 Heatmap ({} pts)", coords.len());
933+
let available_width = (ui.available_width() - 80.0).max(30.0);
934+
let (truncated, _) = super::truncate_label_by_width(ui, &heatmap_text, available_width);
935+
ui.label(truncated);
936+
if let Some(label) = &metadata.label {
937+
let available_width = (ui.available_width() - 40.0).max(100.0);
938+
let (truncated_label, _) =
939+
super::truncate_label_by_width(ui, &label.short(), available_width);
940+
ui.small(format!(" Label: {truncated_label}"));
941+
}
942+
}
930943
Geometry::GeometryCollection(geometries, metadata) => {
931944
let (points, lines, polygons) = Self::count_geometry_types(geometries);
932945
let collection_text = format!("📦 {points}p {lines}l {polygons}poly");
@@ -953,6 +966,9 @@ impl ExternalCommand {
953966
Geometry::GeometryCollection(nested, _) => {
954967
format!(" {}: Collection ({} items)", i + 1, nested.len())
955968
}
969+
Geometry::Heatmap(coords, _) => {
970+
format!(" {}: Heatmap ({} points)", i + 1, coords.len())
971+
}
956972
})
957973
.collect::<Vec<_>>()
958974
.join("\n");

src/map/mapvas_egui/layer/drawable.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ impl<C: Coordinate + 'static> Drawable for Geometry<C> {
138138
stroke: PathStroke::new(DEFAULT_STROKE_WIDTH, style.color().gamma_multiply(0.7)), // More visible outline
139139
})
140140
}
141+
Geometry::Heatmap(_, _) => {
142+
// Heatmaps are rendered through the tile rasterizer only.
143+
Shape::Noop
144+
}
141145
};
142146
painter.add(shape);
143147
}
@@ -178,6 +182,15 @@ impl<C: Coordinate> Geometry<C> {
178182
.collect(),
179183
metadata.clone(),
180184
),
185+
Geometry::Heatmap(coords, metadata) => Geometry::Heatmap(
186+
std::sync::Arc::new(
187+
coords
188+
.iter()
189+
.map(Coordinate::as_pixel_coordinate)
190+
.collect::<Vec<_>>(),
191+
),
192+
metadata.clone(),
193+
),
181194
}
182195
}
183196
}

src/map/mapvas_egui/layer/geometry_highlighting.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ pub fn draw_highlighted_geometry(
127127
draw_highlighted_geometry(nested_geometry, painter, transform, false);
128128
}
129129
}
130+
Geometry::Heatmap(_, _) => {
131+
// Heatmaps are not individually highlightable.
132+
}
130133
}
131134
}
132135

0 commit comments

Comments
 (0)