Skip to main content

damascene_core/plot/
spec.rs

1//! The declarative description of a plot: its marks, axes, and styling.
2//!
3//! [`PlotSpec`] is to [`plot`](crate::tree::plot) what
4//! [`SceneSpec`](crate::scene::SceneSpec) is to `chart3d` — a fluent builder
5//! the app assembles each frame and hands to the widget, which resolves it
6//! (in `draw_ops`) to the reused [`DrawOp::Scene3D`](crate::ir::DrawOp) data
7//! layer plus themed axis/grid/legend chrome.
8//!
9//! Following the house two-tier convention, each mark has a terse
10//! default-style adder on the builder ([`PlotSpec::line`],
11//! [`PlotSpec::scatter`]) and a full-control escape hatch (the standalone
12//! [`line`]/[`scatter`] builders + [`PlotSpec::add_mark`]) for power users.
13
14#![warn(missing_docs)]
15
16use crate::color::Color;
17use crate::plot::scale::Scale;
18use crate::plot::series::SeriesHandle;
19use crate::scene::style::PointShape;
20
21/// Pointer control scheme for a [`plot`](crate::tree::plot), the 2D analogue
22/// of [`CameraControls`](crate::scene::CameraControls) for `chart3d`: the app
23/// picks one on the spec and there is deliberately no built-in scheme-picker
24/// widget. Double-click always resets to the full extent and the wheel always
25/// zooms the time (X) axis, regardless of scheme; the scheme selects what the
26/// primary (unmodified) left-drag does, with `Shift` doing the other.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
28pub enum PlotControls {
29    /// Scientific default (uPlot / Grafana / InfluxDB): left-drag draws a
30    /// directional zoom box (X or Y by the larger drag delta), Shift+left-drag
31    /// pans.
32    #[default]
33    ZoomDrag,
34    /// Pan-first (trading charts / maps): left-drag pans, Shift+left-drag draws
35    /// a directional zoom box.
36    PanDrag,
37}
38
39/// Default line width, in screen pixels.
40pub const DEFAULT_LINE_WIDTH: f32 = 1.5;
41/// Default scatter marker size, in screen pixels.
42pub const DEFAULT_MARKER_SIZE: f32 = 5.0;
43
44/// A line mark: a series drawn as a connected polyline (segments + round
45/// join/cap discs, per the reuse-the-pipelines decision). `color = None`
46/// auto-assigns from the theme's series palette at resolve time.
47#[derive(Clone, Debug)]
48pub struct LineMark {
49    /// The data this line reads from.
50    pub series: SeriesHandle,
51    /// Stroke colour, or `None` to auto-assign a series colour.
52    pub color: Option<Color>,
53    /// Stroke width, in screen pixels.
54    pub width: f32,
55    /// Display name for the legend / cursor readout, or `None` to fall back to
56    /// `"Series N"`.
57    pub label: Option<String>,
58}
59
60impl LineMark {
61    /// Set the stroke colour.
62    pub fn color(mut self, color: Color) -> Self {
63        self.color = Some(color);
64        self
65    }
66
67    /// Set the stroke width (screen pixels).
68    pub fn width(mut self, width: f32) -> Self {
69        self.width = width;
70        self
71    }
72
73    /// Set the series' display name (legend + cursor readout).
74    pub fn label(mut self, label: impl Into<String>) -> Self {
75        self.label = Some(label.into());
76        self
77    }
78}
79
80/// A scatter mark: a series drawn as discrete markers. `color = None`
81/// auto-assigns from the theme's series palette at resolve time.
82#[derive(Clone, Debug)]
83pub struct ScatterMark {
84    /// The data this scatter reads from.
85    pub series: SeriesHandle,
86    /// Marker colour, or `None` to auto-assign a series colour.
87    pub color: Option<Color>,
88    /// Marker size, in screen pixels.
89    pub size: f32,
90    /// Marker shape.
91    pub shape: PointShape,
92    /// Display name for the legend / cursor readout, or `None` to fall back to
93    /// `"Series N"`.
94    pub label: Option<String>,
95}
96
97impl ScatterMark {
98    /// Set the marker colour.
99    pub fn color(mut self, color: Color) -> Self {
100        self.color = Some(color);
101        self
102    }
103
104    /// Set the marker size (screen pixels).
105    pub fn size(mut self, size: f32) -> Self {
106        self.size = size;
107        self
108    }
109
110    /// Set the marker shape.
111    pub fn shape(mut self, shape: PointShape) -> Self {
112        self.shape = shape;
113        self
114    }
115
116    /// Set the series' display name (legend + cursor readout).
117    pub fn label(mut self, label: impl Into<String>) -> Self {
118        self.label = Some(label.into());
119        self
120    }
121}
122
123/// One mark in a plot. Construct with the [`line`]/[`scatter`] builders or
124/// the [`PlotSpec`] adders.
125#[derive(Clone, Debug)]
126pub enum Mark {
127    /// A connected line series.
128    Line(LineMark),
129    /// A discrete-marker scatter series.
130    Scatter(ScatterMark),
131}
132
133impl Mark {
134    /// The series this mark reads from.
135    pub fn series(&self) -> &SeriesHandle {
136        match self {
137            Mark::Line(m) => &m.series,
138            Mark::Scatter(m) => &m.series,
139        }
140    }
141
142    /// The mark's explicit colour, if set.
143    pub fn explicit_color(&self) -> Option<Color> {
144        match self {
145            Mark::Line(m) => m.color,
146            Mark::Scatter(m) => m.color,
147        }
148    }
149
150    /// The mark's resolved colour: its explicit colour, or the palette colour
151    /// for series index `i`. Shared by the data layer, legend, and cursor so
152    /// a series is one colour everywhere.
153    pub fn color_at(&self, i: usize) -> Color {
154        self.explicit_color()
155            .unwrap_or_else(|| crate::plot::palette::series_color(i))
156    }
157
158    /// The mark's display name: its explicit label, or `"Series {i+1}"`.
159    pub fn display_label(&self, i: usize) -> String {
160        let explicit = match self {
161            Mark::Line(m) => m.label.as_deref(),
162            Mark::Scatter(m) => m.label.as_deref(),
163        };
164        explicit
165            .map(str::to_owned)
166            .unwrap_or_else(|| format!("Series {}", i + 1))
167    }
168}
169
170impl From<LineMark> for Mark {
171    fn from(m: LineMark) -> Self {
172        Mark::Line(m)
173    }
174}
175
176impl From<ScatterMark> for Mark {
177    fn from(m: ScatterMark) -> Self {
178        Mark::Scatter(m)
179    }
180}
181
182/// Build a default-styled [`LineMark`] from a series — the standalone
183/// escape-hatch form (chain `.color(...)` / `.width(...)`, then add with
184/// [`PlotSpec::add_mark`]).
185pub fn line(series: &SeriesHandle) -> LineMark {
186    LineMark {
187        series: series.clone(),
188        color: None,
189        width: DEFAULT_LINE_WIDTH,
190        label: None,
191    }
192}
193
194/// Build a default-styled [`ScatterMark`] from a series — the standalone
195/// escape-hatch form.
196pub fn scatter(series: &SeriesHandle) -> ScatterMark {
197    ScatterMark {
198        series: series.clone(),
199        color: None,
200        size: DEFAULT_MARKER_SIZE,
201        shape: PointShape::Circle,
202        label: None,
203    }
204}
205
206/// Where a plot's legend sits — a corner of the data rect. Set via
207/// [`PlotSpec::legend`]; the spec's `legend` is `None` (no legend) by default.
208#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
209pub enum LegendPosition {
210    /// Top-right corner — the default when a legend is enabled.
211    #[default]
212    TopRight,
213    /// Top-left corner.
214    TopLeft,
215    /// Bottom-right corner.
216    BottomRight,
217    /// Bottom-left corner.
218    BottomLeft,
219}
220
221/// One axis's configuration: its [`Scale`] and an optional title.
222#[derive(Clone, Debug)]
223pub struct Axis {
224    /// The data→scale-space warp + tick policy for this axis.
225    pub scale: Scale,
226    /// An axis title drawn beside the ticks, or `None`.
227    pub title: Option<String>,
228}
229
230impl Axis {
231    /// An axis with the given scale and no title.
232    pub fn new(scale: Scale) -> Self {
233        Self { scale, title: None }
234    }
235
236    /// Set the axis title.
237    pub fn title(mut self, title: impl Into<String>) -> Self {
238        self.title = Some(title.into());
239        self
240    }
241}
242
243impl Default for Axis {
244    fn default() -> Self {
245        Self::new(Scale::linear())
246    }
247}
248
249/// Plot-level styling: background, gridlines, and MSAA. Maps to the reused
250/// scene's [`SceneStyle`](crate::scene::SceneStyle) at resolve time (the
251/// plot draws its own axis/grid chrome, so the scene's own grid/axes are
252/// left off).
253#[derive(Clone, Copy, Debug, PartialEq)]
254pub struct PlotStyle {
255    /// Background fill of the data rect, or `None` for transparent.
256    pub background: Option<Color>,
257    /// Whether to draw gridlines at the axis ticks.
258    pub grid: bool,
259    /// MSAA sample count for the offscreen data layer (`1` or `4`).
260    pub msaa_samples: u32,
261}
262
263impl Default for PlotStyle {
264    fn default() -> Self {
265        Self {
266            background: None,
267            grid: true,
268            msaa_samples: 4,
269        }
270    }
271}
272
273/// The full declarative description of a plot: marks, per-axis scales, view
274/// behaviour, and styling. Assemble fluently and pass to
275/// [`plot`](crate::tree::plot):
276///
277/// ```ignore
278/// plot(
279///     PlotSpec::new()
280///         .x(Scale::time())
281///         .line(&cpu)
282///         .line(&mem)
283///         .crosshair(true),
284/// )
285/// .key("metrics")
286/// ```
287#[derive(Clone, Debug)]
288pub struct PlotSpec {
289    /// The marks, drawn in order.
290    pub marks: Vec<Mark>,
291    /// Horizontal-axis configuration.
292    pub x: Axis,
293    /// Vertical-axis configuration.
294    pub y: Axis,
295    /// Plot-level styling.
296    pub style: PlotStyle,
297    /// Whether to show a crosshair reading out the nearest sample.
298    pub crosshair: bool,
299    /// Whether the vertical axis auto-fits to the data visible in the
300    /// current horizontal window each frame (vs. a fixed Y window).
301    pub y_autoscale: bool,
302    /// Whether the horizontal axis auto-fits to the full data extent each
303    /// frame (vs. sticking at the window it was first seeded with). On by
304    /// default so a plot whose series grow — a streaming dashboard fed by
305    /// [`SeriesHandle::append`](crate::plot::SeriesHandle::append) — keeps
306    /// the new samples in view. Any manual X gesture (pan, wheel zoom, X
307    /// box-zoom) or [`crate::state::UiState::set_plot_view`] takes manual
308    /// control and stops the tracking until a double-click reset or a
309    /// [`PlotRequest::FitAll`](crate::plot::PlotRequest::FitAll). With
310    /// static data the re-fit resolves to the same window every frame, so
311    /// this is invisible for non-streaming plots.
312    pub x_autoscale: bool,
313    /// Pointer control scheme — what the primary drag does (see
314    /// [`PlotControls`]).
315    pub controls: PlotControls,
316    /// Legend placement, or `None` for no legend.
317    pub legend: Option<LegendPosition>,
318    /// Library-side down-sampling for over-dense line series (the
319    /// dump-everything path). `None` draws every sample; `Some` reduces each
320    /// line to the pixel budget over the visible window before upload. A
321    /// virtual app that resamples its own source leaves this `None`.
322    pub downsample: Option<crate::plot::Decimation>,
323}
324
325impl Default for PlotSpec {
326    fn default() -> Self {
327        Self {
328            marks: Vec::new(),
329            x: Axis::default(),
330            y: Axis::default(),
331            style: PlotStyle::default(),
332            crosshair: false,
333            y_autoscale: true,
334            x_autoscale: true,
335            controls: PlotControls::default(),
336            legend: None,
337            downsample: None,
338        }
339    }
340}
341
342impl PlotSpec {
343    /// An empty spec with default axes and styling.
344    pub fn new() -> Self {
345        Self::default()
346    }
347
348    /// Add a default-styled line mark for `series`.
349    pub fn line(mut self, series: &SeriesHandle) -> Self {
350        self.marks.push(Mark::Line(line(series)));
351        self
352    }
353
354    /// Add a default-styled scatter mark for `series`.
355    pub fn scatter(mut self, series: &SeriesHandle) -> Self {
356        self.marks.push(Mark::Scatter(scatter(series)));
357        self
358    }
359
360    /// Add a fully-built mark (the escape hatch for styled marks, e.g.
361    /// `spec.add_mark(line(&h).color(c).width(2.0))`).
362    pub fn add_mark(mut self, mark: impl Into<Mark>) -> Self {
363        self.marks.push(mark.into());
364        self
365    }
366
367    /// Set the horizontal-axis [`Scale`] (keeps any existing title).
368    pub fn x(mut self, scale: Scale) -> Self {
369        self.x.scale = scale;
370        self
371    }
372
373    /// Set the vertical-axis [`Scale`] (keeps any existing title).
374    pub fn y(mut self, scale: Scale) -> Self {
375        self.y.scale = scale;
376        self
377    }
378
379    /// Replace the whole horizontal-axis configuration.
380    pub fn x_axis(mut self, axis: Axis) -> Self {
381        self.x = axis;
382        self
383    }
384
385    /// Replace the whole vertical-axis configuration.
386    pub fn y_axis(mut self, axis: Axis) -> Self {
387        self.y = axis;
388        self
389    }
390
391    /// Set whether to draw the crosshair / nearest-sample readout.
392    pub fn crosshair(mut self, on: bool) -> Self {
393        self.crosshair = on;
394        self
395    }
396
397    /// Set whether the vertical axis auto-scales to the visible window.
398    pub fn y_autoscale(mut self, on: bool) -> Self {
399        self.y_autoscale = on;
400        self
401    }
402
403    /// Set whether the horizontal axis auto-scales to the full data extent
404    /// each frame (see [`Self::x_autoscale`]; on by default). Turn this off
405    /// for a plot that should hold a fixed time window regardless of what
406    /// the series contain.
407    pub fn x_autoscale(mut self, on: bool) -> Self {
408        self.x_autoscale = on;
409        self
410    }
411
412    /// Set the pointer control scheme (what the primary drag does). Defaults
413    /// to [`PlotControls::ZoomDrag`].
414    pub fn controls(mut self, controls: PlotControls) -> Self {
415        self.controls = controls;
416        self
417    }
418
419    /// Show a legend at `position`. Series are labelled by their
420    /// [`label`](LineMark::label), falling back to `"Series N"`.
421    pub fn legend(mut self, position: LegendPosition) -> Self {
422        self.legend = Some(position);
423        self
424    }
425
426    /// Set whether gridlines are drawn.
427    pub fn grid(mut self, on: bool) -> Self {
428        self.style.grid = on;
429        self
430    }
431
432    /// Enable library-side down-sampling of line series to the pixel budget
433    /// (the dump-everything path). Leave unset for the virtual-data flow.
434    pub fn downsample(mut self, decimation: crate::plot::Decimation) -> Self {
435        self.downsample = Some(decimation);
436        self
437    }
438
439    /// Set the data-rect background fill.
440    pub fn background(mut self, color: Color) -> Self {
441        self.style.background = Some(color);
442        self
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn handle() -> SeriesHandle {
451        SeriesHandle::new(vec![crate::plot::series::Sample::new(0.0, 0.0)])
452    }
453
454    #[test]
455    fn builder_collects_marks_and_axes() {
456        let h = handle();
457        let spec = PlotSpec::new()
458            .x(Scale::time())
459            .y(Scale::linear())
460            .line(&h)
461            .scatter(&h)
462            .crosshair(true);
463        assert_eq!(spec.marks.len(), 2);
464        assert!(matches!(spec.marks[0], Mark::Line(_)));
465        assert!(matches!(spec.marks[1], Mark::Scatter(_)));
466        assert_eq!(spec.x.scale, Scale::time());
467        assert!(spec.crosshair);
468        assert!(spec.y_autoscale); // default
469    }
470
471    #[test]
472    fn styled_marks_via_escape_hatch() {
473        let h = handle();
474        let red = Color::srgb_u8(220, 40, 40);
475        let spec = PlotSpec::new().add_mark(line(&h).color(red).width(3.0));
476        match &spec.marks[0] {
477            Mark::Line(m) => {
478                assert_eq!(m.color, Some(red));
479                assert_eq!(m.width, 3.0);
480            }
481            _ => panic!("expected a line mark"),
482        }
483    }
484
485    #[test]
486    fn default_line_is_auto_colored() {
487        let h = handle();
488        let m = line(&h);
489        assert_eq!(m.color, None);
490        assert_eq!(m.width, DEFAULT_LINE_WIDTH);
491    }
492}