1#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub enum LineStyle {
13 Solid,
14 Dashed,
15 Dotted,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub enum AxisKind {
21 X,
22 Y,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
27pub struct Axis {
28 pub kind: AxisKind,
29 pub label: String,
30 pub min: f64,
31 pub max: f64,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36pub enum SceneObjectKind {
37 Polyline {
38 points: Vec<(f64, f64)>,
39 color: String,
40 style: LineStyle,
41 width: f64,
42 },
43 BoundedBox {
45 x0: f64,
46 y0: f64,
47 x1: f64,
48 y1: f64,
49 fill_color: Option<String>,
50 border_color: Option<String>,
51 },
52 Fill {
54 points: Vec<(f64, f64)>,
55 color: String,
56 },
57 Text {
58 x: f64,
59 y: f64,
60 content: String,
61 color: String,
62 },
63 Tooltip {
64 x: f64,
65 y: f64,
66 content: String,
67 },
68 Table {
69 x: f64,
70 y: f64,
71 rows: Vec<Vec<String>>,
72 },
73}
74
75#[derive(Debug, Clone, PartialEq)]
76#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77pub struct SceneObject {
78 pub id: String,
79 pub z_order: i32,
81 pub opacity: f64,
83 pub kind: SceneObjectKind,
84}
85
86impl SceneObject {
87 pub fn new(id: impl Into<String>, z_order: i32, opacity: f64, kind: SceneObjectKind) -> Self {
88 Self {
89 id: id.into(),
90 z_order,
91 opacity: opacity.clamp(0.0, 1.0),
92 kind,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99pub struct Pane {
100 pub id: String,
101 pub height_ratio: f64,
104 pub axes: Vec<Axis>,
105 objects: Vec<SceneObject>,
106}
107
108impl Pane {
109 pub fn new(id: impl Into<String>, height_ratio: f64) -> Self {
110 Self {
111 id: id.into(),
112 height_ratio,
113 axes: Vec::new(),
114 objects: Vec::new(),
115 }
116 }
117
118 pub fn upsert_object(&mut self, object: SceneObject) {
122 match self.objects.iter_mut().find(|o| o.id == object.id) {
123 Some(existing) => *existing = object,
124 None => self.objects.push(object),
125 }
126 }
127
128 pub fn remove_object(&mut self, id: &str) -> bool {
129 let before = self.objects.len();
130 self.objects.retain(|o| o.id != id);
131 self.objects.len() != before
132 }
133
134 pub fn objects(&self) -> &[SceneObject] {
135 &self.objects
136 }
137
138 pub fn objects_z_ordered(&self) -> Vec<&SceneObject> {
140 let mut ordered: Vec<&SceneObject> = self.objects.iter().collect();
141 ordered.sort_by_key(|o| o.z_order);
142 ordered
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Default)]
147#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
148pub struct Scene {
149 panes: Vec<Pane>,
150}
151
152impl Scene {
153 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn upsert_pane(&mut self, pane: Pane) {
159 match self.panes.iter_mut().find(|p| p.id == pane.id) {
160 Some(existing) => *existing = pane,
161 None => self.panes.push(pane),
162 }
163 }
164
165 pub fn pane_mut(&mut self, id: &str) -> Option<&mut Pane> {
166 self.panes.iter_mut().find(|p| p.id == id)
167 }
168
169 pub fn pane(&self, id: &str) -> Option<&Pane> {
170 self.panes.iter().find(|p| p.id == id)
171 }
172
173 pub fn panes(&self) -> &[Pane] {
174 &self.panes
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_upsert_pane_replaces_by_id() {
184 let mut scene = Scene::new();
185 scene.upsert_pane(Pane::new("price", 3.0));
186 scene.upsert_pane(Pane::new("price", 5.0));
187 assert_eq!(scene.panes().len(), 1);
188 assert_eq!(scene.pane("price").unwrap().height_ratio, 5.0);
189 }
190
191 #[test]
192 fn test_upsert_object_updates_in_place() {
193 let mut pane = Pane::new("price", 1.0);
194 pane.upsert_object(SceneObject::new(
195 "sma20",
196 1,
197 1.0,
198 SceneObjectKind::Polyline {
199 points: vec![(0.0, 100.0)],
200 color: "#fff".to_string(),
201 style: LineStyle::Solid,
202 width: 1.0,
203 },
204 ));
205 pane.upsert_object(SceneObject::new(
206 "sma20",
207 1,
208 1.0,
209 SceneObjectKind::Polyline {
210 points: vec![(0.0, 100.0), (1.0, 101.0)],
211 color: "#fff".to_string(),
212 style: LineStyle::Solid,
213 width: 1.0,
214 },
215 ));
216
217 assert_eq!(
218 pane.objects().len(),
219 1,
220 "same id must update, not duplicate"
221 );
222 match &pane.objects()[0].kind {
223 SceneObjectKind::Polyline { points, .. } => assert_eq!(points.len(), 2),
224 _ => panic!("expected Polyline"),
225 }
226 }
227
228 #[test]
229 fn test_objects_z_ordered_sorts_ascending() {
230 let mut pane = Pane::new("price", 1.0);
231 pane.upsert_object(SceneObject::new(
232 "top",
233 10,
234 1.0,
235 SceneObjectKind::Text {
236 x: 0.0,
237 y: 0.0,
238 content: "top".to_string(),
239 color: "#fff".to_string(),
240 },
241 ));
242 pane.upsert_object(SceneObject::new(
243 "bottom",
244 -5,
245 1.0,
246 SceneObjectKind::Text {
247 x: 0.0,
248 y: 0.0,
249 content: "bottom".to_string(),
250 color: "#fff".to_string(),
251 },
252 ));
253
254 let ordered = pane.objects_z_ordered();
255 assert_eq!(ordered[0].id, "bottom");
256 assert_eq!(ordered[1].id, "top");
257 }
258
259 #[test]
260 fn test_opacity_is_clamped() {
261 let object = SceneObject::new(
262 "x",
263 0,
264 1.5,
265 SceneObjectKind::Text {
266 x: 0.0,
267 y: 0.0,
268 content: String::new(),
269 color: "#fff".to_string(),
270 },
271 );
272 assert_eq!(object.opacity, 1.0);
273 }
274
275 #[test]
276 fn test_remove_object() {
277 let mut pane = Pane::new("price", 1.0);
278 pane.upsert_object(SceneObject::new(
279 "x",
280 0,
281 1.0,
282 SceneObjectKind::Text {
283 x: 0.0,
284 y: 0.0,
285 content: String::new(),
286 color: "#fff".to_string(),
287 },
288 ));
289 assert!(pane.remove_object("x"));
290 assert!(pane.objects().is_empty());
291 assert!(
292 !pane.remove_object("x"),
293 "removing again must be a no-op returning false"
294 );
295 }
296}