Skip to main content

model/
categorical.rs

1//! Bounded categorical bars. Values are model-owned, not ColumnSource payloads.
2//! Stable category/series IDs identify edits and selection; values follow category order.
3use crate::Color;
4use std::collections::HashSet;
5
6pub const MAX_CATEGORIES: usize = 64;
7pub const MAX_BAR_SERIES: usize = 16;
8pub const MAX_CATEGORY_BARS: usize = 512;
9
10#[derive(Clone, Debug, PartialEq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct Category {
13    pub id: String,
14    pub label: String,
15}
16impl Category {
17    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
18        Self {
19            id: id.into(),
20            label: label.into(),
21        }
22    }
23}
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct CategoryBarTarget {
27    pub category_id: String,
28    pub series_id: String,
29}
30impl CategoryBarTarget {
31    pub fn new(category: impl Into<String>, series: impl Into<String>) -> Self {
32        Self {
33            category_id: category.into(),
34            series_id: series.into(),
35        }
36    }
37}
38#[derive(Clone, Debug, PartialEq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub struct BarSeries {
41    pub id: String,
42    pub label: String,
43    /// Exactly one entry per category. None is missing; Some(0) is a real zero.
44    pub values: Vec<Option<f64>>,
45    pub color: Color,
46    /// Complete override; None inherits the chart style.
47    pub style: Option<CategoryBarStyle>,
48}
49impl BarSeries {
50    pub fn new(
51        id: impl Into<String>,
52        label: impl Into<String>,
53        values: Vec<Option<f64>>,
54        color: Color,
55    ) -> Self {
56        Self {
57            id: id.into(),
58            label: label.into(),
59            values,
60            color,
61            style: None,
62        }
63    }
64}
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub enum CategoryBarMode {
68    #[default]
69    Grouped,
70    Stacked,
71    PercentStacked,
72}
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub enum CategoryBarDirection {
76    #[default]
77    Vertical,
78    Horizontal,
79}
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub enum CategoryBarMaterial {
83    #[default]
84    Flat,
85    Matte,
86    SatinMetal,
87    Enamel,
88    Paper,
89}
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub enum CategoryBarLabels {
93    None,
94    Inside,
95    Outside,
96    #[default]
97    Auto,
98}
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub enum CategoryBarLabelFormat {
102    #[default]
103    Value,
104    Percent,
105    ValuePercent,
106    CategoryValue,
107}
108#[derive(Clone, Debug, PartialEq)]
109#[cfg_attr(
110    feature = "serde",
111    derive(serde::Serialize, serde::Deserialize),
112    serde(default)
113)]
114pub struct CategoryBarStyle {
115    pub material: CategoryBarMaterial,
116    /// Logical pixels. Only the exposed end of a stack is rounded.
117    pub corner_radius: f32,
118    pub outline: bool,
119    pub outline_width: f32,
120    pub outline_color: Color,
121    pub texture_strength: f32,
122    pub texture_scale: f32,
123    pub gloss: f32,
124    /// Highlight preserves the data endpoint; it never grows/lifts a bar.
125    pub emphasis_brightness: f32,
126}
127impl Default for CategoryBarStyle {
128    fn default() -> Self {
129        Self {
130            material: CategoryBarMaterial::Flat,
131            corner_radius: 5.0,
132            outline: false,
133            outline_width: 1.0,
134            outline_color: Color::from_rgb8(40, 55, 75),
135            texture_strength: 0.2,
136            texture_scale: 1.0,
137            gloss: 0.45,
138            emphasis_brightness: 0.08,
139        }
140    }
141}
142#[derive(Clone, Debug, PartialEq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144pub struct CategoryBarOverride {
145    pub target: CategoryBarTarget,
146    pub color: Option<Color>,
147    pub style: Option<CategoryBarStyle>,
148    pub labels: Option<CategoryBarLabels>,
149    pub label_format: Option<CategoryBarLabelFormat>,
150}
151impl CategoryBarOverride {
152    pub fn new(target: CategoryBarTarget) -> Self {
153        Self {
154            target,
155            color: None,
156            style: None,
157            labels: None,
158            label_format: None,
159        }
160    }
161}
162#[derive(Clone, Debug, PartialEq)]
163#[cfg_attr(
164    feature = "serde",
165    derive(serde::Serialize, serde::Deserialize),
166    serde(default)
167)]
168pub struct CategoricalChart {
169    /// Editable decoration positions and selection, in logical pixels.
170    #[cfg_attr(feature = "serde", serde(default))]
171    pub decorations: crate::DecorationLayout,
172    pub title: String,
173    pub categories: Vec<Category>,
174    pub series: Vec<BarSeries>,
175    pub mode: CategoryBarMode,
176    pub direction: CategoryBarDirection,
177    pub style: CategoryBarStyle,
178    pub overrides: Vec<CategoryBarOverride>,
179    pub hovered: Option<CategoryBarTarget>,
180    pub selected: Option<CategoryBarTarget>,
181    pub labels: CategoryBarLabels,
182    pub label_format: CategoryBarLabelFormat,
183    pub label_decimals: usize,
184    pub value_suffix: String,
185    pub value_title: String,
186    pub category_title: String,
187    pub grid: bool,
188    pub legend: bool,
189    /// Fraction of the category slot occupied by all bars, 0.1..0.95.
190    pub group_width: f32,
191    /// Logical pixels between grouped bars; rejected if bars no longer fit.
192    pub bar_gap: f32,
193    pub font_family: String,
194    pub font_size: f32,
195    pub text_color: Color,
196    pub background: Color,
197}
198impl Default for CategoricalChart {
199    fn default() -> Self {
200        Self {
201            decorations: Default::default(),
202            title: String::new(),
203            categories: vec![],
204            series: vec![],
205            mode: CategoryBarMode::Grouped,
206            direction: CategoryBarDirection::Vertical,
207            style: CategoryBarStyle::default(),
208            overrides: vec![],
209            hovered: None,
210            selected: None,
211            labels: CategoryBarLabels::Auto,
212            label_format: CategoryBarLabelFormat::Value,
213            label_decimals: 1,
214            value_suffix: String::new(),
215            value_title: String::new(),
216            category_title: String::new(),
217            grid: true,
218            legend: true,
219            group_width: 0.7,
220            bar_gap: 3.0,
221            font_family: "sans-serif".into(),
222            font_size: 14.0,
223            text_color: Color::from_rgb8(35, 45, 65),
224            background: Color::WHITE,
225        }
226    }
227}
228/// Resolved data segment in axis units. Percent stacks use 0..100; originals stay unchanged.
229#[derive(Clone, Debug, PartialEq)]
230pub struct CategoryBarSegment {
231    pub target: CategoryBarTarget,
232    pub category_index: usize,
233    pub series_index: usize,
234    pub value: f64,
235    pub start: f64,
236    pub end: f64,
237    pub percent: Option<f64>,
238    pub exposed_end: bool,
239}
240fn text(s: &str, max: usize) -> bool {
241    s.chars().count() <= max && !s.chars().any(char::is_control)
242}
243fn range(v: f32, lo: f32, hi: f32) -> bool {
244    v.is_finite() && (lo..=hi).contains(&v)
245}
246fn color(c: Color, opaque: bool) -> bool {
247    [c.r, c.g, c.b, c.a].into_iter().all(|v| range(v, 0.0, 1.0)) && (!opaque || c.a == 1.0)
248}
249fn style(s: &CategoryBarStyle) -> bool {
250    range(s.corner_radius, 0.0, 32.0)
251        && range(s.outline_width, 0.0, 8.0)
252        && color(s.outline_color, false)
253        && range(s.texture_strength, 0.0, 1.0)
254        && range(s.texture_scale, 0.1, 8.0)
255        && range(s.gloss, 0.0, 1.0)
256        && range(s.emphasis_brightness, 0.0, 0.5)
257}
258impl CategoricalChart {
259    pub fn value(&self, target: &CategoryBarTarget) -> Option<f64> {
260        let i = self
261            .categories
262            .iter()
263            .position(|c| c.id == target.category_id)?;
264        *self
265            .series
266            .iter()
267            .find(|s| s.id == target.series_id)?
268            .values
269            .get(i)?
270    }
271    pub fn bar_override(&self, target: &CategoryBarTarget) -> Option<&CategoryBarOverride> {
272        self.overrides.iter().find(|o| &o.target == target)
273    }
274    /// Reorders categories and every value column together. IDs/styles/selection are preserved.
275    pub fn reorder_categories(&mut self, ids: &[&str]) -> Result<(), &'static str> {
276        self.validate()?;
277        if ids.len() != self.categories.len()
278            || ids.iter().collect::<HashSet<_>>().len() != ids.len()
279        {
280            return Err("category order must be a permutation");
281        }
282        let order = ids
283            .iter()
284            .map(|id| {
285                self.categories
286                    .iter()
287                    .position(|c| c.id == *id)
288                    .ok_or("unknown category ID")
289            })
290            .collect::<Result<Vec<_>, _>>()?;
291        self.categories = order.iter().map(|i| self.categories[*i].clone()).collect();
292        for series in &mut self.series {
293            series.values = order.iter().map(|i| series.values[*i]).collect();
294        }
295        Ok(())
296    }
297    pub fn validate(&self) -> Result<(), &'static str> {
298        self.decorations.validate()?;
299        let n = self.categories.len();
300        let m = self.series.len();
301        if n == 0 || n > MAX_CATEGORIES || m == 0 || m > MAX_BAR_SERIES || n * m > MAX_CATEGORY_BARS
302        {
303            return Err("categorical limits: 1..64 categories, 1..16 series, at most 512 entries");
304        }
305        let mut ids = HashSet::new();
306        for c in &self.categories {
307            if c.id.is_empty() || !text(&c.id, 80) || !text(&c.label, 80) || !ids.insert(&c.id) {
308                return Err("invalid or duplicate category ID/label");
309            }
310        }
311        ids.clear();
312        for s in &self.series {
313            if s.id.is_empty()
314                || !text(&s.id, 80)
315                || !text(&s.label, 80)
316                || !ids.insert(&s.id)
317                || s.values.len() != n
318                || !color(s.color, true)
319                || s.style.as_ref().is_some_and(|s| !style(s))
320            {
321                return Err("invalid series, value count, color or style");
322            }
323            if s.values.iter().flatten().any(|v| {
324                !v.is_finite() || (self.mode == CategoryBarMode::PercentStacked && *v < 0.0)
325            }) {
326                return Err("values must be finite; percent stacks require nonnegative values");
327            }
328        }
329        if !style(&self.style)
330            || !range(self.font_size, 8.0, 32.0)
331            || !range(self.group_width, 0.1, 0.95)
332            || !range(self.bar_gap, 0.0, 24.0)
333            || !color(self.text_color, false)
334            || !color(self.background, false)
335            || self.label_decimals > 6
336        {
337            return Err("invalid categorical style or layout");
338        }
339        for s in [&self.title, &self.value_title, &self.category_title] {
340            if !text(s, 160) {
341                return Err("titles must be single-line, at most 160 characters");
342            }
343        }
344        if !text(&self.value_suffix, 24) {
345            return Err("invalid value suffix");
346        }
347        let mut targets = HashSet::new();
348        for o in &self.overrides {
349            if !self.categories.iter().any(|c| c.id == o.target.category_id)
350                || !self.series.iter().any(|s| s.id == o.target.series_id)
351                || !targets.insert(&o.target)
352                || o.color.is_some_and(|c| !color(c, true))
353                || o.style.as_ref().is_some_and(|s| !style(s))
354            {
355                return Err("invalid or duplicate bar override");
356            }
357        }
358        for t in [&self.hovered, &self.selected].into_iter().flatten() {
359            if self.value(t).is_none() {
360                return Err("interaction target is missing or unknown");
361            }
362        }
363        self.resolve_segments()?;
364        Ok(())
365    }
366    /// Validates the model, then computes positive/negative stacks independently.
367    /// Percentages are undefined (None) for a zero or signed category total.
368    pub fn segments(&self) -> Result<Vec<CategoryBarSegment>, &'static str> {
369        self.validate()?;
370        self.resolve_segments()
371    }
372    fn resolve_segments(&self) -> Result<Vec<CategoryBarSegment>, &'static str> {
373        let mut out = Vec::new();
374        for (ci, c) in self.categories.iter().enumerate() {
375            let max = self
376                .series
377                .iter()
378                .filter_map(|s| s.values.get(ci).copied().flatten())
379                .fold(0.0, f64::max);
380            let has_negative = self
381                .series
382                .iter()
383                .any(|s| s.values.get(ci).copied().flatten().is_some_and(|v| v < 0.0));
384            // Normalize before summing: percent stacks remain valid even if the raw total overflows.
385            let denominator = if max > 0.0 {
386                self.series
387                    .iter()
388                    .filter_map(|s| s.values.get(ci).copied().flatten())
389                    .map(|v| v / max)
390                    .sum::<f64>()
391            } else {
392                0.0
393            };
394            let mut positive = 0.0;
395            let mut negative = 0.0;
396            let mut last_positive = None;
397            let mut last_negative = None;
398            for (si, s) in self.series.iter().enumerate() {
399                let Some(value) = s.values.get(ci).copied().flatten() else {
400                    continue;
401                };
402                let percent = if !has_negative && denominator > 0.0 {
403                    Some(value / max / denominator * 100.0)
404                } else {
405                    None
406                };
407                let amount = if self.mode == CategoryBarMode::PercentStacked {
408                    percent.unwrap_or(0.0)
409                } else {
410                    value
411                };
412                let start = if self.mode == CategoryBarMode::Grouped {
413                    0.0
414                } else if amount < 0.0 {
415                    negative
416                } else {
417                    positive
418                };
419                let end = start + amount;
420                if !end.is_finite() {
421                    return Err("stack total overflow");
422                }
423                if amount < 0.0 {
424                    negative = end;
425                    last_negative = Some(out.len());
426                } else if amount > 0.0 {
427                    positive = end;
428                    last_positive = Some(out.len());
429                }
430                out.push(CategoryBarSegment {
431                    target: CategoryBarTarget::new(&c.id, &s.id),
432                    category_index: ci,
433                    series_index: si,
434                    value,
435                    start,
436                    end,
437                    percent,
438                    exposed_end: self.mode == CategoryBarMode::Grouped,
439                });
440            }
441            if self.mode != CategoryBarMode::Grouped {
442                for i in [last_positive, last_negative].into_iter().flatten() {
443                    out[i].exposed_end = true;
444                }
445            }
446            // Sum rounding must not leave the final percent stack short of 100.
447            if self.mode == CategoryBarMode::PercentStacked {
448                if let Some(i) = last_positive {
449                    out[i].end = 100.0;
450                }
451            }
452        }
453        Ok(out)
454    }
455    pub fn label_text(&self, segment: &CategoryBarSegment) -> String {
456        let number = |v: f64| {
457            let s = format!("{:.*}", self.label_decimals, v);
458            if self.label_decimals == 0 {
459                s
460            } else {
461                s.trim_end_matches('0').trim_end_matches('.').to_owned()
462            }
463        };
464        let value = format!("{}{}", number(segment.value), self.value_suffix);
465        let pct = segment
466            .percent
467            .map(|v| format!("{}%", number(v)))
468            .unwrap_or_else(|| "—".into());
469        match self
470            .bar_override(&segment.target)
471            .and_then(|o| o.label_format)
472            .unwrap_or(self.label_format)
473        {
474            CategoryBarLabelFormat::Value => value,
475            CategoryBarLabelFormat::Percent => pct,
476            CategoryBarLabelFormat::ValuePercent => format!("{value} ({pct})"),
477            CategoryBarLabelFormat::CategoryValue => {
478                format!("{}: {value}", self.categories[segment.category_index].label)
479            }
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    fn chart() -> CategoricalChart {
488        CategoricalChart {
489            categories: vec![Category::new("a", "A"), Category::new("b", "B")],
490            series: vec![
491                BarSeries::new("s", "S", vec![Some(2.0), None], Color::WHITE),
492                BarSeries::new("t", "T", vec![Some(3.0), Some(0.0)], Color::WHITE),
493            ],
494            ..Default::default()
495        }
496    }
497    #[test]
498    fn missing_zero_and_stacks_are_distinct() {
499        let mut c = chart();
500        c.mode = CategoryBarMode::Stacked;
501        let s = c.segments().unwrap();
502        assert_eq!(s.len(), 3);
503        assert_eq!((s[1].start, s[1].end), (2.0, 5.0));
504        assert!(!s[0].exposed_end);
505        assert!(s[1].exposed_end);
506        assert_eq!(s[2].percent, None);
507        c.mode = CategoryBarMode::PercentStacked;
508        let s = c.segments().unwrap();
509        assert_eq!(s[0].end, 40.0);
510        assert_eq!(s[1].end, 100.0);
511        assert_eq!(s[2].end, 0.0);
512    }
513    #[test]
514    fn signed_stacks_and_overflow_have_explicit_rules() {
515        let mut c = chart();
516        c.mode = CategoryBarMode::Stacked;
517        c.series[1].values[0] = Some(-3.0);
518        let s = c.segments().unwrap();
519        assert_eq!((s[1].start, s[1].end), (0.0, -3.0));
520        assert!(s[0].exposed_end && s[1].exposed_end);
521        assert_eq!(s[0].percent, None);
522        c.mode = CategoryBarMode::PercentStacked;
523        assert!(c.validate().is_err());
524        c.series[0].values[0] = Some(f64::MAX);
525        c.series[1].values[0] = Some(f64::MAX);
526        assert_eq!(c.segments().unwrap()[1].end, 100.0);
527        c.mode = CategoryBarMode::Stacked;
528        assert!(c.validate().is_err());
529    }
530    #[test]
531    fn identity_survives_reordering_and_rename() {
532        let mut c = chart();
533        let t = CategoryBarTarget::new("a", "s");
534        c.selected = Some(t.clone());
535        c.overrides.push(CategoryBarOverride::new(t.clone()));
536        c.reorder_categories(&["b", "a"]).unwrap();
537        c.categories[1].label = "Renamed".into();
538        assert_eq!(c.value(&t), Some(2.0));
539        assert_eq!(c.selected, Some(t));
540        let old = c.clone();
541        assert!(c.reorder_categories(&["a", "a"]).is_err());
542        assert_eq!(old, c);
543    }
544    #[test]
545    fn malformed_payload_is_rejected() {
546        let mut c = chart();
547        c.series[0].values.pop();
548        assert!(c.validate().is_err());
549        let mut c = chart();
550        c.categories[1].id = "a".into();
551        assert!(c.validate().is_err());
552        let mut c = chart();
553        c.series[0].values[0] = Some(f64::NAN);
554        assert!(c.validate().is_err());
555        let mut c = chart();
556        c.selected = Some(CategoryBarTarget::new("b", "s"));
557        assert!(c.validate().is_err());
558    }
559    #[cfg(feature = "serde")]
560    #[test]
561    fn serde_preserves_full_edit_state() {
562        let mut c = chart();
563        c.style.material = CategoryBarMaterial::Paper;
564        c.selected = Some(CategoryBarTarget::new("a", "s"));
565        let mut o = CategoryBarOverride::new(CategoryBarTarget::new("a", "t"));
566        o.label_format = Some(CategoryBarLabelFormat::ValuePercent);
567        c.overrides.push(o);
568        assert_eq!(
569            c,
570            serde_json::from_str::<CategoricalChart>(&serde_json::to_string(&c).unwrap()).unwrap()
571        );
572    }
573    #[test]
574    fn independent_limits_and_duplicate_overrides_are_checked() {
575        let make = |n: usize, m: usize| CategoricalChart {
576            categories: (0..n)
577                .map(|i| Category::new(format!("c{i}"), "Category"))
578                .collect(),
579            series: (0..m)
580                .map(|i| BarSeries::new(format!("s{i}"), "Series", vec![Some(1.); n], Color::WHITE))
581                .collect(),
582            ..Default::default()
583        };
584        for (n, m) in [(64, 8), (32, 16), (1, 1)] {
585            assert!(make(n, m).validate().is_ok());
586        }
587        for (n, m) in [(65, 1), (1, 17), (33, 16), (64, 9), (0, 1), (1, 0)] {
588            assert!(
589                make(n, m).validate().is_err(),
590                "accepted {n} categories / {m} series"
591            );
592        }
593        let mut c = make(1, 1);
594        let target = CategoryBarTarget::new("c0", "s0");
595        c.overrides = vec![
596            CategoryBarOverride::new(target.clone()),
597            CategoryBarOverride::new(target),
598        ];
599        assert!(c.validate().is_err());
600        c.overrides.truncate(1);
601        c.overrides[0].target.category_id = "missing".into();
602        assert!(c.validate().is_err());
603    }
604    #[test]
605    fn style_validation_is_identical_at_every_inheritance_level() {
606        let edits: Vec<Box<dyn Fn(&mut CategoryBarStyle)>> = vec![
607            Box::new(|s| s.corner_radius = -0.1),
608            Box::new(|s| s.corner_radius = 32.1),
609            Box::new(|s| s.outline_width = 8.1),
610            Box::new(|s| s.outline_color.a = -0.1),
611            Box::new(|s| s.texture_strength = 1.1),
612            Box::new(|s| s.texture_scale = 0.09),
613            Box::new(|s| s.texture_scale = 8.1),
614            Box::new(|s| s.gloss = f32::NAN),
615            Box::new(|s| s.emphasis_brightness = 0.51),
616        ];
617        for edit in edits {
618            let mut invalid = CategoryBarStyle::default();
619            edit(&mut invalid);
620            let mut c = chart();
621            c.style = invalid.clone();
622            assert!(c.validate().is_err());
623            let mut c = chart();
624            c.series[0].style = Some(invalid.clone());
625            assert!(c.validate().is_err());
626            let mut c = chart();
627            let mut o = CategoryBarOverride::new(CategoryBarTarget::new("a", "s"));
628            o.style = Some(invalid);
629            c.overrides.push(o);
630            assert!(c.validate().is_err());
631        }
632    }
633}