#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum LineStyle {
Solid,
Dashed,
Dotted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum AxisKind {
X,
Y,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Axis {
pub kind: AxisKind,
pub label: String,
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum SceneObjectKind {
Polyline {
points: Vec<(f64, f64)>,
color: String,
style: LineStyle,
width: f64,
},
BoundedBox {
x0: f64,
y0: f64,
x1: f64,
y1: f64,
fill_color: Option<String>,
border_color: Option<String>,
},
Fill {
points: Vec<(f64, f64)>,
color: String,
},
Text {
x: f64,
y: f64,
content: String,
color: String,
},
Tooltip {
x: f64,
y: f64,
content: String,
},
Table {
x: f64,
y: f64,
rows: Vec<Vec<String>>,
},
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SceneObject {
pub id: String,
pub z_order: i32,
pub opacity: f64,
pub kind: SceneObjectKind,
}
impl SceneObject {
pub fn new(id: impl Into<String>, z_order: i32, opacity: f64, kind: SceneObjectKind) -> Self {
Self {
id: id.into(),
z_order,
opacity: opacity.clamp(0.0, 1.0),
kind,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Pane {
pub id: String,
pub height_ratio: f64,
pub axes: Vec<Axis>,
objects: Vec<SceneObject>,
}
impl Pane {
pub fn new(id: impl Into<String>, height_ratio: f64) -> Self {
Self {
id: id.into(),
height_ratio,
axes: Vec::new(),
objects: Vec::new(),
}
}
pub fn upsert_object(&mut self, object: SceneObject) {
match self.objects.iter_mut().find(|o| o.id == object.id) {
Some(existing) => *existing = object,
None => self.objects.push(object),
}
}
pub fn remove_object(&mut self, id: &str) -> bool {
let before = self.objects.len();
self.objects.retain(|o| o.id != id);
self.objects.len() != before
}
pub fn objects(&self) -> &[SceneObject] {
&self.objects
}
pub fn objects_z_ordered(&self) -> Vec<&SceneObject> {
let mut ordered: Vec<&SceneObject> = self.objects.iter().collect();
ordered.sort_by_key(|o| o.z_order);
ordered
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Scene {
panes: Vec<Pane>,
}
impl Scene {
pub fn new() -> Self {
Self::default()
}
pub fn upsert_pane(&mut self, pane: Pane) {
match self.panes.iter_mut().find(|p| p.id == pane.id) {
Some(existing) => *existing = pane,
None => self.panes.push(pane),
}
}
pub fn pane_mut(&mut self, id: &str) -> Option<&mut Pane> {
self.panes.iter_mut().find(|p| p.id == id)
}
pub fn pane(&self, id: &str) -> Option<&Pane> {
self.panes.iter().find(|p| p.id == id)
}
pub fn panes(&self) -> &[Pane] {
&self.panes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_upsert_pane_replaces_by_id() {
let mut scene = Scene::new();
scene.upsert_pane(Pane::new("price", 3.0));
scene.upsert_pane(Pane::new("price", 5.0));
assert_eq!(scene.panes().len(), 1);
assert_eq!(scene.pane("price").unwrap().height_ratio, 5.0);
}
#[test]
fn test_upsert_object_updates_in_place() {
let mut pane = Pane::new("price", 1.0);
pane.upsert_object(SceneObject::new(
"sma20",
1,
1.0,
SceneObjectKind::Polyline {
points: vec![(0.0, 100.0)],
color: "#fff".to_string(),
style: LineStyle::Solid,
width: 1.0,
},
));
pane.upsert_object(SceneObject::new(
"sma20",
1,
1.0,
SceneObjectKind::Polyline {
points: vec![(0.0, 100.0), (1.0, 101.0)],
color: "#fff".to_string(),
style: LineStyle::Solid,
width: 1.0,
},
));
assert_eq!(
pane.objects().len(),
1,
"same id must update, not duplicate"
);
match &pane.objects()[0].kind {
SceneObjectKind::Polyline { points, .. } => assert_eq!(points.len(), 2),
_ => panic!("expected Polyline"),
}
}
#[test]
fn test_objects_z_ordered_sorts_ascending() {
let mut pane = Pane::new("price", 1.0);
pane.upsert_object(SceneObject::new(
"top",
10,
1.0,
SceneObjectKind::Text {
x: 0.0,
y: 0.0,
content: "top".to_string(),
color: "#fff".to_string(),
},
));
pane.upsert_object(SceneObject::new(
"bottom",
-5,
1.0,
SceneObjectKind::Text {
x: 0.0,
y: 0.0,
content: "bottom".to_string(),
color: "#fff".to_string(),
},
));
let ordered = pane.objects_z_ordered();
assert_eq!(ordered[0].id, "bottom");
assert_eq!(ordered[1].id, "top");
}
#[test]
fn test_opacity_is_clamped() {
let object = SceneObject::new(
"x",
0,
1.5,
SceneObjectKind::Text {
x: 0.0,
y: 0.0,
content: String::new(),
color: "#fff".to_string(),
},
);
assert_eq!(object.opacity, 1.0);
}
#[test]
fn test_remove_object() {
let mut pane = Pane::new("price", 1.0);
pane.upsert_object(SceneObject::new(
"x",
0,
1.0,
SceneObjectKind::Text {
x: 0.0,
y: 0.0,
content: String::new(),
color: "#fff".to_string(),
},
));
assert!(pane.remove_object("x"));
assert!(pane.objects().is_empty());
assert!(
!pane.remove_object("x"),
"removing again must be a no-op returning false"
);
}
}