diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index e7d42ea..b42c532 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -902,12 +902,19 @@ impl ModelApp { /// them): asks the save picker for `--.csv`; without a picker, the /// command's form opens instead. pub fn export_csv(&mut self, params: Value) { + self.export_csv_with("results.export.csv", params); + } + + /// [`Self::export_csv`] through `command`, which takes a `path` (results.export.csv, + /// results.xy). + pub fn export_csv_with(&mut self, command: &str, params: Value) { let Some(pick) = self.services.pick_save.as_ref() else { - self.open_form("results.export.csv", params); + self.open_form(command, params); return; }; let part = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); - let name = format!("{}-{}-{}.csv", self.model_name(), part("step"), part("what")); + let what = if command == "results.xy" { "xy".to_string() } else { part("what") }; + let name = format!("{}-{}-{what}.csv", self.model_name(), part("step")); let Some(path) = pick(&name, &[Filter { name: "CSV", extensions: &["csv"] }]) else { return }; let path = if Path::new(&path).extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case("csv")) { path @@ -916,7 +923,7 @@ impl ModelApp { }; let mut params = params; params["path"] = json!(path); - if self.run("results.export.csv", params).is_ok() { + if self.run(command, params).is_ok() { self.set_status(format!("Wrote {}", file_name(&path)), false); } } @@ -969,6 +976,7 @@ impl ModelApp { "diagnostics.bug_report" => self.report_bug(None), "diagnostics.crash_reports" | "diagnostics.crash_report" | "diagnostics.crash_reports.delete" => self.open_crash_reports(), "solve.run" => self.solve_all(), + "results.xy" => self.open_xy(), "deck.write" if can_save => match preset.get("format").and_then(Value::as_str) { Some(f) => self.write_deck(f), None => return false, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7103301..d03e5f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -302,6 +302,17 @@ pub struct Viewport { pub menu_target: Option, } +/// The XY plot in the results panel (results.xy): the quantity picked, the node it is read at, +/// and the series the command replied with for `key` (step, result identity, `y`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XyPlot { + /// `results.xy`'s `y`; a nodal field's `node` is filled from [`XyPlot::node`]. + pub y: Option, + pub node: Option, + pub key: Option<(String, usize, Value)>, + pub series: Option, +} + /// A probed node: its values as (what, value) rows and as lines of text, for the step, mode /// and frame they were read from. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -377,6 +388,8 @@ pub struct ModelApp { pub probe: Option, /// The reaction sums asked for last in the results panel. pub reactions: Option, + /// The XY plot, while it is open in the results panel. + pub xy: Option, /// The last command reply with more to it than its status line, shown in the context panel. pub reply: Option, /// Whether results were shown last frame (the Results tab comes up when they appear). @@ -508,6 +521,7 @@ impl ModelApp { plot: (None, None), probe: None, reactions: None, + xy: None, reply: None, results_shown: false, reframe: None, @@ -632,6 +646,19 @@ impl ModelApp { { self.reactions = Some(v.clone()); } + // The XY plot's series, however it was asked for; the panel opens on it. + if id == "results.xy" && (r.is_ok() || params.get("path").is_none()) { + let xy = self.xy.get_or_insert_with(XyPlot::default); + xy.series = r.as_ref().ok().cloned(); + // The picker follows a series asked for elsewhere (the control channel, a script). + if let Some(y) = params.get("y").filter(|_| r.is_ok()) { + let mut template = y.clone(); + if let Some(node) = template.as_object_mut().and_then(|o| o.remove("node")) { + xy.node = node.as_u64().and_then(|n| u32::try_from(n).ok()); + } + xy.y = Some(template); + } + } // What the duplicate check found is selected, so the viewport shows it (#234). if id == "mesh.check.duplicates" && let Ok(v) = &r @@ -1851,6 +1878,7 @@ impl ModelApp { // The probe and the reaction sums were of the old document's results. self.probe = None; self.reactions = None; + self.xy = None; self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Home); } } @@ -2778,6 +2806,39 @@ mod tests { out.textures_delta.clear(); } + /// XY Plot from the ribbon opens the plot in the results panel, which runs results.xy for + /// its quantity and keeps the series; a nodal field waits for a probed node (#94). + #[test] + fn the_xy_plot_runs_results_xy() { + let ctx = egui::Context::default(); + let mut app = ModelApp::new(Session::default()); + app.open_sample("impact"); + app.run("solve.run", json!({})).unwrap(); + app.start("results.xy").unwrap(); + assert!(app.dialog.is_none(), "the plot opens in the panel, not a form"); + draw(&mut app, &ctx, 1280.0, 800.0); + let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("the panel ran results.xy"); + assert_eq!((s["step"].as_str(), s["x"]["axis"].as_str()), (Some("Impact"), Some("time"))); + assert!(s["points"].as_u64().unwrap_or(0) > 1, "{s}"); + // A nodal field needs a node: the probed one. + app.xy.as_mut().unwrap().y = Some(json!({"field": "velocity"})); + draw(&mut app, &ctx, 1280.0, 800.0); + assert!(app.xy.as_ref().unwrap().series.is_none()); + app.probe_node(1).unwrap(); + draw(&mut app, &ctx, 1280.0, 800.0); + let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("read at the probed node"); + assert_eq!(s["frames"].as_array().map(Vec::len), Some(11), "{s}"); + // A linear static step offers nothing; a frequency step its frequencies. + let mut app = ModelApp::new(Session::default()); + app.open_sample("cantilever"); + app.run("solve.run", json!({})).unwrap(); + app.run("results.modes", json!({"step": "Modes"})).unwrap(); + app.start("results.xy").unwrap(); + draw(&mut app, &ctx, 1280.0, 800.0); + let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("the frequencies"); + assert_eq!(s["x"]["axis"], "mode"); + } + fn is_iso(cam: &Camera) -> bool { let (yaw, pitch) = StandardView::Iso.angles(); (cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9 diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index d5f24a4..0f130b1 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -125,6 +125,11 @@ pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String { None => format!("Showing {step}, last frame"), }), "results.clear" => Some("Back to the model".to_string()), + "results.xy" => { + let points = v.get("points").and_then(Value::as_u64).unwrap_or(0); + let what = v.pointer("/y/label").and_then(Value::as_str).unwrap_or("the series"); + Some(format!("Plotted {what}: {}", plural(points, "point"))) + } "file.sample" => { let label = v.get("sample").and_then(Value::as_str).and_then(fieldcraft_engine::sample::find).map(|s| s.label); label.map(|l| format!("Opened the {l} sample")) diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index ca54791..ceaf55a 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -9,6 +9,8 @@ use fieldcraft_engine::results::{Field, Kind, Quantity, true_scale, with_unit as use fieldcraft_explicit::{Energies, ExplicitResult}; use serde_json::{Value, json}; +use std::sync::Arc; + use crate::ModelApp; use crate::results::{Plot, components, count, number, number_in, with_unit}; use crate::theme::Tokens; @@ -398,6 +400,11 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) reactions_table(ui, r, plot, t); } } + let mut xy_export = None; + if app.xy.is_some() { + ui.separator(); + xy_export = xy_section(app, plot, ui, t, &mut run); + } ui.separator(); if let Some(p) = probed(app, plot, ui, t) { export = Some(p); @@ -405,6 +412,9 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) if let Some(p) = export { app.export_csv(p); } + if let Some(p) = xy_export { + app.export_csv_with("results.xy", p); + } for (id, params) in run { // The reaction sums land in `app.reactions` (ModelApp::run_form). let _ = app.run(id, params); @@ -465,60 +475,30 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: ]; let energy_unit = Quantity::Energy.unit(plot.units); ui.label(RichText::new(titled("Energies", energy_unit.as_deref())).color(t.text_dim)); - let (outer, response) = ui.allocate_exact_size(vec2(ui.available_width(), PLOT_HEIGHT + AXIS_BOTTOM), egui::Sense::click_and_drag()); - let rect = Rect::from_min_max(outer.min + vec2(AXIS_LEFT, 0.0), outer.max - vec2(0.0, AXIS_BOTTOM)); - let painter = ui.painter_at(outer); - painter.rect_filled(rect, 2.0, t.field); - let t_end = h.time.last().copied().unwrap_or(0.0).max(f64::MIN_POSITIVE); - // The value axis spans every curve and 0 (a curve below 0 stays in the frame). - let values = || h.energies.iter().flat_map(|e| series.iter().map(move |(_, _, f)| f(e))).filter(|v| v.is_finite()); - let hi = values().fold(0.0, f64::max); - let lo = values().fold(0.0, f64::min); - let span = if hi > lo { hi - lo } else { 1.0 }; - let at = |time: f64, v: f64| { - pos2(rect.left() + (time / t_end) as f32 * rect.width(), rect.bottom() - 2.0 - ((v - lo) / span) as f32 * (rect.height() - 4.0)) - }; - // Axes: the value range left, the time range under it. - let small = FontId::proportional(10.0); - let scale = hi.abs().max(lo.abs()); - painter.text(pos2(rect.left() - 4.0, rect.top()), Align2::RIGHT_TOP, number_in(hi, scale), small.clone(), t.text_dim); - painter.text(pos2(rect.left() - 4.0, rect.bottom()), Align2::RIGHT_BOTTOM, number_in(lo, scale), small.clone(), t.text_dim); - if lo < 0.0 && hi > 0.0 { - let zero = at(0.0, 0.0).y; - painter.line_segment([pos2(rect.left(), zero), pos2(rect.right(), zero)], Stroke::new(1.0, t.border)); - } - painter.text(pos2(rect.left(), rect.bottom() + 2.0), Align2::LEFT_TOP, "0", small.clone(), t.text_dim); - let end = format!("t {}", with_unit(number(t_end), time_unit.as_deref())); - painter.text(pos2(rect.right(), rect.bottom() + 2.0), Align2::RIGHT_TOP, end, small, t.text_dim); - // At most a few hundred points per line, however long the history. - let stride = (h.time.len() / MAX_PLOT_POINTS).max(1); - for (_, color, f) in &series { - let points: Vec = - h.time.iter().zip(&h.energies).step_by(stride).filter(|(_, e)| f(e).is_finite()).map(|(time, e)| at(*time, f(e))).collect(); - painter.add(egui::Shape::line(points, Stroke::new(1.5, *color))); - } + let lines: Vec = series + .iter() + .map(|(_, color, f)| Line { color: *color, points: h.time.iter().zip(&h.energies).map(|(time, e)| (*time, f(e))).collect() }) + .collect(); + let t_end = h.time.last().copied().unwrap_or(0.0); + let now = plot.frame().map_or(t_end, |(_, time, _)| time); + let (response, axes) = line_plot(ui, &lines, "t", time_unit.as_deref(), Some(now), t); + let scale = axes.lo.abs().max(axes.hi.abs()); let nearest = |time: f64| h.time.iter().enumerate().min_by(|a, b| (a.1 - time).abs().total_cmp(&(b.1 - time).abs())).map_or(0, |(i, _)| i); - let time_at = |x: f32| f64::from(((x - rect.left()) / rect.width()).clamp(0.0, 1.0)) * t_end; // A click or drag on the plot shows the frame nearest that time. if (response.clicked() || response.dragged()) && let Some(p) = response.interact_pointer_pos() { - run.push(("results.frame", json!({"time": time_at(p.x)}))); + run.push(("results.frame", json!({"time": axes.x_of(p.x)}))); } // Hover reads the curves at the pointer's time. - if let Some(p) = response.hover_pos().filter(|p| rect.contains(*p)) { - let x = p.x; - painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.0, t.border)); - if let Some(e) = h.energies.get(nearest(time_at(x))) { - let mut lines = vec![format!("t = {}", with_unit(number(time_at(x)), time_unit.as_deref()))]; - lines.extend(series.iter().map(|(name, _, f)| format!("{name} {}", number_in(f(e), scale)))); - response.clone().on_hover_text(lines.join("\n")); - } + if let Some(p) = response.hover_pos().filter(|p| axes.rect.contains(*p)) + && let Some(e) = h.energies.get(nearest(axes.x_of(p.x))) + { + let mut text = vec![format!("t = {}", with_unit(number(axes.x_of(p.x)), time_unit.as_deref()))]; + text.extend(series.iter().map(|(name, _, f)| format!("{name} {}", number_in(f(e), scale)))); + response.on_hover_text(text.join("\n")); } - // The frame's time: the cursor, and the values there. - let now = plot.frame().map_or(t_end, |(_, time, _)| time); - let x = at(now, 0.0).x; - painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.5, t.text)); + // The values at the frame's time. let Some(at_now) = h.energies.get(nearest(now)) else { return }; let frame = plot.frame().map_or_else(String::new, |(k, ..)| format!(", frame {k}")); ui.label(RichText::new(format!("At t = {}{frame}", with_unit(number(now), time_unit.as_deref()))).small().color(t.text_dim)); @@ -534,6 +514,233 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: }); } +/// A line of a plot: its colour and its (x, y) points (those not finite are left out). +struct Line { + color: Color32, + points: Vec<(f64, f64)>, +} + +/// Where a plot's values land: its drawing area and the x and value ranges it spans. +struct Axes { + rect: Rect, + x0: f64, + x1: f64, + lo: f64, + hi: f64, +} + +impl Axes { + fn at(&self, x: f64, y: f64) -> Pos2 { + let (w, h) = ((self.x1 - self.x0).max(f64::MIN_POSITIVE), (self.hi - self.lo).max(f64::MIN_POSITIVE)); + pos2( + self.rect.left() + ((x - self.x0) / w) as f32 * self.rect.width(), + self.rect.bottom() - 2.0 - ((y - self.lo) / h) as f32 * (self.rect.height() - 4.0), + ) + } + + /// The x value under the screen x `px` (clamped to the range). + fn x_of(&self, px: f32) -> f64 { + self.x0 + f64::from(((px - self.rect.left()) / self.rect.width()).clamp(0.0, 1.0)) * (self.x1 - self.x0) + } +} + +/// A line plot the panel's width: the lines (at most [`MAX_PLOT_POINTS`] points each), the +/// value range left (spanning 0, with a 0 line when it crosses it), the x range under it named +/// `x_name` with `x_unit`, and a cursor at x `cursor`. The response senses clicks and drags. +fn line_plot(ui: &mut egui::Ui, lines: &[Line], x_name: &str, x_unit: Option<&str>, cursor: Option, t: &Tokens) -> (egui::Response, Axes) { + let (outer, response) = ui.allocate_exact_size(vec2(ui.available_width(), PLOT_HEIGHT + AXIS_BOTTOM), egui::Sense::click_and_drag()); + let rect = Rect::from_min_max(outer.min + vec2(AXIS_LEFT, 0.0), outer.max - vec2(0.0, AXIS_BOTTOM)); + let painter = ui.painter_at(outer); + painter.rect_filled(rect, 2.0, t.field); + let finite = || lines.iter().flat_map(|l| l.points.iter()).filter(|(x, y)| x.is_finite() && y.is_finite()); + let (mut x0, mut x1) = finite().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (x, _)| (a.min(*x), b.max(*x))); + if !(x0 <= x1) { + (x0, x1) = (0.0, 1.0); + } else if x0 == x1 { + x1 = x0 + 1.0; + } + let hi = finite().map(|p| p.1).fold(0.0, f64::max); + let lo = finite().map(|p| p.1).fold(0.0, f64::min); + let hi = if hi > lo { hi } else { lo + 1.0 }; + let axes = Axes { rect, x0, x1, lo, hi }; + let small = FontId::proportional(10.0); + let scale = hi.abs().max(lo.abs()); + painter.text(pos2(rect.left() - 4.0, rect.top()), Align2::RIGHT_TOP, number_in(hi, scale), small.clone(), t.text_dim); + painter.text(pos2(rect.left() - 4.0, rect.bottom()), Align2::RIGHT_BOTTOM, number_in(lo, scale), small.clone(), t.text_dim); + if lo < 0.0 && hi > 0.0 { + let zero = axes.at(x0, 0.0).y; + painter.line_segment([pos2(rect.left(), zero), pos2(rect.right(), zero)], Stroke::new(1.0, t.border)); + } + painter.text(pos2(rect.left(), rect.bottom() + 2.0), Align2::LEFT_TOP, number(x0), small.clone(), t.text_dim); + let end = format!("{x_name} {}", with_unit(number(x1), x_unit)); + painter.text(pos2(rect.right(), rect.bottom() + 2.0), Align2::RIGHT_TOP, end, small, t.text_dim); + for line in lines { + let stride = (line.points.len() / MAX_PLOT_POINTS).max(1); + let points: Vec = + line.points.iter().step_by(stride).filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| axes.at(*x, *y)).collect(); + // A single point (a steady heat step) shows as a dot. + if let [p] = points.as_slice() { + painter.circle_filled(*p, 2.5, line.color); + } + painter.add(egui::Shape::line(points, Stroke::new(1.5, line.color))); + } + if let Some(x) = cursor.filter(|x| x.is_finite()) { + let x = axes.at(x, 0.0).x.clamp(rect.left(), rect.right()); + painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.5, t.text)); + } + if let Some(p) = response.hover_pos().filter(|p| rect.contains(*p)) { + painter.line_segment([pos2(p.x, rect.top()), pos2(p.x, rect.bottom())], Stroke::new(1.0, t.border)); + } + (response, axes) +} + +/// The quantities the XY plot offers for a result, as (label, `results.xy`'s `y`); a nodal +/// field's `node` is filled in from the probed node. +pub fn xy_options(r: &fieldcraft_engine::StepResult) -> Vec<(String, Value)> { + use fieldcraft_engine::cmd::xy; + let mut out = Vec::new(); + if let Kind::Frequency(f) = r.kind() { + out.push(if f.buckling.is_some() { + ("Buckling load factor".to_string(), json!("load_factor")) + } else { + ("Frequency".to_string(), json!("frequency")) + }); + } + if let Kind::Explicit(e) = r.kind() { + out.extend(xy::ENERGIES.iter().map(|n| (format!("Energy: {}", n.replace('_', " ")), json!({"energy": n})))); + out.extend(xy::HISTORIES.iter().map(|n| (n.replace('_', " "), json!({"history": n})))); + for node in e.history.nodes.iter().take(8) { + out.extend( + xy::NODE_QUANTITIES + .iter() + .map(|q| (format!("History node {node}: {}", q.replace('_', " ")), json!({"history_node": node, "quantity": q}))), + ); + } + } + let linear = matches!(r.kind(), Kind::Static(s) if s.nonlinear.is_none()); + if !linear { + out.extend(xy::plottable(r).into_iter().map(|f| (format!("{} at a node", f.label()), json!({"field": f.id()})))); + } + out +} + +/// The XY plot (results.xy): a quantity picker, the series plotted against the step's own axis +/// with the frame (mode) shown marked (a click shows the nearest one), and its export's +/// parameters when Export CSV… is clicked. A nodal field is read at the probed node. +fn xy_section(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens, run: &mut Vec<(&'static str, Value)>) -> Option { + let step = plot.view.step.clone(); + let mut close = false; + ui.horizontal(|ui| { + ui.label(RichText::new("XY Plot").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close = ui.small_button("×").on_hover_text("Close the XY plot").clicked(); + }); + }); + if close { + app.xy = None; + return None; + } + let options = xy_options(&plot.result); + if options.is_empty() { + ui.label( + RichText::new("A linear static step has one state: plot a nonlinear static, explicit, heat or frequency step").small().color(t.text_dim), + ); + return None; + } + let probed = app.probe.as_ref().filter(|p| p.step == step).map(|p| p.node); + let xy = app.xy.get_or_insert_with(Default::default); + if probed.is_some() { + xy.node = probed; + } + let template = xy.y.clone().filter(|y| options.iter().any(|(_, o)| o == y)).unwrap_or_else(|| options[0].1.clone()); + let shown = options.iter().find(|(_, o)| *o == template).map_or("", |(l, _)| l.as_str()); + let mut picked = template.clone(); + egui::ComboBox::from_id_salt("fc_results_xy").selected_text(shown).width(ui.available_width() - 8.0).truncate().show_ui(ui, |ui| { + for (label, y) in &options { + if ui.selectable_label(*y == template, label).clicked() { + picked = y.clone(); + } + } + }); + xy.y = Some(picked.clone()); + let y = if picked.get("field").is_some() { + let Some(node) = xy.node else { + (xy.key, xy.series) = (None, None); + ui.label(RichText::new("Click the model to pick the node").small().color(t.text_dim)); + return None; + }; + let mut y = picked; + y["node"] = json!(node); + y + } else { + picked + }; + let key = (step.clone(), Arc::as_ptr(&plot.result) as usize, y.clone()); + if xy.key.as_ref() != Some(&key) { + xy.key = Some(key); + xy.series = None; + run.push(("results.xy", json!({"step": step, "y": y}))); + return None; + } + let s = xy.series.clone().filter(|s| s["step"] == step.as_str())?; + let floats = |v: &Value| -> Vec { v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() }; + let (xs, ys) = (floats(&s["x"]["values"]), floats(&s["y"]["values"])); + let frames: Vec = s["frames"].as_array().into_iter().flatten().filter_map(|f| f.as_u64().and_then(|f| usize::try_from(f).ok())).collect(); + let axis = s["x"]["axis"].as_str().unwrap_or_default(); + // The point shown: the frame's, or the mode's. + let nearest = + |x: f64| xs.iter().enumerate().filter(|(_, v)| v.is_finite()).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map(|(i, _)| i); + let current = match plot.frame() { + Some((k, ..)) if !frames.is_empty() => frames.iter().position(|f| *f == k), + // A history against time: the sample nearest the frame's time. + Some((_, time, _)) if axis == "time" => nearest(time), + _ if axis == "mode" => Some(plot.view.mode.saturating_sub(1)), + _ => None, + }; + ui.label(RichText::new(s["y"]["label"].as_str().unwrap_or_default()).small().color(t.text_dim)); + let x_name = match axis { + "load_factor" => "λ", + "time" => "t", + _ => "mode", + }; + let x_unit = (axis == "time").then(|| Quantity::Time.unit(plot.units)).flatten(); + let line = Line { color: t.accent, points: xs.iter().copied().zip(ys.iter().copied()).collect() }; + let (response, axes) = line_plot(ui, &[line], x_name, x_unit.as_deref(), current.and_then(|i| xs.get(i).copied()), t); + let x_label = s["x"]["label"].as_str().unwrap_or_default().to_string(); + if response.clicked() + && let Some(i) = response.interact_pointer_pos().and_then(|p| nearest(axes.x_of(p.x))) + { + if let Some(f) = frames.get(i) { + run.push(("results.frame", json!({"step": step, "frame": f}))); + } else if axis == "time" { + run.push(("results.frame", json!({"step": step, "time": xs.get(i)}))); + } else if axis == "mode" { + run.push(("results.modes", json!({"step": step, "mode": i + 1}))); + } + } + if let Some(i) = response.hover_pos().filter(|p| axes.rect.contains(*p)).and_then(|p| nearest(axes.x_of(p.x))) { + let (x, y) = (xs.get(i).copied().unwrap_or(f64::NAN), ys.get(i).copied().unwrap_or(f64::NAN)); + let scale = axes.lo.abs().max(axes.hi.abs()); + response.on_hover_text(format!("{x_label} {}\n{}", number(x), number_in(y, scale))); + } + if let Some(i) = current { + let at = |v: &[f64]| v.get(i).map_or("—".into(), |x| number(*x)); + ui.label(RichText::new(format!("{x_label} {}: {}", at(&xs), at(&ys))).small().color(t.text_dim)); + } + ui.button("Export CSV…").on_hover_text("Write the series to a CSV file (results.xy)").clicked().then(|| json!({"step": step, "y": y})) +} + +impl ModelApp { + /// XY Plot from the ribbon: the plot opens in the results panel, on the step shown (the + /// first solved one when none is). + pub(crate) fn open_xy(&mut self) { + if self.session.results_view.is_none() { + let _ = self.run("results.deformed", json!({})); + } + self.xy.get_or_insert_with(Default::default); + } +} + /// A heat step's balance: the heat each heat load carries in (negative: out), the prescribed /// temperatures' total, and what is left over (about 0 when steady). fn heat_flows(ui: &mut egui::Ui, flows: &Value, t: &Tokens) {