1use 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
15pub fn render(frame: &mut Frame, state: &MapState, model: &GalaxyModel) {
17 let area = frame.area();
18
19 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 if state.show_help {
33 render_help(frame, area);
34 }
35}
36
37fn 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 let grid = bin_systems(state, model, inner.width, inner.height);
53
54 let mut overlays: HashMap<(u16, u16), (String, Color)> = HashMap::new();
59
60 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 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 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 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; 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 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
161fn 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
181fn render_legend(frame: &mut Frame, area: Rect, state: &MapState) {
183 let mut parts: Vec<Span> = Vec::new();
184
185 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 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 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
217fn 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 let left_w = 36;
232 let gap = 4;
233
234 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 let help_text = vec![
245 Line::from(Span::styled(" Galaxy Map ", bold)),
247 Line::from(""),
249 Line::from(vec![
251 Span::styled(pad(" Keys:", left_w), bold),
252 Span::raw(pad("", gap)),
253 Span::styled("Density:", bold),
254 ]),
255 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 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 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 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 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 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 Line::from(""),
301 Line::from(vec![
303 Span::styled(pad("", left_w), normal),
304 Span::raw(pad("", gap)),
305 Span::styled("Zoom Levels:", bold),
306 ]),
307 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 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 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 Line::from(""),
327 Line::from(Span::styled(" Press any key to close", dim)),
329 ];
330
331 let help_height = help_text.len() as u16 + 2; 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 frame.render_widget(Clear, help_area);
353 frame.render_widget(help_block, help_area);
354}
355
356fn 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 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
384fn 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}