Skip to main content

kestrel_chartkit/viz/
scene.rs

1//! Renderer-neutral scene model: panes, axes, z-ordered/opacity-tagged objects (polylines with
2//! line styles, bounded boxes, area fills, text/tooltips, tables), and identity-keyed dynamic
3//! object updates. Pure geometry-and-style data — no SVG/Canvas/WebGL specifics — complementing
4//! [`crate::viz::ChartRenderData`]/[`crate::viz::render_chart_svg`]'s single-pane, price-only DTO
5//! and its static (non-updatable) SVG string output.
6
7#[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    /// A bounded box (e.g. a zone, order block, or pattern annotation).
44    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    /// An area fill between an arbitrary polygon's vertices (e.g. the region between two lines).
53    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    /// Higher draws on top. Ties broken by insertion order.
80    pub z_order: i32,
81    /// `0.0` (fully transparent) ..= `1.0` (fully opaque).
82    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    /// This pane's share of total vertical space, relative to sibling panes (e.g. a price pane
102    /// at `3.0` and a volume pane at `1.0` split 75%/25%).
103    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    /// Inserts `object`, or replaces the existing object with the same `id` — the "dynamische
119    /// Objekt-Updates" this scene model provides: callers re-`upsert_object` the same ID every
120    /// tick instead of clearing and rebuilding the whole pane.
121    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    /// Objects in draw order (ascending `z_order`, ties in insertion order).
139    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    /// Inserts `pane`, or replaces the existing pane with the same `id`.
158    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}