1#![forbid(unsafe_code)]
43
44use egui::{Color32, RichText, Sense, Stroke, Ui, Vec2};
45use serde_json::Value;
46
47use datagrout_panels::{Panel, PanelKind};
48
49#[derive(Debug, Clone, PartialEq)]
56pub enum PanelAction {
57 Submit { panel_id: String, field_id: String },
59 ValueChanged {
61 panel_id: String,
62 field_id: String,
63 value: String,
64 },
65}
66
67#[derive(Debug, Default, Clone)]
75pub struct FormState {
76 pub values: std::collections::BTreeMap<String, String>,
77 pub checks: std::collections::BTreeMap<String, bool>,
78}
79
80impl FormState {
81 pub fn submission(&self, panel: &Panel) -> std::collections::BTreeMap<String, String> {
95 panel
96 .fields
97 .iter()
98 .filter(|field| field.kind != PanelKind::Button)
99 .map(|field| {
100 let value = match field.kind {
101 PanelKind::Checkbox => self
102 .checks
103 .get(&field.id)
104 .copied()
105 .unwrap_or_else(|| field.default_value().as_deref() == Some("true"))
106 .to_string(),
107 _ => self
108 .values
109 .get(&field.id)
110 .cloned()
111 .unwrap_or_else(|| field.default_value().unwrap_or_default()),
112 };
113 (field.id.clone(), value)
114 })
115 .collect()
116 }
117}
118
119pub fn render_panel(ui: &mut Ui, panel: &Panel) -> Vec<PanelAction> {
121 let mut state = FormState::default();
122 render_panel_with_state(ui, panel, &mut state)
123}
124
125pub fn render_panel_with_state(
127 ui: &mut Ui,
128 panel: &Panel,
129 state: &mut FormState,
130) -> Vec<PanelAction> {
131 let mut actions = Vec::new();
132
133 ui.vertical(|ui| {
134 ui.label(RichText::new(panel.title()).heading());
135 if let Some(desc) = panel.description() {
136 ui.label(RichText::new(desc).weak().small());
137 }
138 ui.add_space(4.0);
139
140 match &panel.kind {
141 PanelKind::Dashboard => actions.extend(dashboard(ui, panel, state)),
142 PanelKind::Metric => metric(ui, panel),
143 PanelKind::Gauge => gauge(ui, panel),
144 PanelKind::Table => table(ui, panel),
145 PanelKind::List => list(ui, panel),
146 PanelKind::LineChart | PanelKind::AreaChart => line_chart(ui, panel),
147 PanelKind::BarChart => bar_chart(ui, panel),
148 PanelKind::Heatmap => heatmap(ui, panel),
149 PanelKind::Markdown | PanelKind::Doc => {
150 ui.label(panel.prop_str("body").unwrap_or_default());
154 }
155 k if k.is_form_kind() => actions.extend(form(ui, panel, state)),
156 other => placeholder(ui, other),
157 }
158 });
159
160 actions
161}
162
163fn dashboard(ui: &mut Ui, panel: &Panel, state: &mut FormState) -> Vec<PanelAction> {
172 if panel.children.is_empty() {
173 ui.label(
174 RichText::new("no panels on this dashboard")
175 .weak()
176 .italics(),
177 );
178 return Vec::new();
179 }
180
181 let columns = (panel.prop_f64("columns_per_row").unwrap_or(2.0).max(1.0) as usize)
184 .min(panel.children.len());
185 let mut actions = Vec::new();
186
187 ui.columns(columns, |cols| {
188 for child in &panel.children {
189 let target = (0..columns)
191 .min_by(|a, b| {
192 cols[*a]
193 .min_rect()
194 .height()
195 .total_cmp(&cols[*b].min_rect().height())
196 })
197 .unwrap_or(0);
198 let col = &mut cols[target];
199 col.group(|ui| {
200 ui.set_min_width(180.0);
201 actions.extend(render_panel_with_state(ui, child, state));
202 });
203 col.add_space(12.0);
204 }
205 });
206
207 actions
208}
209
210fn metric(ui: &mut Ui, panel: &Panel) {
214 let value = first_cell(panel)
215 .map(render_cell)
216 .unwrap_or_else(|| "—".to_string());
217 let unit = panel.prop_str("unit").unwrap_or_default();
218
219 ui.horizontal(|ui| {
220 ui.label(RichText::new(value).size(34.0).strong().monospace());
221 if !unit.is_empty() {
222 ui.label(RichText::new(unit).weak());
223 }
224 });
225}
226
227fn gauge(ui: &mut Ui, panel: &Panel) {
230 let Some(value) = first_cell(panel).and_then(as_f64) else {
231 return metric(ui, panel);
232 };
233 let min = panel.prop_f64("min").unwrap_or(0.0);
234 let max = panel.prop_f64("max").unwrap_or(100.0);
235 let frac = if (max - min).abs() < f64::EPSILON {
236 0.0
237 } else {
238 ((value - min) / (max - min)).clamp(0.0, 1.0) as f32
239 };
240
241 ui.label(
242 RichText::new(format!("{value:.3}"))
243 .size(28.0)
244 .strong()
245 .monospace(),
246 );
247
248 let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 10.0), Sense::hover());
249 let painter = ui.painter();
250 painter.rect_filled(rect, 3.0_f32, ui.visuals().extreme_bg_color);
251 let mut filled = rect;
252 filled.set_width(rect.width() * frac);
253 painter.rect_filled(filled, 3.0_f32, accent(ui));
254
255 ui.label(RichText::new(format!("{min} – {max}")).weak().small());
256}
257
258fn table(ui: &mut Ui, panel: &Panel) {
259 if panel.rows.is_empty() {
260 return empty(ui);
261 }
262 let headers = panel.columns();
263
264 egui::Grid::new(format!("dgp_table_{}_{}", panel.namespace, panel.id))
265 .striped(true)
266 .show(ui, |ui| {
267 if !headers.is_empty() {
268 for h in &headers {
269 ui.label(RichText::new(h).strong().small());
270 }
271 ui.end_row();
272 }
273 for row in panel.rows.iter().take(200) {
276 for cell in row {
277 ui.label(RichText::new(render_cell(cell)).monospace().small());
278 }
279 ui.end_row();
280 }
281 });
282}
283
284fn list(ui: &mut Ui, panel: &Panel) {
285 if panel.rows.is_empty() {
286 return empty(ui);
287 }
288 for row in panel.rows.iter().take(200) {
289 let text = row.iter().map(render_cell).collect::<Vec<_>>().join(" · ");
290 ui.label(format!("• {text}"));
291 }
292}
293
294fn line_chart(ui: &mut Ui, panel: &Panel) {
300 let values = numeric_series(panel);
301 if values.is_empty() {
302 return empty(ui);
303 }
304
305 let height = panel.prop_f64("height").unwrap_or(120.0) as f32;
306 let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
307 let painter = ui.painter_at(rect);
308
309 let (min, max) = min_max(&values);
310 let range = if (max - min).abs() < 1e-12 {
311 1.0
312 } else {
313 max - min
314 };
315 let dx = if values.len() > 1 {
316 rect.width() / (values.len() - 1) as f32
317 } else {
318 rect.width()
319 };
320
321 let points: Vec<egui::Pos2> = values
322 .iter()
323 .enumerate()
324 .map(|(i, v)| {
325 let y = rect.bottom() - (((v - min) / range) as f32) * rect.height();
326 egui::pos2(rect.left() + i as f32 * dx, y)
327 })
328 .collect();
329
330 painter.add(egui::Shape::line(points, Stroke::new(1.5_f32, accent(ui))));
331 ui.label(
332 RichText::new(format!("{} pts · {min:.3} … {max:.3}", values.len()))
333 .weak()
334 .small(),
335 );
336}
337
338fn bar_chart(ui: &mut Ui, panel: &Panel) {
339 let values = numeric_series(panel);
340 if values.is_empty() {
341 return empty(ui);
342 }
343 let height = panel.prop_f64("height").unwrap_or(120.0) as f32;
344 let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
345 let painter = ui.painter_at(rect);
346
347 let (min, max) = min_max(&values);
348 let base = min.min(0.0);
349 let range = if (max - base).abs() < 1e-12 {
350 1.0
351 } else {
352 max - base
353 };
354 let bw = rect.width() / values.len() as f32;
355
356 for (i, v) in values.iter().enumerate() {
357 let h = (((v - base) / range) as f32) * rect.height();
358 let bar = egui::Rect::from_min_size(
359 egui::pos2(rect.left() + i as f32 * bw, rect.bottom() - h),
360 Vec2::new((bw - 2.0).max(1.0), h),
361 );
362 painter.rect_filled(bar, 1.0_f32, accent(ui));
363 }
364}
365
366fn heatmap(ui: &mut Ui, panel: &Panel) {
368 if panel.rows.is_empty() {
369 return empty(ui);
370 }
371 let cell = panel.prop_f64("cell").unwrap_or(8.0) as f32;
372 let cols = panel.rows.iter().map(Vec::len).max().unwrap_or(0);
373 if cols == 0 {
374 return empty(ui);
375 }
376
377 let size = Vec2::new(cols as f32 * cell, panel.rows.len() as f32 * cell);
378 let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
379 let painter = ui.painter_at(rect);
380
381 let all: Vec<f64> = panel.rows.iter().flatten().filter_map(as_f64).collect();
382 let (min, max) = min_max(&all);
383 let range = if (max - min).abs() < 1e-12 {
384 1.0
385 } else {
386 max - min
387 };
388
389 for (r, row) in panel.rows.iter().enumerate() {
390 for (c, v) in row.iter().enumerate() {
391 let Some(v) = as_f64(v) else { continue };
392 let t = ((v - min) / range).clamp(0.0, 1.0) as f32;
393 let px = egui::Rect::from_min_size(
394 egui::pos2(rect.left() + c as f32 * cell, rect.top() + r as f32 * cell),
395 Vec2::splat(cell),
396 );
397 painter.rect_filled(px, 0.0_f32, intensity(t));
398 }
399 }
400}
401
402fn form(ui: &mut Ui, panel: &Panel, state: &mut FormState) -> Vec<PanelAction> {
405 let mut actions = Vec::new();
406
407 for field in &panel.fields {
408 let label = field.label();
409 ui.horizontal(|ui| {
410 let text = if field.required() {
411 format!("{label} *")
412 } else {
413 label.clone()
414 };
415 ui.label(RichText::new(text).small());
416 });
417
418 let id = field.id.clone();
419 match field.kind {
420 PanelKind::Checkbox => {
421 let checked = state.checks.entry(id.clone()).or_default();
422 if ui.checkbox(checked, "").changed() {
423 actions.push(PanelAction::ValueChanged {
424 panel_id: panel.id.clone(),
425 field_id: id,
426 value: checked.to_string(),
427 });
428 }
429 }
430 PanelKind::Button => {
431 if ui.button(&label).clicked() {
432 actions.push(PanelAction::Submit {
433 panel_id: panel.id.clone(),
434 field_id: id,
435 });
436 }
437 }
438 PanelKind::TextArea | PanelKind::RichText => {
439 let value = state
440 .values
441 .entry(id.clone())
442 .or_insert_with(|| field.default_value().unwrap_or_default());
443 if ui.text_edit_multiline(value).changed() {
444 actions.push(PanelAction::ValueChanged {
445 panel_id: panel.id.clone(),
446 field_id: id,
447 value: value.clone(),
448 });
449 }
450 }
451 _ => {
452 let value = state
453 .values
454 .entry(id.clone())
455 .or_insert_with(|| field.default_value().unwrap_or_default());
456 let widget = egui::TextEdit::singleline(value).hint_text(field.placeholder());
457 if ui.add(widget).changed() {
458 actions.push(PanelAction::ValueChanged {
459 panel_id: panel.id.clone(),
460 field_id: id,
461 value: value.clone(),
462 });
463 }
464 }
465 }
466 ui.add_space(6.0);
467 }
468
469 actions
470}
471
472fn placeholder(ui: &mut Ui, kind: &PanelKind) {
475 ui.label(
476 RichText::new(format!("(no renderer for {})", kind.as_str()))
477 .weak()
478 .italics(),
479 );
480}
481
482fn empty(ui: &mut Ui) {
483 ui.label(RichText::new("no data").weak().italics());
484}
485
486fn accent(ui: &Ui) -> Color32 {
487 ui.visuals().selection.bg_fill
488}
489
490fn intensity(t: f32) -> Color32 {
493 let t = t.clamp(0.0, 1.0);
494 Color32::from_rgb(
495 (20.0 + 200.0 * t) as u8,
496 (30.0 + 120.0 * t) as u8,
497 (60.0 + 60.0 * (1.0 - t)) as u8,
498 )
499}
500
501fn first_cell(panel: &Panel) -> Option<&Value> {
502 panel.rows.first()?.last()
503}
504
505fn as_f64(v: &Value) -> Option<f64> {
506 match v {
507 Value::Number(n) => n.as_f64(),
508 Value::String(s) => s.trim().parse().ok(),
509 _ => None,
510 }
511}
512
513fn numeric_series(panel: &Panel) -> Vec<f64> {
516 panel
517 .rows
518 .iter()
519 .filter_map(|row| row.iter().rev().find_map(as_f64))
520 .collect()
521}
522
523fn min_max(values: &[f64]) -> (f64, f64) {
524 values
525 .iter()
526 .fold((f64::MAX, f64::MIN), |(lo, hi), v| (lo.min(*v), hi.max(*v)))
527}
528
529fn render_cell(v: &Value) -> String {
530 match v {
531 Value::String(s) => s.clone(),
532 Value::Number(n) => n.to_string(),
533 Value::Bool(b) => b.to_string(),
534 Value::Null => String::new(),
535 other => other.to_string(),
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use datagrout_panels::{PanelFacts, Props};
543 use serde_json::json;
544
545 fn panel_with(kind: PanelKind, rows: Vec<Vec<Value>>) -> Panel {
546 Panel {
547 id: "t".into(),
548 kind,
549 namespace: "ns".into(),
550 props: Props::new(),
551 rows,
552 source: None,
553 fields: Vec::new(),
554 children: Vec::new(),
555 published: true,
556 }
557 }
558
559 fn render_once(panel: &Panel) -> Vec<PanelAction> {
565 let ctx = egui::Context::default();
566 let mut actions = Vec::new();
567 let _ = ctx.run(egui::RawInput::default(), |ctx| {
568 egui::CentralPanel::default().show(ctx, |ui| {
569 actions = render_panel(ui, panel);
570 });
571 });
572 actions
573 }
574
575 #[test]
576 fn numeric_series_takes_the_rightmost_number() {
577 let p = panel_with(
578 PanelKind::LineChart,
579 vec![
580 vec![json!("Jan"), json!(4.0)],
581 vec![json!("Feb"), json!(6.0)],
582 ],
583 );
584 assert_eq!(numeric_series(&p), vec![4.0, 6.0]);
585 }
586
587 #[test]
588 fn numeric_series_skips_rows_with_no_number() {
589 let p = panel_with(
590 PanelKind::LineChart,
591 vec![
592 vec![json!("Jan"), json!("n/a")],
593 vec![json!("Feb"), json!(6.0)],
594 ],
595 );
596 assert_eq!(numeric_series(&p), vec![6.0]);
597 }
598
599 #[test]
600 fn min_max_of_a_flat_series_does_not_divide_by_zero() {
601 let (lo, hi) = min_max(&[2.0, 2.0]);
602 assert_eq!((lo, hi), (2.0, 2.0));
603 }
604
605 #[test]
606 fn every_display_kind_renders_with_and_without_rows() {
607 let kinds = [
608 PanelKind::BarChart,
609 PanelKind::LineChart,
610 PanelKind::AreaChart,
611 PanelKind::PieChart,
612 PanelKind::Scatter,
613 PanelKind::Table,
614 PanelKind::Metric,
615 PanelKind::Gauge,
616 PanelKind::Markdown,
617 PanelKind::List,
618 PanelKind::Heatmap,
619 PanelKind::Timeline,
620 PanelKind::Funnel,
621 PanelKind::Game,
622 PanelKind::Doc,
623 PanelKind::Dashboard,
624 PanelKind::Unknown("sonar".into()),
625 ];
626 let rows = vec![
627 vec![json!("a"), json!(1.5)],
628 vec![json!("b"), json!(-2.0)],
629 vec![json!("c"), json!("n/a")],
630 ];
631 for kind in kinds {
632 render_once(&panel_with(kind.clone(), Vec::new()));
633 render_once(&panel_with(kind, rows.clone()));
634 }
635 }
636
637 #[test]
638 fn a_flat_series_renders_without_dividing_by_zero() {
639 let p = panel_with(PanelKind::BarChart, vec![vec![json!(3)], vec![json!(3)]]);
640 render_once(&p);
641 let g = panel_with(PanelKind::Gauge, vec![vec![json!(50)]]);
642 render_once(&g);
643 }
644
645 #[test]
646 fn a_dashboard_renders_its_children() {
647 let facts = PanelFacts {
648 panels: vec![
649 json!({"Id": "board", "Kind": "dashboard", "Namespace": "ns"}),
650 json!({"Id": "a", "Kind": "metric", "Namespace": "ns"}),
651 json!({"Id": "b", "Kind": "table", "Namespace": "ns"}),
652 json!({"Id": "c", "Kind": "bar_chart", "Namespace": "ns"}),
653 ],
654 props: vec![
655 json!({"Id": "a", "Key": "parent", "Value": "board"}),
656 json!({"Id": "b", "Key": "parent", "Value": "board"}),
657 json!({"Id": "c", "Key": "parent", "Value": "board"}),
658 json!({"Id": "b", "Key": "columns", "Value": ["Name", "N"]}),
659 ],
660 data: vec![
661 json!({"Id": "a", "Rows": [["x", 7]]}),
662 json!({"Id": "b", "Rows": [["p", 1], ["q", 2]]}),
663 ],
664 ..Default::default()
665 };
666 let board = Panel::all_from_facts(&facts).remove(0);
667 assert_eq!(board.children.len(), 3);
668 render_once(&board);
670 }
671
672 #[test]
673 fn a_form_renders_every_field_kind_and_seeds_defaults() {
674 let facts = PanelFacts {
675 panels: vec![
676 json!({"Id": "f", "Kind": "form", "Namespace": "ns"}),
677 json!({"Id": "name", "Kind": "text_input", "Namespace": "ns"}),
678 json!({"Id": "notes", "Kind": "textarea", "Namespace": "ns"}),
679 json!({"Id": "ok", "Kind": "checkbox", "Namespace": "ns"}),
680 json!({"Id": "go", "Kind": "button", "Namespace": "ns"}),
681 ],
682 props: vec![
683 json!({"Id": "name", "Key": "parent", "Value": "f"}),
684 json!({"Id": "name", "Key": "default", "Value": "Ada"}),
685 json!({"Id": "notes", "Key": "parent", "Value": "f"}),
686 json!({"Id": "ok", "Key": "parent", "Value": "f"}),
687 json!({"Id": "go", "Key": "parent", "Value": "f"}),
688 ],
689 ..Default::default()
690 };
691 let form = Panel::all_from_facts(&facts).remove(0);
692 assert_eq!(form.fields.len(), 4);
693
694 let ctx = egui::Context::default();
695 let mut state = FormState::default();
696 let _ = ctx.run(egui::RawInput::default(), |ctx| {
697 egui::CentralPanel::default().show(ctx, |ui| {
698 let actions = render_panel_with_state(ui, &form, &mut state);
699 assert!(actions.is_empty());
701 });
702 });
703 assert_eq!(state.values.get("name").map(String::as_str), Some("Ada"));
705 }
706
707 #[test]
708 fn a_submission_carries_every_field_a_host_must_send() {
709 let facts = PanelFacts {
710 panels: vec![
711 json!({"Id": "f", "Kind": "form", "Namespace": "ns"}),
712 json!({"Id": "company_name", "Kind": "text_input", "Namespace": "ns"}),
713 json!({"Id": "notes", "Kind": "textarea", "Namespace": "ns"}),
714 json!({"Id": "urgent", "Kind": "checkbox", "Namespace": "ns"}),
715 json!({"Id": "submit_it", "Kind": "button", "Namespace": "ns"}),
716 ],
717 props: vec![
718 json!({"Id": "company_name", "Key": "parent", "Value": "f"}),
719 json!({"Id": "company_name", "Key": "default", "Value": "Acme"}),
720 json!({"Id": "notes", "Key": "parent", "Value": "f"}),
721 json!({"Id": "urgent", "Key": "parent", "Value": "f"}),
722 json!({"Id": "submit_it", "Key": "parent", "Value": "f"}),
723 ],
724 ..Default::default()
725 };
726 let form = Panel::all_from_facts(&facts).remove(0);
727
728 let mut state = FormState::default();
729 state.values.insert("notes".into(), "ship tuesday".into());
730 state.checks.insert("urgent".into(), true);
731
732 let submission = state.submission(&form);
733 assert_eq!(
736 submission,
737 [
738 ("company_name", "Acme"),
739 ("notes", "ship tuesday"),
740 ("urgent", "true"),
741 ]
742 .into_iter()
743 .map(|(k, v)| (k.to_string(), v.to_string()))
744 .collect()
745 );
746 }
747
748 #[test]
749 fn tables_use_the_columns_prop_for_headers() {
750 let mut p = panel_with(PanelKind::Table, vec![vec![json!("INV-1"), json!(30)]]);
751 p.props.insert("columns".into(), json!(["Invoice", "Days"]));
752 assert_eq!(p.columns(), vec!["Invoice", "Days"]);
753 render_once(&p);
754 }
755}