From d7d701c543b4e78462b4b9d4687581c1b650efd1 Mon Sep 17 00:00:00 2001 From: Udo Hoffmann Date: Thu, 30 Apr 2026 16:51:45 +0200 Subject: [PATCH 1/2] Shape layer split --- src/map/mapvas_egui/layer/shape_layer.rs | 2224 +---------------- .../mapvas_egui/layer/shape_layer/search.rs | 329 +++ .../mapvas_egui/layer/shape_layer/sidebar.rs | 1379 ++++++++++ .../mapvas_egui/layer/shape_layer/temporal.rs | 376 +++ 4 files changed, 2171 insertions(+), 2137 deletions(-) create mode 100644 src/map/mapvas_egui/layer/shape_layer/search.rs create mode 100644 src/map/mapvas_egui/layer/shape_layer/sidebar.rs create mode 100644 src/map/mapvas_egui/layer/shape_layer/temporal.rs diff --git a/src/map/mapvas_egui/layer/shape_layer.rs b/src/map/mapvas_egui/layer/shape_layer.rs index 0c22eef..6127302 100644 --- a/src/map/mapvas_egui/layer/shape_layer.rs +++ b/src/map/mapvas_egui/layer/shape_layer.rs @@ -1,6 +1,6 @@ use super::{ Layer, LayerProperties, Searchable, SubLayerInfo, geometry_highlighting::GeometryHighlighter, - geometry_rasterizer, geometry_selection, + geometry_rasterizer, }; use rstar::{AABB, RTree, RTreeObject}; @@ -8,10 +8,10 @@ use crate::{ config::{Config, HeadingStyle}, map::{ coordinates::{ - BoundingBox, Coordinate, PixelCoordinate, PixelPosition, TILE_SIZE, Tile, TileCoordinate, - Transform, WGS84Coordinate, tiles_in_box, + BoundingBox, PixelCoordinate, PixelPosition, TILE_SIZE, Tile, TileCoordinate, Transform, + tiles_in_box, }, - geometry_collection::{Geometry, Metadata, Style}, + geometry_collection::Geometry, map_event::{Layer as EventLayer, MapEvent}, }, profile_scope, @@ -22,14 +22,13 @@ use egui::{Color32, ColorImage, Pos2, Rect, Ui}; use regex::Regex; use std::{ collections::{HashMap, HashSet}, - fmt::Write, sync::{ Arc, Mutex, mpsc::{Receiver, Sender}, }, }; -const SCROLL_AREA_MAX_HEIGHT: f32 = 600.0; +pub(super) const SCROLL_AREA_MAX_HEIGHT: f32 = 600.0; const HIGLIGHT_PIXEL_DISTANCE: f64 = 10.0; /// Render resolution (pixels) for every geometry tile, regardless of zoom level. const GEO_TILE_PIXEL_SIZE: u32 = 512; @@ -112,93 +111,22 @@ pub struct ShapeLayer { } #[derive(PartialEq, Eq)] -struct HighlightCacheKey { +pub(super) struct HighlightCacheKey { geometry_path: (String, usize, Vec), viewport: [u32; 4], transform: [u32; 3], version: u64, } -struct HighlightTextureCache { +pub(super) struct HighlightTextureCache { key: HighlightCacheKey, texture: egui::TextureHandle, screen_rect: egui::Rect, } -fn truncate_label_by_width(ui: &egui::Ui, label: &str, available_width: f32) -> (String, bool) { - // Ensure minimum available width - if available_width < 20.0 { - return ("...".to_string(), true); - } - - let chars: Vec = label.chars().collect(); - - // Fast fallback for very long strings to prevent hanging - if chars.len() > 200 { - let truncated: String = chars[..50].iter().collect(); - return (format!("{truncated}..."), true); - } - - let font_id = ui.style().text_styles.get(&egui::TextStyle::Body).unwrap(); - let ellipsis = "..."; - - // Measure using egui's text measurement utilities - let galley = ui - .ctx() - .fonts_mut(|f| f.layout_no_wrap(label.to_string(), font_id.clone(), egui::Color32::BLACK)); - let full_width = galley.size().x; - - // Add some safety margin to prevent edge cases - let safe_available_width = available_width - 5.0; - - if full_width <= safe_available_width { - return (label.to_string(), false); - } - - // Find the longest substring that fits with ellipsis - let ellipsis_galley = ui - .ctx() - .fonts_mut(|f| f.layout_no_wrap(ellipsis.to_string(), font_id.clone(), egui::Color32::BLACK)); - let ellipsis_width = ellipsis_galley.size().x; - - // If even ellipsis doesn't fit, return just dots - if ellipsis_width > safe_available_width { - return ("...".to_string(), true); - } - - let mut best_len = 0; - - // Use binary search for efficiency with long strings - let mut left = 0; - let mut right = chars.len().min(100); // Cap to prevent excessive measurements - - while left <= right { - let mid = usize::midpoint(left, right); - if mid == 0 { - break; - } - - let substring: String = chars[..mid].iter().collect(); - let substring_galley = ui - .ctx() - .fonts_mut(|f| f.layout_no_wrap(substring, font_id.clone(), egui::Color32::BLACK)); - let test_width = substring_galley.size().x + ellipsis_width; - - if test_width <= safe_available_width { - best_len = mid; - left = mid + 1; - } else { - right = mid - 1; - } - } - - if best_len == 0 { - (ellipsis.to_string(), true) - } else { - let truncated: String = chars[..best_len].iter().collect(); - (format!("{truncated}{ellipsis}"), true) - } -} +mod search; +mod sidebar; +mod temporal; /// CPU-heavy rasterization of a geometry tile into a `ColorImage`. /// This is designed to be called from `tokio::task::spawn_blocking`. @@ -530,1711 +458,100 @@ impl ShapeLayer { fn paint_geo_tile( painter: &egui::Painter, handle: &egui::TextureHandle, - tile: Tile, - transform: &Transform, - ) { - let (nw, se) = tile.position(); - let screen_nw: egui::Pos2 = transform.apply(nw).into(); - let screen_se: egui::Pos2 = transform.apply(se).into(); - let tile_rect = egui::Rect::from_min_max(screen_nw, screen_se); - painter.image( - handle.id(), - tile_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - Color32::WHITE, - ); - } - - /// Recursively filter a geometry tree based on nested visibility settings. - /// Returns None if the geometry itself is hidden. - fn filter_nested_visibility( - &self, - layer_id: &str, - shape_idx: usize, - path: &[usize], - geometry: &Geometry, - ) -> Option> { - let key = (layer_id.to_string(), shape_idx, path.to_vec()); - if !*self.nested_geometry_visibility.get(&key).unwrap_or(&true) { - return None; - } - - match geometry { - Geometry::GeometryCollection(geometries, metadata) => { - let filtered: Vec<_> = geometries - .iter() - .enumerate() - .filter_map(|(i, g)| { - let mut child_path = path.to_vec(); - child_path.push(i); - self.filter_nested_visibility(layer_id, shape_idx, &child_path, g) - }) - .collect(); - if filtered.is_empty() { - None - } else { - Some(Geometry::GeometryCollection(filtered, metadata.clone())) - } - } - other => { - // Check temporal visibility for nested geometries - if let Some(current_time) = self.temporal_current_time - && !self.is_individual_geometry_visible_at_time(other, current_time) - { - return None; - } - Some(other.clone()) - } - } - } - - #[must_use] - pub fn get_sender(&self) -> Sender { - self.send.clone() - } - - /// Collect shape info for a given layer ID (used by HTTP query handler). - fn collect_shape_info(&self, id: &str) -> Vec { - let Some(shapes) = self.shape_map.get(id) else { - return vec![]; - }; - shapes - .iter() - .enumerate() - .map(|(idx, shape)| { - let (label, shape_type) = match shape { - Geometry::Point(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Point"), - Geometry::LineString(_, meta) => { - (meta.label.as_ref().map(|l| l.name.clone()), "LineString") - } - Geometry::Polygon(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Polygon"), - Geometry::GeometryCollection(_, meta) => { - (meta.label.as_ref().map(|l| l.name.clone()), "Collection") - } - Geometry::Heatmap(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Heatmap"), - }; - crate::remote::ShapeInfo { - index: idx, - label, - shape_type, - visible: *self - .geometry_visibility - .get(&(id.to_owned(), idx)) - .unwrap_or(&true), - } - }) - .collect() - } - - #[allow(clippy::too_many_lines)] - fn show_shape_layers(&mut self, ui: &mut egui::Ui) { - let layer_ids: Vec = self.shape_map.keys().cloned().collect(); - - for layer_id in layer_ids { - let shapes_count = self.shape_map.get(&layer_id).map_or(0, Vec::len); - - // Check if any geometry in this layer is highlighted - let has_highlighted_geometry = self.geometry_highlighter.has_highlighted_geometry(); - - let header_id = egui::Id::new(format!("shape_layer_{layer_id}")); - - let font_id = ui.style().text_styles.get(&egui::TextStyle::Body).unwrap(); - let reserved_galley = ui.ctx().fonts_mut(|f| { - f.layout_no_wrap( - "📁 (9999) ".to_string(), - font_id.clone(), - egui::Color32::BLACK, - ) - }); - let reserved_width = reserved_galley.size().x + 60.0; - let available_width = (ui.available_width() - reserved_width).max(30.0); - let (truncated_layer_id, was_truncated) = - truncate_label_by_width(ui, &layer_id, available_width); - let mut header = - egui::CollapsingHeader::new(format!("📁 {truncated_layer_id} ({shapes_count})")) - .id_salt(header_id) - .default_open(has_highlighted_geometry); - - if was_truncated { - header = header.show_background(true); - } - - // Open sidebar on double-click (but not hover) - if let Some((clicked_layer, _, _)) = &self.just_double_clicked - && clicked_layer == &layer_id - { - header = header.open(Some(true)); - } - - let header_response = header.show(ui, |ui| { - let shapes_count = self.shape_map.get(&layer_id).map_or(0, Vec::len); - let row_height = ui.spacing().interact_size.y; - let scroll_id = egui::Id::new(format!("layer_scroll_{layer_id}")); - - let mut scroll_area = egui::ScrollArea::vertical() - .id_salt(scroll_id) - .max_height(SCROLL_AREA_MAX_HEIGHT); - - // Jump directly to the double-clicked row. Row index = shape index (no filter). - if let Some((clicked_layer, clicked_idx, _)) = &self.just_double_clicked - && clicked_layer == &layer_id - { - #[allow(clippy::cast_precision_loss)] - let offset = (*clicked_idx as f32 * (row_height + ui.spacing().item_spacing.y) - - SCROLL_AREA_MAX_HEIGHT / 2.0) - .max(0.0); - scroll_area = scroll_area.vertical_scroll_offset(offset); - } - - scroll_area.show_rows(ui, row_height, shapes_count, |ui, row_range| { - for idx in row_range { - if let Some(shape) = self.shape_map.get(&layer_id).and_then(|s| s.get(idx)) { - let shape = shape.clone(); - self.show_shape_ui(ui, &layer_id, idx, &shape); - } - } - }); - }); - - let header_resp = header_response.header_response; - if was_truncated && header_resp.clicked() { - ui.memory_mut(|mem| { - mem.data.insert_temp( - egui::Id::new(format!("layer_popup_{layer_id}")), - layer_id.clone(), - ); - }); - } - - header_resp.context_menu(|ui| { - let layer_visible = *self.layer_visibility.get(&layer_id).unwrap_or(&true); - - self.show_visibility_button(ui, layer_visible, "Layer", |this| { - this - .layer_visibility - .insert(layer_id.clone(), !layer_visible); - this.invalidate_cache(); - }); - - ui.separator(); - - if ui.button("🗑 Delete Layer").clicked() { - self.shape_map.remove(&layer_id); - self.layer_visibility.remove(&layer_id); - self - .geometry_visibility - .retain(|(lid, _), _| lid != &layer_id); - self.invalidate_cache(); - ui.close(); - } - }); - - let popup_id = egui::Id::new(format!("layer_popup_{layer_id}")); - if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { - let mut is_open = true; - egui::Window::new("Full Layer Name") - .id(popup_id) - .open(&mut is_open) - .collapsible(false) - .resizable(true) - .movable(true) - .default_width(500.0) - .min_width(400.0) - .max_width(800.0) - .max_height(400.0) - .show(ui.ctx(), |ui| { - egui::ScrollArea::vertical() - .max_height(300.0) - .show(ui, |ui| { - ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { - ui.add(egui::Label::new(&full_text).wrap()); - }); - }); - }); - - if !is_open { - ui.memory_mut(|mem| mem.data.remove::(popup_id)); - } - } - } - - // Handle nested collection label popups once per frame (not per row). - for (layer_id, shapes) in &self.shape_map { - for (shape_idx, shape) in shapes.iter().enumerate() { - if let Geometry::GeometryCollection(geometries, _) = shape { - Self::check_nested_popups_recursive(ui, layer_id, shape_idx, geometries, &mut Vec::new()); - } - } - } - - // Show color picker windows once per frame (not per row). - let mut color_picker_requests = Vec::new(); - for (layer_id, shapes) in &self.shape_map { - for (shape_idx, shape) in shapes.iter().enumerate() { - let popup_id = egui::Id::new(format!("color_picker_{layer_id}_{shape_idx}")); - if ui - .memory(|mem| mem.data.get_temp::(popup_id)) - .unwrap_or(false) - { - let window_title = match shape { - Geometry::Polygon(_, _) => "Choose Colors", - _ => "Choose Color", - }; - color_picker_requests.push((layer_id.clone(), shape_idx, window_title, popup_id)); - } - } - } - - for (layer_id, shape_idx, window_title, popup_id) in color_picker_requests { - let mut is_open = true; - egui::Window::new(window_title) - .id(popup_id) - .open(&mut is_open) - .collapsible(false) - .resizable(false) - .movable(true) - .default_width(250.0) - .show(ui.ctx(), |ui| { - if let Some(shapes) = self.shape_map.get_mut(&layer_id) - && let Some(shape) = shapes.get_mut(shape_idx) - { - let metadata = match shape { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - - if metadata.style.is_none() { - metadata.style = Some(crate::map::geometry_collection::Style::default()); - } - - if let Some(style) = &metadata.style { - let mut stroke_color = style.color(); - let mut fill_color = style.fill_color(); - let is_polygon = matches!(shape, Geometry::Polygon(_, _)); - - if is_polygon { - ui.label("Stroke Color:"); - if ui.color_edit_button_srgba(&mut stroke_color).changed() { - self.update_shape_stroke_color(&layer_id, shape_idx, stroke_color); - } - - let mut stroke_hsva = egui::ecolor::Hsva::from(stroke_color); - egui::widgets::color_picker::color_picker_hsva_2d( - ui, - &mut stroke_hsva, - egui::widgets::color_picker::Alpha::Opaque, - ); - let new_stroke_color = egui::Color32::from(stroke_hsva); - if new_stroke_color != stroke_color { - self.update_shape_stroke_color(&layer_id, shape_idx, new_stroke_color); - } - - ui.separator(); - ui.label("Fill Color:"); - if ui.color_edit_button_srgba(&mut fill_color).changed() { - self.update_shape_fill_color(&layer_id, shape_idx, fill_color); - } - - let mut fill_hsva = egui::ecolor::Hsva::from(fill_color); - egui::widgets::color_picker::color_picker_hsva_2d( - ui, - &mut fill_hsva, - egui::widgets::color_picker::Alpha::BlendOrAdditive, - ); - let new_fill_color = egui::Color32::from(fill_hsva); - if new_fill_color != fill_color { - self.update_shape_fill_color(&layer_id, shape_idx, new_fill_color); - } - } else { - if ui.color_edit_button_srgba(&mut stroke_color).changed() { - self.update_shape_color(&layer_id, shape_idx, stroke_color); - } - - ui.separator(); - let mut hsva = egui::ecolor::Hsva::from(stroke_color); - egui::widgets::color_picker::color_picker_hsva_2d( - ui, - &mut hsva, - egui::widgets::color_picker::Alpha::Opaque, - ); - let new_color = egui::Color32::from(hsva); - if new_color != stroke_color { - self.update_shape_color(&layer_id, shape_idx, new_color); - } - } - } - } - }); - - if !is_open { - ui.memory_mut(|mem| mem.data.remove::(popup_id)); - } - } - } - - fn show_shape_ui( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - shape: &Geometry, - ) { - let geometry_key = (layer_id.to_string(), shape_idx); - let geometry_visible = *self.geometry_visibility.get(&geometry_key).unwrap_or(&true); - let geometry_key_for_highlight = (layer_id.to_string(), shape_idx, Vec::new()); - let is_highlighted = self.geometry_highlighter.is_highlighted( - &geometry_key_for_highlight.0, - geometry_key_for_highlight.1, - &geometry_key_for_highlight.2, - ); - - let bg_color = if is_highlighted { - Some(egui::Color32::from_rgb(100, 100, 200)) - } else { - None - }; - - let frame = if let Some(color) = bg_color { - egui::Frame::default() - .fill(color) - .corner_radius(egui::CornerRadius::same(4)) - .inner_margin(egui::Margin::same(4)) - } else { - egui::Frame::default() - }; - - frame.show(ui, |ui| { - // Handle collections differently - they get their own CollapsingHeader without eye icon - if let Geometry::GeometryCollection(geometries, metadata) = shape { - self.show_geometry_collection_inline(ui, layer_id, shape_idx, geometries, metadata); - } else { - // Non-collections get the traditional eye icon + content layout - ui.horizontal(|ui| { - let visibility_icon = if geometry_visible { "👁" } else { "🚫" }; - let eye_response = ui.add_sized([24.0, 20.0], egui::Button::new(visibility_icon)); - if eye_response.double_clicked() { - if let Some(shapes) = self.shape_map.get(layer_id) { - // Check if this element is already solo (only visible one) - let is_solo = geometry_visible - && (0..shapes.len()).all(|i| { - i == shape_idx - || !*self - .geometry_visibility - .get(&(layer_id.to_string(), i)) - .unwrap_or(&true) - }); - for i in 0..shapes.len() { - self.geometry_visibility.insert( - (layer_id.to_string(), i), - if is_solo { true } else { i == shape_idx }, - ); - } - self.invalidate_cache(); - } - } else if eye_response.clicked() { - self - .geometry_visibility - .insert(geometry_key.clone(), !geometry_visible); - self.invalidate_cache(); - } - - let content_response = ui - .horizontal(|ui| { - self.show_shape_content(ui, layer_id, shape_idx, shape); - }) - .response; - - // Handle double-click to show popup (TODO: implement popup) - if content_response.double_clicked() { - println!("TODO: Show detail popup for sidebar geometry"); - } - - content_response.context_menu(|ui| { - self.show_visibility_button(ui, geometry_visible, "Geometry", |this| { - this - .geometry_visibility - .insert(geometry_key.clone(), !geometry_visible); - }); - - ui.separator(); - - self.show_delete_geometry_button(ui, layer_id, shape_idx, &geometry_key); - }); - }); - } - }); - } - - #[allow(clippy::too_many_lines)] - fn show_shape_content( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - shape: &Geometry, - ) { - match shape { - Geometry::Point(coord, metadata) => { - let wgs84 = coord.as_wgs84(); - self.show_colored_icon(ui, layer_id, shape_idx, "📍", metadata, false); - - if let Some(label) = &metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - let response = ui.strong(truncated_label); - if was_truncated && response.clicked() { - let popup_id = egui::Id::new(format!("point_popup_{layer_id}_{shape_idx}")); - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); - } - let coord_text = format!("({:.3}, {:.3})", wgs84.lat, wgs84.lon); - let available_width = (ui.available_width() - 20.0).max(30.0); - let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); - ui.small(truncated_coord); - } else { - let coord_text = format!("{:.3}, {:.3}", wgs84.lat, wgs84.lon); - let available_width = (ui.available_width() - 20.0).max(30.0); - let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); - ui.label(truncated_coord); - } - } - - Geometry::LineString(coords, metadata) => { - self.show_colored_icon(ui, layer_id, shape_idx, "📏", metadata, false); - - if let Some(label) = &metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - let response = ui.strong(truncated_label); - if was_truncated && response.clicked() { - let popup_id = egui::Id::new(format!("line_popup_{layer_id}_{shape_idx}")); - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); - } - } else { - let response = ui.strong("Line"); - if response.clicked() { - let popup_id = egui::Id::new(format!("line_popup_{layer_id}_{shape_idx}")); - let line_info = format!( - "📏 LineString\nPoints: {}\nStart: {:.4}, {:.4}\nEnd: {:.4}, {:.4}", - coords.len(), - coords.first().map_or(0.0, |c| c.as_wgs84().lat), - coords.first().map_or(0.0, |c| c.as_wgs84().lon), - coords.last().map_or(0.0, |c| c.as_wgs84().lat), - coords.last().map_or(0.0, |c| c.as_wgs84().lon) - ); - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, line_info)); - } - } - - ui.small(format!("({} pts)", coords.len())); - - if let (Some(first), Some(last)) = (coords.first(), coords.last()) { - let first_wgs84 = first.as_wgs84(); - let last_wgs84 = last.as_wgs84(); - let coord_text = format!( - "{:.2},{:.2}→{:.2},{:.2}", - first_wgs84.lat, first_wgs84.lon, last_wgs84.lat, last_wgs84.lon - ); - let available_width = (ui.available_width() - 20.0).max(30.0); - let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); - let response = ui.small(truncated_coord); - if response.clicked() { - let popup_id = egui::Id::new(format!("line_coords_popup_{layer_id}_{shape_idx}")); - let all_coords = coords - .iter() - .enumerate() - .map(|(i, coord)| { - let wgs84 = coord.as_wgs84(); - format!("{:2}: {:.6}, {:.6}", i + 1, wgs84.lat, wgs84.lon) - }) - .collect::>() - .join("\n"); - let coords_info = format!( - "📏 LineString Coordinates\nTotal Points: {}\n\nAll Coordinates:\n{}", - coords.len(), - all_coords - ); - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, coords_info)); - } - } - } - - Geometry::Polygon(coords, metadata) => { - self.show_colored_icon(ui, layer_id, shape_idx, "⬟", metadata, true); - - if let Some(label) = &metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - let response = ui.strong(truncated_label); - if was_truncated && response.clicked() { - let popup_id = egui::Id::new(format!("polygon_popup_{layer_id}_{shape_idx}")); - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); - } - } else { - ui.label("Polygon"); - } - - ui.small(format!("({} pts)", coords.len())); - - if !coords.is_empty() { - let wgs84_coords: Vec = - coords.iter().map(Coordinate::as_wgs84).collect(); - let min_lat = wgs84_coords - .iter() - .map(|c| c.lat) - .min_by(f32::total_cmp) - .unwrap_or(0.0); - let max_lat = wgs84_coords - .iter() - .map(|c| c.lat) - .max_by(f32::total_cmp) - .unwrap_or(0.0); - let min_lon = wgs84_coords - .iter() - .map(|c| c.lon) - .min_by(f32::total_cmp) - .unwrap_or(0.0); - let max_lon = wgs84_coords - .iter() - .map(|c| c.lon) - .max_by(f32::total_cmp) - .unwrap_or(0.0); - - let bounds_text = format!("{min_lat:.1},{min_lon:.1}→{max_lat:.1},{max_lon:.1}"); - let available_width = (ui.available_width() - 20.0).max(30.0); - let (truncated_bounds, _) = truncate_label_by_width(ui, &bounds_text, available_width); - ui.small(truncated_bounds); - } - } - - Geometry::GeometryCollection(geometries, metadata) => { - // Collections should use CollapsingHeader, not the eye icon UI - // This handles the case where a top-level geometry is a collection - self.show_geometry_collection_inline(ui, layer_id, shape_idx, geometries, metadata); - } - - Geometry::Heatmap(coords, metadata) => { - self.show_colored_icon(ui, layer_id, shape_idx, "🔥", metadata, false); - - if let Some(label) = &metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, _was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - ui.strong(truncated_label); - } else { - ui.label("Heatmap"); - } - - let pts_text = format!("{} pts", coords.len()); - let available_width = (ui.available_width() - 20.0).max(30.0); - let (truncated, _) = truncate_label_by_width(ui, &pts_text, available_width); - ui.small(truncated); - } - } - - let geometry_popup_ids = [ - format!("point_popup_{layer_id}_{shape_idx}"), - format!("line_popup_{layer_id}_{shape_idx}"), - format!("line_coords_popup_{layer_id}_{shape_idx}"), - format!("polygon_popup_{layer_id}_{shape_idx}"), - format!("collection_popup_{layer_id}_{shape_idx}"), - format!("collection_label_popup_{layer_id}_{shape_idx}"), - ]; - - for popup_id_str in geometry_popup_ids { - let popup_id = egui::Id::new(&popup_id_str); - if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { - let mut is_open = true; - egui::Window::new("Full Label") - .id(popup_id) - .open(&mut is_open) - .collapsible(false) - .resizable(true) - .movable(true) - .default_width(500.0) - .min_width(400.0) - .max_width(800.0) - .max_height(400.0) - .show(ui.ctx(), |ui| { - egui::ScrollArea::vertical() - .max_height(300.0) - .show(ui, |ui| { - ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { - ui.add(egui::Label::new(&full_text).wrap()); - }); - }); - }); - - if !is_open { - ui.memory_mut(|mem| mem.data.remove::(popup_id)); - } - } - } - } - - fn show_geometry_collection_inline( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - geometries: &[Geometry], - metadata: &Metadata, - ) { - let collection_key = (layer_id.to_string(), shape_idx, vec![]); - let is_expanded = self - .collection_expansion - .get(&collection_key) - .unwrap_or(&false); - - let collection_label = if let Some(label) = &metadata.label { - format!("📁 {} ({} items)", label.short(), geometries.len()) - } else { - format!("📁 Collection ({} items)", geometries.len()) - }; - - let header_id = egui::Id::new(format!("collection_{layer_id}_{shape_idx}")); - let header_response = egui::CollapsingHeader::new(collection_label) - .id_salt(header_id) - .default_open(*is_expanded) - .show(ui, |ui| { - for (nested_idx, nested_geometry) in geometries.iter().enumerate() { - let nested_path = vec![nested_idx]; - self.show_nested_geometry_content( - ui, - layer_id, - shape_idx, - &nested_path, - nested_geometry, - geometries.len(), - ); - if nested_idx < geometries.len() - 1 { - ui.separator(); - } - } - }); - - // Update expansion state based on the body response (if body was shown, header was open) - let is_currently_open = header_response.body_response.is_some(); - self - .collection_expansion - .insert(collection_key, is_currently_open); - - // Handle double-click to show popup (TODO: implement popup) - if header_response.header_response.double_clicked() { - println!("TODO: Show detail popup for collection"); - } - - // Add context menu for collection - header_response.header_response.context_menu(|ui| { - let geometry_key = (layer_id.to_string(), shape_idx); - let geometry_visible = *self.geometry_visibility.get(&geometry_key).unwrap_or(&true); - - self.show_visibility_button(ui, geometry_visible, "Collection", |this| { - this - .geometry_visibility - .insert(geometry_key.clone(), !geometry_visible); - }); - - ui.separator(); - ui.separator(); - - if let Some(label) = &metadata.label { - let popup_id = format!("collection_label_popup_{layer_id}_{shape_idx}"); - Self::show_label_button(ui, label, &popup_id); - } else { - ui.label("(No label available)"); - } - - let popup_id = format!("collection_popup_{layer_id}_{shape_idx}"); - Self::show_collection_info_button(ui, geometries, &popup_id); - - ui.separator(); - - self.show_delete_collection_button(ui, layer_id, shape_idx, &geometry_key); - }); - } - - #[allow(clippy::too_many_lines)] - fn show_nested_geometry_content( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - nested_path: &[usize], - geometry: &Geometry, - sibling_count: usize, - ) { - let nested_key = (layer_id.to_string(), shape_idx, nested_path.to_vec()); - let nested_visible = *self - .nested_geometry_visibility - .get(&nested_key) - .unwrap_or(&true); - - if let Geometry::GeometryCollection(nested_geometries, nested_metadata) = geometry { - let collection_key = nested_key.clone(); - let is_expanded = *self - .collection_expansion - .get(&collection_key) - .unwrap_or(&false); - - let collection_label = if let Some(label) = &nested_metadata.label { - format!("📁 {} ({} items)", label.short(), nested_geometries.len()) - } else { - format!("📁 Collection ({} items)", nested_geometries.len()) - }; - - let header_id = egui::Id::new(format!( - "nested_collection_{layer_id}_{shape_idx}_{nested_path:?}" - )); - let header_response = egui::CollapsingHeader::new(collection_label) - .id_salt(header_id) - .default_open(is_expanded) - .show(ui, |ui| { - let total_items = nested_geometries.len(); - let sibling_count = nested_geometries.len(); - - let scroll_id = egui::Id::new(format!( - "nested_scroll_{layer_id}_{shape_idx}_{nested_path:?}" - )); - egui::ScrollArea::vertical() - .id_salt(scroll_id) - .max_height(SCROLL_AREA_MAX_HEIGHT) - .show(ui, |ui| { - for (sub_idx, sub_geometry) in nested_geometries.iter().enumerate() { - let mut sub_path = nested_path.to_vec(); - sub_path.push(sub_idx); - self.show_nested_geometry_content( - ui, - layer_id, - shape_idx, - &sub_path, - sub_geometry, - sibling_count, - ); - if sub_idx < total_items - 1 { - ui.separator(); - } - } - }); - }); - - // Update expansion state - let is_currently_open = header_response.body_response.is_some(); - self - .collection_expansion - .insert(collection_key, is_currently_open); - - // Add context menu for nested collection - header_response.header_response.context_menu(|ui| { - self.show_visibility_button(ui, nested_visible, "Collection", |this| { - this - .nested_geometry_visibility - .insert(nested_key, !nested_visible); - }); - - ui.separator(); - - // Show full label option for nested collections - if let Some(label) = &nested_metadata.label { - let popup_id_str = format!( - "nested_label_{layer_id}_{shape_idx}_{}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join("_") - ); - Self::show_label_button(ui, label, &popup_id_str); - } else { - ui.label("(No label available)"); - } - }); - } else { - // Individual geometries get eye icon + content with indentation - let mut toggle_visibility = false; - - let horizontal_response = ui.horizontal(|ui| { - // Add minimal indentation based on nesting level (only for individual geometries) - let indent_level = nested_path.len(); - #[allow(clippy::cast_precision_loss)] - ui.add_space(4.0 * (indent_level as f32)); - - // Visibility toggle button for individual geometries - let visibility_icon = if nested_visible { "👁" } else { "🚫" }; - let eye_response = ui.add_sized([24.0, 20.0], egui::Button::new(visibility_icon)); - if eye_response.double_clicked() { - let parent_path = &nested_path[..nested_path.len() - 1]; - let current_idx = nested_path[nested_path.len() - 1]; - // Check if this element is already solo (only visible one among siblings) - let is_solo = nested_visible - && (0..sibling_count).all(|i| { - i == current_idx - || !*self - .nested_geometry_visibility - .get(&{ - let mut p = parent_path.to_vec(); - p.push(i); - (layer_id.to_string(), shape_idx, p) - }) - .unwrap_or(&true) - }); - for i in 0..sibling_count { - let mut sibling_path = parent_path.to_vec(); - sibling_path.push(i); - self.nested_geometry_visibility.insert( - (layer_id.to_string(), shape_idx, sibling_path), - if is_solo { true } else { i == current_idx }, - ); - } - self.invalidate_cache(); - } else if eye_response.clicked() { - toggle_visibility = true; - } - - // Show individual geometry content - match geometry { - Geometry::Point(coord, nested_metadata) => { - let wgs84 = coord.as_wgs84(); - ui.label("📍"); - if let Some(label) = &nested_metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, _was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - ui.strong(truncated_label); - } else { - ui.label("Point"); - } - ui.small(format!("({:.3}, {:.3})", wgs84.lat, wgs84.lon)); - } - Geometry::LineString(coords, nested_metadata) => { - ui.label("📏"); - if let Some(label) = &nested_metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, _was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - ui.strong(truncated_label); - } else { - ui.label("Line"); - } - ui.small(format!("({} pts)", coords.len())); - } - Geometry::Polygon(coords, nested_metadata) => { - ui.label("⬟"); - if let Some(label) = &nested_metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, _was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - ui.strong(truncated_label); - } else { - ui.label("Polygon"); - } - ui.small(format!("({} pts)", coords.len())); - } - Geometry::GeometryCollection(..) => { - // This should not happen in individual geometry context - } - Geometry::Heatmap(coords, nested_metadata) => { - ui.label("🔥"); - if let Some(label) = &nested_metadata.label { - let available_width = (ui.available_width() - 40.0).max(100.0); - let (truncated_label, _was_truncated) = - truncate_label_by_width(ui, &label.short(), available_width); - ui.strong(truncated_label); - } else { - ui.label("Heatmap"); - } - ui.small(format!("({} pts)", coords.len())); - } - } - }); - - // Check if this individual nested geometry is highlighted for sidebar background - let geometry_key_for_highlight = (layer_id.to_string(), shape_idx, nested_path.to_vec()); - let is_highlighted = self.geometry_highlighter.is_highlighted( - &geometry_key_for_highlight.0, - geometry_key_for_highlight.1, - &geometry_key_for_highlight.2, - ); - - // Add background color to the horizontal response if highlighted - if is_highlighted { - let rect = horizontal_response.response.rect; - ui.painter() - .rect_filled(rect, 2.0, egui::Color32::from_rgb(100, 100, 200)); - - // Scroll to this element if it was just double-clicked on the map - if self - .just_double_clicked - .as_ref() - .is_some_and(|(l, idx, path)| l == layer_id && *idx == shape_idx && path == nested_path) - { - horizontal_response - .response - .scroll_to_me(Some(egui::Align::Center)); - } - } - - // Handle visibility toggle after the horizontal closure - if toggle_visibility { - self - .nested_geometry_visibility - .insert(nested_key.clone(), !nested_visible); - self.invalidate_cache(); - } - - // Handle double-click to show popup (TODO: implement popup) - if horizontal_response.response.double_clicked() { - println!("TODO: Show detail popup for individual nested geometry"); - } - - // Add context menu to individual geometries - horizontal_response.response.context_menu(|ui| { - self.show_visibility_button(ui, nested_visible, "Geometry", |this| { - this - .nested_geometry_visibility - .insert(nested_key, !nested_visible); - this.invalidate_cache(); - }); - }); - } - } - - fn show_colored_icon( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - icon: &str, - metadata: &Metadata, - is_polygon: bool, - ) { - let stroke_color = if let Some(style) = &metadata.style { - style.color() - } else { - egui::Color32::BLUE - }; - - let colored_text = egui::RichText::new(icon).color(stroke_color); - - let hover_text = if is_polygon { - "Click to change stroke & fill colors" - } else { - "Click to change color" - }; - let icon_response = ui.button(colored_text).on_hover_text(hover_text); - - let popup_id = egui::Id::new(format!("color_picker_{layer_id}_{shape_idx}")); - - if icon_response.clicked() { - if metadata.style.is_none() - && let Some(shapes) = self.shape_map.get_mut(layer_id) - && let Some(shape) = shapes.get_mut(shape_idx) - { - let shape_metadata = match shape { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - shape_metadata.style = Some(crate::map::geometry_collection::Style::default()); - } - ui.memory_mut(|mem| mem.data.insert_temp(popup_id, true)); - } - } - - fn update_shape_color(&mut self, layer_id: &str, shape_idx: usize, new_color: Color32) { - if let Some(shapes) = self.shape_map.get_mut(layer_id) - && let Some(shape) = shapes.get_mut(shape_idx) - { - let metadata = match shape { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - - let new_style = if let Some(existing_style) = &metadata.style { - Style::default() - .with_color(new_color) - .with_fill_color(existing_style.fill_color()) - .with_visible(true) - } else { - Style::default().with_color(new_color) - }; - metadata.style = Some(new_style); - } - } - - fn update_shape_stroke_color(&mut self, layer_id: &str, shape_idx: usize, new_color: Color32) { - if let Some(shapes) = self.shape_map.get_mut(layer_id) - && let Some(shape) = shapes.get_mut(shape_idx) - { - let metadata = match shape { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - - let new_style = if let Some(existing_style) = &metadata.style { - Style::default() - .with_color(new_color) - .with_fill_color(existing_style.fill_color()) - .with_visible(true) - } else { - Style::default().with_color(new_color) - }; - metadata.style = Some(new_style); - } - } - - fn update_shape_fill_color(&mut self, layer_id: &str, shape_idx: usize, new_fill_color: Color32) { - if let Some(shapes) = self.shape_map.get_mut(layer_id) - && let Some(shape) = shapes.get_mut(shape_idx) - { - let metadata = match shape { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - - let new_style = if let Some(existing_style) = &metadata.style { - Style::default() - .with_color(existing_style.color()) - .with_fill_color(new_fill_color) - .with_visible(true) - } else { - Style::default() - .with_color(Color32::BLUE) - .with_fill_color(new_fill_color) - }; - metadata.style = Some(new_style); - } - } - - /// Check for nested collection popups at any depth - fn check_nested_popups_recursive( - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - geometries: &[Geometry], - current_path: &mut Vec, - ) { - for (nested_idx, nested_geometry) in geometries.iter().enumerate() { - current_path.push(nested_idx); - - if let Geometry::GeometryCollection(sub_geometries, metadata) = nested_geometry { - // Check if this collection has a label and could be a popup target - if metadata.label.is_some() { - let popup_id_str = format!( - "nested_label_{layer_id}_{shape_idx}_{}", - current_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join("_") - ); - let popup_id = egui::Id::new(&popup_id_str); - - if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { - let mut is_open = true; - egui::Window::new("Full Label") - .id(popup_id) - .open(&mut is_open) - .collapsible(false) - .resizable(true) - .movable(true) - .default_width(500.0) - .min_width(400.0) - .max_width(800.0) - .max_height(400.0) - .show(ui.ctx(), |ui| { - egui::ScrollArea::vertical() - .max_height(300.0) - .show(ui, |ui| { - ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { - ui.add(egui::Label::new(&full_text).wrap()); - }); - }); - }); - - if !is_open { - ui.memory_mut(|mem| mem.data.remove::(popup_id)); - } - } - } - - // Recursively check deeper nesting levels - Self::check_nested_popups_recursive(ui, layer_id, shape_idx, sub_geometries, current_path); - } - - current_path.pop(); - } - } - // Context menu helpers - fn show_visibility_button( - &mut self, - ui: &mut egui::Ui, - is_visible: bool, - item_type: &str, - toggle_action: impl FnOnce(&mut Self), - ) { - let visibility_text = if is_visible { "Hide" } else { "Show" }; - if ui - .button(format!("{visibility_text} {item_type}")) - .clicked() - { - toggle_action(self); - ui.close(); - } - } - - fn show_label_button( - ui: &mut egui::Ui, - label: &crate::map::geometry_collection::Label, - popup_id: &str, - ) { - if ui.button("📄 Show Full Label").clicked() { - let id = egui::Id::new(popup_id); - ui.memory_mut(|mem| mem.data.insert_temp(id, label.full())); - ui.close(); - } - } - - fn show_delete_geometry_button( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - geometry_key: &(String, usize), - ) { - if ui.button("🗑 Delete Geometry").clicked() { - if let Some(shapes) = self.shape_map.get_mut(layer_id) - && shape_idx < shapes.len() - { - shapes.remove(shape_idx); - self.geometry_visibility.remove(geometry_key); - - // Update indices for remaining geometries - let keys_to_update: Vec<_> = self - .geometry_visibility - .keys() - .filter(|(lid, idx)| lid == layer_id && *idx > shape_idx) - .cloned() - .collect(); - - for (lid, idx) in keys_to_update { - if let Some(visible) = self.geometry_visibility.remove(&(lid.clone(), idx)) { - self.geometry_visibility.insert((lid, idx - 1), visible); - } - } - self.invalidate_cache(); - } - ui.close(); - } - } - - fn show_collection_info_button( - ui: &mut egui::Ui, - geometries: &[Geometry], - popup_id: &str, - ) { - if ui.button("📋 Collection Info").clicked() { - let id = egui::Id::new(popup_id); - let collection_info = format!( - "📁 Geometry Collection\nItems: {}\nNested geometries: {}", - geometries.len(), - geometries - .iter() - .map(|g| match g { - Geometry::Point(_, _) => "Point".to_string(), - Geometry::LineString(_, _) => "LineString".to_string(), - Geometry::Polygon(_, _) => "Polygon".to_string(), - Geometry::GeometryCollection(nested, _) => format!("Collection ({})", nested.len()), - Geometry::Heatmap(coords, _) => format!("Heatmap ({})", coords.len()), - }) - .collect::>() - .join(", ") - ); - ui.memory_mut(|mem| mem.data.insert_temp(id, collection_info)); - ui.close(); - } - } - - /// Highlight a geometry by its path (converts to ID-based highlighting) - fn highlight_geometry(&mut self, layer_id: &str, shape_idx: usize, nested_path: &[usize]) { - self - .geometry_highlighter - .highlight_geometry(layer_id, shape_idx, nested_path); - } - - /// Draw highlighting for a single specific geometry using the `geometry_highlighting` module - fn draw_highlighted_geometry( - geometry: &Geometry, - painter: &egui::Painter, - transform: &Transform, - _highlight_all: bool, // Unused - we never highlight entire collections - ) { - use super::geometry_highlighting::draw_highlighted_geometry; - draw_highlighted_geometry(geometry, painter, transform, false); - } - - /// Render the hover-highlight for the currently selected geometry. - /// Polygon fills are rasterized via tiny-skia and cached as a texture; - /// strokes/points/lines are added as egui shapes. - fn draw_highlight_overlay(&mut self, ui: &mut egui::Ui, transform: &Transform, rect: Rect) { - let Some((layer_id, shape_idx, nested_path)) = - self.geometry_highlighter.get_highlighted_geometry() - else { - self.highlight_texture = None; - return; - }; - if !nested_path.is_empty() { - // The render loop only handles top-level highlights; preserve that. - self.highlight_texture = None; - return; - } - if !*self.layer_visibility.get(&layer_id).unwrap_or(&true) { - self.highlight_texture = None; - return; - } - if !*self - .geometry_visibility - .get(&(layer_id.clone(), shape_idx)) - .unwrap_or(&true) - { - self.highlight_texture = None; - return; - } - let Some(shape) = self - .shape_map - .get(&layer_id) - .and_then(|s| s.get(shape_idx)) - .cloned() - else { - self.highlight_texture = None; - return; - }; - - let key = HighlightCacheKey { - geometry_path: (layer_id, shape_idx, nested_path), - viewport: [ - rect.min.x.to_bits(), - rect.min.y.to_bits(), - rect.max.x.to_bits(), - rect.max.y.to_bits(), - ], - transform: [ - transform.zoom.to_bits(), - transform.trans.x.to_bits(), - transform.trans.y.to_bits(), - ], - version: self.version, - }; - - let needs_rebuild = self.highlight_texture.as_ref().is_none_or(|c| c.key != key); - if needs_rebuild { - use super::geometry_highlighting::rasterize_highlighted_polygons; - if let Some((image, screen_rect)) = rasterize_highlighted_polygons(&shape, transform, rect) { - let handle = - ui.ctx() - .load_texture("highlight_polygon", image, egui::TextureOptions::LINEAR); - self.highlight_texture = Some(HighlightTextureCache { - key, - texture: handle, - screen_rect, - }); - } else { - self.highlight_texture = None; - } - } - - let painter = ui.painter_at(rect); - if let Some(cache) = &self.highlight_texture { - painter.image( - cache.texture.id(), - cache.screen_rect, - Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - Color32::WHITE, - ); - } - Self::draw_highlighted_geometry(&shape, &painter, transform, false); - } - - fn show_delete_collection_button( - &mut self, - ui: &mut egui::Ui, - layer_id: &str, - shape_idx: usize, - geometry_key: &(String, usize), - ) { - if ui.button("🗑 Delete Collection").clicked() { - if let Some(shapes) = self.shape_map.get_mut(layer_id) - && shape_idx < shapes.len() - { - shapes.remove(shape_idx); - self.geometry_visibility.remove(geometry_key); - - // Clean up any nested visibility state for this collection - self - .nested_geometry_visibility - .retain(|(lid, idx, _), _| !(lid == layer_id && *idx == shape_idx)); - self - .collection_expansion - .retain(|(lid, idx, _), _| !(lid == layer_id && *idx == shape_idx)); - - // Update indices for remaining geometries - let keys_to_update: Vec<_> = self - .geometry_visibility - .keys() - .filter(|(lid, idx)| lid == layer_id && *idx > shape_idx) - .cloned() - .collect(); - - for (lid, idx) in keys_to_update { - if let Some(visible) = self.geometry_visibility.remove(&(lid.clone(), idx)) { - self.geometry_visibility.insert((lid, idx - 1), visible); - } - } - self.invalidate_cache(); - } - ui.close(); - } - } - - /// Search for geometries that match the given query string (supports regex) - pub fn search_geometries(&mut self, query: &str) { - self.search_results.clear(); - - // Try to compile as regex first, fallback to literal string search - let search_pattern = match regex::Regex::new(query) { - Ok(regex) => SearchPattern::Regex(regex), - Err(_) => { - // If regex compilation fails, treat as literal string (case-insensitive) - SearchPattern::Literal(query.to_lowercase()) - } - }; - - // Collect results first to avoid borrowing issues - let mut results = Vec::new(); - - for (layer_id, shapes) in &self.shape_map { - for (shape_idx, shape) in shapes.iter().enumerate() { - Self::search_in_geometry_static( - layer_id, - shape_idx, - &Vec::new(), - shape, - &search_pattern, - &mut results, - ); - } - } - - self.search_results = results.clone(); - - // Highlight all found geometries - if results.is_empty() { - // Clear highlighting if no results found - self.geometry_highlighter.clear_highlighting(); - } else { - // Clear any previous highlighting - self.geometry_highlighter.clear_highlighting(); - - // Highlight the first search result - if let Some((layer_id, shape_idx, nested_path)) = results.first() { - self.highlight_geometry(layer_id, *shape_idx, nested_path); - - // Show popup for the first search result - self.show_search_result_popup(); - } - } - } - - /// Get current search results - #[must_use] - pub fn get_search_results(&self) -> &Vec<(String, usize, Vec)> { - &self.search_results - } - - /// Show popup for currently highlighted search result - pub fn show_search_result_popup(&mut self) { - if let Some((layer_id, shape_idx, nested_path)) = - self.geometry_highlighter.get_highlighted_geometry() - && let Some(detail_info) = - self.generate_geometry_detail_info(&layer_id, shape_idx, &nested_path) - { - // Find the geometry to get its representative coordinate for popup positioning - if let Some(coord) = - self.get_geometry_representative_coordinate(&layer_id, shape_idx, &nested_path) - { - // Convert to screen position using current transform - let screen_pos = if self.current_transform.is_invalid() { - egui::pos2(0.0, 0.0) // Fallback position - } else { - let pixel_pos = self.current_transform.apply(coord); - egui::pos2(pixel_pos.x, pixel_pos.y) - }; - - let creation_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs_f64(); - - self.pending_detail_popup = Some((screen_pos, coord, detail_info, creation_time)); - } - } - } - - /// Navigate to next search result - pub fn next_search_result(&mut self) -> bool { - if self.search_results.is_empty() { - return false; - } - - let current_highlighted = self.geometry_highlighter.get_highlighted_geometry(); - let current_idx = if let Some(current) = current_highlighted { - self - .search_results - .iter() - .position(|result| result == ¤t) - } else { - None - }; - - let next_idx = match current_idx { - Some(idx) => (idx + 1) % self.search_results.len(), - None => 0, - }; - - if let Some((layer_id, shape_idx, nested_path)) = self.search_results.get(next_idx).cloned() { - self.highlight_geometry(&layer_id, shape_idx, &nested_path); - self.show_search_result_popup(); - true - } else { - false - } - } - - /// Navigate to previous search result - pub fn previous_search_result(&mut self) -> bool { - if self.search_results.is_empty() { - return false; - } - - let current_highlighted = self.geometry_highlighter.get_highlighted_geometry(); - let current_idx = if let Some(current) = current_highlighted { - self - .search_results - .iter() - .position(|result| result == ¤t) - } else { - None - }; - - let prev_idx = match current_idx { - Some(idx) => { - if idx == 0 { - self.search_results.len() - 1 - } else { - idx - 1 - } - } - None => self.search_results.len() - 1, - }; - - if let Some((layer_id, shape_idx, nested_path)) = self.search_results.get(prev_idx).cloned() { - self.highlight_geometry(&layer_id, shape_idx, &nested_path); - self.show_search_result_popup(); - true - } else { - false - } + tile: Tile, + transform: &Transform, + ) { + let (nw, se) = tile.position(); + let screen_nw: egui::Pos2 = transform.apply(nw).into(); + let screen_se: egui::Pos2 = transform.apply(se).into(); + let tile_rect = egui::Rect::from_min_max(screen_nw, screen_se); + painter.image( + handle.id(), + tile_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); } - /// Get representative coordinate for a geometry (used for popup positioning) - fn get_geometry_representative_coordinate( + /// Recursively filter a geometry tree based on nested visibility settings. + /// Returns None if the geometry itself is hidden. + fn filter_nested_visibility( &self, layer_id: &str, shape_idx: usize, - nested_path: &[usize], - ) -> Option { - let shapes = self.shape_map.get(layer_id)?; - let mut current_geometry = shapes.get(shape_idx)?; - - // Navigate to the specific nested geometry if there's a path - for &idx in nested_path { - if let Geometry::GeometryCollection(geometries, _) = current_geometry { - current_geometry = geometries.get(idx)?; - } else { - return None; // Invalid path - } - } - - // Return representative coordinate based on geometry type - Self::get_geometry_first_coordinate(current_geometry) - } - - /// Get the first coordinate from any geometry type - fn get_geometry_first_coordinate( + path: &[usize], geometry: &Geometry, - ) -> Option { - match geometry { - Geometry::Point(coord, _) => Some(*coord), - Geometry::LineString(coords, _) | Geometry::Polygon(coords, _) => coords.first().copied(), - Geometry::Heatmap(coords, _) => coords.first().copied(), - Geometry::GeometryCollection(geometries, _) => { - // For collections, try to get coordinate from first child geometry - geometries - .first() - .and_then(Self::get_geometry_first_coordinate) - } - } - } - - /// Apply filter to hide non-matching geometries - pub fn filter_geometries(&mut self, query: &str) { - // Try to compile as regex first, fallback to literal string search - let filter_pattern = match regex::Regex::new(query) { - Ok(regex) => SearchPattern::Regex(regex), - Err(_) => { - // If regex compilation fails, treat as literal string (case-insensitive) - SearchPattern::Literal(query.to_lowercase()) - } - }; - - self.filter_pattern = Some(filter_pattern); - self.invalidate_cache(); - } - - /// Clear filter and show all geometries - pub fn clear_filter(&mut self) { - self.filter_pattern = None; - self.invalidate_cache(); - } - - /// Check if a geometry matches the current filter - fn geometry_matches_filter(&self, geometry: &Geometry) -> bool { - if let Some(ref pattern) = self.filter_pattern { - Self::geometry_matches_pattern_static(geometry, pattern) - } else { - true // No filter active, show all geometries + ) -> Option> { + let key = (layer_id.to_string(), shape_idx, path.to_vec()); + if !*self.nested_geometry_visibility.get(&key).unwrap_or(&true) { + return None; } - } - - /// Check if a geometry matches a search pattern (static version) - fn geometry_matches_pattern_static( - geometry: &Geometry, - pattern: &SearchPattern, - ) -> bool { - let metadata = match geometry { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, - }; - - // Check if metadata contains the search pattern - if let Some(label) = &metadata.label { - if Self::matches_pattern(&label.name, pattern) - || Self::matches_pattern(&label.short(), pattern) - || Self::matches_pattern(&label.full(), pattern) - { - return true; - } - if let Some(description) = &label.description - && Self::matches_pattern(description, pattern) - { - return true; + match geometry { + Geometry::GeometryCollection(geometries, metadata) => { + let filtered: Vec<_> = geometries + .iter() + .enumerate() + .filter_map(|(i, g)| { + let mut child_path = path.to_vec(); + child_path.push(i); + self.filter_nested_visibility(layer_id, shape_idx, &child_path, g) + }) + .collect(); + if filtered.is_empty() { + None + } else { + Some(Geometry::GeometryCollection(filtered, metadata.clone())) + } } - } - - // For collections, check nested geometries recursively - if let Geometry::GeometryCollection(geometries, _) = geometry { - for nested_geometry in geometries { - if Self::geometry_matches_pattern_static(nested_geometry, pattern) { - return true; + other => { + // Check temporal visibility for nested geometries + if let Some(current_time) = self.temporal_current_time + && !self.is_individual_geometry_visible_at_time(other, current_time) + { + return None; } + Some(other.clone()) } } - - false } - /// Check if text matches the search pattern - fn matches_pattern(text: &str, pattern: &SearchPattern) -> bool { - match pattern { - SearchPattern::Regex(regex) => regex.is_match(text), - SearchPattern::Literal(literal) => text.to_lowercase().contains(literal), - } + #[must_use] + pub fn get_sender(&self) -> Sender { + self.send.clone() } - /// Recursively search within a geometry for the query string (static version to avoid borrow checker issues) - fn search_in_geometry_static( - layer_id: &str, - shape_idx: usize, - nested_path: &[usize], - geometry: &Geometry, - pattern: &SearchPattern, - results: &mut Vec<(String, usize, Vec)>, - ) { - let metadata = match geometry { - Geometry::Point(_, metadata) - | Geometry::LineString(_, metadata) - | Geometry::Polygon(_, metadata) - | Geometry::GeometryCollection(_, metadata) - | Geometry::Heatmap(_, metadata) => metadata, + /// Collect shape info for a given layer ID (used by HTTP query handler). + fn collect_shape_info(&self, id: &str) -> Vec { + let Some(shapes) = self.shape_map.get(id) else { + return vec![]; }; - - // Check if metadata contains the search pattern - let mut matches = false; - - if let Some(label) = &metadata.label { - if Self::matches_pattern(&label.name, pattern) - || Self::matches_pattern(&label.short(), pattern) - || Self::matches_pattern(&label.full(), pattern) - { - matches = true; - } - - if let Some(description) = &label.description - && Self::matches_pattern(description, pattern) - { - matches = true; - } - } - - if matches { - results.push((layer_id.to_string(), shape_idx, nested_path.to_vec())); - } - - // Recursively search in nested geometries - if let Geometry::GeometryCollection(nested_geometries, _) = geometry { - for (nested_idx, nested_geometry) in nested_geometries.iter().enumerate() { - let mut nested_path = nested_path.to_vec(); - nested_path.push(nested_idx); - Self::search_in_geometry_static( - layer_id, - shape_idx, - &nested_path, - nested_geometry, - pattern, - results, - ); - } - } + shapes + .iter() + .enumerate() + .map(|(idx, shape)| { + let (label, shape_type) = match shape { + Geometry::Point(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Point"), + Geometry::LineString(_, meta) => { + (meta.label.as_ref().map(|l| l.name.clone()), "LineString") + } + Geometry::Polygon(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Polygon"), + Geometry::GeometryCollection(_, meta) => { + (meta.label.as_ref().map(|l| l.name.clone()), "Collection") + } + Geometry::Heatmap(_, meta) => (meta.label.as_ref().map(|l| l.name.clone()), "Heatmap"), + }; + crate::remote::ShapeInfo { + index: idx, + label, + shape_type, + visible: *self + .geometry_visibility + .get(&(id.to_owned(), idx)) + .unwrap_or(&true), + } + }) + .collect() } } @@ -2795,373 +1112,6 @@ impl Searchable for ShapeLayer { } } -impl ShapeLayer { - /// Navigate to a specific geometry within nested collections - fn get_geometry_at_path<'a>( - geometry: &'a Geometry, - nested_path: &[usize], - ) -> Option<&'a Geometry> { - let mut current_geometry = geometry; - - for &path_index in nested_path { - match current_geometry { - Geometry::GeometryCollection(nested_geometries, _) - if path_index < nested_geometries.len() => - { - current_geometry = &nested_geometries[path_index]; - } - _ => return None, - } - } - - Some(current_geometry) - } - - /// Get temporal range from all geometries in this layer - #[must_use] - pub fn get_temporal_range(&self) -> (Option>, Option>) { - let mut earliest: Option> = None; - let mut latest: Option> = None; - - for shapes in self.shape_map.values() { - for shape in shapes { - Self::extract_temporal_from_geometry(shape, &mut earliest, &mut latest); - } - } - - (earliest, latest) - } - - /// Recursively extract temporal data from a geometry and its children - fn extract_temporal_from_geometry( - geometry: &Geometry, - earliest: &mut Option>, - latest: &mut Option>, - ) { - let metadata = match geometry { - Geometry::Point(_, meta) - | Geometry::LineString(_, meta) - | Geometry::Polygon(_, meta) - | Geometry::Heatmap(_, meta) => meta, - Geometry::GeometryCollection(children, meta) => { - // Recursively process child geometries first - for child in children { - Self::extract_temporal_from_geometry(child, earliest, latest); - } - meta - } - }; - - // Extract temporal data from this geometry's metadata - if let Some(time_data) = &metadata.time_data { - if let Some(timestamp) = time_data.timestamp { - *earliest = Some(earliest.map_or(timestamp, |e| e.min(timestamp))); - *latest = Some(latest.map_or(timestamp, |l| l.max(timestamp))); - } - - if let Some(time_span) = &time_data.time_span { - if let Some(begin) = time_span.begin { - *earliest = Some(earliest.map_or(begin, |e| e.min(begin))); - } - if let Some(end) = time_span.end { - *latest = Some(latest.map_or(end, |l| l.max(end))); - } - } - } - } - - /// Check if a top-level geometry should be visible at the given time - fn is_geometry_visible_at_time( - &self, - geometry: &Geometry, - current_time: DateTime, - ) -> bool { - match geometry { - Geometry::Point(_, meta) - | Geometry::LineString(_, meta) - | Geometry::Polygon(_, meta) - | Geometry::Heatmap(_, meta) => { - // For individual geometries, check their metadata - if let Some(time_window) = self.temporal_time_window { - meta.is_visible_in_time_window(current_time, time_window) - } else { - meta.is_visible_at_time(current_time) - } - } - Geometry::GeometryCollection(children, meta) => { - // For GeometryCollections, first check if the collection itself has temporal data - if meta.time_data.is_some() { - if let Some(time_window) = self.temporal_time_window { - meta.is_visible_in_time_window(current_time, time_window) - } else { - meta.is_visible_at_time(current_time) - } - } else { - // If collection has no temporal data, check if ANY child is visible - // We still show the collection if at least one child is visible - children - .iter() - .any(|child| self.is_geometry_visible_at_time(child, current_time)) - } - } - } - } - - /// Check if an individual geometry (not a collection) should be visible at the given time - fn is_individual_geometry_visible_at_time( - &self, - geometry: &Geometry, - current_time: DateTime, - ) -> bool { - let meta = match geometry { - Geometry::Point(_, meta) - | Geometry::LineString(_, meta) - | Geometry::Polygon(_, meta) - | Geometry::GeometryCollection(_, meta) - | Geometry::Heatmap(_, meta) => meta, // Collections shouldn't reach here, but handle gracefully - }; - - if let Some(time_window) = self.temporal_time_window { - meta.is_visible_in_time_window(current_time, time_window) - } else { - meta.is_visible_at_time(current_time) - } - } - - /// Generate detailed information about a geometry for popup display - #[allow(clippy::too_many_lines)] - fn generate_geometry_detail_info( - &self, - layer_id: &str, - shape_idx: usize, - nested_path: &[usize], - ) -> Option { - let shapes = self.shape_map.get(layer_id)?; - let current_shape = shapes.get(shape_idx)?; - let mut current_geometry = current_shape; - - // Navigate to the specific nested geometry if there's a path - for &idx in nested_path { - if let Geometry::GeometryCollection(geometries, _) = current_geometry { - current_geometry = geometries.get(idx)?; - } else { - return None; // Invalid path - } - } - - // Generate basic information for geometry type - let detail_info = match current_geometry { - Geometry::Point(coord, metadata) => { - let wgs84 = coord.as_wgs84(); - let mut info = format!("📍 Point\nCoordinates: {:.6}, {:.6}", wgs84.lat, wgs84.lon); - - if let Some(label) = &metadata.label { - write!(info, "\nLabel: {}", label.full()).unwrap(); - } - - if let Some(time_data) = &metadata.time_data - && let Some(timestamp) = time_data.timestamp - { - write!( - info, - "\nTimestamp: {}", - timestamp.format("%Y-%m-%d %H:%M:%S UTC") - ) - .unwrap(); - } - - write!(info, "\nLayer: {layer_id}").unwrap(); - if !nested_path.is_empty() { - write!( - info, - "\nNested Path: {}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(" → ") - ) - .unwrap(); - } - - info - } - - Geometry::LineString(coords, metadata) => { - let mut info = format!("📏 LineString\nPoints: {}", coords.len()); - - if let Some(label) = &metadata.label { - write!(info, "\nLabel: {}", label.full()).unwrap(); - } - - if let Some(time_data) = &metadata.time_data - && let Some(timestamp) = time_data.timestamp - { - write!( - info, - "\nTimestamp: {}", - timestamp.format("%Y-%m-%d %H:%M:%S UTC") - ) - .unwrap(); - } - - write!(info, "\nLayer: {layer_id}").unwrap(); - if !nested_path.is_empty() { - write!( - info, - "\nNested Path: {}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(" → ") - ) - .unwrap(); - } - - info - } - - Geometry::Polygon(coords, metadata) => { - let mut info = format!("⬟ Polygon\nVertices: {}", coords.len()); - - if let Some(label) = &metadata.label { - write!(info, "\nLabel: {}", label.full()).unwrap(); - } - - if let Some(time_data) = &metadata.time_data - && let Some(timestamp) = time_data.timestamp - { - write!( - info, - "\nTimestamp: {}", - timestamp.format("%Y-%m-%d %H:%M:%S UTC") - ) - .unwrap(); - } - - write!(info, "\nLayer: {layer_id}").unwrap(); - if !nested_path.is_empty() { - write!( - info, - "\nNested Path: {}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(" → ") - ) - .unwrap(); - } - - info - } - - Geometry::Heatmap(coords, metadata) => { - let mut info = format!("🔥 Heatmap\nPoints: {}", coords.len()); - - if let Some(label) = &metadata.label { - write!(info, "\nLabel: {}", label.full()).unwrap(); - } - - write!(info, "\nLayer: {layer_id}").unwrap(); - if !nested_path.is_empty() { - write!( - info, - "\nNested Path: {}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(" → ") - ) - .unwrap(); - } - - info - } - - Geometry::GeometryCollection(geometries, metadata) => { - let mut info = format!("📁 Collection\nItems: {}", geometries.len()); - - if let Some(label) = &metadata.label { - write!(info, "\nLabel: {}", label.full()).unwrap(); - } - - if let Some(time_data) = &metadata.time_data - && let Some(timestamp) = time_data.timestamp - { - write!( - info, - "\nTimestamp: {}", - timestamp.format("%Y-%m-%d %H:%M:%S UTC") - ) - .unwrap(); - } - - write!(info, "\nLayer: {layer_id}").unwrap(); - if !nested_path.is_empty() { - write!( - info, - "\nNested Path: {}", - nested_path - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(" → ") - ) - .unwrap(); - } - - info - } - }; - - Some(detail_info) - } - - /// Recursively find the closest individual geometry to a point - #[allow(clippy::too_many_arguments)] - fn find_closest_in_geometry( - &self, - layer_id: &str, - shape_idx: usize, - nested_path: &[usize], - geometry: &Geometry, - click_pos: Pos2, - transform: &Transform, - closest_distance: &mut f64, - closest_geometry: &mut Option<(String, usize, Vec)>, - ) { - geometry_selection::find_closest_in_geometry( - layer_id, - shape_idx, - nested_path, - geometry, - click_pos, - transform, - closest_distance, - closest_geometry, - |layer_id, shape_idx, nested_path| { - // Nested visibility check - let nested_key = (layer_id.to_string(), shape_idx, nested_path.to_vec()); - *self - .nested_geometry_visibility - .get(&nested_key) - .unwrap_or(&true) - }, - |nested_geometry| { - // Temporal visibility check - if let Some(current_time) = self.temporal_current_time { - self.is_individual_geometry_visible_at_time(nested_geometry, current_time) - } else { - true - } - }, - ); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/map/mapvas_egui/layer/shape_layer/search.rs b/src/map/mapvas_egui/layer/shape_layer/search.rs new file mode 100644 index 0000000..601f3e5 --- /dev/null +++ b/src/map/mapvas_egui/layer/shape_layer/search.rs @@ -0,0 +1,329 @@ +use super::{SearchPattern, ShapeLayer}; +use crate::map::{coordinates::PixelCoordinate, geometry_collection::Geometry}; + +impl ShapeLayer { + /// Search for geometries that match the given query string (supports regex) + pub fn search_geometries(&mut self, query: &str) { + self.search_results.clear(); + + // Try to compile as regex first, fallback to literal string search + let search_pattern = match regex::Regex::new(query) { + Ok(regex) => SearchPattern::Regex(regex), + Err(_) => { + // If regex compilation fails, treat as literal string (case-insensitive) + SearchPattern::Literal(query.to_lowercase()) + } + }; + + // Collect results first to avoid borrowing issues + let mut results = Vec::new(); + + for (layer_id, shapes) in &self.shape_map { + for (shape_idx, shape) in shapes.iter().enumerate() { + Self::search_in_geometry_static( + layer_id, + shape_idx, + &Vec::new(), + shape, + &search_pattern, + &mut results, + ); + } + } + + self.search_results = results.clone(); + + // Highlight all found geometries + if results.is_empty() { + // Clear highlighting if no results found + self.geometry_highlighter.clear_highlighting(); + } else { + // Clear any previous highlighting + self.geometry_highlighter.clear_highlighting(); + + // Highlight the first search result + if let Some((layer_id, shape_idx, nested_path)) = results.first() { + self.highlight_geometry(layer_id, *shape_idx, nested_path); + + // Show popup for the first search result + self.show_search_result_popup(); + } + } + } + + /// Get current search results + #[must_use] + pub fn get_search_results(&self) -> &Vec<(String, usize, Vec)> { + &self.search_results + } + + /// Show popup for currently highlighted search result + pub fn show_search_result_popup(&mut self) { + if let Some((layer_id, shape_idx, nested_path)) = + self.geometry_highlighter.get_highlighted_geometry() + && let Some(detail_info) = + self.generate_geometry_detail_info(&layer_id, shape_idx, &nested_path) + { + // Find the geometry to get its representative coordinate for popup positioning + if let Some(coord) = + self.get_geometry_representative_coordinate(&layer_id, shape_idx, &nested_path) + { + // Convert to screen position using current transform + let screen_pos = if self.current_transform.is_invalid() { + egui::pos2(0.0, 0.0) // Fallback position + } else { + let pixel_pos = self.current_transform.apply(coord); + egui::pos2(pixel_pos.x, pixel_pos.y) + }; + + let creation_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + + self.pending_detail_popup = Some((screen_pos, coord, detail_info, creation_time)); + } + } + } + + /// Navigate to next search result + pub fn next_search_result(&mut self) -> bool { + if self.search_results.is_empty() { + return false; + } + + let current_highlighted = self.geometry_highlighter.get_highlighted_geometry(); + let current_idx = if let Some(current) = current_highlighted { + self + .search_results + .iter() + .position(|result| result == ¤t) + } else { + None + }; + + let next_idx = match current_idx { + Some(idx) => (idx + 1) % self.search_results.len(), + None => 0, + }; + + if let Some((layer_id, shape_idx, nested_path)) = self.search_results.get(next_idx).cloned() { + self.highlight_geometry(&layer_id, shape_idx, &nested_path); + self.show_search_result_popup(); + true + } else { + false + } + } + + /// Navigate to previous search result + pub fn previous_search_result(&mut self) -> bool { + if self.search_results.is_empty() { + return false; + } + + let current_highlighted = self.geometry_highlighter.get_highlighted_geometry(); + let current_idx = if let Some(current) = current_highlighted { + self + .search_results + .iter() + .position(|result| result == ¤t) + } else { + None + }; + + let prev_idx = match current_idx { + Some(idx) => { + if idx == 0 { + self.search_results.len() - 1 + } else { + idx - 1 + } + } + None => self.search_results.len() - 1, + }; + + if let Some((layer_id, shape_idx, nested_path)) = self.search_results.get(prev_idx).cloned() { + self.highlight_geometry(&layer_id, shape_idx, &nested_path); + self.show_search_result_popup(); + true + } else { + false + } + } + + /// Get representative coordinate for a geometry (used for popup positioning) + fn get_geometry_representative_coordinate( + &self, + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + ) -> Option { + let shapes = self.shape_map.get(layer_id)?; + let mut current_geometry = shapes.get(shape_idx)?; + + // Navigate to the specific nested geometry if there's a path + for &idx in nested_path { + if let Geometry::GeometryCollection(geometries, _) = current_geometry { + current_geometry = geometries.get(idx)?; + } else { + return None; // Invalid path + } + } + + // Return representative coordinate based on geometry type + Self::get_geometry_first_coordinate(current_geometry) + } + + /// Get the first coordinate from any geometry type + fn get_geometry_first_coordinate( + geometry: &Geometry, + ) -> Option { + match geometry { + Geometry::Point(coord, _) => Some(*coord), + Geometry::LineString(coords, _) | Geometry::Polygon(coords, _) => coords.first().copied(), + Geometry::Heatmap(coords, _) => coords.first().copied(), + Geometry::GeometryCollection(geometries, _) => { + // For collections, try to get coordinate from first child geometry + geometries + .first() + .and_then(Self::get_geometry_first_coordinate) + } + } + } + + /// Apply filter to hide non-matching geometries + pub fn filter_geometries(&mut self, query: &str) { + // Try to compile as regex first, fallback to literal string search + let filter_pattern = match regex::Regex::new(query) { + Ok(regex) => SearchPattern::Regex(regex), + Err(_) => { + // If regex compilation fails, treat as literal string (case-insensitive) + SearchPattern::Literal(query.to_lowercase()) + } + }; + + self.filter_pattern = Some(filter_pattern); + self.invalidate_cache(); + } + + /// Clear filter and show all geometries + pub fn clear_filter(&mut self) { + self.filter_pattern = None; + self.invalidate_cache(); + } + + /// Check if a geometry matches the current filter + pub(super) fn geometry_matches_filter(&self, geometry: &Geometry) -> bool { + if let Some(ref pattern) = self.filter_pattern { + Self::geometry_matches_pattern_static(geometry, pattern) + } else { + true // No filter active, show all geometries + } + } + + /// Check if a geometry matches a search pattern (static version) + fn geometry_matches_pattern_static( + geometry: &Geometry, + pattern: &SearchPattern, + ) -> bool { + let metadata = match geometry { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + // Check if metadata contains the search pattern + if let Some(label) = &metadata.label { + if Self::matches_pattern(&label.name, pattern) + || Self::matches_pattern(&label.short(), pattern) + || Self::matches_pattern(&label.full(), pattern) + { + return true; + } + + if let Some(description) = &label.description + && Self::matches_pattern(description, pattern) + { + return true; + } + } + + // For collections, check nested geometries recursively + if let Geometry::GeometryCollection(geometries, _) = geometry { + for nested_geometry in geometries { + if Self::geometry_matches_pattern_static(nested_geometry, pattern) { + return true; + } + } + } + + false + } + + /// Check if text matches the search pattern + fn matches_pattern(text: &str, pattern: &SearchPattern) -> bool { + match pattern { + SearchPattern::Regex(regex) => regex.is_match(text), + SearchPattern::Literal(literal) => text.to_lowercase().contains(literal), + } + } + + /// Recursively search within a geometry for the query string (static version to avoid borrow checker issues) + fn search_in_geometry_static( + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + geometry: &Geometry, + pattern: &SearchPattern, + results: &mut Vec<(String, usize, Vec)>, + ) { + let metadata = match geometry { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + // Check if metadata contains the search pattern + let mut matches = false; + + if let Some(label) = &metadata.label { + if Self::matches_pattern(&label.name, pattern) + || Self::matches_pattern(&label.short(), pattern) + || Self::matches_pattern(&label.full(), pattern) + { + matches = true; + } + + if let Some(description) = &label.description + && Self::matches_pattern(description, pattern) + { + matches = true; + } + } + + if matches { + results.push((layer_id.to_string(), shape_idx, nested_path.to_vec())); + } + + // Recursively search in nested geometries + if let Geometry::GeometryCollection(nested_geometries, _) = geometry { + for (nested_idx, nested_geometry) in nested_geometries.iter().enumerate() { + let mut nested_path = nested_path.to_vec(); + nested_path.push(nested_idx); + Self::search_in_geometry_static( + layer_id, + shape_idx, + &nested_path, + nested_geometry, + pattern, + results, + ); + } + } + } +} diff --git a/src/map/mapvas_egui/layer/shape_layer/sidebar.rs b/src/map/mapvas_egui/layer/shape_layer/sidebar.rs new file mode 100644 index 0000000..b949985 --- /dev/null +++ b/src/map/mapvas_egui/layer/shape_layer/sidebar.rs @@ -0,0 +1,1379 @@ +use super::{HighlightCacheKey, HighlightTextureCache, SCROLL_AREA_MAX_HEIGHT, ShapeLayer}; +use crate::map::{ + coordinates::{Coordinate, PixelCoordinate, Transform, WGS84Coordinate}, + geometry_collection::{Geometry, Metadata, Style}, +}; +use egui::{Color32, Rect}; + +fn truncate_label_by_width(ui: &egui::Ui, label: &str, available_width: f32) -> (String, bool) { + // Ensure minimum available width + if available_width < 20.0 { + return ("...".to_string(), true); + } + + let chars: Vec = label.chars().collect(); + + // Fast fallback for very long strings to prevent hanging + if chars.len() > 200 { + let truncated: String = chars[..50].iter().collect(); + return (format!("{truncated}..."), true); + } + + let font_id = ui.style().text_styles.get(&egui::TextStyle::Body).unwrap(); + let ellipsis = "..."; + + // Measure using egui's text measurement utilities + let galley = ui + .ctx() + .fonts_mut(|f| f.layout_no_wrap(label.to_string(), font_id.clone(), egui::Color32::BLACK)); + let full_width = galley.size().x; + + // Add some safety margin to prevent edge cases + let safe_available_width = available_width - 5.0; + + if full_width <= safe_available_width { + return (label.to_string(), false); + } + + // Find the longest substring that fits with ellipsis + let ellipsis_galley = ui + .ctx() + .fonts_mut(|f| f.layout_no_wrap(ellipsis.to_string(), font_id.clone(), egui::Color32::BLACK)); + let ellipsis_width = ellipsis_galley.size().x; + + // If even ellipsis doesn't fit, return just dots + if ellipsis_width > safe_available_width { + return ("...".to_string(), true); + } + + let mut best_len = 0; + + // Use binary search for efficiency with long strings + let mut left = 0; + let mut right = chars.len().min(100); // Cap to prevent excessive measurements + + while left <= right { + let mid = usize::midpoint(left, right); + if mid == 0 { + break; + } + + let substring: String = chars[..mid].iter().collect(); + let substring_galley = ui + .ctx() + .fonts_mut(|f| f.layout_no_wrap(substring, font_id.clone(), egui::Color32::BLACK)); + let test_width = substring_galley.size().x + ellipsis_width; + + if test_width <= safe_available_width { + best_len = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + if best_len == 0 { + (ellipsis.to_string(), true) + } else { + let truncated: String = chars[..best_len].iter().collect(); + (format!("{truncated}{ellipsis}"), true) + } +} + +impl ShapeLayer { + #[allow(clippy::too_many_lines)] + pub(super) fn show_shape_layers(&mut self, ui: &mut egui::Ui) { + let layer_ids: Vec = self.shape_map.keys().cloned().collect(); + + for layer_id in layer_ids { + let shapes_count = self.shape_map.get(&layer_id).map_or(0, Vec::len); + + // Check if any geometry in this layer is highlighted + let has_highlighted_geometry = self.geometry_highlighter.has_highlighted_geometry(); + + let header_id = egui::Id::new(format!("shape_layer_{layer_id}")); + + let font_id = ui.style().text_styles.get(&egui::TextStyle::Body).unwrap(); + let reserved_galley = ui.ctx().fonts_mut(|f| { + f.layout_no_wrap( + "📁 (9999) ".to_string(), + font_id.clone(), + egui::Color32::BLACK, + ) + }); + let reserved_width = reserved_galley.size().x + 60.0; + let available_width = (ui.available_width() - reserved_width).max(30.0); + let (truncated_layer_id, was_truncated) = + truncate_label_by_width(ui, &layer_id, available_width); + let mut header = + egui::CollapsingHeader::new(format!("📁 {truncated_layer_id} ({shapes_count})")) + .id_salt(header_id) + .default_open(has_highlighted_geometry); + + if was_truncated { + header = header.show_background(true); + } + + // Open sidebar on double-click (but not hover) + if let Some((clicked_layer, _, _)) = &self.just_double_clicked + && clicked_layer == &layer_id + { + header = header.open(Some(true)); + } + + let header_response = header.show(ui, |ui| { + let shapes_count = self.shape_map.get(&layer_id).map_or(0, Vec::len); + let row_height = ui.spacing().interact_size.y; + let scroll_id = egui::Id::new(format!("layer_scroll_{layer_id}")); + + let mut scroll_area = egui::ScrollArea::vertical() + .id_salt(scroll_id) + .max_height(SCROLL_AREA_MAX_HEIGHT); + + // Jump directly to the double-clicked row. Row index = shape index (no filter). + if let Some((clicked_layer, clicked_idx, _)) = &self.just_double_clicked + && clicked_layer == &layer_id + { + #[allow(clippy::cast_precision_loss)] + let offset = (*clicked_idx as f32 * (row_height + ui.spacing().item_spacing.y) + - SCROLL_AREA_MAX_HEIGHT / 2.0) + .max(0.0); + scroll_area = scroll_area.vertical_scroll_offset(offset); + } + + scroll_area.show_rows(ui, row_height, shapes_count, |ui, row_range| { + for idx in row_range { + if let Some(shape) = self.shape_map.get(&layer_id).and_then(|s| s.get(idx)) { + let shape = shape.clone(); + self.show_shape_ui(ui, &layer_id, idx, &shape); + } + } + }); + }); + + let header_resp = header_response.header_response; + if was_truncated && header_resp.clicked() { + ui.memory_mut(|mem| { + mem.data.insert_temp( + egui::Id::new(format!("layer_popup_{layer_id}")), + layer_id.clone(), + ); + }); + } + + header_resp.context_menu(|ui| { + let layer_visible = *self.layer_visibility.get(&layer_id).unwrap_or(&true); + + self.show_visibility_button(ui, layer_visible, "Layer", |this| { + this + .layer_visibility + .insert(layer_id.clone(), !layer_visible); + this.invalidate_cache(); + }); + + ui.separator(); + + if ui.button("🗑 Delete Layer").clicked() { + self.shape_map.remove(&layer_id); + self.layer_visibility.remove(&layer_id); + self + .geometry_visibility + .retain(|(lid, _), _| lid != &layer_id); + self.invalidate_cache(); + ui.close(); + } + }); + + let popup_id = egui::Id::new(format!("layer_popup_{layer_id}")); + if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { + let mut is_open = true; + egui::Window::new("Full Layer Name") + .id(popup_id) + .open(&mut is_open) + .collapsible(false) + .resizable(true) + .movable(true) + .default_width(500.0) + .min_width(400.0) + .max_width(800.0) + .max_height(400.0) + .show(ui.ctx(), |ui| { + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + ui.add(egui::Label::new(&full_text).wrap()); + }); + }); + }); + + if !is_open { + ui.memory_mut(|mem| mem.data.remove::(popup_id)); + } + } + } + + // Handle nested collection label popups once per frame (not per row). + for (layer_id, shapes) in &self.shape_map { + for (shape_idx, shape) in shapes.iter().enumerate() { + if let Geometry::GeometryCollection(geometries, _) = shape { + Self::check_nested_popups_recursive(ui, layer_id, shape_idx, geometries, &mut Vec::new()); + } + } + } + + // Show color picker windows once per frame (not per row). + let mut color_picker_requests = Vec::new(); + for (layer_id, shapes) in &self.shape_map { + for (shape_idx, shape) in shapes.iter().enumerate() { + let popup_id = egui::Id::new(format!("color_picker_{layer_id}_{shape_idx}")); + if ui + .memory(|mem| mem.data.get_temp::(popup_id)) + .unwrap_or(false) + { + let window_title = match shape { + Geometry::Polygon(_, _) => "Choose Colors", + _ => "Choose Color", + }; + color_picker_requests.push((layer_id.clone(), shape_idx, window_title, popup_id)); + } + } + } + + for (layer_id, shape_idx, window_title, popup_id) in color_picker_requests { + let mut is_open = true; + egui::Window::new(window_title) + .id(popup_id) + .open(&mut is_open) + .collapsible(false) + .resizable(false) + .movable(true) + .default_width(250.0) + .show(ui.ctx(), |ui| { + if let Some(shapes) = self.shape_map.get_mut(&layer_id) + && let Some(shape) = shapes.get_mut(shape_idx) + { + let metadata = match shape { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + if metadata.style.is_none() { + metadata.style = Some(crate::map::geometry_collection::Style::default()); + } + + if let Some(style) = &metadata.style { + let mut stroke_color = style.color(); + let mut fill_color = style.fill_color(); + let is_polygon = matches!(shape, Geometry::Polygon(_, _)); + + if is_polygon { + ui.label("Stroke Color:"); + if ui.color_edit_button_srgba(&mut stroke_color).changed() { + self.update_shape_stroke_color(&layer_id, shape_idx, stroke_color); + } + + let mut stroke_hsva = egui::ecolor::Hsva::from(stroke_color); + egui::widgets::color_picker::color_picker_hsva_2d( + ui, + &mut stroke_hsva, + egui::widgets::color_picker::Alpha::Opaque, + ); + let new_stroke_color = egui::Color32::from(stroke_hsva); + if new_stroke_color != stroke_color { + self.update_shape_stroke_color(&layer_id, shape_idx, new_stroke_color); + } + + ui.separator(); + ui.label("Fill Color:"); + if ui.color_edit_button_srgba(&mut fill_color).changed() { + self.update_shape_fill_color(&layer_id, shape_idx, fill_color); + } + + let mut fill_hsva = egui::ecolor::Hsva::from(fill_color); + egui::widgets::color_picker::color_picker_hsva_2d( + ui, + &mut fill_hsva, + egui::widgets::color_picker::Alpha::BlendOrAdditive, + ); + let new_fill_color = egui::Color32::from(fill_hsva); + if new_fill_color != fill_color { + self.update_shape_fill_color(&layer_id, shape_idx, new_fill_color); + } + } else { + if ui.color_edit_button_srgba(&mut stroke_color).changed() { + self.update_shape_color(&layer_id, shape_idx, stroke_color); + } + + ui.separator(); + let mut hsva = egui::ecolor::Hsva::from(stroke_color); + egui::widgets::color_picker::color_picker_hsva_2d( + ui, + &mut hsva, + egui::widgets::color_picker::Alpha::Opaque, + ); + let new_color = egui::Color32::from(hsva); + if new_color != stroke_color { + self.update_shape_color(&layer_id, shape_idx, new_color); + } + } + } + } + }); + + if !is_open { + ui.memory_mut(|mem| mem.data.remove::(popup_id)); + } + } + } + + fn show_shape_ui( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + shape: &Geometry, + ) { + let geometry_key = (layer_id.to_string(), shape_idx); + let geometry_visible = *self.geometry_visibility.get(&geometry_key).unwrap_or(&true); + let geometry_key_for_highlight = (layer_id.to_string(), shape_idx, Vec::new()); + let is_highlighted = self.geometry_highlighter.is_highlighted( + &geometry_key_for_highlight.0, + geometry_key_for_highlight.1, + &geometry_key_for_highlight.2, + ); + + let bg_color = if is_highlighted { + Some(egui::Color32::from_rgb(100, 100, 200)) + } else { + None + }; + + let frame = if let Some(color) = bg_color { + egui::Frame::default() + .fill(color) + .corner_radius(egui::CornerRadius::same(4)) + .inner_margin(egui::Margin::same(4)) + } else { + egui::Frame::default() + }; + + frame.show(ui, |ui| { + // Handle collections differently - they get their own CollapsingHeader without eye icon + if let Geometry::GeometryCollection(geometries, metadata) = shape { + self.show_geometry_collection_inline(ui, layer_id, shape_idx, geometries, metadata); + } else { + // Non-collections get the traditional eye icon + content layout + ui.horizontal(|ui| { + let visibility_icon = if geometry_visible { "👁" } else { "🚫" }; + let eye_response = ui.add_sized([24.0, 20.0], egui::Button::new(visibility_icon)); + if eye_response.double_clicked() { + if let Some(shapes) = self.shape_map.get(layer_id) { + // Check if this element is already solo (only visible one) + let is_solo = geometry_visible + && (0..shapes.len()).all(|i| { + i == shape_idx + || !*self + .geometry_visibility + .get(&(layer_id.to_string(), i)) + .unwrap_or(&true) + }); + for i in 0..shapes.len() { + self.geometry_visibility.insert( + (layer_id.to_string(), i), + if is_solo { true } else { i == shape_idx }, + ); + } + self.invalidate_cache(); + } + } else if eye_response.clicked() { + self + .geometry_visibility + .insert(geometry_key.clone(), !geometry_visible); + self.invalidate_cache(); + } + + let content_response = ui + .horizontal(|ui| { + self.show_shape_content(ui, layer_id, shape_idx, shape); + }) + .response; + + // Handle double-click to show popup (TODO: implement popup) + if content_response.double_clicked() { + println!("TODO: Show detail popup for sidebar geometry"); + } + + content_response.context_menu(|ui| { + self.show_visibility_button(ui, geometry_visible, "Geometry", |this| { + this + .geometry_visibility + .insert(geometry_key.clone(), !geometry_visible); + }); + + ui.separator(); + + self.show_delete_geometry_button(ui, layer_id, shape_idx, &geometry_key); + }); + }); + } + }); + } + + #[allow(clippy::too_many_lines)] + fn show_shape_content( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + shape: &Geometry, + ) { + match shape { + Geometry::Point(coord, metadata) => { + let wgs84 = coord.as_wgs84(); + self.show_colored_icon(ui, layer_id, shape_idx, "📍", metadata, false); + + if let Some(label) = &metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + let response = ui.strong(truncated_label); + if was_truncated && response.clicked() { + let popup_id = egui::Id::new(format!("point_popup_{layer_id}_{shape_idx}")); + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); + } + let coord_text = format!("({:.3}, {:.3})", wgs84.lat, wgs84.lon); + let available_width = (ui.available_width() - 20.0).max(30.0); + let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); + ui.small(truncated_coord); + } else { + let coord_text = format!("{:.3}, {:.3}", wgs84.lat, wgs84.lon); + let available_width = (ui.available_width() - 20.0).max(30.0); + let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); + ui.label(truncated_coord); + } + } + + Geometry::LineString(coords, metadata) => { + self.show_colored_icon(ui, layer_id, shape_idx, "📏", metadata, false); + + if let Some(label) = &metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + let response = ui.strong(truncated_label); + if was_truncated && response.clicked() { + let popup_id = egui::Id::new(format!("line_popup_{layer_id}_{shape_idx}")); + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); + } + } else { + let response = ui.strong("Line"); + if response.clicked() { + let popup_id = egui::Id::new(format!("line_popup_{layer_id}_{shape_idx}")); + let line_info = format!( + "📏 LineString\nPoints: {}\nStart: {:.4}, {:.4}\nEnd: {:.4}, {:.4}", + coords.len(), + coords.first().map_or(0.0, |c| c.as_wgs84().lat), + coords.first().map_or(0.0, |c| c.as_wgs84().lon), + coords.last().map_or(0.0, |c| c.as_wgs84().lat), + coords.last().map_or(0.0, |c| c.as_wgs84().lon) + ); + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, line_info)); + } + } + + ui.small(format!("({} pts)", coords.len())); + + if let (Some(first), Some(last)) = (coords.first(), coords.last()) { + let first_wgs84 = first.as_wgs84(); + let last_wgs84 = last.as_wgs84(); + let coord_text = format!( + "{:.2},{:.2}→{:.2},{:.2}", + first_wgs84.lat, first_wgs84.lon, last_wgs84.lat, last_wgs84.lon + ); + let available_width = (ui.available_width() - 20.0).max(30.0); + let (truncated_coord, _) = truncate_label_by_width(ui, &coord_text, available_width); + let response = ui.small(truncated_coord); + if response.clicked() { + let popup_id = egui::Id::new(format!("line_coords_popup_{layer_id}_{shape_idx}")); + let all_coords = coords + .iter() + .enumerate() + .map(|(i, coord)| { + let wgs84 = coord.as_wgs84(); + format!("{:2}: {:.6}, {:.6}", i + 1, wgs84.lat, wgs84.lon) + }) + .collect::>() + .join("\n"); + let coords_info = format!( + "📏 LineString Coordinates\nTotal Points: {}\n\nAll Coordinates:\n{}", + coords.len(), + all_coords + ); + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, coords_info)); + } + } + } + + Geometry::Polygon(coords, metadata) => { + self.show_colored_icon(ui, layer_id, shape_idx, "⬟", metadata, true); + + if let Some(label) = &metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + let response = ui.strong(truncated_label); + if was_truncated && response.clicked() { + let popup_id = egui::Id::new(format!("polygon_popup_{layer_id}_{shape_idx}")); + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, label.full())); + } + } else { + ui.label("Polygon"); + } + + ui.small(format!("({} pts)", coords.len())); + + if !coords.is_empty() { + let wgs84_coords: Vec = + coords.iter().map(Coordinate::as_wgs84).collect(); + let min_lat = wgs84_coords + .iter() + .map(|c| c.lat) + .min_by(f32::total_cmp) + .unwrap_or(0.0); + let max_lat = wgs84_coords + .iter() + .map(|c| c.lat) + .max_by(f32::total_cmp) + .unwrap_or(0.0); + let min_lon = wgs84_coords + .iter() + .map(|c| c.lon) + .min_by(f32::total_cmp) + .unwrap_or(0.0); + let max_lon = wgs84_coords + .iter() + .map(|c| c.lon) + .max_by(f32::total_cmp) + .unwrap_or(0.0); + + let bounds_text = format!("{min_lat:.1},{min_lon:.1}→{max_lat:.1},{max_lon:.1}"); + let available_width = (ui.available_width() - 20.0).max(30.0); + let (truncated_bounds, _) = truncate_label_by_width(ui, &bounds_text, available_width); + ui.small(truncated_bounds); + } + } + + Geometry::GeometryCollection(geometries, metadata) => { + // Collections should use CollapsingHeader, not the eye icon UI + // This handles the case where a top-level geometry is a collection + self.show_geometry_collection_inline(ui, layer_id, shape_idx, geometries, metadata); + } + + Geometry::Heatmap(coords, metadata) => { + self.show_colored_icon(ui, layer_id, shape_idx, "🔥", metadata, false); + + if let Some(label) = &metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, _was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + ui.strong(truncated_label); + } else { + ui.label("Heatmap"); + } + + let pts_text = format!("{} pts", coords.len()); + let available_width = (ui.available_width() - 20.0).max(30.0); + let (truncated, _) = truncate_label_by_width(ui, &pts_text, available_width); + ui.small(truncated); + } + } + + let geometry_popup_ids = [ + format!("point_popup_{layer_id}_{shape_idx}"), + format!("line_popup_{layer_id}_{shape_idx}"), + format!("line_coords_popup_{layer_id}_{shape_idx}"), + format!("polygon_popup_{layer_id}_{shape_idx}"), + format!("collection_popup_{layer_id}_{shape_idx}"), + format!("collection_label_popup_{layer_id}_{shape_idx}"), + ]; + + for popup_id_str in geometry_popup_ids { + let popup_id = egui::Id::new(&popup_id_str); + if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { + let mut is_open = true; + egui::Window::new("Full Label") + .id(popup_id) + .open(&mut is_open) + .collapsible(false) + .resizable(true) + .movable(true) + .default_width(500.0) + .min_width(400.0) + .max_width(800.0) + .max_height(400.0) + .show(ui.ctx(), |ui| { + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + ui.add(egui::Label::new(&full_text).wrap()); + }); + }); + }); + + if !is_open { + ui.memory_mut(|mem| mem.data.remove::(popup_id)); + } + } + } + } + + fn show_geometry_collection_inline( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + geometries: &[Geometry], + metadata: &Metadata, + ) { + let collection_key = (layer_id.to_string(), shape_idx, vec![]); + let is_expanded = self + .collection_expansion + .get(&collection_key) + .unwrap_or(&false); + + let collection_label = if let Some(label) = &metadata.label { + format!("📁 {} ({} items)", label.short(), geometries.len()) + } else { + format!("📁 Collection ({} items)", geometries.len()) + }; + + let header_id = egui::Id::new(format!("collection_{layer_id}_{shape_idx}")); + let header_response = egui::CollapsingHeader::new(collection_label) + .id_salt(header_id) + .default_open(*is_expanded) + .show(ui, |ui| { + for (nested_idx, nested_geometry) in geometries.iter().enumerate() { + let nested_path = vec![nested_idx]; + self.show_nested_geometry_content( + ui, + layer_id, + shape_idx, + &nested_path, + nested_geometry, + geometries.len(), + ); + if nested_idx < geometries.len() - 1 { + ui.separator(); + } + } + }); + + // Update expansion state based on the body response (if body was shown, header was open) + let is_currently_open = header_response.body_response.is_some(); + self + .collection_expansion + .insert(collection_key, is_currently_open); + + // Handle double-click to show popup (TODO: implement popup) + if header_response.header_response.double_clicked() { + println!("TODO: Show detail popup for collection"); + } + + // Add context menu for collection + header_response.header_response.context_menu(|ui| { + let geometry_key = (layer_id.to_string(), shape_idx); + let geometry_visible = *self.geometry_visibility.get(&geometry_key).unwrap_or(&true); + + self.show_visibility_button(ui, geometry_visible, "Collection", |this| { + this + .geometry_visibility + .insert(geometry_key.clone(), !geometry_visible); + }); + + ui.separator(); + ui.separator(); + + if let Some(label) = &metadata.label { + let popup_id = format!("collection_label_popup_{layer_id}_{shape_idx}"); + Self::show_label_button(ui, label, &popup_id); + } else { + ui.label("(No label available)"); + } + + let popup_id = format!("collection_popup_{layer_id}_{shape_idx}"); + Self::show_collection_info_button(ui, geometries, &popup_id); + + ui.separator(); + + self.show_delete_collection_button(ui, layer_id, shape_idx, &geometry_key); + }); + } + + #[allow(clippy::too_many_lines)] + fn show_nested_geometry_content( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + geometry: &Geometry, + sibling_count: usize, + ) { + let nested_key = (layer_id.to_string(), shape_idx, nested_path.to_vec()); + let nested_visible = *self + .nested_geometry_visibility + .get(&nested_key) + .unwrap_or(&true); + + if let Geometry::GeometryCollection(nested_geometries, nested_metadata) = geometry { + let collection_key = nested_key.clone(); + let is_expanded = *self + .collection_expansion + .get(&collection_key) + .unwrap_or(&false); + + let collection_label = if let Some(label) = &nested_metadata.label { + format!("📁 {} ({} items)", label.short(), nested_geometries.len()) + } else { + format!("📁 Collection ({} items)", nested_geometries.len()) + }; + + let header_id = egui::Id::new(format!( + "nested_collection_{layer_id}_{shape_idx}_{nested_path:?}" + )); + let header_response = egui::CollapsingHeader::new(collection_label) + .id_salt(header_id) + .default_open(is_expanded) + .show(ui, |ui| { + let total_items = nested_geometries.len(); + let sibling_count = nested_geometries.len(); + + let scroll_id = egui::Id::new(format!( + "nested_scroll_{layer_id}_{shape_idx}_{nested_path:?}" + )); + egui::ScrollArea::vertical() + .id_salt(scroll_id) + .max_height(SCROLL_AREA_MAX_HEIGHT) + .show(ui, |ui| { + for (sub_idx, sub_geometry) in nested_geometries.iter().enumerate() { + let mut sub_path = nested_path.to_vec(); + sub_path.push(sub_idx); + self.show_nested_geometry_content( + ui, + layer_id, + shape_idx, + &sub_path, + sub_geometry, + sibling_count, + ); + if sub_idx < total_items - 1 { + ui.separator(); + } + } + }); + }); + + // Update expansion state + let is_currently_open = header_response.body_response.is_some(); + self + .collection_expansion + .insert(collection_key, is_currently_open); + + // Add context menu for nested collection + header_response.header_response.context_menu(|ui| { + self.show_visibility_button(ui, nested_visible, "Collection", |this| { + this + .nested_geometry_visibility + .insert(nested_key, !nested_visible); + }); + + ui.separator(); + + // Show full label option for nested collections + if let Some(label) = &nested_metadata.label { + let popup_id_str = format!( + "nested_label_{layer_id}_{shape_idx}_{}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join("_") + ); + Self::show_label_button(ui, label, &popup_id_str); + } else { + ui.label("(No label available)"); + } + }); + } else { + // Individual geometries get eye icon + content with indentation + let mut toggle_visibility = false; + + let horizontal_response = ui.horizontal(|ui| { + // Add minimal indentation based on nesting level (only for individual geometries) + let indent_level = nested_path.len(); + #[allow(clippy::cast_precision_loss)] + ui.add_space(4.0 * (indent_level as f32)); + + // Visibility toggle button for individual geometries + let visibility_icon = if nested_visible { "👁" } else { "🚫" }; + let eye_response = ui.add_sized([24.0, 20.0], egui::Button::new(visibility_icon)); + if eye_response.double_clicked() { + let parent_path = &nested_path[..nested_path.len() - 1]; + let current_idx = nested_path[nested_path.len() - 1]; + // Check if this element is already solo (only visible one among siblings) + let is_solo = nested_visible + && (0..sibling_count).all(|i| { + i == current_idx + || !*self + .nested_geometry_visibility + .get(&{ + let mut p = parent_path.to_vec(); + p.push(i); + (layer_id.to_string(), shape_idx, p) + }) + .unwrap_or(&true) + }); + for i in 0..sibling_count { + let mut sibling_path = parent_path.to_vec(); + sibling_path.push(i); + self.nested_geometry_visibility.insert( + (layer_id.to_string(), shape_idx, sibling_path), + if is_solo { true } else { i == current_idx }, + ); + } + self.invalidate_cache(); + } else if eye_response.clicked() { + toggle_visibility = true; + } + + // Show individual geometry content + match geometry { + Geometry::Point(coord, nested_metadata) => { + let wgs84 = coord.as_wgs84(); + ui.label("📍"); + if let Some(label) = &nested_metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, _was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + ui.strong(truncated_label); + } else { + ui.label("Point"); + } + ui.small(format!("({:.3}, {:.3})", wgs84.lat, wgs84.lon)); + } + Geometry::LineString(coords, nested_metadata) => { + ui.label("📏"); + if let Some(label) = &nested_metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, _was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + ui.strong(truncated_label); + } else { + ui.label("Line"); + } + ui.small(format!("({} pts)", coords.len())); + } + Geometry::Polygon(coords, nested_metadata) => { + ui.label("⬟"); + if let Some(label) = &nested_metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, _was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + ui.strong(truncated_label); + } else { + ui.label("Polygon"); + } + ui.small(format!("({} pts)", coords.len())); + } + Geometry::GeometryCollection(..) => { + // This should not happen in individual geometry context + } + Geometry::Heatmap(coords, nested_metadata) => { + ui.label("🔥"); + if let Some(label) = &nested_metadata.label { + let available_width = (ui.available_width() - 40.0).max(100.0); + let (truncated_label, _was_truncated) = + truncate_label_by_width(ui, &label.short(), available_width); + ui.strong(truncated_label); + } else { + ui.label("Heatmap"); + } + ui.small(format!("({} pts)", coords.len())); + } + } + }); + + // Check if this individual nested geometry is highlighted for sidebar background + let geometry_key_for_highlight = (layer_id.to_string(), shape_idx, nested_path.to_vec()); + let is_highlighted = self.geometry_highlighter.is_highlighted( + &geometry_key_for_highlight.0, + geometry_key_for_highlight.1, + &geometry_key_for_highlight.2, + ); + + // Add background color to the horizontal response if highlighted + if is_highlighted { + let rect = horizontal_response.response.rect; + ui.painter() + .rect_filled(rect, 2.0, egui::Color32::from_rgb(100, 100, 200)); + + // Scroll to this element if it was just double-clicked on the map + if self + .just_double_clicked + .as_ref() + .is_some_and(|(l, idx, path)| l == layer_id && *idx == shape_idx && path == nested_path) + { + horizontal_response + .response + .scroll_to_me(Some(egui::Align::Center)); + } + } + + // Handle visibility toggle after the horizontal closure + if toggle_visibility { + self + .nested_geometry_visibility + .insert(nested_key.clone(), !nested_visible); + self.invalidate_cache(); + } + + // Handle double-click to show popup (TODO: implement popup) + if horizontal_response.response.double_clicked() { + println!("TODO: Show detail popup for individual nested geometry"); + } + + // Add context menu to individual geometries + horizontal_response.response.context_menu(|ui| { + self.show_visibility_button(ui, nested_visible, "Geometry", |this| { + this + .nested_geometry_visibility + .insert(nested_key, !nested_visible); + this.invalidate_cache(); + }); + }); + } + } + + fn show_colored_icon( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + icon: &str, + metadata: &Metadata, + is_polygon: bool, + ) { + let stroke_color = if let Some(style) = &metadata.style { + style.color() + } else { + egui::Color32::BLUE + }; + + let colored_text = egui::RichText::new(icon).color(stroke_color); + + let hover_text = if is_polygon { + "Click to change stroke & fill colors" + } else { + "Click to change color" + }; + let icon_response = ui.button(colored_text).on_hover_text(hover_text); + + let popup_id = egui::Id::new(format!("color_picker_{layer_id}_{shape_idx}")); + + if icon_response.clicked() { + if metadata.style.is_none() + && let Some(shapes) = self.shape_map.get_mut(layer_id) + && let Some(shape) = shapes.get_mut(shape_idx) + { + let shape_metadata = match shape { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + shape_metadata.style = Some(crate::map::geometry_collection::Style::default()); + } + ui.memory_mut(|mem| mem.data.insert_temp(popup_id, true)); + } + } + + fn update_shape_color(&mut self, layer_id: &str, shape_idx: usize, new_color: Color32) { + if let Some(shapes) = self.shape_map.get_mut(layer_id) + && let Some(shape) = shapes.get_mut(shape_idx) + { + let metadata = match shape { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + let new_style = if let Some(existing_style) = &metadata.style { + Style::default() + .with_color(new_color) + .with_fill_color(existing_style.fill_color()) + .with_visible(true) + } else { + Style::default().with_color(new_color) + }; + metadata.style = Some(new_style); + } + } + + fn update_shape_stroke_color(&mut self, layer_id: &str, shape_idx: usize, new_color: Color32) { + if let Some(shapes) = self.shape_map.get_mut(layer_id) + && let Some(shape) = shapes.get_mut(shape_idx) + { + let metadata = match shape { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + let new_style = if let Some(existing_style) = &metadata.style { + Style::default() + .with_color(new_color) + .with_fill_color(existing_style.fill_color()) + .with_visible(true) + } else { + Style::default().with_color(new_color) + }; + metadata.style = Some(new_style); + } + } + + fn update_shape_fill_color(&mut self, layer_id: &str, shape_idx: usize, new_fill_color: Color32) { + if let Some(shapes) = self.shape_map.get_mut(layer_id) + && let Some(shape) = shapes.get_mut(shape_idx) + { + let metadata = match shape { + Geometry::Point(_, metadata) + | Geometry::LineString(_, metadata) + | Geometry::Polygon(_, metadata) + | Geometry::GeometryCollection(_, metadata) + | Geometry::Heatmap(_, metadata) => metadata, + }; + + let new_style = if let Some(existing_style) = &metadata.style { + Style::default() + .with_color(existing_style.color()) + .with_fill_color(new_fill_color) + .with_visible(true) + } else { + Style::default() + .with_color(Color32::BLUE) + .with_fill_color(new_fill_color) + }; + metadata.style = Some(new_style); + } + } + + /// Check for nested collection popups at any depth + fn check_nested_popups_recursive( + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + geometries: &[Geometry], + current_path: &mut Vec, + ) { + for (nested_idx, nested_geometry) in geometries.iter().enumerate() { + current_path.push(nested_idx); + + if let Geometry::GeometryCollection(sub_geometries, metadata) = nested_geometry { + // Check if this collection has a label and could be a popup target + if metadata.label.is_some() { + let popup_id_str = format!( + "nested_label_{layer_id}_{shape_idx}_{}", + current_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join("_") + ); + let popup_id = egui::Id::new(&popup_id_str); + + if let Some(full_text) = ui.memory(|mem| mem.data.get_temp::(popup_id)) { + let mut is_open = true; + egui::Window::new("Full Label") + .id(popup_id) + .open(&mut is_open) + .collapsible(false) + .resizable(true) + .movable(true) + .default_width(500.0) + .min_width(400.0) + .max_width(800.0) + .max_height(400.0) + .show(ui.ctx(), |ui| { + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + ui.add(egui::Label::new(&full_text).wrap()); + }); + }); + }); + + if !is_open { + ui.memory_mut(|mem| mem.data.remove::(popup_id)); + } + } + } + + // Recursively check deeper nesting levels + Self::check_nested_popups_recursive(ui, layer_id, shape_idx, sub_geometries, current_path); + } + + current_path.pop(); + } + } + // Context menu helpers + fn show_visibility_button( + &mut self, + ui: &mut egui::Ui, + is_visible: bool, + item_type: &str, + toggle_action: impl FnOnce(&mut Self), + ) { + let visibility_text = if is_visible { "Hide" } else { "Show" }; + if ui + .button(format!("{visibility_text} {item_type}")) + .clicked() + { + toggle_action(self); + ui.close(); + } + } + + fn show_label_button( + ui: &mut egui::Ui, + label: &crate::map::geometry_collection::Label, + popup_id: &str, + ) { + if ui.button("📄 Show Full Label").clicked() { + let id = egui::Id::new(popup_id); + ui.memory_mut(|mem| mem.data.insert_temp(id, label.full())); + ui.close(); + } + } + + fn show_delete_geometry_button( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + geometry_key: &(String, usize), + ) { + if ui.button("🗑 Delete Geometry").clicked() { + if let Some(shapes) = self.shape_map.get_mut(layer_id) + && shape_idx < shapes.len() + { + shapes.remove(shape_idx); + self.geometry_visibility.remove(geometry_key); + + // Update indices for remaining geometries + let keys_to_update: Vec<_> = self + .geometry_visibility + .keys() + .filter(|(lid, idx)| lid == layer_id && *idx > shape_idx) + .cloned() + .collect(); + + for (lid, idx) in keys_to_update { + if let Some(visible) = self.geometry_visibility.remove(&(lid.clone(), idx)) { + self.geometry_visibility.insert((lid, idx - 1), visible); + } + } + self.invalidate_cache(); + } + ui.close(); + } + } + + fn show_collection_info_button( + ui: &mut egui::Ui, + geometries: &[Geometry], + popup_id: &str, + ) { + if ui.button("📋 Collection Info").clicked() { + let id = egui::Id::new(popup_id); + let collection_info = format!( + "📁 Geometry Collection\nItems: {}\nNested geometries: {}", + geometries.len(), + geometries + .iter() + .map(|g| match g { + Geometry::Point(_, _) => "Point".to_string(), + Geometry::LineString(_, _) => "LineString".to_string(), + Geometry::Polygon(_, _) => "Polygon".to_string(), + Geometry::GeometryCollection(nested, _) => format!("Collection ({})", nested.len()), + Geometry::Heatmap(coords, _) => format!("Heatmap ({})", coords.len()), + }) + .collect::>() + .join(", ") + ); + ui.memory_mut(|mem| mem.data.insert_temp(id, collection_info)); + ui.close(); + } + } + + /// Highlight a geometry by its path (converts to ID-based highlighting) + pub(super) fn highlight_geometry( + &mut self, + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + ) { + self + .geometry_highlighter + .highlight_geometry(layer_id, shape_idx, nested_path); + } + + /// Draw highlighting for a single specific geometry using the `geometry_highlighting` module + fn draw_highlighted_geometry( + geometry: &Geometry, + painter: &egui::Painter, + transform: &Transform, + _highlight_all: bool, // Unused - we never highlight entire collections + ) { + use super::super::geometry_highlighting::draw_highlighted_geometry; + draw_highlighted_geometry(geometry, painter, transform, false); + } + + /// Render the hover-highlight for the currently selected geometry. + /// Polygon fills are rasterized via tiny-skia and cached as a texture; + /// strokes/points/lines are added as egui shapes. + pub(super) fn draw_highlight_overlay( + &mut self, + ui: &mut egui::Ui, + transform: &Transform, + rect: Rect, + ) { + let Some((layer_id, shape_idx, nested_path)) = + self.geometry_highlighter.get_highlighted_geometry() + else { + self.highlight_texture = None; + return; + }; + if !nested_path.is_empty() { + // The render loop only handles top-level highlights; preserve that. + self.highlight_texture = None; + return; + } + if !*self.layer_visibility.get(&layer_id).unwrap_or(&true) { + self.highlight_texture = None; + return; + } + if !*self + .geometry_visibility + .get(&(layer_id.clone(), shape_idx)) + .unwrap_or(&true) + { + self.highlight_texture = None; + return; + } + let Some(shape) = self + .shape_map + .get(&layer_id) + .and_then(|s| s.get(shape_idx)) + .cloned() + else { + self.highlight_texture = None; + return; + }; + + let key = HighlightCacheKey { + geometry_path: (layer_id, shape_idx, nested_path), + viewport: [ + rect.min.x.to_bits(), + rect.min.y.to_bits(), + rect.max.x.to_bits(), + rect.max.y.to_bits(), + ], + transform: [ + transform.zoom.to_bits(), + transform.trans.x.to_bits(), + transform.trans.y.to_bits(), + ], + version: self.version, + }; + + let needs_rebuild = self.highlight_texture.as_ref().is_none_or(|c| c.key != key); + if needs_rebuild { + use super::super::geometry_highlighting::rasterize_highlighted_polygons; + if let Some((image, screen_rect)) = rasterize_highlighted_polygons(&shape, transform, rect) { + let handle = + ui.ctx() + .load_texture("highlight_polygon", image, egui::TextureOptions::LINEAR); + self.highlight_texture = Some(HighlightTextureCache { + key, + texture: handle, + screen_rect, + }); + } else { + self.highlight_texture = None; + } + } + + let painter = ui.painter_at(rect); + if let Some(cache) = &self.highlight_texture { + painter.image( + cache.texture.id(), + cache.screen_rect, + Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); + } + Self::draw_highlighted_geometry(&shape, &painter, transform, false); + } + + fn show_delete_collection_button( + &mut self, + ui: &mut egui::Ui, + layer_id: &str, + shape_idx: usize, + geometry_key: &(String, usize), + ) { + if ui.button("🗑 Delete Collection").clicked() { + if let Some(shapes) = self.shape_map.get_mut(layer_id) + && shape_idx < shapes.len() + { + shapes.remove(shape_idx); + self.geometry_visibility.remove(geometry_key); + + // Clean up any nested visibility state for this collection + self + .nested_geometry_visibility + .retain(|(lid, idx, _), _| !(lid == layer_id && *idx == shape_idx)); + self + .collection_expansion + .retain(|(lid, idx, _), _| !(lid == layer_id && *idx == shape_idx)); + + // Update indices for remaining geometries + let keys_to_update: Vec<_> = self + .geometry_visibility + .keys() + .filter(|(lid, idx)| lid == layer_id && *idx > shape_idx) + .cloned() + .collect(); + + for (lid, idx) in keys_to_update { + if let Some(visible) = self.geometry_visibility.remove(&(lid.clone(), idx)) { + self.geometry_visibility.insert((lid, idx - 1), visible); + } + } + self.invalidate_cache(); + } + ui.close(); + } + } +} diff --git a/src/map/mapvas_egui/layer/shape_layer/temporal.rs b/src/map/mapvas_egui/layer/shape_layer/temporal.rs new file mode 100644 index 0000000..751f29b --- /dev/null +++ b/src/map/mapvas_egui/layer/shape_layer/temporal.rs @@ -0,0 +1,376 @@ +use super::super::geometry_selection; +use super::ShapeLayer; +use crate::map::{ + coordinates::{Coordinate, PixelCoordinate, Transform}, + geometry_collection::Geometry, +}; +use chrono::{DateTime, Utc}; +use egui::Pos2; +use std::fmt::Write; + +impl ShapeLayer { + /// Navigate to a specific geometry within nested collections + pub(super) fn get_geometry_at_path<'a>( + geometry: &'a Geometry, + nested_path: &[usize], + ) -> Option<&'a Geometry> { + let mut current_geometry = geometry; + + for &path_index in nested_path { + match current_geometry { + Geometry::GeometryCollection(nested_geometries, _) + if path_index < nested_geometries.len() => + { + current_geometry = &nested_geometries[path_index]; + } + _ => return None, + } + } + + Some(current_geometry) + } + + /// Get temporal range from all geometries in this layer + #[must_use] + pub fn get_temporal_range(&self) -> (Option>, Option>) { + let mut earliest: Option> = None; + let mut latest: Option> = None; + + for shapes in self.shape_map.values() { + for shape in shapes { + Self::extract_temporal_from_geometry(shape, &mut earliest, &mut latest); + } + } + + (earliest, latest) + } + + /// Recursively extract temporal data from a geometry and its children + fn extract_temporal_from_geometry( + geometry: &Geometry, + earliest: &mut Option>, + latest: &mut Option>, + ) { + let metadata = match geometry { + Geometry::Point(_, meta) + | Geometry::LineString(_, meta) + | Geometry::Polygon(_, meta) + | Geometry::Heatmap(_, meta) => meta, + Geometry::GeometryCollection(children, meta) => { + // Recursively process child geometries first + for child in children { + Self::extract_temporal_from_geometry(child, earliest, latest); + } + meta + } + }; + + // Extract temporal data from this geometry's metadata + if let Some(time_data) = &metadata.time_data { + if let Some(timestamp) = time_data.timestamp { + *earliest = Some(earliest.map_or(timestamp, |e| e.min(timestamp))); + *latest = Some(latest.map_or(timestamp, |l| l.max(timestamp))); + } + + if let Some(time_span) = &time_data.time_span { + if let Some(begin) = time_span.begin { + *earliest = Some(earliest.map_or(begin, |e| e.min(begin))); + } + if let Some(end) = time_span.end { + *latest = Some(latest.map_or(end, |l| l.max(end))); + } + } + } + } + + /// Check if a top-level geometry should be visible at the given time + pub(super) fn is_geometry_visible_at_time( + &self, + geometry: &Geometry, + current_time: DateTime, + ) -> bool { + match geometry { + Geometry::Point(_, meta) + | Geometry::LineString(_, meta) + | Geometry::Polygon(_, meta) + | Geometry::Heatmap(_, meta) => { + // For individual geometries, check their metadata + if let Some(time_window) = self.temporal_time_window { + meta.is_visible_in_time_window(current_time, time_window) + } else { + meta.is_visible_at_time(current_time) + } + } + Geometry::GeometryCollection(children, meta) => { + // For GeometryCollections, first check if the collection itself has temporal data + if meta.time_data.is_some() { + if let Some(time_window) = self.temporal_time_window { + meta.is_visible_in_time_window(current_time, time_window) + } else { + meta.is_visible_at_time(current_time) + } + } else { + // If collection has no temporal data, check if ANY child is visible + // We still show the collection if at least one child is visible + children + .iter() + .any(|child| self.is_geometry_visible_at_time(child, current_time)) + } + } + } + } + + /// Check if an individual geometry (not a collection) should be visible at the given time + pub(super) fn is_individual_geometry_visible_at_time( + &self, + geometry: &Geometry, + current_time: DateTime, + ) -> bool { + let meta = match geometry { + Geometry::Point(_, meta) + | Geometry::LineString(_, meta) + | Geometry::Polygon(_, meta) + | Geometry::GeometryCollection(_, meta) + | Geometry::Heatmap(_, meta) => meta, // Collections shouldn't reach here, but handle gracefully + }; + + if let Some(time_window) = self.temporal_time_window { + meta.is_visible_in_time_window(current_time, time_window) + } else { + meta.is_visible_at_time(current_time) + } + } + + /// Generate detailed information about a geometry for popup display + #[allow(clippy::too_many_lines)] + pub(super) fn generate_geometry_detail_info( + &self, + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + ) -> Option { + let shapes = self.shape_map.get(layer_id)?; + let current_shape = shapes.get(shape_idx)?; + let mut current_geometry = current_shape; + + // Navigate to the specific nested geometry if there's a path + for &idx in nested_path { + if let Geometry::GeometryCollection(geometries, _) = current_geometry { + current_geometry = geometries.get(idx)?; + } else { + return None; // Invalid path + } + } + + // Generate basic information for geometry type + let detail_info = match current_geometry { + Geometry::Point(coord, metadata) => { + let wgs84 = coord.as_wgs84(); + let mut info = format!("📍 Point\nCoordinates: {:.6}, {:.6}", wgs84.lat, wgs84.lon); + + if let Some(label) = &metadata.label { + write!(info, "\nLabel: {}", label.full()).unwrap(); + } + + if let Some(time_data) = &metadata.time_data + && let Some(timestamp) = time_data.timestamp + { + write!( + info, + "\nTimestamp: {}", + timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ) + .unwrap(); + } + + write!(info, "\nLayer: {layer_id}").unwrap(); + if !nested_path.is_empty() { + write!( + info, + "\nNested Path: {}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(" → ") + ) + .unwrap(); + } + + info + } + + Geometry::LineString(coords, metadata) => { + let mut info = format!("📏 LineString\nPoints: {}", coords.len()); + + if let Some(label) = &metadata.label { + write!(info, "\nLabel: {}", label.full()).unwrap(); + } + + if let Some(time_data) = &metadata.time_data + && let Some(timestamp) = time_data.timestamp + { + write!( + info, + "\nTimestamp: {}", + timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ) + .unwrap(); + } + + write!(info, "\nLayer: {layer_id}").unwrap(); + if !nested_path.is_empty() { + write!( + info, + "\nNested Path: {}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(" → ") + ) + .unwrap(); + } + + info + } + + Geometry::Polygon(coords, metadata) => { + let mut info = format!("⬟ Polygon\nVertices: {}", coords.len()); + + if let Some(label) = &metadata.label { + write!(info, "\nLabel: {}", label.full()).unwrap(); + } + + if let Some(time_data) = &metadata.time_data + && let Some(timestamp) = time_data.timestamp + { + write!( + info, + "\nTimestamp: {}", + timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ) + .unwrap(); + } + + write!(info, "\nLayer: {layer_id}").unwrap(); + if !nested_path.is_empty() { + write!( + info, + "\nNested Path: {}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(" → ") + ) + .unwrap(); + } + + info + } + + Geometry::Heatmap(coords, metadata) => { + let mut info = format!("🔥 Heatmap\nPoints: {}", coords.len()); + + if let Some(label) = &metadata.label { + write!(info, "\nLabel: {}", label.full()).unwrap(); + } + + write!(info, "\nLayer: {layer_id}").unwrap(); + if !nested_path.is_empty() { + write!( + info, + "\nNested Path: {}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(" → ") + ) + .unwrap(); + } + + info + } + + Geometry::GeometryCollection(geometries, metadata) => { + let mut info = format!("📁 Collection\nItems: {}", geometries.len()); + + if let Some(label) = &metadata.label { + write!(info, "\nLabel: {}", label.full()).unwrap(); + } + + if let Some(time_data) = &metadata.time_data + && let Some(timestamp) = time_data.timestamp + { + write!( + info, + "\nTimestamp: {}", + timestamp.format("%Y-%m-%d %H:%M:%S UTC") + ) + .unwrap(); + } + + write!(info, "\nLayer: {layer_id}").unwrap(); + if !nested_path.is_empty() { + write!( + info, + "\nNested Path: {}", + nested_path + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(" → ") + ) + .unwrap(); + } + + info + } + }; + + Some(detail_info) + } + + /// Recursively find the closest individual geometry to a point + #[allow(clippy::too_many_arguments)] + pub(super) fn find_closest_in_geometry( + &self, + layer_id: &str, + shape_idx: usize, + nested_path: &[usize], + geometry: &Geometry, + click_pos: Pos2, + transform: &Transform, + closest_distance: &mut f64, + closest_geometry: &mut Option<(String, usize, Vec)>, + ) { + geometry_selection::find_closest_in_geometry( + layer_id, + shape_idx, + nested_path, + geometry, + click_pos, + transform, + closest_distance, + closest_geometry, + |layer_id, shape_idx, nested_path| { + // Nested visibility check + let nested_key = (layer_id.to_string(), shape_idx, nested_path.to_vec()); + *self + .nested_geometry_visibility + .get(&nested_key) + .unwrap_or(&true) + }, + |nested_geometry| { + // Temporal visibility check + if let Some(current_time) = self.temporal_current_time { + self.is_individual_geometry_visible_at_time(nested_geometry, current_time) + } else { + true + } + }, + ); + } +} From 6e754ccfe073de2e084313b01dcea9f6cb0c3554 Mon Sep 17 00:00:00 2001 From: Udo Hoffmann Date: Thu, 30 Apr 2026 16:58:54 +0200 Subject: [PATCH 2/2] Tests --- src/map/mapvas_egui/layer/shape_layer.rs | 2 +- .../mapvas_egui/layer/shape_layer/search.rs | 152 ++++++++++++++ .../mapvas_egui/layer/shape_layer/temporal.rs | 186 ++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) diff --git a/src/map/mapvas_egui/layer/shape_layer.rs b/src/map/mapvas_egui/layer/shape_layer.rs index 6127302..14adb13 100644 --- a/src/map/mapvas_egui/layer/shape_layer.rs +++ b/src/map/mapvas_egui/layer/shape_layer.rs @@ -1219,7 +1219,7 @@ mod tests { impl ShapeLayer { // Helper method for testing #[allow(clippy::arc_with_non_send_sync)] - fn new_with_test_receiver() -> Self { + pub(crate) fn new_with_test_receiver() -> Self { let (send, recv) = mpsc::channel(); Self { shape_map: HashMap::new(), diff --git a/src/map/mapvas_egui/layer/shape_layer/search.rs b/src/map/mapvas_egui/layer/shape_layer/search.rs index 601f3e5..67365b9 100644 --- a/src/map/mapvas_egui/layer/shape_layer/search.rs +++ b/src/map/mapvas_egui/layer/shape_layer/search.rs @@ -327,3 +327,155 @@ impl ShapeLayer { } } } + +#[cfg(test)] +mod tests { + use super::super::ShapeLayer; + use crate::map::{ + coordinates::PixelCoordinate, + geometry_collection::{Geometry, Label, Metadata}, + }; + + fn point_with_label(name: &str) -> Geometry { + Geometry::Point( + PixelCoordinate { x: 0.0, y: 0.0 }, + Metadata::default().with_label(name), + ) + } + + fn layer_with(shapes: Vec>) -> ShapeLayer { + let mut layer = ShapeLayer::new_with_test_receiver(); + layer.shape_map.insert("layer".to_string(), shapes); + layer + } + + // --- search_geometries --- + + #[test] + fn search_empty_layer_returns_no_results() { + let mut layer = layer_with(vec![]); + layer.search_geometries("foo"); + assert!(layer.get_search_results().is_empty()); + } + + #[test] + fn search_literal_match() { + let mut layer = layer_with(vec![point_with_label("Berlin"), point_with_label("Paris")]); + layer.search_geometries("Berlin"); + assert_eq!(layer.get_search_results().len(), 1); + assert_eq!(layer.get_search_results()[0].0, "layer"); + assert_eq!(layer.get_search_results()[0].1, 0); + } + + #[test] + fn search_literal_case_insensitive() { + // Invalid regex triggers the literal (case-insensitive) fallback path + let mut layer = layer_with(vec![point_with_label("BERLIN")]); + layer.search_geometries("[invalid regex"); + assert!(layer.get_search_results().is_empty()); + + // Case-insensitive literal match via regex flag + layer.search_geometries("(?i)berlin"); + assert_eq!(layer.get_search_results().len(), 1); + } + + #[test] + fn search_regex_match() { + let mut layer = layer_with(vec![ + point_with_label("stop_123"), + point_with_label("stop_456"), + point_with_label("depot"), + ]); + layer.search_geometries("stop_[0-9]+"); + assert_eq!(layer.get_search_results().len(), 2); + } + + #[test] + fn search_no_match_clears_results() { + let mut layer = layer_with(vec![point_with_label("Berlin")]); + layer.search_geometries("Berlin"); + assert_eq!(layer.get_search_results().len(), 1); + layer.search_geometries("Tokyo"); + assert!(layer.get_search_results().is_empty()); + } + + #[test] + fn search_in_nested_collection() { + let inner = point_with_label("Hamburg"); + let collection = Geometry::GeometryCollection(vec![inner], Metadata::default()); + let mut layer = layer_with(vec![collection]); + layer.search_geometries("Hamburg"); + assert_eq!(layer.get_search_results().len(), 1); + assert_eq!(layer.get_search_results()[0].2, vec![0]); // nested_path points to child + } + + // --- next/previous_search_result --- + + #[test] + fn next_result_cycles_through_results() { + let mut layer = layer_with(vec![ + point_with_label("stop_1"), + point_with_label("stop_2"), + point_with_label("stop_3"), + ]); + layer.search_geometries("stop_"); + assert_eq!(layer.get_search_results().len(), 3); + + assert!(layer.next_search_result()); + assert!(layer.next_search_result()); + assert!(layer.next_search_result()); // wraps around + } + + #[test] + fn previous_result_on_empty_returns_false() { + let mut layer = layer_with(vec![]); + layer.search_geometries("anything"); + assert!(!layer.previous_search_result()); + } + + // --- filter_geometries / geometry_matches_filter --- + + #[test] + fn filter_matches_hides_non_matching() { + let berlin = point_with_label("Berlin"); + let paris = point_with_label("Paris"); + + let layer = layer_with(vec![]); + assert!(layer.geometry_matches_filter(&berlin)); // no filter → always true + + let mut layer = layer_with(vec![]); + layer.filter_geometries("Berlin"); + assert!(layer.geometry_matches_filter(&berlin)); + assert!(!layer.geometry_matches_filter(&paris)); + } + + #[test] + fn clear_filter_shows_all() { + let mut layer = layer_with(vec![]); + let paris = point_with_label("Paris"); + layer.filter_geometries("berlin"); + assert!(!layer.geometry_matches_filter(&paris)); + layer.clear_filter(); + assert!(layer.geometry_matches_filter(&paris)); + } + + #[test] + fn filter_regex() { + let mut layer = layer_with(vec![]); + layer.filter_geometries("stop_[0-9]+"); + assert!(layer.geometry_matches_filter(&point_with_label("stop_42"))); + assert!(!layer.geometry_matches_filter(&point_with_label("depot"))); + } + + #[test] + fn filter_matches_description() { + let mut layer = layer_with(vec![]); + layer.filter_geometries("central hub"); + let geo = Geometry::Point( + PixelCoordinate { x: 0.0, y: 0.0 }, + Metadata::default() + .with_label(Label::new("stop".to_string()).with_description("central hub".to_string())), + ); + assert!(layer.geometry_matches_filter(&geo)); + } +} diff --git a/src/map/mapvas_egui/layer/shape_layer/temporal.rs b/src/map/mapvas_egui/layer/shape_layer/temporal.rs index 751f29b..9ad8dc4 100644 --- a/src/map/mapvas_egui/layer/shape_layer/temporal.rs +++ b/src/map/mapvas_egui/layer/shape_layer/temporal.rs @@ -374,3 +374,189 @@ impl ShapeLayer { ); } } + +#[cfg(test)] +mod tests { + use super::super::ShapeLayer; + use crate::map::{ + coordinates::PixelCoordinate, + geometry_collection::{Geometry, Metadata}, + }; + use chrono::{Duration, TimeZone, Utc}; + + fn layer_with_shapes(shapes: Vec>) -> ShapeLayer { + let mut layer = ShapeLayer::new_with_test_receiver(); + layer.shape_map.insert("test".to_string(), shapes); + layer + } + + fn point(meta: Metadata) -> Geometry { + Geometry::Point(PixelCoordinate { x: 0.0, y: 0.0 }, meta) + } + + fn t(year: i32, month: u32, day: u32) -> chrono::DateTime { + Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap() + } + + // --- get_temporal_range --- + + #[test] + fn temporal_range_empty_layer() { + let layer = layer_with_shapes(vec![]); + assert_eq!(layer.get_temporal_range(), (None, None)); + } + + #[test] + fn temporal_range_no_time_data() { + let layer = layer_with_shapes(vec![point(Metadata::default())]); + assert_eq!(layer.get_temporal_range(), (None, None)); + } + + #[test] + fn temporal_range_single_timestamp() { + let ts = t(2024, 6, 1); + let layer = layer_with_shapes(vec![point(Metadata::default().with_timestamp(ts))]); + assert_eq!(layer.get_temporal_range(), (Some(ts), Some(ts))); + } + + #[test] + fn temporal_range_multiple_timestamps() { + let t1 = t(2024, 1, 1); + let t2 = t(2024, 6, 1); + let t3 = t(2024, 12, 31); + let layer = layer_with_shapes(vec![ + point(Metadata::default().with_timestamp(t2)), + point(Metadata::default().with_timestamp(t1)), + point(Metadata::default().with_timestamp(t3)), + ]); + assert_eq!(layer.get_temporal_range(), (Some(t1), Some(t3))); + } + + #[test] + fn temporal_range_from_time_span() { + let begin = t(2024, 3, 1); + let end = t(2024, 9, 1); + let layer = layer_with_shapes(vec![point( + Metadata::default().with_time_span(Some(begin), Some(end)), + )]); + assert_eq!(layer.get_temporal_range(), (Some(begin), Some(end))); + } + + #[test] + fn temporal_range_nested_in_collection() { + let ts = t(2024, 6, 15); + let nested = point(Metadata::default().with_timestamp(ts)); + let collection = Geometry::GeometryCollection(vec![nested], Metadata::default()); + let layer = layer_with_shapes(vec![collection]); + assert_eq!(layer.get_temporal_range(), (Some(ts), Some(ts))); + } + + // --- is_geometry_visible_at_time --- + + #[test] + fn visible_at_time_no_time_data_always_visible() { + let layer = layer_with_shapes(vec![]); + let geo = point(Metadata::default()); + assert!(layer.is_geometry_visible_at_time(&geo, t(2024, 1, 1))); + } + + #[test] + fn visible_at_time_matching_timestamp() { + let layer = layer_with_shapes(vec![]); + let ts = t(2024, 6, 1); + let geo = point(Metadata::default().with_timestamp(ts)); + assert!(layer.is_geometry_visible_at_time(&geo, ts)); + } + + #[test] + fn visible_at_time_non_matching_timestamp() { + let layer = layer_with_shapes(vec![]); + let geo = point(Metadata::default().with_timestamp(t(2024, 6, 1))); + assert!(!layer.is_geometry_visible_at_time(&geo, t(2024, 1, 1))); + } + + #[test] + fn visible_at_time_within_span() { + let layer = layer_with_shapes(vec![]); + let geo = point(Metadata::default().with_time_span(Some(t(2024, 1, 1)), Some(t(2024, 12, 31)))); + assert!(layer.is_geometry_visible_at_time(&geo, t(2024, 6, 15))); + } + + #[test] + fn visible_at_time_outside_span() { + let layer = layer_with_shapes(vec![]); + let geo = point(Metadata::default().with_time_span(Some(t(2024, 1, 1)), Some(t(2024, 6, 30)))); + assert!(!layer.is_geometry_visible_at_time(&geo, t(2024, 12, 1))); + } + + #[test] + fn visible_at_time_collection_no_own_time_data_delegates_to_children() { + let mut layer = layer_with_shapes(vec![]); + layer.temporal_current_time = Some(t(2024, 6, 1)); + let visible_child = point(Metadata::default().with_timestamp(t(2024, 6, 1))); + let hidden_child = point(Metadata::default().with_timestamp(t(2024, 1, 1))); + let collection = + Geometry::GeometryCollection(vec![visible_child, hidden_child], Metadata::default()); + assert!(layer.is_geometry_visible_at_time(&collection, t(2024, 6, 1))); + } + + #[test] + fn visible_at_time_collection_all_children_hidden() { + let layer = layer_with_shapes(vec![]); + let child1 = point(Metadata::default().with_timestamp(t(2024, 1, 1))); + let child2 = point(Metadata::default().with_timestamp(t(2024, 2, 1))); + let collection = Geometry::GeometryCollection(vec![child1, child2], Metadata::default()); + assert!(!layer.is_geometry_visible_at_time(&collection, t(2024, 12, 1))); + } + + // --- is_geometry_visible_at_time with time window --- + + #[test] + fn visible_with_time_window_timestamp_in_window() { + let mut layer = layer_with_shapes(vec![]); + layer.temporal_time_window = Some(Duration::hours(1)); + let ts = t(2024, 6, 1); + let geo = point(Metadata::default().with_timestamp(ts)); + // current_time is 30 min after ts — within 1h window + assert!(layer.is_geometry_visible_at_time(&geo, ts + Duration::minutes(30))); + } + + #[test] + fn visible_with_time_window_timestamp_outside_window() { + let mut layer = layer_with_shapes(vec![]); + layer.temporal_time_window = Some(Duration::hours(1)); + let ts = t(2024, 6, 1); + let geo = point(Metadata::default().with_timestamp(ts)); + // current_time is 2h after ts — outside 1h window + assert!(!layer.is_geometry_visible_at_time(&geo, ts + Duration::hours(2))); + } + + // --- get_geometry_at_path --- + + #[test] + fn get_geometry_at_path_empty_path_returns_root() { + let geo = point(Metadata::default()); + let result = ShapeLayer::get_geometry_at_path(&geo, &[]); + assert!(result.is_some()); + } + + #[test] + fn get_geometry_at_path_valid_nested() { + let inner = point(Metadata::default().with_label("inner")); + let collection = Geometry::GeometryCollection(vec![inner], Metadata::default()); + let result = ShapeLayer::get_geometry_at_path(&collection, &[0]); + assert!(matches!(result, Some(Geometry::Point(_, _)))); + } + + #[test] + fn get_geometry_at_path_out_of_bounds() { + let collection = Geometry::GeometryCollection(vec![], Metadata::default()); + assert!(ShapeLayer::get_geometry_at_path(&collection, &[0]).is_none()); + } + + #[test] + fn get_geometry_at_path_non_collection_with_index() { + let geo = point(Metadata::default()); + assert!(ShapeLayer::get_geometry_at_path(&geo, &[0]).is_none()); + } +}