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 if !self.dark_mode && ctx.style().visuals.dark_mode {
108 self.dark_mode = true;
109 }
110
111 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 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 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 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 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 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 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 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 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 if map_response.dragged() {
252 self.pan += map_response.drag_delta();
253 }
254
255 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 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 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 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 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 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}