Skip to main content

model/
radial.rs

1//! Small categorical charts. Values and appearance are independent of Cartesian
2//! axes and column streams. The renderer consumes a complete validated snapshot.
3use crate::Color;
4
5pub const MAX_RADIAL_SLICES: usize = 64;
6
7/// One category. Construct with [`RadialSlice::new`]; optional fields inherit
8/// chart settings. Zero-valued slices keep their index but have no visible surface.
9#[derive(Clone, Debug, PartialEq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct RadialSlice {
12    pub label: String,
13    pub value: f64,
14    pub color: Color,
15    /// Offset along the slice bisector, as a fraction of the outer radius.
16    pub explode: f32,
17    /// None inherits the chart style. Some replaces the entire style, including
18    /// camera tilt and depth; later chart-style edits do not propagate here.
19    #[cfg_attr(feature = "serde", serde(default))]
20    pub style: Option<RadialStyle>,
21    /// None inherits placement; Some(RadialLabels::None) hides this label.
22    #[cfg_attr(feature = "serde", serde(default))]
23    pub labels: Option<RadialLabels>,
24    /// None inherits the chart's label content format.
25    #[cfg_attr(feature = "serde", serde(default))]
26    pub label_format: Option<RadialLabelFormat>,
27    /// Explicit color overrides automatic inside-label contrast selection.
28    #[cfg_attr(feature = "serde", serde(default))]
29    pub label_color: Option<Color>,
30}
31impl RadialSlice {
32    pub fn new(label: impl Into<String>, value: f64, color: Color) -> Self {
33        Self {
34            label: label.into(),
35            value,
36            color,
37            explode: 0.0,
38            style: None,
39            labels: None,
40            label_format: None,
41            label_color: None,
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum RadialKind {
49    Pie,
50    Donut { inner_radius: f32 },
51}
52impl RadialKind {
53    pub fn inner_radius(self) -> f32 {
54        match self {
55            Self::Pie => 0.0,
56            Self::Donut { inner_radius } => inner_radius,
57        }
58    }
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub enum RadialMaterial {
64    Flat,
65    Matte,
66    Ceramic,
67    BrushedMetal,
68    Paper,
69    Wood,
70    SatinMetal,
71    Toon,
72    Enamel,
73    Hatch,
74    Pearl,
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79pub enum RadialLabels {
80    None,
81    Inside,
82    Outside,
83}
84
85/// Label content is independent of placement and can be overridden per slice.
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
87#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
88pub enum RadialLabelFormat {
89    Name,
90    Value,
91    Percent,
92    #[default]
93    NamePercent,
94    NameValue,
95    ValuePercent,
96    NameValuePercent,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct RadialOutline {
102    pub rim: bool,
103    pub separators: bool,
104    pub emphasis: bool,
105    /// Logical pixels, independent of DPR/export scale.
106    pub width: f32,
107    pub color: Color,
108}
109impl Default for RadialOutline {
110    fn default() -> Self {
111        Self {
112            rim: false,
113            separators: false,
114            emphasis: false,
115            width: 1.0,
116            color: Color::new(0.08, 0.12, 0.2, 1.0),
117        }
118    }
119}
120
121/// Index in the original array (including zero-valued slices). group 1 is detail.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub struct RadialTarget {
125    pub group: usize,
126    pub index: usize,
127}
128impl RadialTarget {
129    pub fn main(index: usize) -> Self {
130        Self { group: 0, index }
131    }
132    pub fn detail(index: usize) -> Self {
133        Self { group: 1, index }
134    }
135}
136
137#[derive(Clone, Debug, PartialEq)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139pub struct RadialInteraction {
140    /// Current animation target; the host advances it with [`Self::animate_hover`].
141    pub hovered: Option<RadialTarget>,
142    /// Persistent selection, independent of the pointer. None clears selection.
143    pub selected: Option<RadialTarget>,
144    /// Linear progress in 0..1. Rendering applies smoothstep via [`Self::amount`].
145    pub hover_progress: f32,
146}
147impl Default for RadialInteraction {
148    fn default() -> Self {
149        Self {
150            hovered: None,
151            selected: None,
152            hover_progress: 0.0,
153        }
154    }
155}
156impl RadialInteraction {
157    /// Host supplies elapsed seconds; no wall clock, browser, or permanent animation loop.
158    /// Exit completes before a different slice enters. Returns whether state changed.
159    /// A zero duration switches immediately. Negative/nonfinite time inputs are ignored.
160    /// The host must keep requesting frames until the transition settles.
161    pub fn animate_hover(&mut self, target: Option<RadialTarget>, dt: f32, duration: f32) -> bool {
162        if !dt.is_finite() || dt < 0.0 || !duration.is_finite() || duration < 0.0 {
163            return false;
164        }
165        let old = self.clone();
166        if duration == 0.0 {
167            self.hovered = target;
168            self.hover_progress = if target.is_some() { 1.0 } else { 0.0 };
169        } else {
170            let mut step = dt / duration;
171            if self.hovered != target {
172                let used = step.min(self.hover_progress);
173                self.hover_progress -= used;
174                step -= used;
175                if self.hover_progress <= 0.0 {
176                    self.hovered = target;
177                }
178            }
179            if self.hovered == target && target.is_some() {
180                self.hover_progress = (self.hover_progress + step).min(1.0);
181            }
182        }
183        *self != old
184    }
185    pub fn amount(&self, target: RadialTarget) -> f32 {
186        if self.selected == Some(target) {
187            1.0
188        } else if self.hovered == Some(target) {
189            let t = self.hover_progress;
190            t * t * (3.0 - 2.0 * t)
191        } else {
192            0.0
193        }
194    }
195}
196
197/// Complete style, not a sparse override. Length ratios use the outer radius.
198/// Defaults are flat/top-down; setting a material does not enable extrusion.
199#[derive(Clone, Debug, PartialEq)]
200#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
201#[cfg_attr(feature = "serde", serde(default))]
202pub struct RadialStyle {
203    pub material: RadialMaterial,
204    /// Orthographic camera tilt, 0..65 degrees. Zero is an undistorted top view.
205    pub tilt_degrees: f32,
206    /// Extrusion height / radius, 0..0.4. Does not change slice proportions.
207    pub depth: f32,
208    /// Geometric extrusion bevel / outer radius, clamped to half the depth.
209    pub bevel: f32,
210    /// Visual angular separation, 0..8 degrees, capped at 20% of each slice.
211    pub gap_degrees: f32,
212    /// Highlight spread, 0.05..1; material-dependent.
213    pub roughness: f32,
214    /// Procedural pattern intensity, 0..1. Zero disables the pattern.
215    pub texture_strength: f32,
216    /// Pattern frequency multiplier, 0.1..8. Larger values give finer grain;
217    /// independent of export resolution.
218    pub texture_scale: f32,
219    pub light: [f32; 3],
220    pub shadow: bool,
221    /// Plan-view corner radii / outer radius; individually clamped for narrow slices.
222    pub inner_corner: f32,
223    pub outer_corner: f32,
224    pub outline: RadialOutline,
225    /// Vertical translation / outer radius at full hover or selection. Zero disables lift.
226    pub hover_lift: f32,
227    /// Added brightness at full hover/selection, 0..0.5.
228    pub hover_brightness: f32,
229    /// Highlight intensity, 0..1; material-dependent.
230    pub gloss: f32,
231    /// Finite pattern rotation in degrees, reduced modulo 360 by the renderer.
232    pub texture_angle_degrees: f32,
233}
234impl RadialStyle {
235    /// Oblique 3D view used by the native editor. Explicit preset so existing
236    /// flat charts and serialized styles do not silently change appearance.
237    pub fn three_dimensional() -> Self {
238        Self {
239            material: RadialMaterial::Ceramic,
240            depth: 0.16,
241            tilt_degrees: 55.0,
242            inner_corner: 0.045,
243            outer_corner: 0.045,
244            bevel: 0.025,
245            gap_degrees: 1.4,
246            shadow: true,
247            ..Self::default()
248        }
249    }
250}
251
252impl Default for RadialStyle {
253    fn default() -> Self {
254        Self {
255            material: RadialMaterial::Flat,
256            tilt_degrees: 0.0,
257            depth: 0.0,
258            bevel: 0.015,
259            gap_degrees: 0.0,
260            roughness: 0.35,
261            texture_strength: 0.2,
262            texture_scale: 1.0,
263            light: [-0.5, -0.6, 1.0],
264            shadow: false,
265            inner_corner: 0.0,
266            outer_corner: 0.0,
267            outline: RadialOutline::default(),
268            hover_lift: 0.08,
269            hover_brightness: 0.04,
270            gloss: 0.55,
271            texture_angle_degrees: 0.0,
272        }
273    }
274}
275
276#[derive(Clone, Debug, PartialEq)]
277#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
278pub struct RadialSplit {
279    pub slice_index: usize,
280    /// Same units as the parent. Their sum must equal the parent's value.
281    /// Labels on the detail chart show percentages of this subtotal.
282    pub children: Vec<RadialSlice>,
283    pub kind: RadialKind,
284}
285
286#[derive(Clone, Debug, PartialEq)]
287#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
288pub struct RadialChart {
289    /// Editable decoration positions and selection, in logical pixels.
290    #[cfg_attr(feature = "serde", serde(default))]
291    pub decorations: crate::DecorationLayout,
292    pub title: String,
293    pub slices: Vec<RadialSlice>,
294    pub kind: RadialKind,
295    /// Clockwise from the right; -90 starts at the top.
296    pub start_angle_degrees: f32,
297    pub split: Option<RadialSplit>,
298    pub style: RadialStyle,
299    pub labels: RadialLabels,
300    #[cfg_attr(feature = "serde", serde(default))]
301    pub label_format: RadialLabelFormat,
302    #[cfg_attr(feature = "serde", serde(default = "default_decimals"))]
303    pub label_decimals: usize,
304    #[cfg_attr(feature = "serde", serde(default))]
305    pub value_suffix: String,
306    #[cfg_attr(feature = "serde", serde(default))]
307    pub interaction: RadialInteraction,
308    pub font_family: String,
309    pub font_size: f32,
310    pub label_color: Color,
311    pub background: Color,
312}
313impl Default for RadialChart {
314    fn default() -> Self {
315        Self {
316            decorations: Default::default(),
317            title: String::new(),
318            slices: Vec::new(),
319            kind: RadialKind::Pie,
320            start_angle_degrees: -90.0,
321            split: None,
322            style: RadialStyle::default(),
323            labels: RadialLabels::Outside,
324            label_format: RadialLabelFormat::NamePercent,
325            label_decimals: 1,
326            value_suffix: String::new(),
327            interaction: RadialInteraction::default(),
328            font_family: "sans-serif".into(),
329            font_size: 16.0,
330            label_color: Color::new(0.12, 0.16, 0.22, 1.0),
331            background: Color::WHITE,
332        }
333    }
334}
335
336#[cfg(feature = "serde")]
337fn default_decimals() -> usize {
338    1
339}
340
341fn range(x: f32, lo: f32, hi: f32) -> bool {
342    x.is_finite() && x >= lo && x <= hi
343}
344fn color(c: Color) -> bool {
345    [c.r, c.g, c.b, c.a].into_iter().all(|v| range(v, 0.0, 1.0))
346}
347fn kind(k: RadialKind) -> bool {
348    match k {
349        RadialKind::Pie => true,
350        RadialKind::Donut { inner_radius } => range(inner_radius, 0.1, 0.85),
351    }
352}
353fn total(slices: &[RadialSlice]) -> Result<f64, &'static str> {
354    if slices.is_empty() || slices.len() > MAX_RADIAL_SLICES {
355        return Err("a radial chart needs 1..64 slices");
356    }
357    let mut sum = 0.0;
358    for s in slices {
359        if !s.value.is_finite() || s.value < 0.0 {
360            return Err("slice values must be finite and nonnegative");
361        }
362        if !color(s.color) || s.color.a != 1.0 {
363            return Err("slice colors must be finite opaque RGBA in 0..1");
364        }
365        if let Some(style) = &s.style {
366            validate_style(style)?;
367        }
368        if s.label_color.is_some_and(|c| !color(c)) {
369            return Err("invalid slice label color");
370        }
371        if !range(s.explode, 0.0, 0.35) {
372            return Err("slice explode must be in 0..0.35");
373        }
374        if s.label.chars().count() > 80 || s.label.contains(['\n', '\r']) {
375            return Err("slice labels must be single-line, at most 80 characters");
376        }
377        sum += s.value;
378    }
379    if !sum.is_finite() || sum <= 0.0 {
380        return Err("slice total must be positive and finite");
381    }
382    Ok(sum)
383}
384fn validate_style(s: &RadialStyle) -> Result<(), &'static str> {
385    if !range(s.tilt_degrees, 0.0, 65.0)
386        || !range(s.depth, 0.0, 0.4)
387        || !range(s.bevel, 0.0, 0.1)
388        || !range(s.gap_degrees, 0.0, 8.0)
389        || !range(s.roughness, 0.05, 1.0)
390        || !range(s.texture_strength, 0.0, 1.0)
391        || !range(s.texture_scale, 0.1, 8.0)
392        || !s.light.into_iter().all(|v| range(v, -100.0, 100.0))
393        || !range(s.inner_corner, 0.0, 0.25)
394        || !range(s.outer_corner, 0.0, 0.25)
395        || !range(s.hover_lift, 0.0, 0.25)
396        || !range(s.hover_brightness, 0.0, 0.5)
397        || !range(s.gloss, 0.0, 1.0)
398        || !s.texture_angle_degrees.is_finite()
399        || !range(s.outline.width, 0.0, 8.0)
400        || !color(s.outline.color)
401        || s.light.iter().map(|x| x * x).sum::<f32>() < 0.0001
402    {
403        return Err("invalid radial material, light, or projection");
404    }
405    Ok(())
406}
407impl RadialChart {
408    /// Resolve an original array index; invalid groups/indices return None.
409    pub fn slice(&self, target: RadialTarget) -> Option<&RadialSlice> {
410        match target.group {
411            0 => self.slices.get(target.index),
412            1 => self.split.as_ref()?.children.get(target.index),
413            _ => None,
414        }
415    }
416    /// Edit the source model, then prepare a new renderer frame to display it.
417    pub fn slice_mut(&mut self, target: RadialTarget) -> Option<&mut RadialSlice> {
418        match target.group {
419            0 => self.slices.get_mut(target.index),
420            1 => self.split.as_mut()?.children.get_mut(target.index),
421            _ => None,
422        }
423    }
424    /// Two label lines at most. Values use the original units; detail percentages
425    /// use the detail subtotal. Formatting never changes numeric data.
426    /// Call on a validated chart and pass the positive finite sum for this circle.
427    pub fn label_lines(&self, slice: &RadialSlice, total: f64) -> Vec<String> {
428        let d = self.label_decimals;
429        let trim = |s: String| {
430            if d == 0 {
431                s
432            } else {
433                s.trim_end_matches('0').trim_end_matches('.').to_owned()
434            }
435        };
436        let value = format!(
437            "{}{}",
438            trim(format!("{:.*}", d, slice.value)),
439            self.value_suffix
440        );
441        let pct = format!(
442            "{}%",
443            trim(format!("{:.*}", d, slice.value / total * 100.0))
444        );
445        match slice.label_format.unwrap_or(self.label_format) {
446            RadialLabelFormat::Name => vec![slice.label.clone()],
447            RadialLabelFormat::Value => vec![value],
448            RadialLabelFormat::Percent => vec![pct],
449            RadialLabelFormat::NamePercent => vec![slice.label.clone(), pct],
450            RadialLabelFormat::NameValue => vec![slice.label.clone(), value],
451            RadialLabelFormat::ValuePercent => vec![format!("{value} ({pct})")],
452            RadialLabelFormat::NameValuePercent => {
453                vec![slice.label.clone(), format!("{value} ({pct})")]
454            }
455        }
456    }
457
458    /// Zero values are retained in the model, but have no geometry or label.
459    /// This checks model constraints; the renderer additionally checks output size,
460    /// device limits, label layout and GPU budget when preparing a frame.
461    pub fn validate(&self) -> Result<(), &'static str> {
462        self.decorations.validate()?;
463        total(&self.slices)?;
464        if !kind(self.kind) || !self.start_angle_degrees.is_finite() {
465            return Err("invalid radial shape or start angle");
466        }
467        if !range(self.font_size, 8.0, 64.0) || !color(self.label_color) || !color(self.background)
468        {
469            return Err("invalid radial text or background");
470        }
471        if self.title.chars().count() > 160 || self.title.contains(['\n', '\r']) {
472            return Err("title must be single-line, at most 160 characters");
473        }
474        validate_style(&self.style)?;
475        if self.label_decimals > 6
476            || self.value_suffix.chars().count() > 24
477            || self.value_suffix.contains(['\n', '\r'])
478            || !range(self.interaction.hover_progress, 0.0, 1.0)
479        {
480            return Err("invalid radial label format or interaction");
481        }
482        for target in [self.interaction.hovered, self.interaction.selected]
483            .into_iter()
484            .flatten()
485        {
486            let slice = self
487                .slice(target)
488                .ok_or("radial interaction target is out of range")?;
489            if slice.value == 0.0 {
490                return Err("cannot emphasize a zero-valued slice");
491            }
492        }
493        if let Some(split) = &self.split {
494            let parent = self
495                .slices
496                .get(split.slice_index)
497                .ok_or("split slice index is out of range")?;
498            let sum = total(&split.children)?;
499            if parent.value <= 0.0 || (sum - parent.value).abs() > parent.value.abs() * 1e-9 {
500                return Err("detail values must sum to the parent slice value");
501            }
502            if !kind(split.kind) {
503                return Err("invalid detail chart shape");
504            }
505        }
506        Ok(())
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    fn chart() -> RadialChart {
514        RadialChart {
515            slices: vec![
516                RadialSlice::new("A", 87.0, Color::WHITE),
517                RadialSlice::new("Other", 13.0, Color::WHITE),
518            ],
519            ..Default::default()
520        }
521    }
522    #[test]
523    fn radial_rejects_invalid_values_and_materials() {
524        for value in [f64::NAN, f64::INFINITY, -1.0] {
525            let mut c = chart();
526            c.slices[0].value = value;
527            assert!(c.validate().is_err());
528        }
529        let mut c = chart();
530        c.slices.iter_mut().for_each(|s| s.value = 0.0);
531        assert!(c.validate().is_err());
532        let mut c = chart();
533        c.style.light = [0.0; 3];
534        assert!(c.validate().is_err());
535        let mut c = chart();
536        c.kind = RadialKind::Donut { inner_radius: 1.0 };
537        assert!(c.validate().is_err());
538        let mut c = chart();
539        c.slices[0].value = 0.0;
540        assert!(c.validate().is_ok());
541    }
542    #[test]
543    fn radial_detail_conserves_parent_total() {
544        let mut c = chart();
545        c.split = Some(RadialSplit {
546            slice_index: 1,
547            kind: RadialKind::Pie,
548            children: vec![
549                RadialSlice::new("D", 2.08, Color::WHITE),
550                RadialSlice::new("E", 1.82, Color::WHITE),
551                RadialSlice::new("F", 9.1, Color::WHITE),
552            ],
553        });
554        assert!(c.validate().is_ok());
555        c.split.as_mut().unwrap().children[0].value = 16.0;
556        assert!(c.validate().is_err());
557    }
558    #[cfg(feature = "serde")]
559    #[test]
560    fn radial_serialization_round_trip() {
561        let c = chart();
562        assert_eq!(
563            c,
564            serde_json::from_str::<RadialChart>(&serde_json::to_string(&c).unwrap()).unwrap()
565        );
566    }
567}
568
569#[cfg(test)]
570mod editing_contract {
571    use super::*;
572    #[test]
573    fn formats_selection_and_hover_are_model_owned() {
574        let mut chart = RadialChart {
575            slices: vec![
576                RadialSlice::new("A", 20.0, Color::WHITE),
577                RadialSlice::new("B", 80.0, Color::WHITE),
578            ],
579            ..Default::default()
580        };
581        let a = RadialTarget::main(0);
582        chart.label_decimals = 0;
583        assert_eq!(
584            chart.label_lines(chart.slice(a).unwrap(), 100.0),
585            ["A", "20%"]
586        );
587        chart.slice_mut(a).unwrap().label_format = Some(RadialLabelFormat::NameValuePercent);
588        chart.value_suffix = " kg".into();
589        assert_eq!(
590            chart.label_lines(chart.slice(a).unwrap(), 100.0),
591            ["A", "20 kg (20%)"]
592        );
593        chart.interaction.animate_hover(Some(a), 0.05, 0.2);
594        assert_eq!(chart.interaction.hover_progress, 0.25);
595        assert!(chart.interaction.amount(a) > 0.0);
596        chart.interaction.animate_hover(None, 1.0, 0.2);
597        assert_eq!(chart.interaction, RadialInteraction::default());
598        chart.interaction.selected = Some(a);
599        assert_eq!(chart.interaction.amount(a), 1.0);
600        chart.slice_mut(a).unwrap().color = Color::from_rgb8(20, 120, 240);
601        assert_eq!(chart.slices[1].color, Color::WHITE);
602        assert!(chart.validate().is_ok());
603        chart.interaction.selected = Some(RadialTarget::detail(0));
604        assert!(chart.validate().is_err());
605    }
606    #[test]
607    fn invalid_overrides_are_rejected_before_rendering() {
608        let mut chart = RadialChart {
609            slices: vec![RadialSlice::new("A", 1.0, Color::WHITE)],
610            ..Default::default()
611        };
612        chart.slices[0].style = Some(RadialStyle {
613            outer_corner: f32::NAN,
614            ..Default::default()
615        });
616        assert!(chart.validate().is_err());
617        chart.slices[0].style = None;
618        chart.label_decimals = 7;
619        assert!(chart.validate().is_err());
620    }
621    #[cfg(feature = "serde")]
622    #[test]
623    fn editing_options_roundtrip_and_old_snapshots_default() {
624        let mut chart = RadialChart {
625            slices: vec![RadialSlice::new("A", 1.0, Color::WHITE)],
626            ..Default::default()
627        };
628        chart.style.outer_corner = 0.05;
629        chart.style.material = RadialMaterial::SatinMetal;
630        chart.slices[0].label_format = Some(RadialLabelFormat::Value);
631        chart.interaction.selected = Some(RadialTarget::main(0));
632        let json = serde_json::to_value(&chart).unwrap();
633        assert_eq!(
634            serde_json::from_value::<RadialChart>(json.clone()).unwrap(),
635            chart
636        );
637        let mut old = json;
638        for key in [
639            "label_format",
640            "label_decimals",
641            "value_suffix",
642            "interaction",
643        ] {
644            old.as_object_mut().unwrap().remove(key);
645        }
646        for key in ["style", "labels", "label_format", "label_color"] {
647            old["slices"][0].as_object_mut().unwrap().remove(key);
648        }
649        old["style"].as_object_mut().unwrap().remove("outer_corner");
650        let restored: RadialChart = serde_json::from_value(old).unwrap();
651        assert_eq!(restored.style.outer_corner, 0.0);
652        assert_eq!(restored.label_format, RadialLabelFormat::NamePercent);
653        assert_eq!(restored.label_decimals, 1);
654    }
655}