1use ratatui::{
6 layout::{Constraint, Direction, Layout, Rect},
7 style::Style,
8 text::{Line, Span},
9 widgets::{Chart, Clear, Dataset, GraphType, Paragraph, Widget, Wrap},
10};
11
12use crate::app::pointer::Hit;
13use crate::chart::chart_data::{
14 BarData, BoxPlotData, HeatmapData, HistogramData, KdeData, XAxisTemporalKind, other_at,
15 segments,
16};
17use crate::chart::chart_modal::{
18 Aggregate, ChartFocus, ChartModal, Cumulative, Mark, PickerFor, ShelfUse, TimeUnit,
19};
20use crate::chart::chart_plot::{Axis, Curve, LinesData, Plot, PlotData};
21use crate::config::Theme;
22use crate::glyphs::Glyphs;
23use crate::render::context::RenderContext;
24use crate::widgets::axes::{
25 AxisSpec, Legend, PlotAxes, Track, cut, fit_x_labels, fit_y_labels, resolution,
26};
27use crate::widgets::axis_numbers::AxisNumbers;
28use crate::widgets::crosshair::{self, PlotPlace};
29use crate::widgets::ui::{FormRow, FormValue, Picker, SectionRule, Surface, Working};
30use polars::prelude::Schema;
31use unicode_width::UnicodeWidthStr;
32
33const SIDEBAR_WIDTH: u16 = 40;
35const LABEL_WIDTH: u16 = 13;
38const HEATMAP_TITLE_HEIGHT: u16 = 1;
39const HEATMAP_X_LABEL_HEIGHT: u16 = 2;
40
41pub struct ChartView<'a> {
44 pub plot: Option<Plot<'a>>,
47 pub notes: Vec<String>,
49 pub error: Option<&'a str>,
51 pub working: Option<Working<'a>>,
54 pub schema: Option<&'a Schema>,
56}
57
58enum PanelLine {
60 Blank,
61 Rule(&'static str),
62 Row {
64 label: &'static str,
65 value: Vec<Span<'static>>,
66 field: Option<ChartFocus>,
67 dimmed: bool,
68 },
69}
70
71fn column_span(name: &str, schema: Option<&Schema>, ctx: &RenderContext) -> Span<'static> {
73 let color = schema
74 .and_then(|s| s.get(name))
75 .map(|dtype| ctx.type_color(dtype))
76 .unwrap_or(ctx.text_primary);
77 Span::styled(name.to_string(), Style::default().fg(color))
78}
79
80fn plain(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
81 Span::styled(text.into(), Style::default().fg(ctx.text_primary))
82}
83
84fn quiet(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
85 Span::styled(text.into(), Style::default().fg(ctx.dimmed))
86}
87
88fn columns_spans(
90 names: &[String],
91 schema: Option<&Schema>,
92 ctx: &RenderContext,
93) -> Vec<Span<'static>> {
94 let mut spans = Vec::new();
95 for (i, name) in names.iter().enumerate() {
96 if i > 0 {
97 spans.push(quiet(", ", ctx));
98 }
99 spans.push(column_span(name, schema, ctx));
100 }
101 spans
102}
103
104fn panel_lines(modal: &ChartModal, schema: Option<&Schema>, ctx: &RenderContext) -> Vec<PanelLine> {
107 let g = crate::glyphs::get();
108 let spec = &modal.spec;
109 let mark = spec.mark;
110 let encoding = &spec.encoding;
111 let row = |label, value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
112 label,
113 value,
114 field,
115 dimmed,
116 };
117 let sub = |value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
118 label: "",
119 value,
120 field,
121 dimmed,
122 };
123 let pick = |what: &str| vec![quiet(format!("pick {what}"), ctx)];
124 let mut lines = vec![PanelLine::Rule("Chart"), PanelLine::Blank];
125
126 lines.push(row(
128 "Type",
129 vec![plain(mark.label(), ctx)],
130 Some(ChartFocus::Type),
131 false,
132 ));
133 if let Some(dtype) = &modal.suggested {
134 lines.push(sub(
135 vec![quiet(format!("suggested for {dtype}"), ctx)],
136 None,
137 false,
138 ));
139 }
140 lines.push(PanelLine::Blank);
141
142 let x_value = match encoding.x.field.as_deref() {
144 Some(x) => vec![column_span(x, schema, ctx)],
145 None if mark == Mark::Box => vec![quiet(crate::chart::chart_modal::NONE_ITEM, ctx)],
146 None if mark == Mark::Bar => pick("a category"),
147 None => pick("a column"),
148 };
149 lines.push(row("X", x_value, Some(ChartFocus::X), false));
150 let rows = modal.row_order();
151 if rows.contains(&ChartFocus::TimeUnit) {
152 let unit = encoding.x.time_unit;
153 let value = if unit == TimeUnit::None {
154 vec![quiet("by row", ctx)]
155 } else {
156 vec![quiet("by ", ctx), plain(unit.label(), ctx)]
157 };
158 lines.push(sub(value, Some(ChartFocus::TimeUnit), false));
159 }
160 if mark == Mark::Bar {
161 let order = match modal.bar_order {
162 crate::chart::chart_data::BarOrder::Value => format!("by value {}", g.sort_desc),
163 crate::chart::chart_data::BarOrder::Label => format!("by label {}", g.sort_asc),
164 };
165 lines.push(sub(vec![plain(order, ctx)], Some(ChartFocus::Order), false));
166 }
167 if mark == Mark::Histogram {
168 let mut value = vec![plain(format!("{} bins", modal.hist_bins), ctx)];
169 if modal.value_range != crate::chart::chart_data::ValueRange::All {
170 value.push(quiet(
171 format!(" {} {}", g.middot, modal.value_range.label()),
172 ctx,
173 ));
174 }
175 lines.push(sub(value, Some(ChartFocus::Bins), false));
176 }
177 lines.push(PanelLine::Blank);
178
179 match (mark, modal.y_use()) {
181 (_, ShelfUse::Dimmed(why)) => lines.push(row("Y", vec![quiet(why, ctx)], None, true)),
182 (Mark::Histogram, _) => {
183 let value = if modal.share {
184 if modal.colored() {
185 "share of group"
186 } else {
187 "share"
188 }
189 } else {
190 "count"
191 };
192 lines.push(row(
193 "Y",
194 vec![plain(value, ctx)],
195 Some(ChartFocus::Y),
196 false,
197 ));
198 }
199 _ => {
200 let value = if encoding.y.aggregate == Aggregate::Count {
201 vec![plain("rows", ctx)]
202 } else if encoding.y.field.is_empty() {
203 pick("a column")
204 } else {
205 columns_spans(&encoding.y.field, schema, ctx)
206 };
207 lines.push(row("Y", value, Some(ChartFocus::Y), false));
208 }
209 }
210 if modal.takes_aggregate() {
211 let value = match encoding.y.cumulative {
213 Cumulative::Off if encoding.y.aggregate.follows_row_order() => {
215 let order = modal.row_order.as_deref().unwrap_or("row order");
216 vec![
217 plain(encoding.y.aggregate.label(), ctx),
218 quiet(format!(" {} by {order}", g.middot), ctx),
219 ]
220 }
221 Cumulative::Off => vec![plain(encoding.y.aggregate.label(), ctx)],
222 _ if encoding.y.aggregate == Aggregate::Count => vec![plain("running count", ctx)],
223 how => vec![
224 quiet(encoding.y.aggregate.label(), ctx),
225 quiet(format!(" {} ", g.middot), ctx),
226 plain(how.label(), ctx),
227 quiet(" of rows", ctx),
228 ],
229 };
230 lines.push(row("Aggregate", value, Some(ChartFocus::Aggregate), false));
232 if rows.contains(&ChartFocus::Quantile) {
233 let p = format!("p{}", encoding.y.quantile());
234 lines.push(sub(vec![plain(p, ctx)], Some(ChartFocus::Quantile), false));
235 }
236 }
237 lines.push(PanelLine::Blank);
238
239 match modal.color_use() {
241 ShelfUse::Dimmed(why) => {
242 let value = match encoding.color.field.as_deref() {
243 Some(c) => c.to_string(),
244 None => crate::chart::chart_modal::NONE_ITEM.to_string(),
245 };
246 lines.push(row("Color", vec![quiet(value, ctx)], None, true));
247 lines.push(sub(vec![quiet(why, ctx)], None, true));
248 }
249 ShelfUse::Used => {
250 let value = match encoding.color.field.as_deref() {
251 Some(c) => vec![column_span(c, schema, ctx)],
252 None => vec![quiet(crate::chart::chart_modal::NONE_ITEM, ctx)],
253 };
254 lines.push(row("Color", value, Some(ChartFocus::Color), false));
255 if encoding.color.field.is_some() {
256 lines.push(sub(
257 color_values_line(modal, ctx),
258 Some(ChartFocus::ColorValues),
259 false,
260 ));
261 }
262 }
263 }
264 lines.push(PanelLine::Blank);
265
266 lines.push(PanelLine::Rule("Options"));
268 lines.push(PanelLine::Blank);
269 let on_off = |on: bool| plain(if on { "on" } else { "off" }, ctx);
270 for field in rows.iter().copied() {
271 let (label, value) = match field {
272 ChartFocus::Cumulative => ("Cumulative", plain(encoding.y.cumulative.label(), ctx)),
273 ChartFocus::Bins if mark == Mark::Heatmap => {
274 ("Bins", plain(modal.heatmap_bins.to_string(), ctx))
275 }
276 ChartFocus::Bandwidth => (
277 "Bandwidth",
278 plain(format!("{:.1}x", modal.kde_bandwidth_factor), ctx),
279 ),
280 ChartFocus::Range => ("Range", plain(modal.value_range.label(), ctx)),
281 ChartFocus::YStartsAtZero => ("Y from zero", on_off(modal.y_starts_at_zero)),
282 ChartFocus::LogScale => ("Log scale", on_off(modal.log_scale)),
283 ChartFocus::ShowLegend => (
284 "Legend",
285 plain(if modal.show_legend { "auto" } else { "off" }, ctx),
286 ),
287 ChartFocus::Grid => ("Grid", on_off(modal.grid)),
288 ChartFocus::LimitRows => {
289 lines.push(row("Rows", rows_value(modal, ctx), Some(field), false));
290 if modal.focus == field && !modal.plot_focus {
292 lines.push(sub(rows_hint(modal, ctx), None, false));
293 }
294 continue;
295 }
296 _ => continue,
297 };
298 lines.push(row(label, vec![value], Some(field), false));
299 }
300 if modal.aggregates() {
301 lines.push(row("Rows", vec![quiet("all, exact", ctx)], None, false));
302 }
303 lines
304}
305
306fn rows_value(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
309 let shown = modal.rows_shown();
310 if let Some(typed) = shown.typed {
311 return vec![
312 plain(format!("Sample {typed}"), ctx),
313 Span::styled(crate::glyphs::get().cursor, Style::default().fg(ctx.accent)),
314 ];
315 }
316 if shown.every {
317 let mut spans = vec![plain("Every row", ctx)];
318 if let Some(rows) = modal.view_rows {
319 spans.push(quiet(
320 format!(" ({})", crate::home::discover::format_rows(rows)),
321 ctx,
322 ));
323 }
324 return spans;
325 }
326 vec![plain(
327 format!("Sample {}", crate::numfmt::group_chrome(modal.sample_rows)),
328 ctx,
329 )]
330}
331
332fn rows_hint(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
335 let g = crate::glyphs::get();
336 if let Some(error) = modal.rows_shown().error {
337 return vec![Span::styled(error, Style::default().fg(ctx.warning))];
338 }
339 if modal.rows_pending() {
340 return vec![quiet("Enter to read", ctx)];
341 }
342 vec![quiet(
343 format!(
344 "{}/{} switch {} type a size",
345 g.arrow_left, g.arrow_right, g.middot
346 ),
347 ctx,
348 )]
349}
350
351fn color_values_line(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
354 let picked = &modal.spec.encoding.color.values;
355 let Some(counts) = modal
356 .color_counts
357 .as_ref()
358 .filter(|_| modal.has_color_counts())
359 else {
360 return vec![quiet("counting values", ctx)];
361 };
362 let total = counts.values.len();
363 let of = crate::numfmt::group_chrome(total);
364 let (drawn, rest) = if picked.is_empty() {
365 let drawn = total.min(modal.series_max());
366 (format!("top {drawn}"), total - drawn)
367 } else {
368 let rest = counts
369 .values
370 .iter()
371 .filter(|(v, _)| !picked.contains(v))
372 .count();
373 (format!("{} picked", picked.len()), rest)
374 };
375 if picked.is_empty() && rest == 0 {
376 return vec![plain(format!("all {total}"), ctx)];
377 }
378 if modal.shows_other() && rest > 0 {
380 let rest = crate::numfmt::group_chrome(rest);
381 return vec![plain(drawn, ctx), plain(format!(" + {rest} other"), ctx)];
382 }
383 let by_rows = if picked.is_empty() { " by rows" } else { "" };
384 vec![plain(drawn, ctx), quiet(format!(" of {of}{by_rows}"), ctx)]
385}
386
387fn render_sidebar(
391 area: Rect,
392 buf: &mut ratatui::buffer::Buffer,
393 modal: &ChartModal,
394 schema: Option<&Schema>,
395 ctx: &RenderContext,
396) -> Option<Rect> {
397 if area.height == 0 || area.width < 4 {
398 return None;
399 }
400 let mut lines = panel_lines(modal, schema, ctx);
401 if lines.len() > area.height as usize {
402 lines.retain(|l| !matches!(l, PanelLine::Blank));
403 }
404 let focus = (!modal.plot_focus).then_some(modal.focus);
405 let at = lines
406 .iter()
407 .position(
408 |l| matches!(l, PanelLine::Row { field, .. } if *field == focus && focus.is_some()),
409 )
410 .unwrap_or(0);
411 let height = area.height as usize;
412 let first = at.saturating_sub(height.saturating_sub(1));
413 let g = crate::glyphs::get();
414 let mut focused_at = None;
415 for (i, line) in lines.iter().enumerate().skip(first).take(height) {
416 let y = area.y + (i - first) as u16;
417 let row = Rect {
418 y,
419 height: 1,
420 ..area
421 };
422 match line {
423 PanelLine::Blank => {}
424 PanelLine::Rule(title) => SectionRule { title, chip: None }.render(
425 Rect {
426 x: area.x + 1,
427 width: area.width - 1,
428 ..row
429 },
430 buf,
431 ctx,
432 ),
433 PanelLine::Row {
434 label,
435 value,
436 field,
437 dimmed,
438 } => {
439 let focused = field.is_some() && *field == focus;
440 if let Some(field) = field {
441 crate::app::pointer::record_field::<ChartModal>(row, *field);
442 }
443 if focused {
444 focused_at = Some(row);
445 if modal.picker.is_none() {
448 buf.set_string(area.x, y, g.rail, Style::default().fg(ctx.accent));
449 }
450 }
451 FormRow {
453 label,
454 value: FormValue::Spans {
455 spans: value.clone(),
456 dimmed: *dimmed,
457 },
458 focused,
459 label_width: LABEL_WIDTH,
460 }
461 .render_body(
462 Rect {
463 x: area.x + 2,
464 width: area.width.saturating_sub(2),
465 ..row
466 },
467 buf,
468 ctx,
469 );
470 }
471 }
472 }
473 focused_at
474}
475
476fn render_picker(
479 area: Rect,
480 anchor: Rect,
481 buf: &mut ratatui::buffer::Buffer,
482 modal: &ChartModal,
483 ctx: &RenderContext,
484) {
485 let Some(state) = &modal.picker else {
486 return;
487 };
488 crate::app::pointer::record(area, Hit::Picker);
490 let title = match modal.picker_for {
491 Some(PickerFor::X) => "X",
492 Some(PickerFor::Y) => "Y",
493 Some(PickerFor::Color) => "Color",
494 Some(PickerFor::ColorValues) => modal.color().map(String::as_str).unwrap_or("Values"),
495 None => "",
496 };
497 let filtered = state.filtered();
498 let widest = filtered
499 .iter()
500 .map(|(_, item)| UnicodeWidthStr::width(*item))
501 .max()
502 .unwrap_or(0)
503 .max(14) as u16;
504 let detail_w = modal
505 .picker_details
506 .iter()
507 .map(|d| d.len())
508 .max()
509 .unwrap_or(0) as u16;
510 let width = (widest + detail_w + 9).clamp(28, 44).min(area.width);
511 let x = (anchor.x + 2 + LABEL_WIDTH.saturating_sub(4)).min(area.right().saturating_sub(width));
512 let y = anchor.y + 1;
513 let height = u16::try_from(filtered.len())
514 .unwrap_or(u16::MAX)
515 .saturating_add(5)
516 .min(area.bottom().saturating_sub(y))
517 .max(5);
518 let y = y.min(area.bottom().saturating_sub(height));
519 let frame = Rect {
520 x,
521 y,
522 width,
523 height: height.min(area.height),
524 };
525 Clear.render(frame, buf);
526 let content = Surface::new(title).render(frame, buf, ctx);
527 if content.height == 0 {
528 return;
529 }
530 let filter = if state.filter.is_empty() {
531 quiet("type to narrow", ctx)
532 } else {
533 plain(state.filter.clone(), ctx)
534 };
535 let g = crate::glyphs::get();
536 Paragraph::new(Line::from(vec![
537 quiet(format!("{} ", g.prompt), ctx),
538 filter,
539 ]))
540 .render(
541 Rect {
542 height: 1,
543 ..content
544 },
545 buf,
546 );
547 let mut list = Rect {
548 y: content.y + 1,
549 height: content.height.saturating_sub(1),
550 ..content
551 };
552 if modal.picker_for == Some(PickerFor::ColorValues)
553 && modal.spec.encoding.color.values.is_empty()
554 && list.height > 1
555 {
556 Paragraph::new(quiet(
557 format!("default: top {} by rows", modal.series_max()),
558 ctx,
559 ))
560 .render(Rect { height: 1, ..list }, buf);
561 list.y += 1;
562 list.height -= 1;
563 }
564 let mut picker = Picker::from_state(state, true);
565 if modal.picker_multi() {
566 let marks = filtered.iter().map(|(i, _)| modal.is_marked(*i)).collect();
567 picker = picker.marks(marks);
568 }
569 if !modal.picker_details.is_empty() {
570 let details = filtered
571 .iter()
572 .map(|(i, _)| modal.picker_details.get(*i).cloned().unwrap_or_default())
573 .collect();
574 picker = picker.details(details);
575 }
576 picker.render(list, buf, ctx);
577}
578
579const NOTE_MIN: usize = 8;
581
582fn render_title(
588 area: Rect,
589 buf: &mut ratatui::buffer::Buffer,
590 modal: &ChartModal,
591 notes: &[String],
592 ctx: &RenderContext,
593) {
594 let g = crate::glyphs::get();
595 let width = area.width as usize;
596 let how = cut(&modal.how(), width, g);
597 buf.set_string(
598 area.x,
599 area.y,
600 &how,
601 Style::default().fg(ctx.text_secondary),
602 );
603 let used = how.width();
604 let room = width.saturating_sub(if used > 0 { used + 2 } else { 0 });
606 let notes = notes.join(&format!(" {} ", g.middot));
607 if notes.is_empty() || room < NOTE_MIN.min(notes.width()) {
608 return;
609 }
610 let notes = cut(¬es, room, g);
611 let x = area.right() - notes.width() as u16;
612 buf.set_string(x, area.y, ¬es, Style::default().fg(ctx.dimmed));
613}
614
615pub fn render_chart_view(
617 area: Rect,
618 buf: &mut ratatui::buffer::Buffer,
619 modal: &mut ChartModal,
620 theme: &Theme,
621 ctx: &RenderContext,
622 view: ChartView<'_>,
623) {
624 let sidebar_width = SIDEBAR_WIDTH.min(area.width / 2);
627 let [sidebar, rule, plot_area] = Layout::horizontal([
628 Constraint::Length(sidebar_width),
629 Constraint::Length(1),
630 Constraint::Fill(1),
631 ])
632 .areas(area);
633 let anchor = render_sidebar(sidebar, buf, modal, view.schema, ctx);
634 let g = crate::glyphs::get();
635 for y in rule.top()..rule.bottom() {
636 buf.set_string(rule.x, y, g.rule, Style::default().fg(ctx.column_separator));
637 }
638 let plot_area = Rect {
640 x: plot_area.x + 1,
641 width: plot_area.width.saturating_sub(1),
642 ..plot_area
643 };
644 let [title, chart_inner] =
645 Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(plot_area);
646 let notes: &[String] = if view.error.is_some() {
647 &[]
648 } else {
649 &view.notes
650 };
651 render_title(title, buf, modal, notes, ctx);
652
653 modal.plot = None;
654 if let Some(message) = view.error {
655 Paragraph::new(message)
656 .style(Style::default().fg(ctx.error))
657 .wrap(Wrap { trim: true })
658 .centered()
659 .render(chart_inner, buf);
660 } else {
661 match view.working {
662 Some(working) if view.plot.is_none() => working.render_centered(chart_inner, buf, ctx),
663 working => {
664 modal.plot = render_plot(
665 chart_inner,
666 buf,
667 modal,
668 theme,
669 ctx,
670 view.plot,
671 crate::glyphs::get(),
672 );
673 if let Some(working) = working {
674 working.render_corner(chart_inner, buf, ctx);
675 }
676 }
677 }
678 }
679 if let Some(anchor) = anchor
680 && modal.picker.is_some()
681 {
682 render_picker(area, anchor, buf, modal, ctx);
683 }
684}
685
686fn missing(modal: &ChartModal) -> &'static str {
688 let encoding = &modal.spec.encoding;
689 match modal.mark() {
690 Mark::Line | Mark::Scatter if encoding.x.field.is_none() => "Pick X and Y in the panel",
691 Mark::Bar if encoding.x.field.is_none() => "Pick a category for X",
692 Mark::Histogram | Mark::Kde => "Pick a column for X",
693 Mark::Heatmap => "Pick X and Y in the panel",
694 _ => "Pick a column for Y",
695 }
696}
697
698fn render_plot(
701 area: Rect,
702 buf: &mut ratatui::buffer::Buffer,
703 modal: &ChartModal,
704 theme: &Theme,
705 ctx: &RenderContext,
706 plot: Option<Plot<'_>>,
707 g: &Glyphs,
708) -> Option<PlotPlace> {
709 let text_secondary = theme.text_secondary();
710 let hint = |buf: &mut ratatui::buffer::Buffer| {
711 Paragraph::new(missing(modal))
712 .style(Style::default().fg(text_secondary))
713 .centered()
714 .render(area, buf);
715 };
716 let picked = || ChartModal::is_complete(&modal.effective_spec());
717 let Some(plot) = plot else {
718 if modal.mark() == Mark::Bar {
719 render_bar_chart(
720 area,
721 buf,
722 (ctx, theme),
723 None,
724 (picked(), modal.show_legend),
725 g,
726 );
727 } else {
728 hint(buf);
729 }
730 return None;
731 };
732 let (x, y) = (&plot.x, &plot.y);
733 match &*plot.data {
734 PlotData::Lines(_) | PlotData::XRange(_) => {
735 return render_xy_chart(area, buf, modal, theme, &plot, text_secondary, g);
736 }
737 PlotData::Histogram(data) => {
738 let look = HistogramLook {
739 grid: modal.grid,
740 legend: modal.show_legend,
741 };
742 render_histogram_chart(area, buf, &look, theme, (data, &plot.curves()), (x, y), g)
743 }
744 PlotData::Box(data) => {
745 render_box_plot_chart(area, buf, modal, theme, data, (&x.title, y), g)
746 }
747 PlotData::Kde(data) => {
748 render_kde_chart(area, buf, modal, theme, (data, &plot.curves()), (x, y), g)
749 }
750 PlotData::Heatmap(data) => {
751 render_heatmap_chart(area, buf, theme, data, (x, y), text_secondary, g)
752 }
753 PlotData::Bars(data) => render_bar_chart(
754 area,
755 buf,
756 (ctx, theme),
757 Some(data),
758 (picked(), modal.show_legend),
759 g,
760 ),
761 }
762 None
763}
764
765fn render_bar_chart(
771 area: Rect,
772 buf: &mut ratatui::buffer::Buffer,
773 (ctx, theme): (&RenderContext, &Theme),
774 data: Option<&BarData>,
775 (picked, legend): (bool, bool),
776 g: &Glyphs,
777) {
778 let hint = |text: &str, buf: &mut ratatui::buffer::Buffer| {
779 Paragraph::new(text.to_string())
780 .style(Style::default().fg(ctx.text_secondary))
781 .centered()
782 .render(area, buf);
783 };
784 let Some(data) = data else {
786 if !picked {
787 hint("Pick a category for X", buf);
788 }
789 return;
790 };
791 if data.bars.is_empty() {
792 hint("No data for bar chart", buf);
793 return;
794 }
795 if area.height < 2 || area.width < 5 {
796 return;
797 }
798 let area = Rect {
800 x: area.x + 1,
801 width: area.width - 1,
802 ..area
803 };
804 let width = area.width as usize;
805
806 if !data.groups.is_empty() {
807 render_grouped_bars(area, buf, (ctx, theme), data, legend, g);
808 return;
809 }
810 let rows = area.height as usize - 1;
813 let total = data.bars.len() + data.more;
814 let shown = if total <= rows {
815 data.bars.len()
816 } else {
817 rows.saturating_sub(1).min(data.bars.len())
818 };
819 let bars = &data.bars[..shown];
820 let hidden = total - shown;
821
822 let mut values = data.value_labels(&ctx.number_format);
823 values.truncate(shown);
824 let value_w = values
825 .iter()
826 .map(|v| crate::glyphs::display_width(v))
827 .max()
828 .unwrap_or(0);
829 let label_cap = (width * 2 / 5).max(4);
830 let label_w = bars
832 .iter()
833 .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
834 .chain([crate::glyphs::display_width(&data.category)])
835 .max()
836 .unwrap_or(0)
837 .clamp(1, label_cap);
838 let value_x = label_w + 1;
839 let bar_x = value_x + value_w + 1;
840 let bar_w = width.saturating_sub(bar_x);
841
842 let header = Style::default().fg(ctx.text_secondary);
843 let text = Style::default().fg(ctx.text_primary);
844 let bar_style = Style::default().fg(ctx.primary_chart_series_color);
845 let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
846 if x < width {
847 buf.set_stringn(area.x + x as u16, y, s, width - x, style);
848 }
849 };
850 let fit = |s: &str, w: usize| -> String {
851 if crate::glyphs::display_width(s) <= w {
852 return s.to_string();
853 }
854 let room = w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
855 format!("{}{}", crate::glyphs::take_columns(s, room), g.ellipsis)
856 };
857
858 put(buf, 0, area.y, &fit(&data.category, label_w), header);
861 let name_w = crate::glyphs::display_width(&data.value_column);
862 let name_x = if name_w <= value_w {
863 value_x + value_w - name_w
864 } else {
865 value_x
866 };
867 put(buf, name_x, area.y, &data.value_column, header);
868
869 let lo = bars.iter().map(|b| b.value).fold(0.0_f64, f64::min);
870 let hi = bars.iter().map(|b| b.value).fold(0.0_f64, f64::max);
871 let span = if hi > lo { hi - lo } else { 1.0 };
872 let zero = (((-lo / span) * bar_w as f64).round() as usize)
874 .max(usize::from(lo < 0.0))
875 .min(bar_w);
876 for (i, (bar, value)) in bars.iter().zip(&values).enumerate() {
877 let y = area.y + 1 + i as u16;
878 match &bar.label {
879 Some(label) => put(buf, 0, y, &fit(label, label_w), text),
880 None => put(buf, 0, y, g.null, Style::default().fg(ctx.dimmed)),
881 }
882 let pad = value_w - crate::glyphs::display_width(value);
883 put(buf, value_x + pad, y, value, text);
884 if bar_w == 0 {
885 continue;
886 }
887 let nonzero = usize::from(bar.value != 0.0);
889 if bar.value >= 0.0 {
890 let eighths = ((bar.value / span) * bar_w as f64 * 8.0).round() as usize;
892 let eighths = eighths.max(nonzero).min((bar_w - zero.min(bar_w)) * 8);
893 let mut body = g.bar_eighths[7].repeat(eighths / 8);
894 if let Some(part) = (eighths % 8).checked_sub(1) {
895 body.push_str(g.bar_eighths[part]);
896 }
897 put(buf, bar_x + zero, y, &body, bar_style);
898 } else {
899 let cells = ((-bar.value / span) * bar_w as f64).round() as usize;
901 let cells = cells.max(nonzero).min(zero);
902 put(
903 buf,
904 bar_x + zero - cells,
905 y,
906 &g.bar_eighths[7].repeat(cells),
907 bar_style,
908 );
909 }
910 }
911 if hidden > 0 {
912 let y = area.y + 1 + shown as u16;
913 let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
914 put(
915 buf,
916 0,
917 y,
918 &chip,
919 Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
920 );
921 }
922}
923
924fn render_grouped_bars(
928 area: Rect,
929 buf: &mut ratatui::buffer::Buffer,
930 (ctx, theme): (&RenderContext, &Theme),
931 data: &BarData,
932 legend: bool,
933 g: &Glyphs,
934) {
935 let width = area.width as usize;
936 let groups = data.groups.len();
937 let other = other_at(data.other, groups);
938 let color = |i: usize| series_style(theme, i, other);
939 let mut names = vec![Span::styled(
941 format!("{} ", data.value_column),
942 Style::default().fg(ctx.text_secondary),
943 )];
944 for (i, name) in data.groups.iter().enumerate().filter(|_| legend) {
945 names.push(Span::styled(g.bar_eighths[7].repeat(2), color(i)));
946 names.push(Span::styled(
947 format!(" {name} "),
948 Style::default().fg(ctx.text_primary),
949 ));
950 }
951 Paragraph::new(Line::from(names)).render(Rect { height: 1, ..area }, buf);
952 let rows = (area.height as usize).saturating_sub(1);
953 let total = data.bars.len() + data.more;
954 let per = groups.max(1);
955 let fit = rows / per;
956 let shown = if total * per <= rows {
957 data.bars.len()
958 } else {
959 fit.saturating_sub(1).min(data.bars.len())
960 };
961 let label_w = data.bars[..shown]
962 .iter()
963 .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
964 .max()
965 .unwrap_or(0)
966 .clamp(1, (width * 2 / 5).max(4));
967 let bar_x = label_w + 1;
968 let bar_w = width.saturating_sub(bar_x);
969 let values = || {
970 data.bars[..shown]
971 .iter()
972 .flat_map(|b| b.by_group.iter().flatten().copied())
973 };
974 let lo = values().fold(0.0_f64, f64::min);
975 let hi = values().fold(0.0_f64, f64::max);
976 let span = if hi > lo { hi - lo } else { 1.0 };
977 let zero = (((-lo / span) * bar_w as f64).round() as usize)
978 .max(usize::from(lo < 0.0))
979 .min(bar_w);
980 let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
981 if x < width {
982 buf.set_stringn(area.x + x as u16, y, s, width - x, style);
983 }
984 };
985 for (i, bar) in data.bars[..shown].iter().enumerate() {
986 let top = area.y + 1 + (i * per) as u16;
987 let label = bar.label.as_deref().unwrap_or(g.null);
988 let label = if crate::glyphs::display_width(label) > label_w {
989 let room = label_w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
990 format!("{}{}", crate::glyphs::take_columns(label, room), g.ellipsis)
991 } else {
992 label.to_string()
993 };
994 put(buf, 0, top, &label, Style::default().fg(ctx.text_primary));
995 if bar_w == 0 {
996 continue;
997 }
998 for (k, value) in bar.by_group.iter().enumerate() {
999 let Some(v) = value else { continue };
1000 let y = top + k as u16;
1001 let cells = ((v.abs() / span) * bar_w as f64).round() as usize;
1002 let cells = cells.max(usize::from(*v != 0.0));
1003 let (x, cells) = if *v >= 0.0 {
1004 (bar_x + zero, cells.min(bar_w - zero.min(bar_w)))
1005 } else {
1006 let cells = cells.min(zero);
1007 (bar_x + zero - cells, cells)
1008 };
1009 put(buf, x, y, &g.bar_eighths[7].repeat(cells), color(k));
1010 }
1011 }
1012 let hidden = total - shown;
1013 if hidden > 0 {
1014 let y = area.y + 1 + (shown * per) as u16;
1015 if y < area.bottom() {
1016 let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
1017 put(
1018 buf,
1019 0,
1020 y,
1021 &chip,
1022 Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
1023 );
1024 }
1025 }
1026}
1027
1028fn render_xy_chart(
1029 area: Rect,
1030 buf: &mut ratatui::buffer::Buffer,
1031 modal: &ChartModal,
1032 theme: &Theme,
1033 plot: &Plot<'_>,
1034 text_secondary: ratatui::style::Color,
1035 g: &Glyphs,
1036) -> Option<PlotPlace> {
1037 let empty = LinesData::default();
1038 let (lines, x_bounds) = match &*plot.data {
1039 PlotData::Lines(lines) => (lines, None),
1040 PlotData::XRange(range) => (&empty, Some((range.x_min, range.x_max))),
1041 _ => return None,
1042 };
1043 let other_at = lines.other.then(|| lines.names.len().saturating_sub(1));
1044 let graph_type = if plot.scatter {
1045 GraphType::Scatter
1046 } else {
1047 GraphType::Line
1048 };
1049 let (x, y) = (&plot.x, &plot.y);
1050 let drawn: Vec<_> = plot.drawn().collect();
1051
1052 if drawn.is_empty() {
1053 if modal.x().is_none() {
1054 Paragraph::new(missing(modal))
1055 .style(Style::default().fg(text_secondary))
1056 .centered()
1057 .render(area, buf);
1058 return None;
1059 }
1060 const PLACEHOLDER_MIN: f64 = 0.0;
1062 const PLACEHOLDER_MAX: f64 = 1.0;
1063 let (x_min, x_max) = x_bounds.unwrap_or((PLACEHOLDER_MIN, PLACEHOLDER_MAX));
1064 let axes = plot_axes(
1065 theme,
1066 x_axis([x_min, x_max], x.kind, &x.numbers, &x.title),
1067 AxisSpec::y_numbers([PLACEHOLDER_MIN, PLACEHOLDER_MAX], &y.numbers, &y.title),
1068 g.plot.line,
1069 modal.grid,
1070 );
1071 let empty_dataset = Dataset::default().name("").data(&[]).graph_type(graph_type);
1072 axes.render(Chart::new(vec![empty_dataset]), area, buf, g);
1073 return None;
1074 }
1075
1076 let [all_x_min, all_x_max, all_y_min, all_y_max] =
1078 lines.shown_bounds(y.log).unwrap_or([0.0; 4]);
1079
1080 let points: usize = drawn.iter().map(|s| s.points.len()).sum();
1083 let cells = usize::from(area.width) * usize::from(area.height);
1084 let finer = resolution(g.plot.line) > resolution(g.plot.point);
1085 let marker = match plot.scatter {
1086 false => g.plot.line,
1087 true if finer && points * 4 > cells => g.plot.line,
1088 true => g.plot.point,
1089 };
1090
1091 let curves = plot.curves();
1094 let datasets = curve_datasets(&curves, theme, marker, graph_type);
1095
1096 let y_min_bounds = if plot.y_from_zero {
1097 0.0_f64.min(all_y_min)
1098 } else {
1099 all_y_min
1100 };
1101 let y_max_bounds = if all_y_max > y_min_bounds {
1102 all_y_max
1103 } else {
1104 y_min_bounds + 1.0
1105 };
1106 let (x_min_bounds, x_max_bounds) = if all_x_max > all_x_min {
1107 (all_x_min, all_x_max)
1108 } else {
1109 (all_x_min - 0.5, all_x_min + 0.5)
1110 };
1111
1112 let y_bounds = [y_min_bounds, y_max_bounds];
1113 let y_numbers = if y.log {
1115 y.numbers.clone().fractional()
1116 } else {
1117 y.numbers.clone()
1118 };
1119 let y_spec = if y.log {
1120 AxisSpec::y_log(y_bounds, &y_numbers, &y.title)
1121 } else {
1122 AxisSpec::y_numbers(y_bounds, &y_numbers, &y.title)
1123 };
1124 let mut axes = plot_axes(
1125 theme,
1126 x_axis([x_min_bounds, x_max_bounds], x.kind, &x.numbers, &x.title),
1127 y_spec,
1128 marker,
1129 modal.grid,
1130 );
1131 axes.legend = legend(
1132 modal.show_legend,
1133 drawn
1134 .iter()
1135 .map(|s| (s.name, series_style(theme, s.index, other_at))),
1136 );
1137 let x_bounds = [x_min_bounds, x_max_bounds];
1138 let sub = resolution(marker).0;
1139 let values = &lines.series[..];
1142 let cursor = modal
1143 .cursor_x
1144 .filter(|_| modal.plot_focus)
1145 .and_then(|cursor| {
1146 if lines.xs.is_empty() {
1147 crosshair::nearest(&crosshair::xs(values), cursor)
1148 } else {
1149 crosshair::nearest(&lines.xs, cursor)
1150 }
1151 });
1152 let readout = cursor
1153 .map(|at| {
1154 let written = crosshair::format_x(at, x.kind, &x.numbers);
1155 let entries = readout_entries(
1156 theme,
1157 g,
1158 (at, &x.title, written),
1159 &y.numbers,
1160 values,
1161 &lines.names,
1162 other_at,
1163 );
1164 crosshair::readout_lines(&entries, area.width as usize, g)
1165 })
1166 .unwrap_or_default();
1167 let rows = (readout.len() as u16).min(area.height / 3);
1168 let [plot_area, readout_area] =
1169 Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area);
1170 let frame = axes.render(Chart::new(datasets), plot_area, buf, g);
1171 let place = PlotPlace {
1172 graph: frame.graph,
1173 x_bounds,
1174 sub,
1175 };
1176 if let Some(at) = cursor {
1177 let style = Style::default().fg(theme.accent());
1178 crosshair::draw(buf, &place, at, style, g);
1179 Paragraph::new(readout).render(readout_area, buf);
1180 }
1181 Some(place)
1182}
1183
1184fn readout_entries(
1187 theme: &Theme,
1188 g: &Glyphs,
1189 (x, x_title, written): (f64, &str, String),
1190 y: &AxisNumbers,
1191 series: &[Vec<(f64, f64)>],
1192 names: &[String],
1193 other_at: Option<usize>,
1194) -> Vec<crosshair::Entry> {
1195 let value_style = Style::default().fg(theme.text_primary());
1196 let x_title = if x_title.is_empty() { "x" } else { x_title };
1197 let mut entries = vec![crosshair::Entry {
1198 name: x_title.to_string(),
1199 name_style: Style::default().fg(theme.text_secondary()),
1200 value: written,
1201 value_style,
1202 }];
1203 for (i, (value, name)) in crosshair::values_at(series, x)
1204 .into_iter()
1205 .zip(names)
1206 .enumerate()
1207 {
1208 let (value, value_style) = match value {
1209 Some(v) => (y.write(v), value_style),
1210 None => (g.null.to_string(), Style::default().fg(theme.dimmed())),
1211 };
1212 entries.push(crosshair::Entry {
1213 name: name.clone(),
1214 name_style: series_style(theme, i, other_at),
1215 value,
1216 value_style,
1217 });
1218 }
1219 entries
1220}
1221
1222fn plot_axes<'a>(
1225 theme: &Theme,
1226 x: AxisSpec<'a>,
1227 y: AxisSpec<'a>,
1228 marker: ratatui::symbols::Marker,
1229 grid: bool,
1230) -> PlotAxes<'a> {
1231 let style = Style::default().fg(theme.text_primary());
1232 PlotAxes {
1233 grid: grid.then(|| Style::default().fg(theme.chart_grid())),
1234 ..PlotAxes::new(x, y, style, marker)
1235 }
1236}
1237
1238fn x_axis<'a>(
1241 bounds: [f64; 2],
1242 kind: XAxisTemporalKind,
1243 numbers: &AxisNumbers,
1244 title: &'a str,
1245) -> AxisSpec<'a> {
1246 AxisSpec::calendar(bounds, kind, numbers, title)
1247}
1248
1249fn legend<'a>(show: bool, entries: impl Iterator<Item = (&'a str, Style)>) -> Option<Legend> {
1252 let entries: Vec<(String, Style)> = entries.map(|(n, s)| (n.to_string(), s)).collect();
1253 (show && entries.len() > 1).then_some(Legend { entries })
1254}
1255
1256fn curve_datasets<'a>(
1259 curves: &'a [Curve<'_>],
1260 theme: &Theme,
1261 marker: ratatui::symbols::Marker,
1262 graph_type: GraphType,
1263) -> Vec<Dataset<'a>> {
1264 curves
1265 .iter()
1266 .flat_map(|curve| {
1267 let style = series_style(theme, curve.index, curve.other.then_some(curve.index));
1268 segments(&curve.points, curve.breaks)
1269 .into_iter()
1270 .map(move |run| {
1271 Dataset::default()
1272 .marker(marker)
1273 .graph_type(graph_type)
1274 .style(style)
1275 .data(run)
1276 })
1277 })
1278 .collect()
1279}
1280
1281fn series_style(theme: &Theme, i: usize, other_at: Option<usize>) -> Style {
1284 if other_at == Some(i) {
1285 return Style::default().fg(theme.dimmed());
1286 }
1287 let colors = theme.series_colors();
1288 Style::default().fg(colors[i % colors.len()])
1289}
1290
1291pub struct HistogramLook {
1293 pub grid: bool,
1294 pub legend: bool,
1295}
1296
1297pub fn render_histogram(
1300 area: Rect,
1301 buf: &mut ratatui::buffer::Buffer,
1302 theme: &Theme,
1303 ctx: &RenderContext,
1304 data: &HistogramData,
1305 x: AxisNumbers,
1306) {
1307 let x = Axis {
1308 title: data.column.clone(),
1309 numbers: x,
1310 ..Axis::default()
1311 };
1312 let y = Axis {
1313 title: "Count".to_string(),
1314 numbers: AxisNumbers::count(&ctx.number_format),
1315 ..Axis::default()
1316 };
1317 let look = HistogramLook {
1318 grid: false,
1319 legend: false,
1320 };
1321 render_histogram_chart(
1322 area,
1323 buf,
1324 &look,
1325 theme,
1326 (data, &[]),
1327 (&x, &y),
1328 crate::glyphs::get(),
1329 );
1330}
1331
1332fn render_histogram_chart(
1335 area: Rect,
1336 buf: &mut ratatui::buffer::Buffer,
1337 look: &HistogramLook,
1338 theme: &Theme,
1339 (data, curves): (&HistogramData, &[Curve<'_>]),
1340 (x, y): (&Axis, &Axis),
1341 g: &Glyphs,
1342) {
1343 let text_secondary = theme.text_secondary();
1344 if data.bins.is_empty() {
1345 Paragraph::new("No data for histogram")
1346 .style(Style::default().fg(text_secondary))
1347 .centered()
1348 .render(area, buf);
1349 return;
1350 }
1351
1352 let x_min_bounds = data.x_min;
1353 let x_max_bounds = if data.x_max > data.x_min {
1354 data.x_max
1355 } else {
1356 data.x_min + 1.0
1357 };
1358 let y_min_bounds = 0.0;
1359 let y_max_bounds = if data.max_count > 0.0 {
1360 data.max_count
1361 } else {
1362 1.0
1363 };
1364
1365 let marker = if data.groups.is_empty() {
1366 g.plot.bar
1367 } else {
1368 g.plot.line
1369 };
1370 let mut axes = plot_axes(
1371 theme,
1372 AxisSpec::numbers([x_min_bounds, x_max_bounds], &x.numbers, &x.title),
1373 AxisSpec::y_numbers([y_min_bounds, y_max_bounds], &y.numbers, &y.title),
1374 marker,
1375 look.grid,
1376 );
1377 if !data.groups.is_empty() {
1378 let other = other_at(data.other, data.groups.len());
1379 let datasets = curve_datasets(curves, theme, marker, GraphType::Line);
1380 axes.legend = legend(
1381 look.legend,
1382 data.groups
1383 .iter()
1384 .enumerate()
1385 .map(|(i, group)| (group.name.as_str(), series_style(theme, i, other))),
1386 );
1387 axes.render(Chart::new(datasets), area, buf, g);
1388 return;
1389 }
1390
1391 let frame = axes.frame(area);
1392 let points = bin_columns(data, [x_min_bounds, x_max_bounds], frame.graph.width);
1393 let style = Style::default().fg(theme.chart_1());
1394 let dataset = Dataset::default()
1395 .name("")
1396 .marker(g.plot.bar)
1397 .graph_type(GraphType::Bar)
1398 .style(style)
1399 .data(&points);
1400
1401 axes.render_in(frame, Chart::new(vec![dataset]), buf, g);
1402}
1403
1404fn bin_columns(data: &HistogramData, [lo, hi]: [f64; 2], columns: u16) -> Vec<(f64, f64)> {
1408 let n = data.bins.len();
1409 if n == 0 || columns < 2 || hi <= lo {
1410 return data.bins.iter().map(|b| (b.center, b.count)).collect();
1411 }
1412 let last = f64::from(columns - 1);
1413 let at = |c: u16| lo + f64::from(c) / last * (hi - lo);
1415 let bin_of = |v: f64| (((v - lo) / (hi - lo) * n as f64).floor() as usize).min(n - 1);
1416 let bin_cols: Vec<u16> = (0..columns).map(|c| bin_of(at(c)) as u16).collect();
1417 (0..columns)
1418 .filter_map(|c| {
1419 let bin = bin_cols[c as usize];
1420 let wide = bin_cols.iter().filter(|b| **b == bin).count();
1421 let last_of_bin = bin_cols.get(c as usize + 1).is_some_and(|b| *b != bin);
1422 (!(last_of_bin && wide >= 3)).then(|| (at(c), data.bins[bin as usize].count))
1423 })
1424 .collect()
1425}
1426
1427fn render_kde_chart(
1428 area: Rect,
1429 buf: &mut ratatui::buffer::Buffer,
1430 modal: &ChartModal,
1431 theme: &Theme,
1432 (data, curves): (&KdeData, &[Curve<'_>]),
1433 (x, y): (&Axis, &Axis),
1434 g: &Glyphs,
1435) {
1436 let text_secondary = theme.text_secondary();
1437 if data.series.is_empty() {
1438 Paragraph::new("No data for KDE")
1439 .style(Style::default().fg(text_secondary))
1440 .centered()
1441 .render(area, buf);
1442 return;
1443 }
1444
1445 let other = other_at(data.other, data.series.len());
1446 let datasets = curve_datasets(curves, theme, g.plot.line, GraphType::Line);
1447
1448 let mut axes = plot_axes(
1449 theme,
1450 AxisSpec::numbers([data.x_min, data.x_max], &x.numbers, &x.title),
1451 AxisSpec::y_numbers([0.0, data.y_max], &y.numbers, &y.title),
1452 g.plot.line,
1453 modal.grid,
1454 );
1455 axes.legend = legend(
1456 modal.show_legend,
1457 data.series
1458 .iter()
1459 .enumerate()
1460 .map(|(i, s)| (s.name.as_str(), series_style(theme, i, other))),
1461 );
1462 axes.render(Chart::new(datasets), area, buf, g);
1463}
1464
1465fn render_box_plot_chart(
1466 area: Rect,
1467 buf: &mut ratatui::buffer::Buffer,
1468 modal: &ChartModal,
1469 theme: &Theme,
1470 data: &BoxPlotData,
1471 (x_title, y): (&str, &Axis),
1472 g: &Glyphs,
1473) {
1474 let text_secondary = theme.text_secondary();
1475 if data.stats.is_empty() {
1476 Paragraph::new("No data for box plot")
1477 .style(Style::default().fg(text_secondary))
1478 .centered()
1479 .render(area, buf);
1480 return;
1481 }
1482
1483 let mut segments: Vec<Vec<(f64, f64)>> = Vec::new();
1484 let mut segment_styles: Vec<Style> = Vec::new();
1485 for (i, stat) in data.stats.iter().enumerate() {
1486 let marks = stat.marks(i as f64, 0.3, 0.2);
1487 for segment in [
1488 &marks.outline[..],
1489 &marks.median,
1490 &marks.low,
1491 &marks.high,
1492 &marks.low_cap,
1493 &marks.high_cap,
1494 ] {
1495 segments.push(segment.to_vec());
1496 segment_styles.push(series_style(theme, i, None));
1497 }
1498 }
1499
1500 let datasets: Vec<Dataset> = segments
1501 .iter()
1502 .zip(segment_styles.iter())
1503 .map(|(points, style)| {
1504 Dataset::default()
1505 .name("")
1506 .graph_type(GraphType::Line)
1507 .marker(g.plot.point)
1508 .style(*style)
1509 .data(points)
1510 })
1511 .collect();
1512
1513 let x_min_bounds = -0.5;
1514 let x_max_bounds = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
1515 let name = |i: f64, level| {
1517 let stat = data.stats.get(i as usize)?;
1518 (level == 0).then(|| stat.name.clone())
1519 };
1520 let x = AxisSpec::fixed(
1521 [x_min_bounds, x_max_bounds],
1522 (0..data.stats.len()).map(|i| i as f64).collect(),
1523 Box::new(name),
1524 x_title,
1525 );
1526 let y = AxisSpec::y_numbers([data.y_min, data.y_max], &y.numbers, &y.title);
1527 plot_axes(theme, x, y, g.plot.point, modal.grid).render(Chart::new(datasets), area, buf, g);
1528}
1529
1530fn render_heatmap_chart(
1531 area: Rect,
1532 buf: &mut ratatui::buffer::Buffer,
1533 theme: &Theme,
1534 data: &HeatmapData,
1535 (x, y): (&Axis, &Axis),
1536 text_secondary: ratatui::style::Color,
1537 g: &Glyphs,
1538) {
1539 if data.counts.is_empty() || data.max_count <= 0.0 {
1540 Paragraph::new("No data for heatmap")
1541 .style(Style::default().fg(text_secondary))
1542 .centered()
1543 .render(area, buf);
1544 return;
1545 }
1546
1547 let layout = Layout::default()
1548 .direction(Direction::Vertical)
1549 .constraints([
1550 Constraint::Length(HEATMAP_TITLE_HEIGHT),
1551 Constraint::Min(1),
1552 Constraint::Length(HEATMAP_X_LABEL_HEIGHT),
1553 ])
1554 .split(area);
1555 let title_style = Style::default().fg(theme.text_primary());
1558 let y_title = cut(&y.title, layout[0].width as usize, g);
1559 buf.set_string(layout[0].x, layout[0].y, &y_title, title_style);
1560
1561 const Y_LABEL_MAX: u16 = 12;
1562 let y_axis = AxisSpec::numbers([data.y_min, data.y_max], &y.numbers, "");
1564 let rows = layout[1];
1565 let y_track = Track {
1566 start: rows.y,
1567 cells: rows.height,
1568 sub: 1,
1569 };
1570 let y_labels = fit_y_labels(&y_axis, y_track, Y_LABEL_MAX).labels;
1571 let y_label_width = y_labels.iter().map(|(_, l)| l.width()).max().unwrap_or(1);
1572 let y_label_width = (y_label_width as u16).clamp(4, Y_LABEL_MAX);
1573 let body = Layout::default()
1574 .direction(Direction::Horizontal)
1575 .constraints([Constraint::Length(y_label_width + 1), Constraint::Min(1)])
1576 .split(layout[1]);
1577 let label_area = body[0];
1578 let plot_area = body[1];
1579 if plot_area.width == 0 || plot_area.height == 0 {
1580 return;
1581 }
1582
1583 let label_style = Style::default().fg(theme.text_primary());
1584 for (row, label) in &y_labels {
1585 let pad = usize::from(y_label_width).saturating_sub(label.width()) as u16;
1586 buf.set_stringn(
1587 label_area.x + pad,
1588 *row,
1589 label,
1590 usize::from(y_label_width),
1591 label_style,
1592 );
1593 }
1594
1595 const RAMP: [&str; 10] = [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"];
1597 let style = Style::default().fg(theme.chart_1());
1598 let max_x_bin = data.x_bins.saturating_sub(1) as f64;
1599 let max_y_bin = data.y_bins.saturating_sub(1) as f64;
1600 for row in 0..plot_area.height {
1601 let y_bin_raw = ((row as f64 / plot_area.height as f64) * data.y_bins as f64).floor();
1602 let y_bin = data
1603 .y_bins
1604 .saturating_sub(1)
1605 .saturating_sub(y_bin_raw.clamp(0.0, max_y_bin) as usize);
1606 for col in 0..plot_area.width {
1607 let x_bin = ((col as f64 / plot_area.width as f64) * data.x_bins as f64)
1608 .floor()
1609 .clamp(0.0, max_x_bin) as usize;
1610 let count = data.counts[y_bin][x_bin];
1611 let level = ((count / data.max_count) * (RAMP.len() as f64 - 1.0))
1612 .round()
1613 .clamp(0.0, RAMP.len() as f64 - 1.0) as usize;
1614 buf[(plot_area.x + col, plot_area.y + row)]
1615 .set_symbol(RAMP[level])
1616 .set_style(style);
1617 }
1618 }
1619
1620 let x_label_area = layout[2];
1621 let x_axis = AxisSpec::numbers([data.x_min, data.x_max], &x.numbers, "");
1622 let span = (x_label_area.left(), x_label_area.right());
1623 let x_track = Track {
1624 start: plot_area.x,
1625 cells: plot_area.width,
1626 sub: 1,
1627 };
1628 for (x, label) in fit_x_labels(&x_axis, span, x_track).labels {
1629 buf.set_string(x, x_label_area.y, label, label_style);
1630 }
1631 if x_label_area.height > 1 {
1632 let x_title = cut(&x.title, x_label_area.width as usize, g);
1633 let x = x_label_area.right() - x_title.width() as u16;
1634 buf.set_string(x, x_label_area.y + 1, &x_title, title_style);
1635 }
1636}
1637
1638#[cfg(test)]
1639mod tests;