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}