Skip to main content

kestrel_chartkit/viz/
scene_svg.rs

1//! SVG renderer for the [`crate::viz::scene::Scene`] model. Complements
2//! [`crate::viz::render_chart_svg`] (single-pane `ChartRenderData` DTO) with a renderer for the
3//! generic, multi-pane `Scene` IR — additive, not a replacement.
4
5use super::scene::{AxisKind, LineStyle, Pane, Scene, SceneObject, SceneObjectKind};
6use super::{escape_xml, sanitize_color};
7
8const TABLE_ROW_HEIGHT: f64 = 14.0;
9const TABLE_COL_WIDTH: f64 = 80.0;
10const TOOLTIP_HIT_SIZE: f64 = 10.0;
11
12/// Central style source for [`render_scene_svg`], mirroring the color values otherwise
13/// hardcoded in [`crate::viz::render_chart_svg`].
14#[derive(Debug, Clone, PartialEq)]
15pub struct Theme {
16    pub background: String,
17    pub text: String,
18    pub candle_bull: String,
19    pub candle_bear: String,
20    pub marker_buy: String,
21    pub marker_sell: String,
22    pub marker_exit: String,
23    pub marker_hold: String,
24    pub fallback: String,
25}
26
27impl Default for Theme {
28    fn default() -> Self {
29        Self {
30            background: "#141824".to_string(),
31            text: "#e0e6ed".to_string(),
32            candle_bull: "#26a69a".to_string(),
33            candle_bear: "#ef5350".to_string(),
34            marker_buy: "#00e676".to_string(),
35            marker_sell: "#ff1744".to_string(),
36            marker_exit: "#ff9100".to_string(),
37            marker_hold: "#29b6f6".to_string(),
38            fallback: "#29b6f6".to_string(),
39        }
40    }
41}
42
43/// Maps a pane's data coordinates to pixels inside its own rectangle.
44///
45/// Built from the pane's axes (see [`crate::viz::scene::Axis`]: x is time in Unix seconds).
46/// A pane that declares no usable axes gets [`Projection::identity`], which passes
47/// coordinates through as pixels — the behaviour of scenes written before the domain was
48/// declared.
49#[derive(Debug, Clone, Copy)]
50struct Projection {
51    x_min: f64,
52    x_span: f64,
53    y_min: f64,
54    y_span: f64,
55    width: f64,
56    height: f64,
57}
58
59impl Projection {
60    fn identity() -> Self {
61        Self {
62            x_min: 0.0,
63            x_span: 0.0,
64            y_min: 0.0,
65            y_span: 0.0,
66            width: 0.0,
67            height: 0.0,
68        }
69    }
70
71    fn for_pane(pane: &Pane, width: f64, height: f64) -> Self {
72        let axis = |kind: AxisKind| pane.axes.iter().find(|a| a.kind == kind);
73        match (axis(AxisKind::X), axis(AxisKind::Y)) {
74            (Some(x), Some(y)) if x.span() > 0.0 && y.span() > 0.0 => Self {
75                x_min: x.min,
76                x_span: x.span(),
77                y_min: y.min,
78                y_span: y.span(),
79                width,
80                height,
81            },
82            _ => Self::identity(),
83        }
84    }
85
86    fn is_identity(&self) -> bool {
87        self.x_span == 0.0 || self.y_span == 0.0
88    }
89
90    fn x(&self, value: f64) -> f64 {
91        if self.is_identity() {
92            return value;
93        }
94        (value - self.x_min) / self.x_span * self.width
95    }
96
97    /// Y is inverted: a higher value sits higher on screen, i.e. at a smaller pixel row.
98    fn y(&self, value: f64) -> f64 {
99        if self.is_identity() {
100            return value;
101        }
102        self.height - (value - self.y_min) / self.y_span * self.height
103    }
104}
105
106/// Renders `scene` as a self-contained SVG string. Panes are stacked vertically, each taking a
107/// share of `height` proportional to its `height_ratio` among sibling panes; within a pane,
108/// objects are emitted in `objects_z_ordered()` order.
109///
110/// Object coordinates are mapped through the pane's axes (x = Unix seconds, y = value). Panes
111/// without axes are drawn in pixel coordinates, as before.
112pub fn render_scene_svg(scene: &Scene, width: u32, height: u32, theme: &Theme) -> String {
113    let mut svg = String::new();
114    svg.push_str(&format!(
115        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" style="background-color:{};font-family:sans-serif;">"##,
116        width,
117        height,
118        sanitize_color(&theme.background)
119    ));
120
121    let total_ratio: f64 = scene.panes().iter().map(|p| p.height_ratio).sum();
122    let mut y_offset = 0.0;
123    for pane in scene.panes() {
124        let pane_height = if total_ratio > 0.0 {
125            height as f64 * pane.height_ratio / total_ratio
126        } else {
127            0.0
128        };
129
130        svg.push_str(&format!(
131            r##"<g transform="translate(0,{:.1})">"##,
132            y_offset
133        ));
134        let projection = Projection::for_pane(pane, width as f64, pane_height);
135        for object in pane.objects_z_ordered() {
136            svg.push_str(&render_object(object, theme, &projection));
137        }
138        svg.push_str("</g>");
139
140        y_offset += pane_height;
141    }
142
143    svg.push_str("</svg>");
144    svg
145}
146
147fn render_object(object: &SceneObject, theme: &Theme, p: &Projection) -> String {
148    let opacity = object.opacity;
149    match &object.kind {
150        SceneObjectKind::Polyline {
151            points,
152            color,
153            style,
154            width,
155        } => {
156            let points_str: Vec<String> = points
157                .iter()
158                .map(|&(x, y)| format!("{:.1},{:.1}", p.x(x), p.y(y)))
159                .collect();
160            let dash = match style {
161                LineStyle::Solid => "",
162                LineStyle::Dashed => r##" stroke-dasharray="6,4""##,
163                LineStyle::Dotted => r##" stroke-dasharray="2,2""##,
164            };
165            format!(
166                r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" opacity="{}"{}/>"##,
167                points_str.join(" "),
168                sanitize_color(color),
169                width,
170                opacity,
171                dash
172            )
173        }
174        SceneObjectKind::BoundedBox {
175            x0,
176            y0,
177            x1,
178            y1,
179            fill_color,
180            border_color,
181        } => {
182            let (px0, px1) = (p.x(*x0), p.x(*x1));
183            let (py0, py1) = (p.y(*y0), p.y(*y1));
184            let x = px0.min(px1);
185            let y = py0.min(py1);
186            let w = (px1 - px0).abs();
187            let h = (py1 - py0).abs();
188            let mut attrs = String::new();
189            if let Some(fc) = fill_color {
190                attrs.push_str(&format!(r##" fill="{}""##, sanitize_color(fc)));
191            }
192            if let Some(bc) = border_color {
193                attrs.push_str(&format!(r##" stroke="{}""##, sanitize_color(bc)));
194            }
195            format!(
196                r##"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" opacity="{}"{}/>"##,
197                x, y, w, h, opacity, attrs
198            )
199        }
200        SceneObjectKind::Fill { points, color } => {
201            let points_str: Vec<String> = points
202                .iter()
203                .map(|&(x, y)| format!("{:.1},{:.1}", p.x(x), p.y(y)))
204                .collect();
205            format!(
206                r##"<polygon points="{}" fill="{}" opacity="{}"/>"##,
207                points_str.join(" "),
208                sanitize_color(color),
209                opacity
210            )
211        }
212        SceneObjectKind::Text {
213            x,
214            y,
215            content,
216            color,
217        } => {
218            format!(
219                r##"<text x="{:.1}" y="{:.1}" fill="{}" opacity="{}">{}</text>"##,
220                p.x(*x),
221                p.y(*y),
222                sanitize_color(color),
223                opacity,
224                escape_xml(content)
225            )
226        }
227        SceneObjectKind::Tooltip { x, y, content } => {
228            format!(
229                r##"<rect x="{:.1}" y="{:.1}" width="{}" height="{}" fill="transparent" opacity="{}"><title>{}</title></rect>"##,
230                p.x(*x),
231                p.y(*y),
232                TOOLTIP_HIT_SIZE,
233                TOOLTIP_HIT_SIZE,
234                opacity,
235                escape_xml(content)
236            )
237        }
238        SceneObjectKind::Table { x, y, rows } => {
239            // Der Ankerpunkt liegt in Datenkoordinaten, Zeilenhöhe und Spaltenbreite
240            // sind Pixelmaße: eine Tabelle ist ein Bedienelement, keine Geometrie.
241            let anchor_x = p.x(*x);
242            let anchor_y = p.y(*y);
243            let mut s = String::new();
244            for (row_idx, row) in rows.iter().enumerate() {
245                let row_y = anchor_y + row_idx as f64 * TABLE_ROW_HEIGHT;
246                for (col_idx, cell) in row.iter().enumerate() {
247                    let cell_x = anchor_x + col_idx as f64 * TABLE_COL_WIDTH;
248                    s.push_str(&format!(
249                        r##"<text x="{:.1}" y="{:.1}" fill="{}" text-anchor="start" opacity="{}">{}</text>"##,
250                        cell_x,
251                        row_y,
252                        sanitize_color(&theme.text),
253                        opacity,
254                        escape_xml(cell)
255                    ));
256                }
257            }
258            s
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::viz::scene::Pane;
267
268    #[test]
269    fn test_empty_scene_renders_valid_empty_svg() {
270        let scene = Scene::new();
271        let svg = render_scene_svg(&scene, 800, 400, &Theme::default());
272        assert!(svg
273            .starts_with(r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="400""##));
274        assert!(svg.ends_with("</svg>"));
275        assert!(!svg.contains("<g "));
276    }
277
278    /// A pane that declares axes gets its coordinates mapped: the time range spans the
279    /// full width, and the value range fills the height with y inverted.
280    #[test]
281    fn test_declared_axes_map_data_coordinates_to_pixels() {
282        use super::super::scene::Axis;
283
284        let mut pane = Pane::new("price", 1.0);
285        pane.axes = vec![
286            Axis::time("time", 1_000, 2_000),
287            Axis::value("price", 100.0, 200.0),
288        ];
289        pane.upsert_object(SceneObject::new(
290            "line",
291            0,
292            1.0,
293            SceneObjectKind::Polyline {
294                points: vec![(1_000.0, 100.0), (2_000.0, 200.0)],
295                color: "#ffffff".to_string(),
296                style: LineStyle::Solid,
297                width: 1.0,
298            },
299        ));
300        let mut scene = Scene::new();
301        scene.upsert_pane(pane);
302
303        let svg = render_scene_svg(&scene, 400, 200, &Theme::default());
304
305        // Start: left edge, bottom (lowest price). End: right edge, top (highest price).
306        assert!(
307            svg.contains(r##"points="0.0,200.0 400.0,0.0""##),
308            "unexpected mapping: {svg}"
309        );
310    }
311
312    /// A pane without axes keeps the old behaviour: coordinates are pixels.
313    #[test]
314    fn test_a_pane_without_axes_passes_coordinates_through() {
315        let mut pane = Pane::new("legacy", 1.0);
316        pane.upsert_object(SceneObject::new(
317            "line",
318            0,
319            1.0,
320            SceneObjectKind::Polyline {
321                points: vec![(10.0, 20.0), (30.0, 40.0)],
322                color: "#ffffff".to_string(),
323                style: LineStyle::Solid,
324                width: 1.0,
325            },
326        ));
327        let mut scene = Scene::new();
328        scene.upsert_pane(pane);
329
330        let svg = render_scene_svg(&scene, 400, 200, &Theme::default());
331        assert!(svg.contains(r##"points="10.0,20.0 30.0,40.0""##));
332    }
333
334    /// A degenerate range must not divide by zero — it falls back to pass-through.
335    #[test]
336    fn test_a_degenerate_axis_range_falls_back_to_pixels() {
337        use super::super::scene::Axis;
338
339        let mut pane = Pane::new("flat", 1.0);
340        pane.axes = vec![
341            Axis::time("time", 1_000, 1_000),
342            Axis::value("price", 100.0, 100.0),
343        ];
344        pane.upsert_object(SceneObject::new(
345            "line",
346            0,
347            1.0,
348            SceneObjectKind::Polyline {
349                points: vec![(10.0, 20.0), (30.0, 40.0)],
350                color: "#ffffff".to_string(),
351                style: LineStyle::Solid,
352                width: 1.0,
353            },
354        ));
355        let mut scene = Scene::new();
356        scene.upsert_pane(pane);
357
358        let svg = render_scene_svg(&scene, 400, 200, &Theme::default());
359        assert!(svg.contains(r##"points="10.0,20.0 30.0,40.0""##));
360    }
361
362    #[test]
363    fn test_xml_escaping_in_text() {
364        let mut pane = Pane::new("price", 1.0);
365        pane.upsert_object(SceneObject::new(
366            "t",
367            0,
368            1.0,
369            SceneObjectKind::Text {
370                x: 0.0,
371                y: 0.0,
372                content: "<A> & \"B\"".to_string(),
373                color: "#fff".to_string(),
374            },
375        ));
376        let mut scene = Scene::new();
377        scene.upsert_pane(pane);
378
379        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
380        assert!(svg.contains("&lt;A&gt; &amp; &quot;B&quot;"));
381    }
382
383    #[test]
384    fn test_color_sanitization_fallback() {
385        let mut pane = Pane::new("price", 1.0);
386        pane.upsert_object(SceneObject::new(
387            "t",
388            0,
389            1.0,
390            SceneObjectKind::Text {
391                x: 0.0,
392                y: 0.0,
393                content: "x".to_string(),
394                color: "not-a-color".to_string(),
395            },
396        ));
397        let mut scene = Scene::new();
398        scene.upsert_pane(pane);
399
400        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
401        assert!(svg.contains("#29b6f6"));
402    }
403
404    #[test]
405    fn test_z_order_reflected_in_output_order() {
406        let mut pane = Pane::new("price", 1.0);
407        pane.upsert_object(SceneObject::new(
408            "top",
409            10,
410            1.0,
411            SceneObjectKind::Text {
412                x: 0.0,
413                y: 0.0,
414                content: "TOPMARK".to_string(),
415                color: "#fff".to_string(),
416            },
417        ));
418        pane.upsert_object(SceneObject::new(
419            "bottom",
420            -5,
421            1.0,
422            SceneObjectKind::Text {
423                x: 0.0,
424                y: 0.0,
425                content: "BOTTOMMARK".to_string(),
426                color: "#fff".to_string(),
427            },
428        ));
429        let mut scene = Scene::new();
430        scene.upsert_pane(pane);
431
432        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
433        let bottom_pos = svg.find("BOTTOMMARK").unwrap();
434        let top_pos = svg.find("TOPMARK").unwrap();
435        assert!(bottom_pos < top_pos, "lower z_order must render first");
436    }
437
438    #[test]
439    fn test_opacity_attribute_present() {
440        let mut pane = Pane::new("price", 1.0);
441        pane.upsert_object(SceneObject::new(
442            "t",
443            0,
444            0.5,
445            SceneObjectKind::Text {
446                x: 0.0,
447                y: 0.0,
448                content: "x".to_string(),
449                color: "#fff".to_string(),
450            },
451        ));
452        let mut scene = Scene::new();
453        scene.upsert_pane(pane);
454
455        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
456        assert!(svg.contains(r##"opacity="0.5""##));
457    }
458
459    #[test]
460    fn test_dashed_and_dotted_lines_produce_stroke_dasharray() {
461        let mut pane = Pane::new("price", 1.0);
462        pane.upsert_object(SceneObject::new(
463            "dashed",
464            0,
465            1.0,
466            SceneObjectKind::Polyline {
467                points: vec![(0.0, 0.0), (1.0, 1.0)],
468                color: "#fff".to_string(),
469                style: LineStyle::Dashed,
470                width: 1.0,
471            },
472        ));
473        pane.upsert_object(SceneObject::new(
474            "dotted",
475            1,
476            1.0,
477            SceneObjectKind::Polyline {
478                points: vec![(0.0, 0.0), (1.0, 1.0)],
479                color: "#fff".to_string(),
480                style: LineStyle::Dotted,
481                width: 1.0,
482            },
483        ));
484        let mut scene = Scene::new();
485        scene.upsert_pane(pane);
486
487        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
488        assert!(svg.contains(r##"stroke-dasharray="6,4""##));
489        assert!(svg.contains(r##"stroke-dasharray="2,2""##));
490    }
491
492    #[test]
493    fn test_bounded_box_omits_unset_fill_and_stroke() {
494        let mut pane = Pane::new("price", 1.0);
495        pane.upsert_object(SceneObject::new(
496            "box",
497            0,
498            1.0,
499            SceneObjectKind::BoundedBox {
500                x0: 0.0,
501                y0: 0.0,
502                x1: 10.0,
503                y1: 10.0,
504                fill_color: None,
505                border_color: None,
506            },
507        ));
508        let mut scene = Scene::new();
509        scene.upsert_pane(pane);
510
511        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
512        assert!(!svg.contains("fill="));
513        assert!(!svg.contains("stroke="));
514    }
515
516    #[test]
517    fn test_table_grid_layout() {
518        let mut pane = Pane::new("price", 1.0);
519        pane.upsert_object(SceneObject::new(
520            "table",
521            0,
522            1.0,
523            SceneObjectKind::Table {
524                x: 0.0,
525                y: 0.0,
526                rows: vec![
527                    vec!["a".to_string(), "b".to_string()],
528                    vec!["c".to_string(), "d".to_string()],
529                ],
530            },
531        ));
532        let mut scene = Scene::new();
533        scene.upsert_pane(pane);
534
535        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
536        assert_eq!(svg.matches("<text").count(), 4);
537        assert!(svg.contains(r##"x="0.0" y="0.0""##));
538        assert!(svg.contains(&format!(r##"x="{:.1}" y="0.0""##, TABLE_COL_WIDTH)));
539        assert!(svg.contains(&format!(r##"x="0.0" y="{:.1}""##, TABLE_ROW_HEIGHT)));
540    }
541
542    #[test]
543    fn test_tooltip_renders_nested_title() {
544        let mut pane = Pane::new("price", 1.0);
545        pane.upsert_object(SceneObject::new(
546            "tip",
547            0,
548            1.0,
549            SceneObjectKind::Tooltip {
550                x: 5.0,
551                y: 5.0,
552                content: "hover <me>".to_string(),
553            },
554        ));
555        let mut scene = Scene::new();
556        scene.upsert_pane(pane);
557
558        let svg = render_scene_svg(&scene, 100, 100, &Theme::default());
559        assert!(svg.contains("<title>hover &lt;me&gt;</title>"));
560        assert!(svg.contains("fill=\"transparent\""));
561    }
562
563    #[test]
564    fn test_multi_pane_height_split_by_ratio() {
565        let mut price = Pane::new("price", 3.0);
566        price.upsert_object(SceneObject::new(
567            "p",
568            0,
569            1.0,
570            SceneObjectKind::Text {
571                x: 0.0,
572                y: 0.0,
573                content: "P".to_string(),
574                color: "#fff".to_string(),
575            },
576        ));
577        let mut volume = Pane::new("volume", 1.0);
578        volume.upsert_object(SceneObject::new(
579            "v",
580            0,
581            1.0,
582            SceneObjectKind::Text {
583                x: 0.0,
584                y: 0.0,
585                content: "V".to_string(),
586                color: "#fff".to_string(),
587            },
588        ));
589        let mut scene = Scene::new();
590        scene.upsert_pane(price);
591        scene.upsert_pane(volume);
592
593        let svg = render_scene_svg(&scene, 100, 400, &Theme::default());
594        assert!(svg.contains(r##"<g transform="translate(0,0.0)">"##));
595        assert!(svg.contains(r##"<g transform="translate(0,300.0)">"##));
596    }
597}