this repo has no description
at main 337 lines 9.8 kB view raw
1use bevy::input::mouse::{MouseMotion, MouseWheel}; 2use bevy::prelude::*; 3use bevy_panorbit_camera::PanOrbitCamera; 4 5use crate::db::{DbChannel, DbReadRequest}; 6use crate::helix::{HelixHierarchy, HelixState, compute_focal_point}; 7use crate::ingest::{HistoryQueryState, JetstreamEventMarker, LoadedRanges, TimeWindow}; 8 9const SCROLL_SENSITIVITY: f32 = 0.5; 10const ZOOM_INTERPOLATION_FACTOR: f32 = 5.0; 11const DRAG_SENSITIVITY: f32 = 0.0005; 12const INERTIA_DECAY_RATE: f32 = 3.0; 13const VELOCITY_EPSILON: f32 = 0.0001; 14 15#[derive(Resource, Debug, Default)] 16pub struct DragState { 17 pub is_dragging: bool, 18 pub velocity: f32, 19} 20 21#[derive(Resource, Debug)] 22pub struct ZoomConfig { 23 pub zoom_button: MouseButton, 24 pub drag_sensitivity: f32, 25} 26 27impl Default for ZoomConfig { 28 fn default() -> Self { 29 Self { 30 zoom_button: MouseButton::Other(8), // forward side button (trackball) 31 drag_sensitivity: 0.02, 32 } 33 } 34} 35 36/// Scroll wheel → zoom level. 37pub fn scroll_zoom_level( 38 mut scroll_events: MessageReader<MouseWheel>, 39 mut state: ResMut<HelixState>, 40 hierarchy: Res<HelixHierarchy>, 41) { 42 if hierarchy.levels.is_empty() { 43 return; 44 } 45 46 let mut delta = 0.0; 47 for event in scroll_events.read() { 48 delta += event.y; 49 } 50 51 if delta != 0.0 { 52 let delta_level = delta * SCROLL_SENSITIVITY; 53 let new_target = state.target_level - delta_level; 54 let max_level = (hierarchy.levels.len() - 1) as f32; 55 state.target_level = new_target.clamp(0.0, max_level); 56 } 57} 58 59/// Right-drag pans through time with inertia. 60pub fn drag_pan_time( 61 mut motion_events: MessageReader<MouseMotion>, 62 mouse_buttons: Res<ButtonInput<MouseButton>>, 63 mut state: ResMut<HelixState>, 64 mut drag: ResMut<DragState>, 65 time: Res<Time>, 66) { 67 let right_pressed = mouse_buttons.pressed(MouseButton::Right); 68 let mut frame_delta = 0.0; 69 70 if right_pressed { 71 for event in motion_events.read() { 72 frame_delta += event.delta.y + event.delta.x * 0.5; 73 } 74 75 drag.is_dragging = true; 76 state.auto_follow = false; 77 78 let time_shift = frame_delta * DRAG_SENSITIVITY; 79 state.focal_time -= time_shift; 80 81 let avg_factor = 0.7; 82 drag.velocity = drag.velocity * avg_factor + time_shift * (1.0 - avg_factor); 83 } else { 84 if drag.is_dragging { 85 drag.is_dragging = false; 86 } 87 88 let delta_secs = time.delta_secs(); 89 state.focal_time -= drag.velocity; 90 drag.velocity *= (-INERTIA_DECAY_RATE * delta_secs).exp(); 91 92 if drag.velocity.abs() < VELOCITY_EPSILON { 93 drag.velocity = 0.0; 94 } 95 } 96} 97 98/// Side-button + vertical drag → zoom level. 99pub fn button_zoom_level( 100 mut motion_events: MessageReader<MouseMotion>, 101 mouse_buttons: Res<ButtonInput<MouseButton>>, 102 zoom_config: Res<ZoomConfig>, 103 mut state: ResMut<HelixState>, 104 hierarchy: Res<HelixHierarchy>, 105) { 106 if !mouse_buttons.pressed(zoom_config.zoom_button) { 107 return; 108 } 109 110 if hierarchy.levels.is_empty() { 111 return; 112 } 113 114 let mut delta_y = 0.0; 115 for event in motion_events.read() { 116 delta_y += event.delta.y; 117 } 118 119 if delta_y != 0.0 { 120 let delta_level = delta_y * zoom_config.drag_sensitivity; 121 let new_target = state.target_level - delta_level; 122 let max_level = (hierarchy.levels.len() - 1) as f32; 123 state.target_level = new_target.clamp(0.0, max_level); 124 } 125} 126 127/// Smooth interpolation of zoom level. 128pub fn interpolate_zoom_level(mut state: ResMut<HelixState>, time: Res<Time>) { 129 let lerp_factor = (ZOOM_INTERPOLATION_FACTOR * time.delta_secs()).clamp(0.0, 1.0); 130 state.interpolated_level = 131 state.interpolated_level * (1.0 - lerp_factor) + state.target_level * lerp_factor; 132 state.active_level = state.interpolated_level.round() as usize; 133} 134 135/// Space: toggle auto-follow (pause/unpause). 136pub fn toggle_pause(keys: Res<ButtonInput<KeyCode>>, mut state: ResMut<HelixState>) { 137 if keys.just_pressed(KeyCode::Space) { 138 state.auto_follow = !state.auto_follow; 139 info!("auto_follow: {}", state.auto_follow); 140 } 141} 142 143const SLOW_TIME_SCALE: f32 = 0.1; 144 145/// S: toggle slow time (1.0 ↔ 0.1). 146pub fn toggle_slow_time(keys: Res<ButtonInput<KeyCode>>, mut state: ResMut<HelixState>) { 147 if keys.just_pressed(KeyCode::KeyS) { 148 if state.time_scale >= 1.0 { 149 state.time_scale = SLOW_TIME_SCALE; 150 info!("time_scale: {} (slow)", state.time_scale); 151 } else { 152 state.time_scale = 1.0; 153 info!("time_scale: 1.0 (normal)"); 154 } 155 } 156} 157 158/// Track latest data. Snaps at full speed, lerps when slowed or catching up. 159pub fn auto_follow_latest( 160 mut state: ResMut<HelixState>, 161 window: Res<crate::ingest::TimeWindow>, 162 time: Res<Time>, 163) { 164 if !state.auto_follow || !window.is_initialized { 165 return; 166 } 167 168 let dt = time.delta_secs(); 169 let gap = window.latest_t - state.focal_time; 170 171 if state.time_scale >= 1.0 { 172 // Full speed: snap when close, fast lerp when catching up from pause. 173 if gap.abs() < 0.05 { 174 state.focal_time = window.latest_t; 175 } else { 176 let factor = (3.0 * dt).clamp(0.0, 1.0); 177 state.focal_time += gap * factor; 178 } 179 } else { 180 // Slow mode: advance at time_scale fraction of real time. 181 // 1 t-unit = 1 minute, so real-time advance per second = 1/60 t-units. 182 // At time_scale=0.1, we advance at 1/600 t-units per second. 183 let advance = state.time_scale * dt / 60.0; 184 state.focal_time += advance; 185 } 186} 187 188/// Recenter camera on the helix focal point. 189pub fn update_camera_focus( 190 state: Res<HelixState>, 191 hierarchy: Res<HelixHierarchy>, 192 mut cameras: Query<&mut PanOrbitCamera>, 193) { 194 let target = compute_focal_point(&state, &hierarchy); 195 196 for mut cam in cameras.iter_mut() { 197 cam.target_focus = target; 198 } 199} 200 201/// Pan past loaded range -> trigger SQLite query (5 min either side of focal_time). 202pub fn check_pan_triggers_history_load( 203 state: Res<HelixState>, 204 time_window: Res<TimeWindow>, 205 loaded_ranges: Res<LoadedRanges>, 206 mut query_state: ResMut<HistoryQueryState>, 207 db_channel: Res<DbChannel>, 208) { 209 if state.auto_follow { 210 return; 211 } 212 213 if !time_window.is_initialized { 214 return; 215 } 216 217 if query_state.pending.is_some() { 218 return; 219 } 220 221 let focal_us = 222 (state.focal_time as f64 * TimeWindow::MICROS_PER_TURN) as i64 + time_window.anchor_us; 223 const HALF_WINDOW_US: i64 = 300_000_000; 224 let query_start = focal_us - HALF_WINDOW_US; 225 let query_end = focal_us + HALF_WINDOW_US; 226 227 if loaded_ranges.is_covered(query_start, query_end) { 228 return; 229 } 230 231 let (resp_tx, resp_rx) = crossbeam_channel::bounded::<Vec<Vec<rusqlite::types::Value>>>(1); 232 233 if db_channel 234 .reader 235 .send(DbReadRequest::QueryTimeRange { 236 start_us: query_start, 237 end_us: query_end, 238 response_tx: resp_tx, 239 }) 240 .is_err() 241 { 242 warn!("check_pan_triggers_history_load: failed to send QueryTimeRange"); 243 return; 244 } 245 246 debug!( 247 "check_pan_triggers_history_load: querying range [{}, {}] for focal_time={}", 248 query_start, query_end, state.focal_time 249 ); 250 251 query_state.pending = Some(resp_rx); 252 query_state.pending_range = Some((query_start, query_end)); 253} 254 255#[derive(Resource, Default)] 256pub struct SelectedEvent { 257 pub entity: Option<Entity>, 258 pub previous_entity: Option<Entity>, 259} 260 261/// Cached cursor position (Wayland doesn't provide window.cursor_position). 262#[derive(Resource, Default)] 263pub struct LastCursorPosition(pub Option<Vec2>); 264 265pub fn track_cursor( 266 mut cursor_events: MessageReader<bevy::window::CursorMoved>, 267 mut last_pos: ResMut<LastCursorPosition>, 268) { 269 for event in cursor_events.read() { 270 last_pos.0 = Some(event.position); 271 } 272} 273 274pub fn pick_event( 275 cameras: Query<(&Camera, &GlobalTransform), With<PanOrbitCamera>>, 276 events: Query<(Entity, &Transform), With<JetstreamEventMarker>>, 277 mut selected: ResMut<SelectedEvent>, 278 cursor: Res<LastCursorPosition>, 279) { 280 let Some(cursor_pos) = cursor.0 else { 281 return; 282 }; 283 let Ok((camera, camera_transform)) = cameras.single() else { 284 return; 285 }; 286 287 let Ok(ray) = camera.viewport_to_world(camera_transform, cursor_pos) else { 288 return; 289 }; 290 291 let mut closest: Option<(Entity, f32)> = None; 292 let threshold = 0.15_f32; 293 294 for (entity, transform) in events.iter() { 295 let to_point = transform.translation - ray.origin; 296 let projected = to_point.dot(*ray.direction); 297 if projected < 0.0 { 298 continue; 299 } 300 let closest_on_ray = ray.origin + *ray.direction * projected; 301 let distance = (transform.translation - closest_on_ray).length(); 302 303 if distance < threshold { 304 match closest { 305 None => closest = Some((entity, distance)), 306 Some((_, best)) if distance < best => closest = Some((entity, distance)), 307 _ => {} 308 } 309 } 310 } 311 312 selected.entity = closest.map(|(e, _)| e); 313} 314 315/// Gizmo highlight on selected event. 316pub fn highlight_selected( 317 mut gizmos: Gizmos, 318 selected: Res<SelectedEvent>, 319 events: Query<&Transform, With<JetstreamEventMarker>>, 320) { 321 let Some(entity) = selected.entity else { 322 return; 323 }; 324 325 let Ok(transform) = events.get(entity) else { 326 return; 327 }; 328 329 let radius = 0.02; 330 let color = Color::srgb(1.0, 0.95, 0.0); 331 332 gizmos.sphere( 333 Isometry3d::from_translation(transform.translation), 334 radius, 335 color, 336 ); 337}