1use nms_core::galaxy::Galaxy;
4use nms_graph::GalaxyModel;
5
6use crate::session::SessionState;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ZoomLevel {
11 Galaxy,
13 Region,
15 Local,
17}
18
19impl ZoomLevel {
20 pub fn extent(self) -> f64 {
22 match self {
23 Self::Galaxy => 4096.0,
24 Self::Region => 512.0,
25 Self::Local => 64.0,
26 }
27 }
28
29 pub fn zoom_in(self) -> Option<Self> {
31 match self {
32 Self::Galaxy => Some(Self::Region),
33 Self::Region => Some(Self::Local),
34 Self::Local => None,
35 }
36 }
37
38 pub fn zoom_out(self) -> Option<Self> {
40 match self {
41 Self::Galaxy => None,
42 Self::Region => Some(Self::Galaxy),
43 Self::Local => Some(Self::Region),
44 }
45 }
46
47 pub fn label(self) -> &'static str {
49 match self {
50 Self::Galaxy => "Galaxy",
51 Self::Region => "Region",
52 Self::Local => "Local",
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct BaseLabel {
60 pub letter: char,
61 pub name: String,
62 pub voxel_x: i16,
63 pub voxel_z: i16,
64}
65
66pub struct MapState {
68 pub zoom: ZoomLevel,
70 pub center: (f64, f64),
72 pub cursor: (u16, u16),
74 pub grid_size: (u16, u16),
76 pub galaxy: u8,
78 pub galaxy_name: String,
80 pub base_labels: Vec<BaseLabel>,
82 pub player_pos: Option<(i16, i16)>,
84 pub zoom_stack: Vec<(ZoomLevel, f64, f64)>,
86 pub show_help: bool,
88 pub should_quit: bool,
90}
91
92impl MapState {
93 pub fn new(model: &GalaxyModel, session: &SessionState) -> Self {
95 let galaxy = model.active_galaxy;
96 let galaxy_name = Galaxy::by_index(galaxy).name.to_string();
97
98 let player_pos = model.player_position().map(|a| (a.voxel_x(), a.voxel_z()));
99
100 let center = player_pos
102 .map(|(x, z)| (f64::from(x), f64::from(z)))
103 .unwrap_or((0.0, 0.0));
104
105 let mut bases_in_galaxy: Vec<_> = model
107 .bases
108 .values()
109 .filter(|b| b.address.reality_index == galaxy)
110 .collect();
111 bases_in_galaxy.sort_by(|a, b| a.name.cmp(&b.name));
112 let base_labels: Vec<BaseLabel> = bases_in_galaxy
113 .into_iter()
114 .take(26)
115 .enumerate()
116 .map(|(i, b)| BaseLabel {
117 letter: (b'A' + i as u8) as char,
118 name: b.name.clone(),
119 voxel_x: b.address.voxel_x(),
120 voxel_z: b.address.voxel_z(),
121 })
122 .collect();
123
124 let effective_center = session
126 .position
127 .as_ref()
128 .map(|p| {
129 let a = p.address();
130 (f64::from(a.voxel_x()), f64::from(a.voxel_z()))
131 })
132 .unwrap_or(center);
133
134 Self {
135 zoom: ZoomLevel::Galaxy,
136 center: effective_center,
137 cursor: (0, 0),
138 grid_size: (78, 19), galaxy,
140 galaxy_name,
141 base_labels,
142 player_pos,
143 zoom_stack: Vec::new(),
144 show_help: false,
145 should_quit: false,
146 }
147 }
148
149 pub fn resize(&mut self, cols: u16, rows: u16) {
151 let inner_cols = cols.saturating_sub(2);
153 let inner_rows = rows.saturating_sub(3).saturating_sub(2);
154 self.grid_size = (inner_cols, inner_rows);
155 self.clamp_cursor();
156 }
157
158 pub fn move_cursor(&mut self, dx: i16, dy: i16) {
160 let (cx, cy) = self.cursor;
161 let new_x = (cx as i16 + dx).max(0) as u16;
162 let new_y = (cy as i16 + dy).max(0) as u16;
163 self.cursor = (new_x, new_y);
164 self.clamp_cursor();
165 }
166
167 fn clamp_cursor(&mut self) {
169 let (cols, rows) = self.grid_size;
170 if cols > 0 {
171 self.cursor.0 = self.cursor.0.min(cols.saturating_sub(1));
172 }
173 if rows > 0 {
174 self.cursor.1 = self.cursor.1.min(rows.saturating_sub(1));
175 }
176 }
177
178 pub fn zoom_in(&mut self) {
180 if let Some(next_zoom) = self.zoom.zoom_in() {
181 self.zoom_stack
183 .push((self.zoom, self.center.0, self.center.1));
184 self.center = self.cursor_voxel();
186 self.zoom = next_zoom;
187 self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
189 }
190 }
191
192 pub fn zoom_out(&mut self) -> bool {
194 if let Some((prev_zoom, cx, cz)) = self.zoom_stack.pop() {
195 self.zoom = prev_zoom;
196 self.center = (cx, cz);
197 self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
198 true
199 } else {
200 false
202 }
203 }
204
205 pub fn center_on_player(&mut self) {
207 if let Some((px, pz)) = self.player_pos {
208 self.center = (f64::from(px), f64::from(pz));
209 self.cursor = (self.grid_size.0 / 2, self.grid_size.1 / 2);
210 }
211 }
212
213 pub fn cursor_voxel(&self) -> (f64, f64) {
215 let (cols, rows) = self.grid_size;
216 let extent = self.zoom.extent();
217 let cell_size_x = extent / f64::from(cols.max(1));
218 let cell_size_z = extent / f64::from(rows.max(1));
219
220 let half_cols = f64::from(cols) / 2.0;
221 let half_rows = f64::from(rows) / 2.0;
222
223 let vx = self.center.0 + (f64::from(self.cursor.0) - half_cols) * cell_size_x;
224 let vz = self.center.1 + (f64::from(self.cursor.1) - half_rows) * cell_size_z;
225 (vx, vz)
226 }
227
228 pub fn voxel_to_grid(&self, vx: f64, vz: f64) -> Option<(u16, u16)> {
231 let (cols, rows) = self.grid_size;
232 let extent = self.zoom.extent();
233 let cell_size_x = extent / f64::from(cols.max(1));
234 let cell_size_z = extent / f64::from(rows.max(1));
235
236 let half_cols = f64::from(cols) / 2.0;
237 let half_rows = f64::from(rows) / 2.0;
238
239 let col = ((vx - self.center.0) / cell_size_x + half_cols) as i32;
240 let row = ((vz - self.center.1) / cell_size_z + half_rows) as i32;
241
242 if col >= 0 && col < cols as i32 && row >= 0 && row < rows as i32 {
243 Some((col as u16, row as u16))
244 } else {
245 None
246 }
247 }
248
249 pub fn viewport_bounds(&self) -> ((f64, f64), (f64, f64)) {
252 let (cols, rows) = self.grid_size;
253 let extent = self.zoom.extent();
254 let cell_size_x = extent / f64::from(cols.max(1));
255 let cell_size_z = extent / f64::from(rows.max(1));
256
257 let half_w = f64::from(cols) / 2.0 * cell_size_x;
258 let half_h = f64::from(rows) / 2.0 * cell_size_z;
259
260 (
261 (self.center.0 - half_w, self.center.1 - half_h),
262 (self.center.0 + half_w, self.center.1 + half_h),
263 )
264 }
265}
266
267pub fn density_char(count: usize) -> char {
269 match count {
270 0 => ' ',
271 1 => '·',
272 2..=3 => '+',
273 4..=7 => '*',
274 _ => '#',
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_zoom_level_extent_galaxy() {
284 assert_eq!(ZoomLevel::Galaxy.extent(), 4096.0);
285 }
286
287 #[test]
288 fn test_zoom_level_extent_region() {
289 assert_eq!(ZoomLevel::Region.extent(), 512.0);
290 }
291
292 #[test]
293 fn test_zoom_level_extent_local() {
294 assert_eq!(ZoomLevel::Local.extent(), 64.0);
295 }
296
297 #[test]
298 fn test_zoom_in_galaxy_to_region() {
299 assert_eq!(ZoomLevel::Galaxy.zoom_in(), Some(ZoomLevel::Region));
300 }
301
302 #[test]
303 fn test_zoom_in_region_to_local() {
304 assert_eq!(ZoomLevel::Region.zoom_in(), Some(ZoomLevel::Local));
305 }
306
307 #[test]
308 fn test_zoom_in_local_returns_none() {
309 assert_eq!(ZoomLevel::Local.zoom_in(), None);
310 }
311
312 #[test]
313 fn test_zoom_out_galaxy_returns_none() {
314 assert_eq!(ZoomLevel::Galaxy.zoom_out(), None);
315 }
316
317 #[test]
318 fn test_zoom_out_region_to_galaxy() {
319 assert_eq!(ZoomLevel::Region.zoom_out(), Some(ZoomLevel::Galaxy));
320 }
321
322 #[test]
323 fn test_zoom_out_local_to_region() {
324 assert_eq!(ZoomLevel::Local.zoom_out(), Some(ZoomLevel::Region));
325 }
326
327 #[test]
328 fn test_density_char_empty() {
329 assert_eq!(density_char(0), ' ');
330 }
331
332 #[test]
333 fn test_density_char_single() {
334 assert_eq!(density_char(1), '·');
335 }
336
337 #[test]
338 fn test_density_char_few() {
339 assert_eq!(density_char(2), '+');
340 assert_eq!(density_char(3), '+');
341 }
342
343 #[test]
344 fn test_density_char_several() {
345 assert_eq!(density_char(4), '*');
346 assert_eq!(density_char(7), '*');
347 }
348
349 #[test]
350 fn test_density_char_many() {
351 assert_eq!(density_char(8), '#');
352 assert_eq!(density_char(100), '#');
353 }
354
355 #[test]
356 fn test_voxel_to_grid_center_maps_to_center() {
357 let state = MapState {
358 zoom: ZoomLevel::Galaxy,
359 center: (0.0, 0.0),
360 cursor: (40, 12),
361 grid_size: (80, 24),
362 galaxy: 0,
363 galaxy_name: "Euclid".into(),
364 base_labels: vec![],
365 player_pos: None,
366 zoom_stack: vec![],
367 show_help: false,
368 should_quit: false,
369 };
370 let pos = state.voxel_to_grid(0.0, 0.0);
372 assert_eq!(pos, Some((40, 12)));
373 }
374
375 #[test]
376 fn test_voxel_to_grid_outside_returns_none() {
377 let state = MapState {
378 zoom: ZoomLevel::Local,
379 center: (0.0, 0.0),
380 cursor: (0, 0),
381 grid_size: (80, 24),
382 galaxy: 0,
383 galaxy_name: "Euclid".into(),
384 base_labels: vec![],
385 player_pos: None,
386 zoom_stack: vec![],
387 show_help: false,
388 should_quit: false,
389 };
390 assert!(state.voxel_to_grid(2000.0, 2000.0).is_none());
392 }
393
394 #[test]
395 fn test_move_cursor_positive() {
396 let mut state = MapState {
397 zoom: ZoomLevel::Galaxy,
398 center: (0.0, 0.0),
399 cursor: (10, 10),
400 grid_size: (80, 24),
401 galaxy: 0,
402 galaxy_name: "Euclid".into(),
403 base_labels: vec![],
404 player_pos: None,
405 zoom_stack: vec![],
406 show_help: false,
407 should_quit: false,
408 };
409 state.move_cursor(5, 3);
410 assert_eq!(state.cursor, (15, 13));
411 }
412
413 #[test]
414 fn test_move_cursor_clamps_negative() {
415 let mut state = MapState {
416 zoom: ZoomLevel::Galaxy,
417 center: (0.0, 0.0),
418 cursor: (2, 2),
419 grid_size: (80, 24),
420 galaxy: 0,
421 galaxy_name: "Euclid".into(),
422 base_labels: vec![],
423 player_pos: None,
424 zoom_stack: vec![],
425 show_help: false,
426 should_quit: false,
427 };
428 state.move_cursor(-10, -10);
429 assert_eq!(state.cursor, (0, 0));
430 }
431
432 #[test]
433 fn test_move_cursor_clamps_to_grid_bounds() {
434 let mut state = MapState {
435 zoom: ZoomLevel::Galaxy,
436 center: (0.0, 0.0),
437 cursor: (78, 22),
438 grid_size: (80, 24),
439 galaxy: 0,
440 galaxy_name: "Euclid".into(),
441 base_labels: vec![],
442 player_pos: None,
443 zoom_stack: vec![],
444 show_help: false,
445 should_quit: false,
446 };
447 state.move_cursor(10, 10);
448 assert_eq!(state.cursor, (79, 23));
449 }
450
451 #[test]
452 fn test_zoom_in_pushes_stack() {
453 let mut state = MapState {
454 zoom: ZoomLevel::Galaxy,
455 center: (100.0, -200.0),
456 cursor: (40, 12),
457 grid_size: (80, 24),
458 galaxy: 0,
459 galaxy_name: "Euclid".into(),
460 base_labels: vec![],
461 player_pos: None,
462 zoom_stack: vec![],
463 show_help: false,
464 should_quit: false,
465 };
466 state.zoom_in();
467 assert_eq!(state.zoom, ZoomLevel::Region);
468 assert_eq!(state.zoom_stack.len(), 1);
469 assert_eq!(state.zoom_stack[0].0, ZoomLevel::Galaxy);
470 }
471
472 #[test]
473 fn test_zoom_out_pops_stack() {
474 let mut state = MapState {
475 zoom: ZoomLevel::Galaxy,
476 center: (100.0, -200.0),
477 cursor: (40, 12),
478 grid_size: (80, 24),
479 galaxy: 0,
480 galaxy_name: "Euclid".into(),
481 base_labels: vec![],
482 player_pos: None,
483 zoom_stack: vec![],
484 show_help: false,
485 should_quit: false,
486 };
487 state.zoom_in();
488 assert!(state.zoom_out());
489 assert_eq!(state.zoom, ZoomLevel::Galaxy);
490 assert!(state.zoom_stack.is_empty());
491 }
492
493 #[test]
494 fn test_zoom_out_at_galaxy_returns_false() {
495 let mut state = MapState {
496 zoom: ZoomLevel::Galaxy,
497 center: (0.0, 0.0),
498 cursor: (0, 0),
499 grid_size: (80, 24),
500 galaxy: 0,
501 galaxy_name: "Euclid".into(),
502 base_labels: vec![],
503 player_pos: None,
504 zoom_stack: vec![],
505 show_help: false,
506 should_quit: false,
507 };
508 assert!(!state.zoom_out());
509 }
510
511 #[test]
512 fn test_center_on_player_updates_center() {
513 let mut state = MapState {
514 zoom: ZoomLevel::Galaxy,
515 center: (0.0, 0.0),
516 cursor: (10, 10),
517 grid_size: (80, 24),
518 galaxy: 0,
519 galaxy_name: "Euclid".into(),
520 base_labels: vec![],
521 player_pos: Some((100, -200)),
522 zoom_stack: vec![],
523 show_help: false,
524 should_quit: false,
525 };
526 state.center_on_player();
527 assert_eq!(state.center, (100.0, -200.0));
528 assert_eq!(state.cursor, (40, 12));
529 }
530
531 #[test]
532 fn test_viewport_bounds_symmetry() {
533 let state = MapState {
534 zoom: ZoomLevel::Galaxy,
535 center: (0.0, 0.0),
536 cursor: (0, 0),
537 grid_size: (80, 24),
538 galaxy: 0,
539 galaxy_name: "Euclid".into(),
540 base_labels: vec![],
541 player_pos: None,
542 zoom_stack: vec![],
543 show_help: false,
544 should_quit: false,
545 };
546 let ((min_x, min_z), (max_x, max_z)) = state.viewport_bounds();
547 assert!((min_x + max_x).abs() < 0.01);
549 assert!((min_z + max_z).abs() < 0.01);
550 }
551}