1use 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 pub values: Vec<Option<f64>>,
45 pub color: Color,
46 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 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 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 #[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 pub group_width: f32,
191 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#[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 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 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 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 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}