1use crate::camera_projection::CameraProjection;
23use crate::picking::{HitCategory, HitProvenance, PickHit};
24use rustial_math::{GeoCoord, TileId};
25
26#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct ScreenPoint {
29 pub x: f64,
31 pub y: f64,
33}
34
35impl ScreenPoint {
36 pub const fn new(x: f64, y: f64) -> Self {
38 Self { x, y }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub enum PointerKind {
45 #[default]
47 Mouse,
48 Touch,
50 Pen,
52 Unknown,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum InteractionButton {
59 Primary,
61 Secondary,
63 Auxiliary,
65 Other(u16),
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct InteractionModifiers {
72 pub shift: bool,
74 pub ctrl: bool,
76 pub alt: bool,
78 pub meta: bool,
80}
81
82impl InteractionModifiers {
83 pub const fn new(shift: bool, ctrl: bool, alt: bool, meta: bool) -> Self {
85 Self {
86 shift,
87 ctrl,
88 alt,
89 meta,
90 }
91 }
92
93 pub const fn any(self) -> bool {
95 self.shift || self.ctrl || self.alt || self.meta
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum InteractionEventKind {
102 MouseEnter,
104 MouseLeave,
106 MouseMove,
108 MouseOver,
110 MouseOut,
112 MouseDown,
114 MouseUp,
116 Click,
118 DoubleClick,
120 ContextMenu,
122 TouchStart,
124 TouchMove,
126 TouchEnd,
128 TouchCancel,
130}
131
132impl InteractionEventKind {
133 pub const fn as_str(self) -> &'static str {
135 match self {
136 Self::MouseEnter => "mouseenter",
137 Self::MouseLeave => "mouseleave",
138 Self::MouseMove => "mousemove",
139 Self::MouseOver => "mouseover",
140 Self::MouseOut => "mouseout",
141 Self::MouseDown => "mousedown",
142 Self::MouseUp => "mouseup",
143 Self::Click => "click",
144 Self::DoubleClick => "dblclick",
145 Self::ContextMenu => "contextmenu",
146 Self::TouchStart => "touchstart",
147 Self::TouchMove => "touchmove",
148 Self::TouchEnd => "touchend",
149 Self::TouchCancel => "touchcancel",
150 }
151 }
152
153 pub const fn canonical(self) -> Self {
158 match self {
159 Self::MouseOver => Self::MouseEnter,
160 Self::MouseOut => Self::MouseLeave,
161 other => other,
162 }
163 }
164
165 pub const fn is_hover_event(self) -> bool {
167 matches!(
168 self,
169 Self::MouseEnter | Self::MouseLeave | Self::MouseMove | Self::MouseOver | Self::MouseOut
170 )
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum InteractionTargetKind {
177 Terrain,
179 Feature,
181 Symbol,
183 Model,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189pub struct InteractionTarget {
190 pub kind: InteractionTargetKind,
192 pub provenance: HitProvenance,
194 pub layer_id: Option<String>,
196 pub source_id: Option<String>,
198 pub source_layer: Option<String>,
200 pub source_tile: Option<TileId>,
202 pub feature_id: Option<String>,
204 pub feature_index: Option<usize>,
206 pub from_symbol: bool,
208}
209
210impl InteractionTarget {
211 pub fn from_pick_hit(hit: &PickHit) -> Self {
213 Self {
214 kind: InteractionTargetKind::from_hit_category(hit.category),
215 provenance: hit.provenance,
216 layer_id: hit.layer_id.clone(),
217 source_id: hit.source_id.clone(),
218 source_layer: hit.source_layer.clone(),
219 source_tile: hit.source_tile,
220 feature_id: hit.feature_id.clone(),
221 feature_index: hit.feature_index,
222 from_symbol: hit.from_symbol,
223 }
224 }
225
226 pub fn is_feature_backed(&self) -> bool {
228 self.source_id.is_some() && self.feature_id.is_some()
229 }
230}
231
232impl InteractionTargetKind {
233 pub const fn from_hit_category(category: HitCategory) -> Self {
235 match category {
236 HitCategory::Terrain => Self::Terrain,
237 HitCategory::Feature => Self::Feature,
238 HitCategory::Symbol => Self::Symbol,
239 HitCategory::Model => Self::Model,
240 }
241 }
242}
243
244#[derive(Debug, Clone)]
246pub struct InteractionEvent {
247 pub kind: InteractionEventKind,
249 pub pointer_kind: PointerKind,
251 pub screen_point: ScreenPoint,
253 pub query_coord: Option<GeoCoord>,
255 pub projection: Option<CameraProjection>,
257 pub button: Option<InteractionButton>,
259 pub modifiers: InteractionModifiers,
261 pub target: Option<InteractionTarget>,
263 pub related_target: Option<InteractionTarget>,
265 pub hit: Option<PickHit>,
267}
268
269impl InteractionEvent {
270 pub fn new(kind: InteractionEventKind, pointer_kind: PointerKind, screen_point: ScreenPoint) -> Self {
272 Self {
273 kind,
274 pointer_kind,
275 screen_point,
276 query_coord: None,
277 projection: None,
278 button: None,
279 modifiers: InteractionModifiers::default(),
280 target: None,
281 related_target: None,
282 hit: None,
283 }
284 }
285
286 pub fn with_query_coord(mut self, query_coord: GeoCoord) -> Self {
288 self.query_coord = Some(query_coord);
289 self
290 }
291
292 pub fn with_projection(mut self, projection: CameraProjection) -> Self {
294 self.projection = Some(projection);
295 self
296 }
297
298 pub fn with_button(mut self, button: InteractionButton) -> Self {
300 self.button = Some(button);
301 self
302 }
303
304 pub fn with_modifiers(mut self, modifiers: InteractionModifiers) -> Self {
306 self.modifiers = modifiers;
307 self
308 }
309
310 pub fn with_hit(mut self, hit: PickHit) -> Self {
312 self.target = Some(InteractionTarget::from_pick_hit(&hit));
313 self.hit = Some(hit);
314 self
315 }
316
317 pub fn with_related_target(mut self, related_target: InteractionTarget) -> Self {
319 self.related_target = Some(related_target);
320 self
321 }
322
323 pub fn has_target(&self) -> bool {
325 self.target.is_some()
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::picking::PickHit;
333 use std::collections::HashMap;
334
335 #[test]
336 fn mouseover_and_mouseout_canonicalize_to_enter_leave() {
337 assert_eq!(
338 InteractionEventKind::MouseOver.canonical(),
339 InteractionEventKind::MouseEnter
340 );
341 assert_eq!(
342 InteractionEventKind::MouseOut.canonical(),
343 InteractionEventKind::MouseLeave
344 );
345 }
346
347 #[test]
348 fn interaction_target_kind_tracks_pick_hit_category() {
349 let hit = PickHit {
350 category: HitCategory::Symbol,
351 provenance: HitProvenance::GeometricApproximation,
352 layer_id: Some("places".into()),
353 source_id: Some("composite".into()),
354 source_layer: Some("place_label".into()),
355 source_tile: None,
356 feature_id: Some("42".into()),
357 feature_index: Some(3),
358 geometry: None,
359 properties: HashMap::new(),
360 state: HashMap::new(),
361 distance_meters: 0.0,
362 hit_coord: None,
363 layer_priority: 0,
364 from_symbol: true,
365 };
366
367 let target = InteractionTarget::from_pick_hit(&hit);
368 assert_eq!(target.kind, InteractionTargetKind::Symbol);
369 assert!(target.is_feature_backed());
370 assert!(target.from_symbol);
371 }
372
373 #[test]
374 fn interaction_event_with_hit_populates_target() {
375 let hit = PickHit::terrain_surface(GeoCoord::from_lat_lon(10.0, 20.0), Some(25.0));
376 let event = InteractionEvent::new(
377 InteractionEventKind::MouseMove,
378 PointerKind::Mouse,
379 ScreenPoint::new(10.0, 20.0),
380 )
381 .with_hit(hit);
382
383 assert!(event.has_target());
384 assert_eq!(
385 event.target.as_ref().map(|target| target.kind),
386 Some(InteractionTargetKind::Terrain)
387 );
388 assert_eq!(event.kind.as_str(), "mousemove");
389 }
390
391 #[test]
392 fn modifiers_any_detects_active_modifier() {
393 assert!(!InteractionModifiers::default().any());
394 assert!(InteractionModifiers::new(false, true, false, false).any());
395 }
396}