//! Plot a curve (plan E12): `plot.add` draws a function, a parametric curve //! or data from a file as one compound of polylines with axes; the plot's //! parameters stay in the compound's comment, so `plot.update` and the //! dialog (`plot.edit`) redraw it. The geometry is in [`crate::plot`]. use super::{always, num_or, opt_str, CmdResult, Command}; use crate::plot::{self, PlotLook, PlotMode, PlotSpec}; use crate::{Engine, EngineError, UiRequest}; use rustfig_geom::{Point, Rect}; use rustfig_model::{ColorRef, Object, ObjectId, ObjectKind}; use serde_json::{json, Value}; use std::sync::Arc; /// Default frame of a new plot, Fig units (4 by 2.5 inches). pub const DEFAULT_W: f64 = 4800.0; pub const DEFAULT_H: f64 = 3000.0; pub(super) fn commands() -> Vec { vec![ Command { id: "plot.panel", label: "Plot curve...", menu: "Edit", shortcut: "Ctrl+Shift+P", params: "{} — open the Plot dialog for a new plot at the origin (the Plot mode button places it where you drag)", mutates: false, enabled: always, run: |e, _| { e.request(UiRequest::PlotDialog { rect: None, edit: None }); Ok(json!({"requested": true})) }, }, Command { id: "plot.edit", label: "Edit plot...", menu: "Edit", shortcut: "", params: "{\"id\"?: plot compound (default: the selected one)} — open the Plot dialog on an existing plot (Edit mode's left button on a plot does the same)", mutates: false, enabled: always, run: edit, }, Command { id: "plot.get", label: "Get plot parameters", menu: "", shortcut: "", params: "{\"id\"?} — the parameters kept in a plot's comment: {\"id\", \"spec\": {…}, \"frame\": [x0, y0, x1, y1]}", mutates: false, enabled: always, run: get, }, Command { id: "plot.add", label: "Add plot", menu: "", shortcut: "", params: "{\"x\"?: 0, \"y\"?: 0, \"width\"?: 4800, \"height\"?: 3000 (the frame, Fig units), plus the plot's parameters} — draw a plot as one compound; returns {id, curves, points}. Parameters: mode \"function\" (y = f(x): \"expr\") | \"parametric\" (\"xt\", \"yt\" in t; \"tmin\", \"tmax\") | \"data\" (\"file\" and 1-based columns \"xcol\" (0: row number), \"ycol\"; \"data\": the text itself instead of the file), \"xmin\" \"xmax\" \"ymin\" \"ymax\" (null: fit), \"samples\" (400, refined where the curve bends; no line across poles), \"frame\" (true: the box), \"xticks\" and \"yticks\" ({\"ticks\": true (marks), \"numbers\": true, \"manual\", \"first\", \"step\", \"count\", \"minor\", \"length\", \"dir\": in|out|both, \"format\", \"decimals\"}; ticks sit on the frame's edges even when the box is off), \"axes\" (old all-in-one: false turns frame, ticks and numbers off), \"grid\", \"log_x\", \"log_y\", \"xlabel\", \"ylabel\", \"latex\" (labels are LaTeX text with the TeX flag). Expressions: + - * / ^, parentheses, pi, e, sin cos tan asin acos atan sinh cosh tanh exp ln log log10 log2 sqrt abs floor ceil sign, atan2 min max pow(a,b); line style, depth and font come from the indicator", mutates: true, enabled: always, run: add, }, Command { id: "plot.update", label: "Redraw plot", menu: "", shortcut: "", params: "{\"id\"?: plot compound (default: the selected one), any of the plot parameters of plot.add to change} — redraw the plot in its present frame (where the user moved or scaled it) with the changed parameters; returns {id, curves, points}", mutates: true, enabled: always, run: update, }, ] } fn look(e: &Engine) -> PlotLook { let i = &e.indicator; PlotLook { curve: i.style(), text_color: ColorRef(i.pen_color), font: i.font, font_size: i.font_size, postscript: i.postscript, grid_gap: i.dot_gap.clamp(1.0, 100.0), } } /// The plot compound `id` names (default the selected one). fn which(e: &Engine, p: &Value) -> Result { let id = match p.get("id") { None | Some(Value::Null) => match e.selection() { [one] => *one, _ => return Err(EngineError::bad_params("select one plot or give its \"id\"")), }, Some(v) => ObjectId( v.as_u64() .ok_or_else(|| EngineError::bad_params("\"id\" must be an object id"))?, ), }; match e.doc().get(id) { Some(o) if is_plot(o) => Ok(id), Some(_) => Err(EngineError::bad_params(format!("object {} is not a plot", id.0))), None => Err(EngineError::bad_params(format!("no object {}", id.0))), } } /// The text of the data file (data mode only): the `data` parameter, or the /// file, up to [`plot::MAX_DATA_BYTES`]. fn data_text(spec: &PlotSpec, p: &Value) -> Result, EngineError> { if spec.mode != PlotMode::Data { return Ok(None); } if let Some(t) = opt_str(p, "data")? { if t.len() as u64 > plot::MAX_DATA_BYTES { return Err(EngineError::bad_params("the data is too large")); } return Ok(Some(t.to_string())); } if spec.file.trim().is_empty() { return Err(EngineError::bad_params("give a data \"file\" (or the \"data\" text)")); } read_file(&spec.file).map(Some) } #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> Result { use std::io::Read; let path = std::path::Path::new(path.trim()); let f = std::fs::File::open(path).map_err(|e| EngineError::io(format!("{}: {e}", path.display())))?; let mut buf = Vec::new(); // One byte more than the cap tells a file that is too large. f.take(plot::MAX_DATA_BYTES + 1) .read_to_end(&mut buf) .map_err(|e| EngineError::io(format!("{}: {e}", path.display())))?; if buf.len() as u64 > plot::MAX_DATA_BYTES { return Err(EngineError::bad_params(format!( "{} is larger than {} MB", path.display(), plot::MAX_DATA_BYTES / (1024 * 1024) ))); } Ok(String::from_utf8_lossy(&buf).into_owned()) } #[cfg(target_arch = "wasm32")] fn read_file(_: &str) -> Result { Err(EngineError::bad_params( "reading a data file is not available on the web: give the text as \"data\"", )) } fn bad(m: String) -> EngineError { EngineError::bad_params(m) } fn add(e: &mut Engine, p: &Value) -> CmdResult { let mut spec = PlotSpec::from_value(p).map_err(bad)?; spec.rect = None; let (x, y) = (num_or(p, "x", 0.0)?, num_or(p, "y", 0.0)?); let (w, h) = (num_or(p, "width", DEFAULT_W)?, num_or(p, "height", DEFAULT_H)?); let rect = Rect::new(Point::new(x, y), Point::new(x + w, y + h)); let data = data_text(&spec, p)?; let built = plot::build(&spec, rect, data.as_deref(), &look(e)).map_err(bad)?; let id = e.add_object(built.object); e.set_selection(vec![id]); Ok(json!({"id": id.0, "curves": built.curves, "points": built.points})) } fn update(e: &mut Engine, p: &Value) -> CmdResult { let id = which(e, p)?; let (old, frame) = { let o = e .doc() .get(id) .ok_or_else(|| EngineError::bad_params("the plot is gone"))?; let spec = PlotSpec::of_object(o).ok_or_else(|| EngineError::bad_params("not a plot"))?; let frame = plot::frame_of(o, &spec).ok_or_else(|| EngineError::bad_params("the plot has lost its frame"))?; (spec, frame) }; // The changed parameters over the kept ones. let mut merged = serde_json::to_value(&old).map_err(|e| EngineError::internal(e.to_string()))?; if let (Some(m), Some(given)) = (merged.as_object_mut(), p.as_object()) { for (k, v) in given { if k != "id" && k != "data" && k != "rect" { m.insert(k.clone(), v.clone()); } } } let spec = PlotSpec::from_value(&merged).map_err(bad)?; let data = data_text(&spec, p)?; let built = plot::build(&spec, frame, data.as_deref(), &look(e)).map_err(bad)?; let mut obj = built.object; let doc = e.doc_mut(); let idx = doc .index_of(id) .ok_or_else(|| EngineError::bad_params("the plot is gone"))?; // Only the generated parts are replaced: what the user added to the // plot (in an open compound) and left unmarked stays, and so do other // comments of the compound. if let Some(Object { kind: ObjectKind::Compound(old), comments, .. }) = doc.objects.get(idx).map(|o| &**o) { if let ObjectKind::Compound(new) = &mut obj.kind { new.objects .extend(old.objects.iter().filter(|m| !plot::is_part(m)).cloned()); } obj.comments.extend( comments .iter() .filter(|c| !c.trim_start().starts_with(plot::PLOT_COMMENT)) .cloned(), ); } doc.reassign_ids(&mut obj); obj.id = id; if let Some(slot) = doc.objects.get_mut(idx) { *slot = Arc::new(obj); } Ok(json!({"id": id.0, "curves": built.curves, "points": built.points})) } fn get(e: &mut Engine, p: &Value) -> CmdResult { let id = which(e, p)?; let o = e .doc() .get(id) .ok_or_else(|| EngineError::bad_params("the plot is gone"))?; let spec = PlotSpec::of_object(o).ok_or_else(|| EngineError::bad_params("not a plot"))?; let frame = plot::frame_of(o, &spec).map(|r| [r.min.x, r.min.y, r.max.x, r.max.y]); Ok(json!({"id": id.0, "spec": spec, "frame": frame})) } fn edit(e: &mut Engine, p: &Value) -> CmdResult { let id = which(e, p)?; e.request(UiRequest::PlotDialog { rect: None, edit: Some(id), }); Ok(json!({"requested": true, "id": id.0})) } /// Whether the object is a plot made by `plot.add`. pub fn is_plot(o: &rustfig_model::Object) -> bool { matches!(o.kind, ObjectKind::Compound(_)) && PlotSpec::of_object(o).is_some() } .