this repo has no description
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}