Skip to main content

howler_gui/
lib.rs

1use anyhow::Result;
2use eframe::egui;
3use howler_core::Database;
4use std::collections::HashMap;
5
6pub struct HowlerApp {
7    sightings: Vec<SightingData>,
8    selected_sighting: Option<usize>,
9    zoom: f32,
10    pan: egui::Vec2,
11    center_lat: f64,
12    center_lon: f64,
13    show_gbif: bool,
14    show_movebank: bool,
15    show_inaturalist: bool,
16    show_pack_territories: bool,
17    fly_to: Option<(f64, f64, f32)>,
18    dark_mode: bool,
19    touch_points: HashMap<u64, egui::Pos2>,
20    last_pinch_dist: Option<f32>,
21}
22
23struct SightingData {
24    species: String,
25    latitude: f64,
26    longitude: f64,
27    date: String,
28    source: String,
29    details: Option<String>,
30}
31
32impl HowlerApp {
33    pub fn new(_cc: &eframe::CreationContext<'_>) -> Result<Self> {
34        let db = Database::new("howler.db")?;
35        let sightings = db.get_all_sightings()?;
36
37        let sighting_data = sightings
38            .into_iter()
39            .map(|s| SightingData {
40                species: s.species,
41                latitude: s.latitude,
42                longitude: s.longitude,
43                date: s.observed_on.format("%Y-%m-%d").to_string(),
44                source: s.source.to_string(),
45                details: s.details,
46            })
47            .collect();
48
49        Ok(Self {
50            sightings: sighting_data,
51            selected_sighting: None,
52            zoom: 1.0,
53            pan: egui::Vec2::ZERO,
54            center_lat: 44.4280,
55            center_lon: -110.5885,
56            show_gbif: true,
57            show_movebank: true,
58            show_inaturalist: true,
59            show_pack_territories: false,
60            fly_to: None,
61            dark_mode: false,
62            touch_points: HashMap::new(),
63            last_pinch_dist: None,
64        })
65    }
66
67    fn degrees_per_pixel(&self, map_width: f32) -> f64 {
68        360.0 / (map_width as f64 * self.zoom as f64)
69    }
70
71    fn lat_lon_to_screen(&self, lat: f64, lon: f64, map_rect: egui::Rect) -> egui::Pos2 {
72        let dpp = self.degrees_per_pixel(map_rect.width());
73        let cx = map_rect.center().x + self.pan.x;
74        let cy = map_rect.center().y + self.pan.y;
75        let x = cx + ((lon - self.center_lon) / dpp) as f32;
76        let y = cy - ((lat - self.center_lat) / dpp) as f32;
77        egui::Pos2::new(x, y)
78    }
79
80    fn screen_to_lat_lon(&self, pos: egui::Pos2, map_rect: egui::Rect) -> (f64, f64) {
81        let dpp = self.degrees_per_pixel(map_rect.width());
82        let cx = map_rect.center().x + self.pan.x;
83        let cy = map_rect.center().y + self.pan.y;
84        let lon = self.center_lon + (pos.x - cx) as f64 * dpp;
85        let lat = self.center_lat - (pos.y - cy) as f64 * dpp;
86        (lat, lon)
87    }
88
89    fn grid_interval(&self) -> f64 {
90        let dpp = self.degrees_per_pixel(800.0);
91        let targets = [
92            0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 30.0, 45.0,
93        ];
94        let pixel_target = 120.0;
95        for &t in &targets {
96            if t / dpp >= pixel_target {
97                return t;
98            }
99        }
100        45.0
101    }
102}
103
104impl eframe::App for HowlerApp {
105    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
106        // Detect OS dark mode once
107        if !self.dark_mode && ctx.style().visuals.dark_mode {
108            self.dark_mode = true;
109        }
110
111        // Apply theme if changed
112        let current_is_dark = ctx.style().visuals.dark_mode;
113        if self.dark_mode != current_is_dark {
114            if self.dark_mode {
115                ctx.set_visuals(egui::Visuals::dark());
116            } else {
117                ctx.set_visuals(egui::Visuals::light());
118            }
119        }
120
121        // Fly-to animation
122        if let Some((target_lat, target_lon, target_zoom)) = self.fly_to {
123            let lerp_factor = 0.15_f64;
124            self.center_lat += (target_lat - self.center_lat) * lerp_factor;
125            self.center_lon += (target_lon - self.center_lon) * lerp_factor;
126            self.zoom += (target_zoom - self.zoom) * lerp_factor as f32;
127            self.pan = egui::Vec2::ZERO;
128
129            let lat_done = (self.center_lat - target_lat).abs() < 0.001;
130            let lon_done = (self.center_lon - target_lon).abs() < 0.001;
131            let zoom_done = (self.zoom - target_zoom).abs() < 0.05;
132            if lat_done && lon_done && zoom_done {
133                self.center_lat = target_lat;
134                self.center_lon = target_lon;
135                self.zoom = target_zoom;
136                self.fly_to = None;
137            }
138            ctx.request_repaint();
139        }
140
141        egui::CentralPanel::default().show(ctx, |ui| {
142            ui.heading("Howler - Wolf Tracking Map");
143
144            // Layer controls
145            ui.horizontal(|ui| {
146                ui.label("Layers:");
147                ui.checkbox(&mut self.show_gbif, "GBIF");
148                ui.checkbox(&mut self.show_movebank, "Movebank");
149                ui.checkbox(&mut self.show_inaturalist, "iNaturalist");
150                ui.checkbox(&mut self.show_pack_territories, "Pack Territories");
151            });
152
153            ui.horizontal(|ui| {
154                ui.checkbox(&mut self.dark_mode, "Dark Mode");
155            });
156
157            ui.separator();
158
159            // Map canvas
160            let desired_size = egui::vec2(ui.available_width(), 400.0);
161            let (map_rect, map_response) =
162                ui.allocate_exact_size(desired_size, egui::Sense::click_and_drag());
163
164            let painter = ui.painter_at(map_rect);
165
166            // Background
167            let bg = if self.dark_mode {
168                egui::Color32::from_rgb(30, 30, 30)
169            } else {
170                egui::Color32::from_rgb(245, 245, 245)
171            };
172            painter.rect_filled(map_rect, 0.0, bg);
173
174            // Grid lines
175            let interval = self.grid_interval();
176            let grid_color = if self.dark_mode {
177                egui::Color32::from_rgb(60, 60, 60)
178            } else {
179                egui::Color32::from_rgb(200, 200, 200)
180            };
181            let label_color = if self.dark_mode {
182                egui::Color32::from_rgb(140, 140, 140)
183            } else {
184                egui::Color32::from_rgb(120, 120, 120)
185            };
186
187            // Visible lat/lon bounds
188            let (top_lat, _left_lon) =
189                self.screen_to_lat_lon(egui::pos2(map_rect.left(), map_rect.top()), map_rect);
190            let (bottom_lat, _right_lon) =
191                self.screen_to_lat_lon(egui::pos2(map_rect.right(), map_rect.bottom()), map_rect);
192            let (_top_lat, left_lon) =
193                self.screen_to_lat_lon(egui::pos2(map_rect.left(), map_rect.top()), map_rect);
194            let (_bottom_lat, right_lon) =
195                self.screen_to_lat_lon(egui::pos2(map_rect.right(), map_rect.bottom()), map_rect);
196
197            let lat_min = bottom_lat.min(top_lat);
198            let lat_max = bottom_lat.max(top_lat);
199            let lon_min = left_lon.min(right_lon);
200            let lon_max = left_lon.max(right_lon);
201
202            // Horizontal grid lines (latitude)
203            let first_lat = (lat_min / interval).floor() * interval;
204            let mut lat = first_lat;
205            while lat <= lat_max {
206                let pos = self.lat_lon_to_screen(lat, self.center_lon, map_rect);
207                if map_rect.y_range().contains(pos.y) {
208                    painter.line_segment(
209                        [
210                            egui::pos2(map_rect.left(), pos.y),
211                            egui::pos2(map_rect.right(), pos.y),
212                        ],
213                        egui::Stroke::new(1.0_f32, grid_color),
214                    );
215                    painter.text(
216                        egui::pos2(map_rect.left() + 4.0, pos.y + 2.0),
217                        egui::Align2::LEFT_TOP,
218                        format!("{:.1}°", lat),
219                        egui::FontId::proportional(10.0),
220                        label_color,
221                    );
222                }
223                lat += interval;
224            }
225
226            // Vertical grid lines (longitude)
227            let first_lon = (lon_min / interval).floor() * interval;
228            let mut lon = first_lon;
229            while lon <= lon_max {
230                let pos = self.lat_lon_to_screen(self.center_lat, lon, map_rect);
231                if map_rect.x_range().contains(pos.x) {
232                    painter.line_segment(
233                        [
234                            egui::pos2(pos.x, map_rect.top()),
235                            egui::pos2(pos.x, map_rect.bottom()),
236                        ],
237                        egui::Stroke::new(1.0_f32, grid_color),
238                    );
239                    painter.text(
240                        egui::pos2(pos.x + 2.0, map_rect.top() + 2.0),
241                        egui::Align2::LEFT_TOP,
242                        format!("{:.1}°", lon),
243                        egui::FontId::proportional(10.0),
244                        label_color,
245                    );
246                }
247                lon += interval;
248            }
249
250            // Handle pan
251            if map_response.dragged() {
252                self.pan += map_response.drag_delta();
253            }
254
255            // Handle zoom (toward cursor position)
256            if map_response.hovered() {
257                let scroll = ui.input(|i| i.raw_scroll_delta);
258                if scroll.y != 0.0 {
259                    let old_zoom = self.zoom;
260                    self.zoom = (self.zoom * (1.0 + scroll.y * 0.001)).clamp(0.2, 10.0);
261
262                    // Zoom toward cursor
263                    if let Some(cursor) = map_response.hover_pos() {
264                        let zoom_ratio = self.zoom / old_zoom;
265                        let offset_from_center = cursor - map_rect.center();
266                        let new_offset = offset_from_center * zoom_ratio;
267                        self.pan =
268                            (cursor - map_rect.center()) - new_offset + self.pan * zoom_ratio;
269                    }
270                }
271            }
272
273            // Multi-touch pinch-to-zoom
274            ui.input(|i| {
275                for touch in &i.raw.events {
276                    if let egui::Event::Touch { id, phase, pos, .. } = touch {
277                        match phase {
278                            egui::TouchPhase::Start => {
279                                self.touch_points.insert(id.0, *pos);
280                            }
281                            egui::TouchPhase::Move => {
282                                self.touch_points.insert(id.0, *pos);
283                            }
284                            egui::TouchPhase::End | egui::TouchPhase::Cancel => {
285                                self.touch_points.remove(&id.0);
286                            }
287                        }
288                    }
289                }
290
291                if self.touch_points.len() == 2 {
292                    let points: Vec<egui::Pos2> = self.touch_points.values().copied().collect();
293                    let dist = points[0].distance(points[1]);
294
295                    if let Some(prev_dist) = self.last_pinch_dist {
296                        if prev_dist > 1.0 {
297                            let scale = dist / prev_dist;
298                            let old_zoom = self.zoom;
299                            self.zoom = (self.zoom * scale).clamp(0.2, 10.0);
300
301                            let midpoint = (points[0].to_vec2() + points[1].to_vec2()) * 0.5;
302                            let midpoint = midpoint.to_pos2();
303                            let zoom_ratio = self.zoom / old_zoom;
304                            let offset_from_center = midpoint - map_rect.center();
305                            let new_offset = offset_from_center * zoom_ratio;
306                            self.pan =
307                                (midpoint - map_rect.center()) - new_offset + self.pan * zoom_ratio;
308                        }
309                    }
310                    self.last_pinch_dist = Some(dist);
311                } else {
312                    self.last_pinch_dist = None;
313                }
314            });
315
316            // Draw pack territories if enabled
317            if self.show_pack_territories {
318                let tl = self.lat_lon_to_screen(44.55, -110.6, map_rect);
319                let br = self.lat_lon_to_screen(44.45, -110.4, map_rect);
320                let rect = egui::Rect::from_min_max(tl, br);
321                painter.rect_stroke(rect, 0.0, (2.0, egui::Color32::from_rgb(255, 100, 100)));
322            }
323
324            // Draw sightings with layer filtering
325            for (i, sighting) in self.sightings.iter().enumerate() {
326                let visible = match sighting.source.as_str() {
327                    "GBIF" => self.show_gbif,
328                    "Movebank" => self.show_movebank,
329                    "iNaturalist" => self.show_inaturalist,
330                    _ => true,
331                };
332
333                if !visible {
334                    continue;
335                }
336
337                let pos = self.lat_lon_to_screen(sighting.latitude, sighting.longitude, map_rect);
338
339                if !map_rect.contains(pos) {
340                    continue;
341                }
342
343                let color = match sighting.source.as_str() {
344                    "GBIF" => egui::Color32::LIGHT_BLUE,
345                    "Movebank" => egui::Color32::LIGHT_GREEN,
346                    "iNaturalist" => egui::Color32::LIGHT_YELLOW,
347                    _ => egui::Color32::LIGHT_GRAY,
348                };
349
350                let radius = if self.selected_sighting == Some(i) {
351                    8.0 * self.zoom
352                } else {
353                    5.0 * self.zoom
354                };
355
356                painter.circle_filled(pos, radius, color);
357
358                if map_response.clicked() {
359                    let click_pos = map_response.hover_pos().unwrap_or(egui::Pos2::ZERO);
360                    let dist = click_pos.distance(pos);
361                    if dist < radius * 2.0 {
362                        self.selected_sighting = Some(i);
363                        self.fly_to = Some((sighting.latitude, sighting.longitude, 6.0));
364                    }
365                }
366            }
367
368            // Details panel
369            ui.separator();
370            if let Some(idx) = self.selected_sighting {
371                if let Some(sighting) = self.sightings.get(idx) {
372                    ui.heading("Sighting Details");
373                    ui.label(format!("Species: {}", sighting.species));
374                    ui.label(format!("Date: {}", sighting.date));
375                    ui.label(format!("Source: {}", sighting.source));
376                    ui.label(format!("Latitude: {:.4}", sighting.latitude));
377                    ui.label(format!("Longitude: {:.4}", sighting.longitude));
378                    ui.label(format!(
379                        "Details: {}",
380                        sighting.details.as_deref().unwrap_or("N/A")
381                    ));
382                    if ui.button("Zoom to").clicked() {
383                        self.fly_to = Some((sighting.latitude, sighting.longitude, 6.0));
384                    }
385                }
386            } else {
387                ui.label("Click on a marker to see details");
388            }
389
390            ui.separator();
391            ui.label(format!("Total sightings: {}", self.sightings.len()));
392            ui.label("Legend: Blue=GBIF, Green=Movebank, Yellow=iNaturalist");
393            ui.label("Controls: Drag to pan, scroll to zoom");
394            ui.label(format!(
395                "Zoom: {:.1}x | Center: {:.4}, {:.4}",
396                self.zoom, self.center_lat, self.center_lon
397            ));
398        });
399    }
400}
401
402pub fn run() -> Result<()> {
403    let options = eframe::NativeOptions {
404        viewport: egui::ViewportBuilder::default()
405            .with_inner_size([1200.0, 800.0])
406            .with_title("Howler - Wolf Tracking"),
407        ..Default::default()
408    };
409
410    eframe::run_native(
411        "Howler",
412        options,
413        Box::new(|cc| Ok(Box::new(HowlerApp::new(cc).expect("Failed to create app")))),
414    )
415    .map_err(|e| anyhow::anyhow!("GUI error: {}", e))
416}