Skip to main content

model/
decoration.rs

1//! Editable decoration positions for bounded charts. The same `HitMap`,
2//! `Selectable`, `Draggable` and `SelectionBox` policy as Cartesian charts applies.
3use crate::{
4    drag::{DragConfig, Draggable},
5    layout::{NudgeReject, NudgeResult, RectF, Side},
6    select::Selectable,
7    text::MeasureText,
8};
9
10/// Stable identity within a chart. Category/series IDs survive reordering;
11/// radial indices refer to the original arrays, including zero-valued slices.
12#[derive(Clone, Debug, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum DecorationId {
15    /// The plot layout region; registered behind labels and axes.
16    DataArea,
17    /// Mutation target used by the common resize policy, not a separate hit.
18    DataAreaEdge(Side),
19    ChartTitle,
20    Axis(Side),
21    AxisLabel(Side),
22    AxisTitle(Side),
23    Legend,
24    RadialLabel(crate::radial::RadialTarget),
25    DataLabel {
26        category_id: String,
27        series_id: String,
28    },
29}
30impl DecorationId {
31    pub fn element_id(&self) -> String {
32        let side = |s: &Side| match s {
33            Side::Top => "top",
34            Side::Bottom => "bottom",
35            Side::Left => "left",
36            Side::Right => "right",
37        };
38        match self {
39            Self::DataArea => "data_area".into(),
40            Self::DataAreaEdge(s) => format!("data_area_edge_{}", side(s)),
41            Self::ChartTitle => "chart_title".into(),
42            Self::Axis(s) => format!("axis_{}", side(s)),
43            Self::AxisLabel(s) => format!("tick_labels_{}", side(s)),
44            Self::AxisTitle(s) => format!("axis_title_{}", side(s)),
45            Self::Legend => "legend".into(),
46            Self::RadialLabel(t) => format!("radial_label_{}_{}", t.group, t.index),
47            Self::DataLabel {
48                category_id,
49                series_id,
50            } => format!(
51                "data_label_{}:{}{}:{}",
52                category_id.len(),
53                category_id,
54                series_id.len(),
55                series_id
56            ),
57        }
58    }
59}
60#[derive(Clone, Debug, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct DecorationOffset {
63    pub element: DecorationId,
64    /// Chart-local logical pixels, independent of DPR/export scale.
65    pub offset: [f32; 2],
66}
67#[derive(Clone, Debug, Default, PartialEq)]
68#[cfg_attr(
69    feature = "serde",
70    derive(serde::Serialize, serde::Deserialize),
71    serde(default)
72)]
73pub struct DecorationLayout {
74    /// Explicit plot region in logical pixels. None uses the measured automatic
75    /// region; a chosen region must fit inside it to retain space for labels.
76    pub data_area: Option<RectF>,
77    pub offsets: Vec<DecorationOffset>,
78    /// Decoration selection is separate from data selection and hover.
79    pub selected: Option<DecorationId>,
80}
81impl DecorationLayout {
82    pub fn offset(&self, id: &DecorationId) -> [f32; 2] {
83        self.offsets
84            .iter()
85            .find(|o| &o.element == id)
86            .map_or([0.; 2], |o| o.offset)
87    }
88    /// Axis labels follow detached axes, plus their own freely editable offset.
89    pub fn effective_offset(&self, id: &DecorationId) -> [f32; 2] {
90        let mut p = self.offset(id);
91        if let DecorationId::AxisLabel(side) = id {
92            let q = self.offset(&DecorationId::Axis(side.clone()));
93            p[0] += q[0];
94            p[1] += q[1];
95        }
96        p
97    }
98    pub fn reset(&mut self, id: &DecorationId) {
99        if *id == DecorationId::DataArea {
100            self.data_area = None;
101        }
102        self.offsets.retain(|o| &o.element != id);
103    }
104    pub fn resolve_data_area(&self, automatic: RectF) -> Result<RectF, &'static str> {
105        let r = self.data_area.unwrap_or(automatic);
106        if ![r.x, r.y, r.width, r.height, r.x + r.width, r.y + r.height]
107            .into_iter()
108            .all(f32::is_finite)
109            || r.x < 0.
110            || r.y < 0.
111            || r.width < 80.
112            || r.height < 60.
113            || r.x < automatic.x
114            || r.y < automatic.y
115            || r.x + r.width > automatic.x + automatic.width
116            || r.y + r.height > automatic.y + automatic.height
117        {
118            return Err(
119                "data area must fit the measured plot region and be at least 80 by 60 logical pixels",
120            );
121        }
122        Ok(r)
123    }
124    pub fn validate(&self) -> Result<(), &'static str> {
125        if let Some(r) = self.data_area {
126            self.resolve_data_area(r)?;
127        }
128        if self.offsets.len() > 1024 {
129            return Err("too many decoration offsets");
130        }
131        for (i, o) in self.offsets.iter().enumerate() {
132            if o.offset.iter().any(|x| !x.is_finite())
133                || self.offsets[..i].iter().any(|p| p.element == o.element)
134                || matches!(
135                    o.element,
136                    DecorationId::DataArea | DecorationId::DataAreaEdge(_)
137                )
138            {
139                return Err("decoration offsets must be finite and unique");
140            }
141            if let DecorationId::Axis(s) = &o.element {
142                let parallel = match s {
143                    Side::Top | Side::Bottom => o.offset[0],
144                    _ => o.offset[1],
145                };
146                if parallel != 0.0 {
147                    return Err("axes only move perpendicular to their direction");
148                }
149            }
150        }
151        Ok(())
152    }
153}
154/// Renderer-measured base bounds, before user offsets. A fresh frame supplies
155/// these after resizing/reflow; hosts never reconstruct text or axis geometry.
156#[derive(Clone, Debug)]
157pub struct DecorationElement {
158    pub id: DecorationId,
159    pub base_bounds: RectF,
160    pub canvas_size: (u32, u32),
161    /// Parts that move together with this target (e.g. a detached axis's labels).
162    pub followers: Vec<(DecorationId, RectF)>,
163}
164impl Selectable<DecorationLayout> for DecorationElement {
165    fn bounds(&self, cfg: &DecorationLayout, _: &dyn MeasureText) -> Option<RectF> {
166        match self.id {
167            DecorationId::DataArea => return cfg.resolve_data_area(self.base_bounds).ok(),
168            DecorationId::DataAreaEdge(_) => return None,
169            _ => {}
170        }
171        let p = cfg.effective_offset(&self.id);
172        Some(self.base_bounds.translated(p[0], p[1]))
173    }
174    fn as_draggable(&self) -> Option<&dyn Draggable<DecorationLayout>> {
175        Some(self)
176    }
177    fn as_resizable(&self) -> Option<&dyn crate::resize::Resizable<DecorationLayout>> {
178        (self.id == DecorationId::DataArea).then_some(self)
179    }
180    fn element_id(&self) -> String {
181        self.id.element_id()
182    }
183}
184impl Draggable<DecorationLayout> for DecorationElement {
185    fn nudge_element(&self) -> DecorationElement {
186        self.clone()
187    }
188}
189impl crate::resize::Resizable<DecorationLayout> for DecorationElement {
190    fn resize_targets(&self, handle: crate::resize::ResizeHandle) -> (Option<Self>, Option<Self>) {
191        use crate::resize::ResizeHandle::*;
192        let edge = |side| {
193            let mut e = self.clone();
194            e.id = DecorationId::DataAreaEdge(side);
195            e
196        };
197        let x = match handle {
198            NW | W | SW => Some(edge(Side::Left)),
199            NE | E | SE => Some(edge(Side::Right)),
200            N | S => None,
201        };
202        let y = match handle {
203            NW | N | NE => Some(edge(Side::Top)),
204            SW | S | SE => Some(edge(Side::Bottom)),
205            E | W => None,
206        };
207        (x, y)
208    }
209    fn resize_by(
210        &self,
211        cfg: &mut DecorationLayout,
212        handle: crate::resize::ResizeHandle,
213        dx: f32,
214        dy: f32,
215    ) -> NudgeResult {
216        let reject = NudgeResult::Rejected(NudgeReject::OutOfBounds);
217        if self.id != DecorationId::DataArea || !dx.is_finite() || !dy.is_finite() {
218            return reject;
219        }
220        let (x, y) = self.resize_targets(handle);
221        let mut trial = cfg.clone();
222        for (element, dx, dy) in [(x, dx, 0.), (y, 0., dy)] {
223            if let Some(element) = element {
224                if trial.nudge(element, dx, dy) != NudgeResult::Moved {
225                    return reject;
226                }
227            }
228        }
229        *cfg = trial;
230        NudgeResult::Moved
231    }
232}
233impl DragConfig for DecorationLayout {
234    type Element = DecorationElement;
235    fn nudge(&mut self, element: DecorationElement, mut dx: f32, mut dy: f32) -> NudgeResult {
236        let reject = NudgeResult::Rejected(NudgeReject::OutOfBounds);
237        if !dx.is_finite() || !dy.is_finite() {
238            return reject;
239        }
240        if matches!(
241            element.id,
242            DecorationId::DataArea | DecorationId::DataAreaEdge(_)
243        ) {
244            let Ok(mut r) = self.resolve_data_area(element.base_bounds) else {
245                return reject;
246            };
247            match element.id {
248                DecorationId::DataArea => {
249                    r.x += dx;
250                    r.y += dy;
251                }
252                DecorationId::DataAreaEdge(Side::Left) => {
253                    r.x += dx;
254                    r.width -= dx;
255                }
256                DecorationId::DataAreaEdge(Side::Right) => r.width += dx,
257                DecorationId::DataAreaEdge(Side::Top) => {
258                    r.y += dy;
259                    r.height -= dy;
260                }
261                DecorationId::DataAreaEdge(Side::Bottom) => r.height += dy,
262                _ => unreachable!(),
263            }
264            let mut trial = self.clone();
265            trial.data_area = Some(r);
266            if trial.resolve_data_area(element.base_bounds).is_err() {
267                return reject;
268            }
269            self.data_area = Some(r);
270            return NudgeResult::Moved;
271        }
272        if let DecorationId::Axis(side) = &element.id {
273            match side {
274                Side::Top | Side::Bottom => dx = 0.,
275                _ => dy = 0.,
276            }
277        }
278        let inside = |rect: RectF, offset: [f32; 2]| {
279            let r = rect.translated(offset[0] + dx, offset[1] + dy);
280            r.x >= 0.
281                && r.y >= 0.
282                && r.x + r.width <= element.canvas_size.0 as f32
283                && r.y + r.height <= element.canvas_size.1 as f32
284        };
285        if !inside(element.base_bounds, self.effective_offset(&element.id))
286            || element
287                .followers
288                .iter()
289                .any(|(id, rect)| !inside(*rect, self.effective_offset(id)))
290        {
291            return reject;
292        }
293        let old = self.offset(&element.id);
294        let next = [old[0] + dx, old[1] + dy];
295        self.reset(&element.id);
296        if next != [0., 0.] {
297            self.offsets.push(DecorationOffset {
298                element: element.id,
299                offset: next,
300            });
301        }
302        NudgeResult::Moved
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    #[test]
310    fn area_handles_move_only_their_edges_and_reject_atomically() {
311        use crate::resize::{Resizable, ResizeHandle::*};
312        let e = DecorationElement {
313            id: DecorationId::DataArea,
314            base_bounds: RectF {
315                x: 20.,
316                y: 20.,
317                width: 240.,
318                height: 160.,
319            },
320            canvas_size: (300, 200),
321            followers: vec![],
322        };
323        let original = RectF {
324            x: 50.,
325            y: 50.,
326            width: 150.,
327            height: 90.,
328        };
329        for (handle, left, right, top, bottom) in [
330            (NW, true, false, true, false),
331            (N, false, false, true, false),
332            (NE, false, true, true, false),
333            (E, false, true, false, false),
334            (SE, false, true, false, true),
335            (S, false, false, false, true),
336            (SW, true, false, false, true),
337            (W, true, false, false, false),
338        ] {
339            let mut state = DecorationLayout {
340                data_area: Some(original),
341                ..Default::default()
342            };
343            assert_eq!(e.resize_by(&mut state, handle, 7., 9.), NudgeResult::Moved);
344            let r = state.data_area.unwrap();
345            assert_eq!(r.x, original.x + if left { 7. } else { 0. });
346            assert_eq!(r.y, original.y + if top { 9. } else { 0. });
347            assert_eq!(
348                r.x + r.width,
349                original.x + original.width + if right { 7. } else { 0. }
350            );
351            assert_eq!(
352                r.y + r.height,
353                original.y + original.height + if bottom { 9. } else { 0. }
354            );
355        }
356        let mut state = DecorationLayout {
357            data_area: Some(original),
358            ..Default::default()
359        };
360        let before = state.clone();
361        for (dx, dy) in [
362            (7., -1000.),
363            (f32::NAN, 0.),
364            (0., f32::INFINITY),
365            (100., 0.),
366        ] {
367            assert!(matches!(
368                e.resize_by(&mut state, NW, dx, dy),
369                NudgeResult::Rejected(_)
370            ));
371            assert_eq!(state, before);
372        }
373        state.reset(&DecorationId::DataArea);
374        assert_eq!(
375            state.resolve_data_area(e.base_bounds).unwrap(),
376            e.base_bounds
377        );
378    }
379    fn element(id: DecorationId) -> DecorationElement {
380        DecorationElement {
381            id,
382            base_bounds: RectF {
383                x: 20.,
384                y: 20.,
385                width: 100.,
386                height: 20.,
387            },
388            canvas_size: (300, 200),
389            followers: vec![],
390        }
391    }
392    #[test]
393    fn cartesian_hit_map_constructors_keep_type_inference_without_annotations() {
394        let empty = crate::HitMap::new();
395        let default = crate::HitMap::default();
396        assert!(empty.is_empty() && default.is_empty());
397    }
398    #[test]
399    fn drag_rejects_nonfinite_outside_and_axis_follower_overflow_atomically() {
400        let mut state = DecorationLayout::default();
401        let mut axis = element(DecorationId::Axis(Side::Left));
402        axis.followers.push((
403            DecorationId::AxisLabel(Side::Left),
404            RectF {
405                x: 1.,
406                y: 20.,
407                width: 20.,
408                height: 30.,
409            },
410        ));
411        for delta in [[f32::NAN, 0.], [0., f32::INFINITY], [-2., 0.], [1000., 0.]] {
412            assert!(matches!(
413                axis.drag_by(&mut state, delta[0], delta[1]),
414                NudgeResult::Rejected(_)
415            ));
416            assert_eq!(state, DecorationLayout::default());
417        }
418        assert_eq!(axis.drag_by(&mut state, 5., 80.), NudgeResult::Moved);
419        assert_eq!(state.offset(&axis.id), [5., 0.]);
420        assert_eq!(
421            state.effective_offset(&DecorationId::AxisLabel(Side::Left)),
422            [5., 0.]
423        );
424    }
425    #[test]
426    fn duplicate_nonfinite_and_parallel_axis_offsets_are_rejected() {
427        let mut state = DecorationLayout {
428            data_area: None,
429            offsets: vec![DecorationOffset {
430                element: DecorationId::Legend,
431                offset: [f32::NAN, 0.],
432            }],
433            selected: None,
434        };
435        assert!(state.validate().is_err());
436        state.offsets[0].offset = [1., 2.];
437        state.offsets.push(state.offsets[0].clone());
438        assert!(state.validate().is_err());
439        state.offsets.pop();
440        state.offsets[0].element = DecorationId::Axis(Side::Left);
441        assert!(state.validate().is_err());
442    }
443    #[cfg(feature = "serde")]
444    #[test]
445    fn old_documents_default_positions_and_all_models_preserve_edits() {
446        for mut value in [
447            serde_json::to_value(crate::RadialChart::default()).unwrap(),
448            serde_json::to_value(crate::CategoricalChart::default()).unwrap(),
449            serde_json::to_value(crate::BoxPlotChart::default()).unwrap(),
450        ] {
451            value.as_object_mut().unwrap().remove("decorations");
452            assert!(value.get("decorations").is_none());
453            // Each old chart type fills only the newly added state with defaults.
454            if value.get("slices").is_some() {
455                assert_eq!(
456                    serde_json::from_value::<crate::RadialChart>(value)
457                        .unwrap()
458                        .decorations,
459                    DecorationLayout::default()
460                );
461            } else if value.get("mode").is_some() {
462                assert_eq!(
463                    serde_json::from_value::<crate::CategoricalChart>(value)
464                        .unwrap()
465                        .decorations,
466                    DecorationLayout::default()
467                );
468            } else {
469                assert_eq!(
470                    serde_json::from_value::<crate::BoxPlotChart>(value)
471                        .unwrap()
472                        .decorations,
473                    DecorationLayout::default()
474                );
475            }
476        }
477        let mut state = DecorationLayout::default();
478        element(DecorationId::ChartTitle).drag_by(&mut state, 4., 6.);
479        state.selected = Some(DecorationId::ChartTitle);
480        let mut radial = crate::RadialChart::default();
481        radial.decorations = state.clone();
482        let mut categorical = crate::CategoricalChart::default();
483        categorical.decorations = state.clone();
484        let mut boxplot = crate::BoxPlotChart::default();
485        boxplot.decorations = state;
486        assert_eq!(
487            serde_json::from_str::<crate::RadialChart>(&serde_json::to_string(&radial).unwrap())
488                .unwrap(),
489            radial
490        );
491        assert_eq!(
492            serde_json::from_str::<crate::CategoricalChart>(
493                &serde_json::to_string(&categorical).unwrap()
494            )
495            .unwrap(),
496            categorical
497        );
498        assert_eq!(
499            serde_json::from_str::<crate::BoxPlotChart>(&serde_json::to_string(&boxplot).unwrap())
500                .unwrap(),
501            boxplot
502        );
503    }
504}