1use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Paragraph, Wrap};
7use ratatui::{Frame, symbols};
8
9use crate::tui::app::{App, PlotConfig};
10
11const PLOT_TIME_LABEL_COUNT: usize = 8;
12
13pub fn render(app: &mut App, frame: &mut Frame) {
14 let vertical = Layout::default()
15 .direction(Direction::Vertical)
16 .constraints([
17 Constraint::Length(3),
18 Constraint::Min(10),
19 Constraint::Length(3),
20 ])
21 .split(frame.area());
22
23 render_header(app, frame, vertical[0]);
24 render_body(app, frame, vertical[1]);
25 render_footer(frame, vertical[2]);
26}
27
28fn render_header(app: &App, frame: &mut Frame, area: Rect) {
29 let title = format!(
30 "Staff {} | Tick {} / {} | Frame {} / {} | {} | Window {} ticks",
31 app.recording.staffing,
32 app.current_tick(),
33 app.recording.summary.finish_tick,
34 app.frame_index + 1,
35 app.recording.timeline.len(),
36 if app.playing {
37 format!("playing {}", app.speed_label())
38 } else {
39 format!("paused {}", app.speed_label())
40 },
41 app.window_size_ticks()
42 );
43
44 let summary = &app.recording.summary;
45 let lines = vec![
46 Line::from(Span::styled(
47 title,
48 Style::default().add_modifier(Modifier::BOLD),
49 )),
50 Line::from(format!(
51 "Arrivals {} | Served {} | Balked {} | GaveUp {} | Abandoned {} | Profit {:.2}",
52 summary.arrivals,
53 summary.served,
54 summary.balked,
55 summary.gave_up_queue,
56 summary.abandoned,
57 summary.profit
58 )),
59 ];
60
61 frame.render_widget(
62 Paragraph::new(lines)
63 .block(Block::default().borders(Borders::ALL).title("Playback"))
64 .wrap(Wrap { trim: true }),
65 area,
66 );
67}
68
69fn render_body(app: &App, frame: &mut Frame, area: Rect) {
70 let horizontal = Layout::default()
71 .direction(Direction::Horizontal)
72 .constraints([Constraint::Percentage(36), Constraint::Percentage(64)])
73 .split(area);
74
75 render_state_panels(app, frame, horizontal[0]);
76 render_plots(app, frame, horizontal[1]);
77}
78
79fn render_state_panels(app: &App, frame: &mut Frame, area: Rect) {
80 let sections = Layout::default()
81 .direction(Direction::Vertical)
82 .constraints([
83 Constraint::Length(9),
84 Constraint::Length(10),
85 Constraint::Min(8),
86 ])
87 .split(area);
88
89 render_system_state(app, frame, sections[0]);
90 render_customer_states(app, frame, sections[1]);
91 render_recent_events(app, frame, sections[2]);
92}
93
94fn render_system_state(app: &App, frame: &mut Frame, area: Rect) {
95 let snapshot = app.current_snapshot();
96 let metrics = &snapshot.metrics;
97 let staffing_cost = accrued_staffing_cost(app);
98 let overview = vec![
99 formatted_line(
100 vec![
101 ("Till queue", format!("{:>3}", snapshot.till_queue.len())),
102 (
103 "Kitchen queue",
104 format!("{:>3}", snapshot.kitchen_queue.len()),
105 ),
106 ],
107 " ",
108 ),
109 formatted_line(
110 vec![
111 ("Busy till", format!("{:>3}", snapshot.active_till_workers)),
112 (
113 "Busy kitchen",
114 format!("{:>3}", snapshot.active_kitchen_workers),
115 ),
116 ],
117 " ",
118 ),
119 formatted_line(
120 vec![
121 ("Revenue", format!("{:>8.2}", metrics.revenue)),
122 (
123 "Ingredient cost",
124 format!("{:>8.2}", metrics.ingredient_cost),
125 ),
126 ],
127 " ",
128 ),
129 formatted_line(
130 vec![("Staffing cost", format!("{staffing_cost:>8.2}"))],
131 " ",
132 ),
133 formatted_line(
134 vec![
135 ("Orders started", format!("{:>3}", metrics.orders_started)),
136 ("served", format!("{:>3}", metrics.orders_served)),
137 ("abandoned", format!("{:>3}", metrics.orders_abandoned)),
138 ],
139 " ",
140 ),
141 formatted_line(
142 vec![
143 ("Closed", snapshot.closed.to_string()),
144 ("Arrivals complete", snapshot.arrivals_complete.to_string()),
145 ],
146 " ",
147 ),
148 ];
149
150 frame.render_widget(
151 Paragraph::new(overview)
152 .block(Block::default().borders(Borders::ALL).title("System State"))
153 .wrap(Wrap { trim: true }),
154 area,
155 );
156}
157
158fn render_customer_states(app: &App, frame: &mut Frame, area: Rect) {
159 let snapshot = app.current_snapshot();
160 let counts = &snapshot.customer_counts;
161 let customer_lines = vec![
162 formatted_line(
163 vec![
164 ("Planned", format!("{:>3}", counts.planned)),
165 ("Balked", format!("{:>3}", counts.balked)),
166 ],
167 " ",
168 ),
169 formatted_line(
170 vec![
171 ("Waiting till", format!("{:>3}", counts.waiting_till)),
172 ("At till", format!("{:>3}", counts.at_till)),
173 ],
174 " ",
175 ),
176 formatted_line(
177 vec![
178 ("Waiting kitchen", format!("{:>3}", counts.waiting_kitchen)),
179 ("Preparing", format!("{:>3}", counts.preparing_food)),
180 ],
181 " ",
182 ),
183 formatted_line(
184 vec![
185 ("Collecting", format!("{:>3}", counts.collecting_food)),
186 ("Served", format!("{:>3}", counts.served)),
187 ],
188 " ",
189 ),
190 formatted_line(
191 vec![
192 ("Gave up", format!("{:>3}", counts.gave_up_queue)),
193 ("Abandoned", format!("{:>3}", counts.abandoned)),
194 ],
195 " ",
196 ),
197 ];
198 frame.render_widget(
199 Paragraph::new(customer_lines)
200 .block(
201 Block::default()
202 .borders(Borders::ALL)
203 .title("Customer States"),
204 )
205 .wrap(Wrap { trim: true }),
206 area,
207 );
208}
209
210fn render_recent_events(app: &App, frame: &mut Frame, area: Rect) {
211 let visible_event_lines = usize::from(area.height.saturating_sub(2)).max(1);
212 let mut event_lines = Vec::new();
213 for event in app.recent_events(visible_event_lines).into_iter().rev() {
214 event_lines.push(Line::from(format!("[{:>5}] {}", event.tick, event.message)));
215 }
216 if event_lines.is_empty() {
217 event_lines.push(Line::from("No events yet"));
218 }
219 frame.render_widget(
220 Paragraph::new(event_lines)
221 .block(
222 Block::default()
223 .borders(Borders::ALL)
224 .title("Recent Events"),
225 )
226 .wrap(Wrap { trim: false }),
227 area,
228 );
229}
230
231fn render_plots(app: &App, frame: &mut Frame, area: Rect) {
232 let plot_areas = Layout::default()
233 .direction(Direction::Vertical)
234 .constraints([
235 Constraint::Percentage(25),
236 Constraint::Percentage(25),
237 Constraint::Percentage(25),
238 Constraint::Percentage(25),
239 ])
240 .split(area);
241
242 for (index, plot_area) in plot_areas.iter().enumerate() {
243 render_plot(app, frame, *plot_area, index, app.plots[index]);
244 }
245}
246
247fn render_plot(app: &App, frame: &mut Frame, area: Rect, index: usize, plot: PlotConfig) {
248 let salary_cost = app.recording.summary.salary_cost;
249 let day_ticks = app.recording.day_ticks;
250 let window_ticks = app.window_size_ticks();
251 let data: Vec<(f64, f64)> = app
252 .recording
253 .timeline
254 .iter()
255 .enumerate()
256 .take(app.frame_index + 1)
257 .map(|(point_index, point)| {
258 (
259 point.tick as f64,
260 plot_value(
261 &app.recording.timeline,
262 point_index,
263 plot,
264 day_ticks,
265 salary_cost,
266 window_ticks,
267 ),
268 )
269 })
270 .collect();
271
272 let max_x = app.recording.summary.finish_tick.max(1) as f64;
273 let min_y = data
274 .iter()
275 .map(|(_, y)| *y)
276 .fold(0.0_f64, f64::min)
277 .min(0.0);
278 let max_y = data
279 .iter()
280 .map(|(_, y)| *y)
281 .fold(0.0_f64, f64::max)
282 .max(1.0);
283 let title = format!(
284 "Plot {}: {}{}",
285 index + 1,
286 plot.stat.title(plot.windowed, window_ticks),
287 if app.selected_plot == index {
288 " <selected>"
289 } else {
290 ""
291 }
292 );
293
294 let dataset = Dataset::default()
295 .marker(symbols::Marker::Braille)
296 .style(Style::default().fg(match index {
297 0 => Color::Cyan,
298 1 => Color::Yellow,
299 _ => Color::Green,
300 }))
301 .graph_type(ratatui::widgets::GraphType::Line)
302 .data(&data);
303
304 let mut datasets = Vec::new();
305 let zero_line = [(0.0, 0.0), (max_x, 0.0)];
306 if min_y < 0.0 && max_y > 0.0 {
307 datasets.push(
308 Dataset::default()
309 .style(Style::default().fg(Color::DarkGray))
310 .graph_type(ratatui::widgets::GraphType::Line)
311 .data(&zero_line),
312 );
313 }
314 datasets.push(dataset);
315
316 let chart = Chart::new(datasets)
317 .block(
318 Block::default()
319 .borders(Borders::ALL)
320 .title(title)
321 .border_style(if app.selected_plot == index {
322 Style::default().fg(Color::Blue)
323 } else {
324 Style::default()
325 }),
326 )
327 .x_axis(
328 Axis::default()
329 .bounds([0.0, max_x])
330 .labels(plot_time_labels(app, max_x)),
331 )
332 .y_axis(Axis::default().bounds([min_y, max_y]).labels(vec![
333 Span::raw(format!("{min_y:.1}")),
334 Span::raw(format!("{:.1}", f64::midpoint(min_y, max_y))),
335 Span::raw(format!("{max_y:.1}")),
336 ]));
337
338 frame.render_widget(chart, area);
339}
340
341fn plot_time_labels(app: &App, max_x: f64) -> Vec<Span<'static>> {
342 if PLOT_TIME_LABEL_COUNT <= 2 {
343 return vec![time_label(app, 0), time_label(app, max_x.round() as u64)];
344 }
345
346 (0..PLOT_TIME_LABEL_COUNT)
347 .map(|index| {
348 let position = max_x * index as f64 / (PLOT_TIME_LABEL_COUNT - 1) as f64;
349
350 time_label(app, position.round() as u64)
351 })
352 .collect()
353}
354
355fn time_label(app: &App, offset_ticks: u64) -> Span<'static> {
356 Span::raw(
357 app.recording
358 .opening_time
359 .add_ticks(offset_ticks)
360 .round_to_nearest_quarter_hour()
361 .to_string(),
362 )
363}
364
365fn render_footer(frame: &mut Frame, area: Rect) {
366 let key_style = Style::default().add_modifier(Modifier::BOLD);
367 let help = Line::from(vec![
368 Span::styled("q", key_style),
369 Span::raw(" quit | "),
370 Span::styled("space", key_style),
371 Span::raw(" play/pause | "),
372 Span::styled("g/G", key_style),
373 Span::raw(" start/end | "),
374 Span::styled("h/l", key_style),
375 Span::raw(" or "),
376 Span::styled("left/right", key_style),
377 Span::raw(" step | "),
378 Span::styled("[ ]", key_style),
379 Span::raw(" speed | "),
380 Span::styled("{ }", key_style),
381 Span::raw(" window | "),
382 Span::styled("w", key_style),
383 Span::raw(" toggle windowed | "),
384 Span::styled("1-4", key_style),
385 Span::raw(" select plot | "),
386 Span::styled("j/k", key_style),
387 Span::raw(" or "),
388 Span::styled("up/down", key_style),
389 Span::raw(" change stat"),
390 ]);
391 frame.render_widget(
392 Paragraph::new(help).block(Block::default().borders(Borders::ALL).title("Controls")),
393 area,
394 );
395}
396
397fn formatted_line(items: Vec<(&str, String)>, separator: &str) -> Line<'static> {
398 let label_style = Style::default().add_modifier(Modifier::BOLD);
399 let mut spans = Vec::new();
400
401 for (index, (label, value)) in items.into_iter().enumerate() {
402 if index > 0 {
403 spans.push(Span::raw(separator.to_string()));
404 }
405 spans.push(Span::styled(format!("{label}:"), label_style));
406 spans.push(Span::raw(format!(" {value}")));
407 }
408
409 Line::from(spans)
410}
411
412fn plot_value(
413 timeline: &[crate::recording::TimelinePoint],
414 point_index: usize,
415 plot: PlotConfig,
416 day_ticks: u64,
417 salary_cost: f64,
418 window_ticks: u64,
419) -> f64 {
420 let point = &timeline[point_index];
421 let current = plot
422 .stat
423 .base_value(&point.snapshot, point.tick, salary_cost, day_ticks);
424 if !(plot.windowed && plot.stat.supports_windowing()) {
425 return current;
426 }
427
428 let window_start_tick = point.tick.saturating_sub(window_ticks);
429 let base_index = timeline[..=point_index]
430 .iter()
431 .rposition(|timeline_point| timeline_point.tick <= window_start_tick);
432
433 match base_index {
434 Some(index) => {
435 let prior = plot.stat.base_value(
436 &timeline[index].snapshot,
437 timeline[index].tick,
438 salary_cost,
439 day_ticks,
440 );
441 current - prior
442 }
443 None => current,
444 }
445}
446
447fn accrued_staffing_cost(app: &App) -> f64 {
448 let total_salary_cost = app.recording.summary.salary_cost;
449 let day_ticks = app.recording.day_ticks;
450 if day_ticks == 0 {
451 return 0.0;
452 }
453
454 total_salary_cost * (app.current_tick().min(day_ticks) as f64 / day_ticks as f64)
455}