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
7use crate::artifact::Artifact;
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub enum LineStyle {
15    Solid,
16    Dashed,
17    Dotted,
18}
19
20/// Which of a pane's two axes an [`Axis`] describes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23pub enum AxisKind {
24    /// Horizontal axis. **Measured in Unix timestamps, seconds, UTC** — see [`Axis`].
25    X,
26    /// Vertical axis, measured in the pane's value unit (price for a price pane).
27    Y,
28}
29
30/// The value range one axis of a pane covers.
31///
32/// # Coordinate domain
33///
34/// **The X axis is time: Unix timestamps in seconds, UTC.** Not bar indices, not pixels.
35/// Object coordinates are given in the same domain, and a renderer maps them to the screen.
36///
37/// The reason is that a bar index only exists once a bar set is fixed, and the bar set
38/// belongs to the renderer: it culls, it may resample to another timeframe, and it may show
39/// two instruments with different trading calendars side by side. In all three cases the same
40/// fact would carry different indices, while its timestamp stays what it is.
41///
42/// A renderer that wants a gap-free display (no empty weekends) maps time to its own bar
43/// index — that mapping is a table on its side, whereas the reverse would be a guess about
44/// data this crate cannot see. How trading pauses are shown is therefore deliberately not
45/// part of this model.
46///
47/// A pane **without** axes leaves the domain undeclared; [`crate::viz::render_scene_svg`]
48/// then treats coordinates as pixels for backwards compatibility. New scenes should declare
49/// their axes.
50#[derive(Debug, Clone, PartialEq)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub struct Axis {
53    pub kind: AxisKind,
54    pub label: String,
55    pub min: f64,
56    pub max: f64,
57}
58
59impl Axis {
60    /// An X axis over a Unix-second range (UTC).
61    pub fn time(label: impl Into<String>, from_ts: i64, to_ts: i64) -> Self {
62        Self {
63            kind: AxisKind::X,
64            label: label.into(),
65            min: from_ts as f64,
66            max: to_ts as f64,
67        }
68    }
69
70    /// A Y axis over a value range — price, ratio, percent, whatever the pane shows.
71    pub fn value(label: impl Into<String>, min: f64, max: f64) -> Self {
72        Self {
73            kind: AxisKind::Y,
74            label: label.into(),
75            min,
76            max,
77        }
78    }
79
80    /// Width of the range; `0.0` when it is degenerate.
81    pub fn span(&self) -> f64 {
82        let span = self.max - self.min;
83        if span.is_finite() && span > 0.0 {
84            span
85        } else {
86            0.0
87        }
88    }
89}
90
91/// Geometry and style of a scene object.
92///
93/// Every `color` field follows the contract in [`crate::viz::sanitize_color`]:
94/// `#rgb`, `#rrggbb`, `#rrggbbaa` or `var(--name)`. Renderers should pass colors
95/// through that function instead of inventing their own parsing.
96#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
98pub enum SceneObjectKind {
99    Polyline {
100        points: Vec<(f64, f64)>,
101        color: String,
102        style: LineStyle,
103        width: f64,
104    },
105    /// A bounded box (e.g. a zone, order block, or pattern annotation).
106    BoundedBox {
107        x0: f64,
108        y0: f64,
109        x1: f64,
110        y1: f64,
111        fill_color: Option<String>,
112        border_color: Option<String>,
113    },
114    /// An area fill between an arbitrary polygon's vertices (e.g. the region between two lines).
115    Fill {
116        points: Vec<(f64, f64)>,
117        color: String,
118    },
119    Text {
120        x: f64,
121        y: f64,
122        content: String,
123        color: String,
124    },
125    Tooltip {
126        x: f64,
127        y: f64,
128        content: String,
129    },
130    Table {
131        x: f64,
132        y: f64,
133        rows: Vec<Vec<String>>,
134    },
135}
136
137#[derive(Debug, Clone, PartialEq)]
138#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
139pub struct SceneObject {
140    pub id: String,
141    /// Higher draws on top. Ties broken by insertion order.
142    pub z_order: i32,
143    /// `0.0` (fully transparent) ..= `1.0` (fully opaque).
144    pub opacity: f64,
145    pub kind: SceneObjectKind,
146}
147
148impl SceneObject {
149    pub fn new(id: impl Into<String>, z_order: i32, opacity: f64, kind: SceneObjectKind) -> Self {
150        Self {
151            id: id.into(),
152            z_order,
153            opacity: opacity.clamp(0.0, 1.0),
154            kind,
155        }
156    }
157}
158
159#[derive(Debug, Clone, PartialEq)]
160#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
161pub struct Pane {
162    pub id: String,
163    /// This pane's share of total vertical space, relative to sibling panes (e.g. a price pane
164    /// at `3.0` and a volume pane at `1.0` split 75%/25%).
165    ///
166    /// **A suggestion, not a command.** A renderer that owns the surrounding layout — a
167    /// chart with its own indicator panes, say — may lay the scene out differently or draw
168    /// it as a single overlay. The ratio then says only how the scene *would* divide space
169    /// if it were alone. Consumers should document which of the two they do.
170    pub height_ratio: f64,
171    pub axes: Vec<Axis>,
172    objects: Vec<SceneObject>,
173}
174
175impl Pane {
176    pub fn new(id: impl Into<String>, height_ratio: f64) -> Self {
177        Self {
178            id: id.into(),
179            height_ratio,
180            axes: Vec::new(),
181            objects: Vec::new(),
182        }
183    }
184
185    /// Inserts `object`, or replaces the existing object with the same `id` — the "dynamische
186    /// Objekt-Updates" this scene model provides: callers re-`upsert_object` the same ID every
187    /// tick instead of clearing and rebuilding the whole pane.
188    pub fn upsert_object(&mut self, object: SceneObject) {
189        match self.objects.iter_mut().find(|o| o.id == object.id) {
190            Some(existing) => *existing = object,
191            None => self.objects.push(object),
192        }
193    }
194
195    pub fn remove_object(&mut self, id: &str) -> bool {
196        let before = self.objects.len();
197        self.objects.retain(|o| o.id != id);
198        self.objects.len() != before
199    }
200
201    pub fn objects(&self) -> &[SceneObject] {
202        &self.objects
203    }
204
205    /// Objects in draw order (ascending `z_order`, ties in insertion order).
206    pub fn objects_z_ordered(&self) -> Vec<&SceneObject> {
207        let mut ordered: Vec<&SceneObject> = self.objects.iter().collect();
208        ordered.sort_by_key(|o| o.z_order);
209        ordered
210    }
211}
212
213/// A renderer-neutral description of what to draw.
214///
215/// The scene says *what* and *where in data space*, never *how large on screen*: panes
216/// carry relative ratios (see [`Pane::height_ratio`]), objects carry data coordinates.
217/// Mapping to pixels — and deciding whether the scene owns the layout or overlays an
218/// existing one — belongs to the renderer.
219///
220/// Build one from indicator results with [`scene_from_artifacts`].
221#[derive(Debug, Clone, PartialEq, Default)]
222#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
223pub struct Scene {
224    panes: Vec<Pane>,
225}
226
227impl Scene {
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Inserts `pane`, or replaces the existing pane with the same `id`.
233    pub fn upsert_pane(&mut self, pane: Pane) {
234        match self.panes.iter_mut().find(|p| p.id == pane.id) {
235            Some(existing) => *existing = pane,
236            None => self.panes.push(pane),
237        }
238    }
239
240    pub fn pane_mut(&mut self, id: &str) -> Option<&mut Pane> {
241        self.panes.iter_mut().find(|p| p.id == id)
242    }
243
244    pub fn pane(&self, id: &str) -> Option<&Pane> {
245        self.panes.iter().find(|p| p.id == id)
246    }
247
248    pub fn panes(&self) -> &[Pane] {
249        &self.panes
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_upsert_pane_replaces_by_id() {
259        let mut scene = Scene::new();
260        scene.upsert_pane(Pane::new("price", 3.0));
261        scene.upsert_pane(Pane::new("price", 5.0));
262        assert_eq!(scene.panes().len(), 1);
263        assert_eq!(scene.pane("price").unwrap().height_ratio, 5.0);
264    }
265
266    #[test]
267    fn test_upsert_object_updates_in_place() {
268        let mut pane = Pane::new("price", 1.0);
269        pane.upsert_object(SceneObject::new(
270            "sma20",
271            1,
272            1.0,
273            SceneObjectKind::Polyline {
274                points: vec![(0.0, 100.0)],
275                color: "#fff".to_string(),
276                style: LineStyle::Solid,
277                width: 1.0,
278            },
279        ));
280        pane.upsert_object(SceneObject::new(
281            "sma20",
282            1,
283            1.0,
284            SceneObjectKind::Polyline {
285                points: vec![(0.0, 100.0), (1.0, 101.0)],
286                color: "#fff".to_string(),
287                style: LineStyle::Solid,
288                width: 1.0,
289            },
290        ));
291
292        assert_eq!(
293            pane.objects().len(),
294            1,
295            "same id must update, not duplicate"
296        );
297        match &pane.objects()[0].kind {
298            SceneObjectKind::Polyline { points, .. } => assert_eq!(points.len(), 2),
299            _ => panic!("expected Polyline"),
300        }
301    }
302
303    #[test]
304    fn test_objects_z_ordered_sorts_ascending() {
305        let mut pane = Pane::new("price", 1.0);
306        pane.upsert_object(SceneObject::new(
307            "top",
308            10,
309            1.0,
310            SceneObjectKind::Text {
311                x: 0.0,
312                y: 0.0,
313                content: "top".to_string(),
314                color: "#fff".to_string(),
315            },
316        ));
317        pane.upsert_object(SceneObject::new(
318            "bottom",
319            -5,
320            1.0,
321            SceneObjectKind::Text {
322                x: 0.0,
323                y: 0.0,
324                content: "bottom".to_string(),
325                color: "#fff".to_string(),
326            },
327        ));
328
329        let ordered = pane.objects_z_ordered();
330        assert_eq!(ordered[0].id, "bottom");
331        assert_eq!(ordered[1].id, "top");
332    }
333
334    #[test]
335    fn test_opacity_is_clamped() {
336        let object = SceneObject::new(
337            "x",
338            0,
339            1.5,
340            SceneObjectKind::Text {
341                x: 0.0,
342                y: 0.0,
343                content: String::new(),
344                color: "#fff".to_string(),
345            },
346        );
347        assert_eq!(object.opacity, 1.0);
348    }
349
350    #[test]
351    fn test_remove_object() {
352        let mut pane = Pane::new("price", 1.0);
353        pane.upsert_object(SceneObject::new(
354            "x",
355            0,
356            1.0,
357            SceneObjectKind::Text {
358                x: 0.0,
359                y: 0.0,
360                content: String::new(),
361                color: "#fff".to_string(),
362            },
363        ));
364        assert!(pane.remove_object("x"));
365        assert!(pane.objects().is_empty());
366        assert!(
367            !pane.remove_object("x"),
368            "removing again must be a no-op returning false"
369        );
370    }
371}
372
373/// Builds a [`Scene`] from indicator-emitted [`Artifact`]s.
374///
375/// Until now the scene model had no producer: every consumer had to invent its own
376/// mapping from artifacts to objects, which meant two renderers would disagree about
377/// what an order block looks like. This is that mapping, in one place.
378///
379/// Placement in time comes from the artifact itself ([`crate::artifact::ZoneArtifact::span`],
380/// [`crate::artifact::ProfileArtifact::span`], a pivot's `timestamp`). `fallback_span` is used only for
381/// artifacts that carry none; artifacts that end up with neither are **skipped** rather
382/// than stretched across an invented range.
383///
384/// Colors follow the contract in [`crate::viz::sanitize_color`].
385pub fn scene_from_artifacts(artifacts: &[Artifact], fallback_span: Option<(i64, i64)>) -> Scene {
386    const ZONE_FILL: &str = "#58a6ff";
387    const PIVOT_HIGH: &str = "#e5534b";
388    const PIVOT_LOW: &str = "#3fb950";
389    const PROFILE_FILL: &str = "#8b949e";
390    const TEXT: &str = "#c9d3df";
391
392    let mut pane = Pane::new("artifacts", 1.0);
393
394    for (index, artifact) in artifacts.iter().enumerate() {
395        match artifact {
396            Artifact::Pivot(p) => {
397                pane.upsert_object(SceneObject::new(
398                    format!("pivot-{index}"),
399                    30,
400                    if p.confirmed { 1.0 } else { 0.5 },
401                    SceneObjectKind::Polyline {
402                        points: vec![(p.timestamp as f64, p.price), (p.timestamp as f64, p.price)],
403                        color: if p.is_high { PIVOT_HIGH } else { PIVOT_LOW }.to_string(),
404                        style: LineStyle::Solid,
405                        width: 2.0,
406                    },
407                ));
408            }
409            Artifact::Zone(z) => {
410                let Some((from, to)) = z.span().or(fallback_span) else {
411                    continue;
412                };
413                pane.upsert_object(SceneObject::new(
414                    format!("zone-{index}"),
415                    10,
416                    (0.15 + z.strength.clamp(0.0, 1.0) * 0.35).min(0.5),
417                    SceneObjectKind::BoundedBox {
418                        x0: from as f64,
419                        y0: z.price_top,
420                        x1: to as f64,
421                        y1: z.price_bottom,
422                        fill_color: Some(ZONE_FILL.to_string()),
423                        border_color: None,
424                    },
425                ));
426            }
427            Artifact::Profile(p) => {
428                let Some((from, to)) = p.span().or(fallback_span) else {
429                    continue;
430                };
431                let max_value = p.bins.iter().map(|b| b.value.abs()).fold(0.0_f64, f64::max);
432                if max_value <= 0.0 {
433                    continue;
434                }
435                // Bins grow leftwards from the profile's right edge, scaled by value.
436                let span = (to - from) as f64;
437                for (bin_index, bin) in p.bins.iter().enumerate() {
438                    let width = span * 0.25 * (bin.value.abs() / max_value);
439                    pane.upsert_object(SceneObject::new(
440                        format!("profile-{index}-{bin_index}"),
441                        5,
442                        0.35,
443                        SceneObjectKind::BoundedBox {
444                            x0: to as f64 - width,
445                            y0: bin.price_high,
446                            x1: to as f64,
447                            y1: bin.price_low,
448                            fill_color: Some(PROFILE_FILL.to_string()),
449                            border_color: None,
450                        },
451                    ));
452                }
453            }
454            Artifact::Scenario(s) => {
455                let Some((from, _)) = fallback_span else {
456                    continue;
457                };
458                pane.upsert_object(SceneObject::new(
459                    format!("scenario-{index}"),
460                    40,
461                    if s.invalidated { 0.4 } else { 1.0 },
462                    SceneObjectKind::Text {
463                        x: from as f64,
464                        y: 0.0,
465                        content: format!("{} · {} · {:.0}%", s.name, s.stage, s.progress * 100.0),
466                        color: TEXT.to_string(),
467                    },
468                ));
469            }
470        }
471    }
472
473    // Achsen aus den tatsächlich gezeichneten Objekten ableiten, damit die Szene ihre
474    // Domäne mitbringt statt sie dem Renderer zu überlassen.
475    let mut x_bounds: Option<(f64, f64)> = None;
476    let mut y_bounds: Option<(f64, f64)> = None;
477    let widen = |bounds: &mut Option<(f64, f64)>, value: f64| {
478        if !value.is_finite() {
479            return;
480        }
481        *bounds = Some(match *bounds {
482            None => (value, value),
483            Some((lo, hi)) => (lo.min(value), hi.max(value)),
484        });
485    };
486    for object in pane.objects() {
487        match &object.kind {
488            SceneObjectKind::Polyline { points, .. } | SceneObjectKind::Fill { points, .. } => {
489                for (x, y) in points {
490                    widen(&mut x_bounds, *x);
491                    widen(&mut y_bounds, *y);
492                }
493            }
494            SceneObjectKind::BoundedBox { x0, y0, x1, y1, .. } => {
495                widen(&mut x_bounds, *x0);
496                widen(&mut x_bounds, *x1);
497                widen(&mut y_bounds, *y0);
498                widen(&mut y_bounds, *y1);
499            }
500            SceneObjectKind::Text { x, y, .. } | SceneObjectKind::Tooltip { x, y, .. } => {
501                widen(&mut x_bounds, *x);
502                widen(&mut y_bounds, *y);
503            }
504            SceneObjectKind::Table { x, y, .. } => {
505                widen(&mut x_bounds, *x);
506                widen(&mut y_bounds, *y);
507            }
508        }
509    }
510    if let (Some((x_lo, x_hi)), Some((y_lo, y_hi))) = (x_bounds, y_bounds) {
511        pane.axes = vec![
512            Axis::time("time", x_lo as i64, x_hi as i64),
513            Axis::value("value", y_lo, y_hi),
514        ];
515    }
516
517    let mut scene = Scene::new();
518    scene.upsert_pane(pane);
519    scene
520}
521
522#[cfg(test)]
523mod artifact_scene_tests {
524    use super::*;
525    use crate::artifact::{PivotArtifact, ProfileArtifact, ProfileBin, ZoneArtifact};
526
527    fn objects(scene: &Scene) -> usize {
528        scene.panes().iter().map(|p| p.objects().len()).sum()
529    }
530
531    #[test]
532    fn a_zone_uses_its_own_span_over_the_fallback() {
533        let zone = ZoneArtifact::new("order_block", 105.0, 100.0).spanning(1_000, 2_000);
534        let scene = scene_from_artifacts(&[zone.into()], Some((0, 9_999)));
535
536        let object = &scene.panes()[0].objects()[0];
537        match &object.kind {
538            SceneObjectKind::BoundedBox { x0, x1, .. } => {
539                assert_eq!((*x0, *x1), (1_000.0, 2_000.0));
540            }
541            other => panic!("unexpected object: {other:?}"),
542        }
543    }
544
545    #[test]
546    fn a_zone_without_a_span_falls_back() {
547        let zone = ZoneArtifact::new("order_block", 105.0, 100.0);
548        let scene = scene_from_artifacts(&[zone.clone().into()], Some((0, 500)));
549        match &scene.panes()[0].objects()[0].kind {
550            SceneObjectKind::BoundedBox { x0, x1, .. } => {
551                assert_eq!((*x0, *x1), (0.0, 500.0));
552            }
553            other => panic!("unexpected object: {other:?}"),
554        }
555
556        // No span and no fallback: skipped rather than placed at an invented range.
557        let scene = scene_from_artifacts(&[zone.into()], None);
558        assert_eq!(objects(&scene), 0);
559    }
560
561    #[test]
562    fn a_pivot_is_placed_at_its_own_timestamp() {
563        let pivot = PivotArtifact {
564            timestamp: 4_242,
565            price: 101.0,
566            is_high: true,
567            confirmed: true,
568        };
569        let scene = scene_from_artifacts(&[pivot.into()], None);
570        match &scene.panes()[0].objects()[0].kind {
571            SceneObjectKind::Polyline { points, .. } => assert_eq!(points[0].0, 4_242.0),
572            other => panic!("unexpected object: {other:?}"),
573        }
574    }
575
576    #[test]
577    fn profile_bins_become_boxes_scaled_by_value() {
578        let profile = ProfileArtifact {
579            kind: "volume_profile".to_string(),
580            bins: vec![
581                ProfileBin {
582                    price_low: 100.0,
583                    price_high: 101.0,
584                    value: 10.0,
585                },
586                ProfileBin {
587                    price_low: 101.0,
588                    price_high: 102.0,
589                    value: 5.0,
590                },
591            ],
592            poc: 100.5,
593            value_area_high: 102.0,
594            value_area_low: 100.0,
595            from_ts: None,
596            to_ts: None,
597        }
598        .spanning(0, 1_000);
599
600        let scene = scene_from_artifacts(&[profile.into()], None);
601        assert_eq!(objects(&scene), 2);
602
603        let widths: Vec<f64> = scene.panes()[0]
604            .objects()
605            .iter()
606            .filter_map(|o| match &o.kind {
607                SceneObjectKind::BoundedBox { x0, x1, .. } => Some(x1 - x0),
608                _ => None,
609            })
610            .collect();
611        assert!(
612            widths[0] > widths[1],
613            "the heavier bin must be wider ({widths:?})"
614        );
615    }
616
617    #[test]
618    fn the_producer_declares_its_axes() {
619        let zone = ZoneArtifact::new("order_block", 105.0, 100.0).spanning(1_000, 2_000);
620        let scene = scene_from_artifacts(&[zone.into()], None);
621        let pane = &scene.panes()[0];
622
623        let x = pane
624            .axes
625            .iter()
626            .find(|a| a.kind == AxisKind::X)
627            .expect("x axis declared");
628        let y = pane
629            .axes
630            .iter()
631            .find(|a| a.kind == AxisKind::Y)
632            .expect("y axis declared");
633
634        assert_eq!((x.min, x.max), (1_000.0, 2_000.0));
635        assert_eq!((y.min, y.max), (100.0, 105.0));
636    }
637
638    #[test]
639    fn an_empty_scene_declares_no_axes() {
640        let scene = scene_from_artifacts(&[], None);
641        assert!(
642            scene.panes()[0].axes.is_empty(),
643            "no objects, nothing to bound — better than an invented range"
644        );
645    }
646
647    #[test]
648    fn every_emitted_color_satisfies_the_color_contract() {
649        let zone = ZoneArtifact::new("z", 2.0, 1.0).spanning(0, 10);
650        let pivot = PivotArtifact {
651            timestamp: 5,
652            price: 1.5,
653            is_high: false,
654            confirmed: false,
655        };
656        let scene = scene_from_artifacts(&[zone.into(), pivot.into()], None);
657
658        for object in scene.panes()[0].objects() {
659            let colors: Vec<&str> = match &object.kind {
660                SceneObjectKind::Polyline { color, .. } => vec![color],
661                SceneObjectKind::BoundedBox {
662                    fill_color,
663                    border_color,
664                    ..
665                } => fill_color
666                    .iter()
667                    .chain(border_color.iter())
668                    .map(|c| c.as_str())
669                    .collect(),
670                SceneObjectKind::Fill { color, .. } => vec![color],
671                SceneObjectKind::Text { color, .. } => vec![color],
672                _ => Vec::new(),
673            };
674            for color in colors {
675                assert_eq!(
676                    crate::viz::sanitize_color(color),
677                    color,
678                    "emitted color must survive sanitization unchanged"
679                );
680            }
681        }
682    }
683}