Skip to main content

nms_copilot/map/
render.rs

1//! Ratatui rendering for the interactive galaxy map.
2
3use std::collections::HashMap;
4
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
10
11use nms_graph::GalaxyModel;
12
13use super::state::{MapState, ZoomLevel, density_char};
14
15/// Bin systems into grid cells and render the full map frame.
16pub fn render(frame: &mut Frame, state: &MapState, model: &GalaxyModel) {
17    let area = frame.area();
18
19    // Layout: map area | status bar | legend
20    let chunks = Layout::vertical([
21        Constraint::Min(5),
22        Constraint::Length(1),
23        Constraint::Length(2),
24    ])
25    .split(area);
26
27    render_map(frame, chunks[0], state, model);
28    render_status(frame, chunks[1], state);
29    render_legend(frame, chunks[2], state);
30
31    // Help overlay on top
32    if state.show_help {
33        render_help(frame, area);
34    }
35}
36
37/// Render the map grid into the given area.
38fn render_map(frame: &mut Frame, area: Rect, state: &MapState, model: &GalaxyModel) {
39    let block = Block::default().borders(Borders::ALL).title(format!(
40        " {} — {} ",
41        state.galaxy_name,
42        state.zoom.label()
43    ));
44    let inner = block.inner(area);
45    frame.render_widget(block, area);
46
47    if inner.width == 0 || inner.height == 0 {
48        return;
49    }
50
51    // Build a density grid by binning systems into cells
52    let grid = bin_systems(state, model, inner.width, inner.height);
53
54    // Overlay base labels and player position.
55    // Each overlay entry maps (col, row) -> (label_string, color).
56    // At Local zoom, base labels include the name; otherwise just the letter.
57    // When multiple bases share the same cell, spread them to adjacent rows.
58    let mut overlays: HashMap<(u16, u16), (String, Color)> = HashMap::new();
59
60    // Bases
61    let show_names = state.zoom == ZoomLevel::Local;
62    for bl in &state.base_labels {
63        if let Some((col, mut row)) = voxel_to_cell(
64            f64::from(bl.voxel_x),
65            f64::from(bl.voxel_z),
66            state,
67            inner.width,
68            inner.height,
69        ) {
70            // Spread stacked bases to adjacent rows
71            while overlays.contains_key(&(col, row)) && row + 1 < inner.height {
72                row += 1;
73            }
74            let label = if show_names {
75                let max_len = (inner.width - col) as usize;
76                let full = format!("{} {}", bl.letter, bl.name);
77                if full.len() > max_len {
78                    full[..max_len].to_string()
79                } else {
80                    full
81                }
82            } else {
83                bl.letter.to_string()
84            };
85            overlays.insert((col, row), (label, Color::Yellow));
86        }
87    }
88
89    // Player position
90    if let Some((px, pz)) = state.player_pos
91        && let Some((col, row)) = voxel_to_cell(
92            f64::from(px),
93            f64::from(pz),
94            state,
95            inner.width,
96            inner.height,
97        )
98    {
99        overlays.insert((col, row), ("@".to_string(), Color::Green));
100    }
101
102    // Render each row as a Line of Spans
103    let mut lines: Vec<Line> = Vec::with_capacity(inner.height as usize);
104    for row in 0..inner.height {
105        let mut spans: Vec<Span> = Vec::with_capacity(inner.width as usize);
106        let mut skip: u16 = 0; // columns to skip (consumed by multi-char label)
107        for col in 0..inner.width {
108            if skip > 0 {
109                skip -= 1;
110                continue;
111            }
112
113            let is_cursor = col == state.cursor.0 && row == state.cursor.1;
114
115            if let Some((label, color)) = overlays.get(&(col, row)) {
116                let style = Style::default().fg(*color).add_modifier(Modifier::BOLD);
117                if label.len() > 1 {
118                    // Multi-char label: first char may get cursor highlight
119                    let mut chars = label.chars();
120                    let first = chars.next().unwrap();
121                    let first_style = if is_cursor {
122                        style.add_modifier(Modifier::REVERSED)
123                    } else {
124                        style
125                    };
126                    spans.push(Span::styled(first.to_string(), first_style));
127                    let rest: String = chars.collect();
128                    skip = rest.len() as u16;
129                    spans.push(Span::styled(rest, style));
130                } else {
131                    let style = if is_cursor {
132                        style.add_modifier(Modifier::REVERSED)
133                    } else {
134                        style
135                    };
136                    spans.push(Span::styled(label.clone(), style));
137                }
138            } else {
139                let count = grid.get(&(col, row)).copied().unwrap_or(0);
140                let ch = density_char(count);
141                let base_style = if count > 0 {
142                    Style::default().fg(Color::Cyan)
143                } else {
144                    Style::default().fg(Color::DarkGray)
145                };
146                let style = if is_cursor {
147                    base_style.add_modifier(Modifier::REVERSED)
148                } else {
149                    base_style
150                };
151                spans.push(Span::styled(ch.to_string(), style));
152            };
153        }
154        lines.push(Line::from(spans));
155    }
156
157    let paragraph = Paragraph::new(lines);
158    frame.render_widget(paragraph, inner);
159}
160
161/// Render the status bar.
162fn render_status(frame: &mut Frame, area: Rect, state: &MapState) {
163    let (vx, vz) = state.cursor_voxel();
164    let scale = state.zoom.extent() / f64::from(state.grid_size.0.max(1));
165
166    let status = format!(
167        " [{galaxy}]  [{zoom}]  Cursor: X={vx:+.0} Z={vz:+.0}  Scale: {scale:.0} vox/cell",
168        galaxy = state.galaxy_name,
169        zoom = state.zoom.label(),
170    );
171
172    let status_line = Paragraph::new(Line::from(vec![Span::styled(
173        status,
174        Style::default().fg(Color::White),
175    )]))
176    .style(Style::default().bg(Color::DarkGray));
177
178    frame.render_widget(status_line, area);
179}
180
181/// Render the legend showing base labels and key hints.
182fn render_legend(frame: &mut Frame, area: Rect, state: &MapState) {
183    let mut parts: Vec<Span> = Vec::new();
184
185    // Base labels
186    for bl in &state.base_labels {
187        if !parts.is_empty() {
188            parts.push(Span::raw("  "));
189        }
190        parts.push(Span::styled(
191            format!("{}={}", bl.letter, bl.name),
192            Style::default().fg(Color::Yellow),
193        ));
194    }
195
196    // Player marker
197    if state.player_pos.is_some() {
198        if !parts.is_empty() {
199            parts.push(Span::raw("  "));
200        }
201        parts.push(Span::styled("@=You", Style::default().fg(Color::Green)));
202    }
203
204    // Key hints
205    if !parts.is_empty() {
206        parts.push(Span::raw("  "));
207    }
208    parts.push(Span::styled(
209        "?=Help  q=Quit",
210        Style::default().fg(Color::DarkGray),
211    ));
212
213    let legend = Paragraph::new(Line::from(parts)).wrap(Wrap { trim: false });
214    frame.render_widget(legend, area);
215}
216
217/// Render a help overlay centered on the screen.
218///
219/// Two-column layout: keybindings on the left, density symbols and
220/// zoom levels on the right.
221fn render_help(frame: &mut Frame, area: Rect) {
222    let bold = Style::default()
223        .fg(Color::White)
224        .add_modifier(Modifier::BOLD);
225    let cyan = Style::default().fg(Color::Cyan);
226    let green = Style::default().fg(Color::Green);
227    let dim = Style::default().fg(Color::DarkGray);
228    let normal = Style::default().fg(Color::White);
229
230    // Column widths: left 36, gap 4, right 28 = 68 total content + 2 border = 70
231    let left_w = 36;
232    let gap = 4;
233
234    /// Pad or truncate a string to exactly `width` characters.
235    fn pad(s: &str, width: usize) -> String {
236        if s.len() >= width {
237            s[..width].to_string()
238        } else {
239            format!("{s:<width$}")
240        }
241    }
242
243    // Build rows as (left_text, right_spans)
244    let help_text = vec![
245        // Title row
246        Line::from(Span::styled(" Galaxy Map ", bold)),
247        // Blank
248        Line::from(""),
249        // Row: keys header | symbols header
250        Line::from(vec![
251            Span::styled(pad("  Keys:", left_w), bold),
252            Span::raw(pad("", gap)),
253            Span::styled("Density:", bold),
254        ]),
255        // Row: arrow keys | · = 1
256        Line::from(vec![
257            Span::styled(pad("  Arrow keys    Move cursor", left_w), normal),
258            Span::raw(pad("", gap)),
259            Span::styled("·", cyan),
260            Span::styled("  1 system", normal),
261        ]),
262        // Row: enter | + = 2-3
263        Line::from(vec![
264            Span::styled(pad("  Enter / +     Zoom in", left_w), normal),
265            Span::raw(pad("", gap)),
266            Span::styled("+", cyan),
267            Span::styled("  2-3 systems", normal),
268        ]),
269        // Row: esc | * = 4-7
270        Line::from(vec![
271            Span::styled(pad("  Esc / -       Zoom out (exit)", left_w), normal),
272            Span::raw(pad("", gap)),
273            Span::styled("*", cyan),
274            Span::styled("  4-7 systems", normal),
275        ]),
276        // Row: c | # = 8+
277        Line::from(vec![
278            Span::styled(pad("  c             Center on player", left_w), normal),
279            Span::raw(pad("", gap)),
280            Span::styled("#", cyan),
281            Span::styled("  8+ systems", normal),
282        ]),
283        // Row: ? | blank
284        Line::from(vec![
285            Span::styled(pad("  ?             Toggle this help", left_w), normal),
286            Span::raw(pad("", gap)),
287            Span::styled("@", green),
288            Span::styled("  Your position", normal),
289        ]),
290        // Row: q | markers header
291        Line::from(vec![
292            Span::styled(pad("  q / Ctrl+C    Exit map", left_w), normal),
293            Span::raw(pad("", gap)),
294            Span::styled("A", Style::default().fg(Color::Yellow)),
295            Span::styled("-", normal),
296            Span::styled("Z", Style::default().fg(Color::Yellow)),
297            Span::styled("  Base locations", normal),
298        ]),
299        // Blank
300        Line::from(""),
301        // Zoom levels header
302        Line::from(vec![
303            Span::styled(pad("", left_w), normal),
304            Span::raw(pad("", gap)),
305            Span::styled("Zoom Levels:", bold),
306        ]),
307        // Galaxy
308        Line::from(vec![
309            Span::styled(pad("", left_w), normal),
310            Span::raw(pad("", gap)),
311            Span::styled("Galaxy  4096\u{00D7}4096 vox", normal),
312        ]),
313        // Region
314        Line::from(vec![
315            Span::styled(pad("", left_w), normal),
316            Span::raw(pad("", gap)),
317            Span::styled("Region   512\u{00D7}512  vox  8\u{00D7}", normal),
318        ]),
319        // Local
320        Line::from(vec![
321            Span::styled(pad("", left_w), normal),
322            Span::raw(pad("", gap)),
323            Span::styled("Local     64\u{00D7}64   vox 64\u{00D7}", normal),
324        ]),
325        // Blank
326        Line::from(""),
327        // Dismiss
328        Line::from(Span::styled("  Press any key to close", dim)),
329    ];
330
331    let help_height = help_text.len() as u16 + 2; // +2 for borders
332    let help_width = 70;
333
334    let x = area.x + area.width.saturating_sub(help_width) / 2;
335    let y = area.y + area.height.saturating_sub(help_height) / 2;
336    let help_area = Rect::new(
337        x,
338        y,
339        help_width.min(area.width),
340        help_height.min(area.height),
341    );
342
343    let help_block = Paragraph::new(help_text)
344        .block(
345            Block::default()
346                .borders(Borders::ALL)
347                .style(Style::default().bg(Color::Black)),
348        )
349        .style(Style::default().fg(Color::White).bg(Color::Black));
350
351    // Clear the area behind the help (resets all cells to empty)
352    frame.render_widget(Clear, help_area);
353    frame.render_widget(help_block, help_area);
354}
355
356/// Bin systems from the model into grid cells.
357///
358/// Returns a map from (col, row) -> system count.
359fn bin_systems(
360    state: &MapState,
361    model: &GalaxyModel,
362    cols: u16,
363    rows: u16,
364) -> HashMap<(u16, u16), usize> {
365    let mut grid: HashMap<(u16, u16), usize> = HashMap::new();
366
367    // Iterate all systems in the active galaxy
368    for sys in model.systems.values() {
369        if sys.address.reality_index != state.galaxy {
370            continue;
371        }
372
373        let vx = f64::from(sys.address.voxel_x());
374        let vz = f64::from(sys.address.voxel_z());
375
376        if let Some((col, row)) = voxel_to_cell(vx, vz, state, cols, rows) {
377            *grid.entry((col, row)).or_insert(0) += 1;
378        }
379    }
380
381    grid
382}
383
384/// Convert voxel coordinates to grid cell position.
385fn voxel_to_cell(vx: f64, vz: f64, state: &MapState, cols: u16, rows: u16) -> Option<(u16, u16)> {
386    let extent = state.zoom.extent();
387    let cell_size_x = extent / f64::from(cols.max(1));
388    let cell_size_z = extent / f64::from(rows.max(1));
389
390    let half_cols = f64::from(cols) / 2.0;
391    let half_rows = f64::from(rows) / 2.0;
392
393    let col = ((vx - state.center.0) / cell_size_x + half_cols) as i32;
394    let row = ((vz - state.center.1) / cell_size_z + half_rows) as i32;
395
396    if col >= 0 && col < cols as i32 && row >= 0 && row < rows as i32 {
397        Some((col as u16, row as u16))
398    } else {
399        None
400    }
401}