1use crate::Color;
4
5pub const MAX_RADIAL_SLICES: usize = 64;
6
7#[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 pub explode: f32,
17 #[cfg_attr(feature = "serde", serde(default))]
20 pub style: Option<RadialStyle>,
21 #[cfg_attr(feature = "serde", serde(default))]
23 pub labels: Option<RadialLabels>,
24 #[cfg_attr(feature = "serde", serde(default))]
26 pub label_format: Option<RadialLabelFormat>,
27 #[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#[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 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#[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 pub hovered: Option<RadialTarget>,
142 pub selected: Option<RadialTarget>,
144 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 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#[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 pub tilt_degrees: f32,
206 pub depth: f32,
208 pub bevel: f32,
210 pub gap_degrees: f32,
212 pub roughness: f32,
214 pub texture_strength: f32,
216 pub texture_scale: f32,
219 pub light: [f32; 3],
220 pub shadow: bool,
221 pub inner_corner: f32,
223 pub outer_corner: f32,
224 pub outline: RadialOutline,
225 pub hover_lift: f32,
227 pub hover_brightness: f32,
229 pub gloss: f32,
231 pub texture_angle_degrees: f32,
233}
234impl RadialStyle {
235 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 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 #[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 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 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 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 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 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}