Skip to main content

kestrel_chartkit/viz/
mod.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::model::Bar;
5use crate::signal::TriggerAction;
6
7/// Renderer-neutral scene model: panes, axes, z-ordered/opacity-tagged objects, and dynamic
8/// (identity-keyed) object updates.
9pub mod scene;
10pub use scene::{
11    scene_from_artifacts, Axis, AxisKind, LineStyle, Pane, Scene, SceneObject, SceneObjectKind,
12};
13
14/// SVG renderer for the [`Scene`] model.
15mod scene_svg;
16pub use scene_svg::{render_scene_svg, Theme};
17
18/// Interactive Render DTO for charting frontend (Web, Canvas, Tauri, Terminal GUI).
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21pub struct ChartRenderData {
22    pub title: String,
23    pub bars: Vec<ChartBarData>,
24    pub series: Vec<ChartSeries>,
25    pub zones: Vec<ChartZoneData>,
26    pub markers: Vec<ChartMarkerData>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
31pub struct ChartBarData {
32    pub timestamp: i64,
33    pub open: f64,
34    pub high: f64,
35    pub low: f64,
36    pub close: f64,
37    pub volume: f64,
38}
39
40impl From<&Bar> for ChartBarData {
41    fn from(b: &Bar) -> Self {
42        Self {
43            timestamp: b.timestamp,
44            open: b.open,
45            high: b.high,
46            low: b.low,
47            close: b.close,
48            volume: b.volume,
49        }
50    }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55pub struct ChartSeries {
56    pub name: String,
57    pub color: String,
58    pub points: Vec<(i64, f64)>,
59}
60
61#[derive(Debug, Clone, PartialEq)]
62#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
63pub struct ChartZoneData {
64    pub name: String,
65    pub price_top: f64,
66    pub price_bottom: f64,
67    pub color: String,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
72pub struct ChartMarkerData {
73    pub timestamp: i64,
74    pub price: f64,
75    pub label: String,
76    pub action: TriggerAction,
77}
78
79pub(crate) fn escape_xml(s: &str) -> String {
80    s.replace('&', "&amp;")
81        .replace('<', "&lt;")
82        .replace('>', "&gt;")
83        .replace('"', "&quot;")
84        .replace('\'', "&apos;")
85}
86
87/// Accepts a hex literal or a CSS custom-property reference, and nothing else.
88///
89/// The hex case is the obvious one. The `var(--name)` case exists because a fixed colour cannot
90/// serve two themes: a chart exported once has to render in both a light and a dark page, and only
91/// the page knows which is current. Emitting a variable reference lets the consumer map the
92/// library's colour decisions onto its own token system without the library knowing anything about
93/// themes — which is the vendor-neutral way round.
94///
95/// A fallback (`var(--x, #fff)`) is deliberately not accepted: it would put a second colour
96/// decision inside a string this function cannot check, and an unresolvable variable should show
97/// up as an obviously wrong chart rather than quietly render in a colour nobody chose.
98/// The color contract for scene and chart objects.
99///
100/// A color is a CSS-compatible string in one of these forms:
101///
102/// - `#rgb`, `#rrggbb`, `#rrggbbaa` — hex literals, the last one carrying alpha
103/// - `var(--name)` — a CSS custom property, so a host can theme a rendered chart
104///
105/// Anything else is replaced by a neutral fallback rather than dropped: an object in
106/// the wrong color still carries its information, a missing one does not.
107///
108/// Renderers other than the built-in SVG one should apply this same function so that
109/// a scene looks the same wherever it is drawn.
110pub fn sanitize_color(c: &str) -> String {
111    let trimmed = c.trim();
112    if is_hex_literal(trimmed) || is_css_variable(trimmed) {
113        trimmed.to_string()
114    } else {
115        "#29b6f6".to_string()
116    }
117}
118
119/// `#rgb`, `#rrggbb` or `#rrggbbaa`.
120fn is_hex_literal(c: &str) -> bool {
121    (c.len() == 4 || c.len() == 7 || c.len() == 9)
122        && c.starts_with('#')
123        && c[1..].chars().all(|ch| ch.is_ascii_hexdigit())
124}
125
126/// `var(--name)` with a conservative name charset — letters, digits, hyphen, underscore.
127fn is_css_variable(c: &str) -> bool {
128    let Some(inner) = c.strip_prefix("var(").and_then(|r| r.strip_suffix(')')) else {
129        return false;
130    };
131    let name = inner.trim();
132    name.starts_with("--")
133        && name.len() > 2
134        && name[2..]
135            .chars()
136            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
137}
138
139/// Static SVG Renderer producing clean SVG string charts for CLI previews or reports.
140pub fn render_chart_svg(data: &ChartRenderData, width: u32, height: u32) -> String {
141    let safe_title = escape_xml(&data.title);
142
143    if data.bars.is_empty() {
144        return format!(
145            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}"><text x="10" y="20" fill="red">{}</text></svg>"##,
146            width, height, safe_title
147        );
148    }
149
150    let min_price = data.bars.iter().map(|b| b.low).fold(f64::MAX, f64::min);
151    let max_price = data.bars.iter().map(|b| b.high).fold(f64::MIN, f64::max);
152    let price_range = (max_price - min_price).max(1e-8);
153
154    let margin_top = 40.0;
155    let margin_bottom = 30.0;
156    let margin_left = 20.0;
157    let margin_right = 60.0;
158
159    let chart_w = width as f64 - margin_left - margin_right;
160    let chart_h = height as f64 - margin_top - margin_bottom;
161
162    let to_y = |p: f64| -> f64 { margin_top + (1.0 - (p - min_price) / price_range) * chart_h };
163
164    let n = data.bars.len();
165    let bar_w = (chart_w / n as f64).max(1.0);
166
167    // Map timestamps to X coordinates
168    let min_ts = data.bars.first().map(|b| b.timestamp).unwrap_or(0);
169    let max_ts = data.bars.last().map(|b| b.timestamp).unwrap_or(1);
170    let ts_range = (max_ts - min_ts).max(1) as f64;
171
172    let get_x_for_ts = |ts: i64| -> f64 {
173        if n == 1 {
174            return margin_left + chart_w / 2.0;
175        }
176        let ratio = (ts - min_ts) as f64 / ts_range;
177        margin_left + ratio * chart_w
178    };
179
180    let mut svg = String::new();
181    svg.push_str(&format!(
182        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" style="background-color:#141824;font-family:sans-serif;">"##,
183        width, height
184    ));
185
186    // Title
187    svg.push_str(&format!(
188        r##"<text x="20" y="25" fill="#e0e6ed" font-size="16" font-weight="bold">{}</text>"##,
189        safe_title
190    ));
191
192    // Render Zones
193    for zone in &data.zones {
194        let y_top = to_y(zone.price_top);
195        let y_bot = to_y(zone.price_bottom);
196        let zone_h = (y_bot - y_top).abs().max(1.0);
197        let min_y = y_top.min(y_bot);
198        let color = sanitize_color(&zone.color);
199        let safe_name = escape_xml(&zone.name);
200
201        svg.push_str(&format!(
202            r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" opacity="0.2"/>"##,
203            margin_left, min_y, chart_w, zone_h, color
204        ));
205        svg.push_str(&format!(
206            r##"<text x="{}" y="{}" fill="{}" font-size="10" opacity="0.8">{}</text>"##,
207            margin_left + 5.0,
208            min_y + 12.0,
209            color,
210            safe_name
211        ));
212    }
213
214    // Render Candlesticks
215    for (i, b) in data.bars.iter().enumerate() {
216        let x = margin_left + i as f64 * bar_w + bar_w / 2.0;
217        let y_high = to_y(b.high);
218        let y_low = to_y(b.low);
219        let y_open = to_y(b.open);
220        let y_close = to_y(b.close);
221
222        let is_bull = b.close >= b.open;
223        let color = if is_bull { "#26a69a" } else { "#ef5350" };
224
225        // Wick
226        svg.push_str(&format!(
227            r##"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>"##,
228            x, y_high, x, y_low, color
229        ));
230
231        // Body
232        let body_top = y_open.min(y_close);
233        let body_h = (y_open - y_close).abs().max(1.0);
234        let body_w = (bar_w * 0.7).max(1.0);
235        svg.push_str(&format!(
236            r##"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}"/>"##,
237            x - body_w / 2.0,
238            body_top,
239            body_w,
240            body_h,
241            color
242        ));
243    }
244
245    // Render Series Lines
246    for s in &data.series {
247        if s.points.is_empty() {
248            continue;
249        }
250        let color = sanitize_color(&s.color);
251        let points_str: Vec<String> = s
252            .points
253            .iter()
254            .map(|&(ts, val)| format!("{:.1},{:.1}", get_x_for_ts(ts), to_y(val)))
255            .collect();
256
257        svg.push_str(&format!(
258            r##"<polyline points="{}" fill="none" stroke="{}" stroke-width="1.5"/>"##,
259            points_str.join(" "),
260            color
261        ));
262    }
263
264    // Render Markers positioned by timestamp
265    for m in &data.markers {
266        let x = get_x_for_ts(m.timestamp);
267        let y = to_y(m.price);
268        let m_color = match m.action {
269            TriggerAction::Buy => "#00e676",
270            TriggerAction::Sell => "#ff1744",
271            TriggerAction::Exit => "#ff9100",
272            TriggerAction::Hold => "#29b6f6",
273        };
274        let safe_label = escape_xml(&m.label);
275
276        svg.push_str(&format!(
277            r##"<circle cx="{:.1}" cy="{:.1}" r="5" fill="{}"/>"##,
278            x, y, m_color
279        ));
280        svg.push_str(&format!(
281            r##"<text x="{:.1}" y="{:.1}" fill="{}" font-size="9" font-weight="bold">{}</text>"##,
282            x + 7.0,
283            y + 3.0,
284            m_color,
285            safe_label
286        ));
287    }
288
289    svg.push_str("</svg>");
290    svg
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_svg_xml_escaping_and_color_sanitization() {
299        let bars = vec![
300            ChartBarData {
301                timestamp: 1000,
302                open: 100.0,
303                high: 105.0,
304                low: 95.0,
305                close: 104.0,
306                volume: 1000.0,
307            },
308            ChartBarData {
309                timestamp: 2000,
310                open: 104.0,
311                high: 108.0,
312                low: 102.0,
313                close: 107.0,
314                volume: 1500.0,
315            },
316        ];
317        let data = ChartRenderData {
318            title: "<Chart> & \"Test\"".to_string(),
319            bars,
320            zones: vec![ChartZoneData {
321                name: "Zone <A>".to_string(),
322                price_top: 108.0,
323                price_bottom: 105.0,
324                color: "invalid_color_script".to_string(),
325            }],
326            series: vec![ChartSeries {
327                name: "SMA 20".to_string(),
328                points: vec![(1000, 98.0), (2000, 103.0)],
329                color: "#00ff00".to_string(),
330            }],
331            markers: vec![
332                ChartMarkerData {
333                    timestamp: 1000,
334                    price: 100.0,
335                    label: "Buy & Hold <NOW>".to_string(),
336                    action: TriggerAction::Buy,
337                },
338                ChartMarkerData {
339                    timestamp: 2000,
340                    price: 107.0,
341                    label: "Exit".to_string(),
342                    action: TriggerAction::Exit,
343                },
344            ],
345        };
346
347        let svg = render_chart_svg(&data, 800, 400);
348
349        // Escaped text
350        assert!(svg.contains("&lt;Chart&gt; &amp; &quot;Test&quot;"));
351        assert!(svg.contains("Zone &lt;A&gt;"));
352        assert!(svg.contains("Buy &amp; Hold &lt;NOW&gt;"));
353
354        // Sanitized color (fallback to #29b6f6)
355        assert!(svg.contains("#29b6f6"));
356
357        // Series polyline
358        assert!(svg.contains("<polyline points="));
359
360        // Markers at the first/last timestamp receive distinct X coordinates.
361        assert!(svg.contains("<circle cx=\"20.0\""));
362        assert!(svg.contains("<circle cx=\"740.0\""));
363    }
364}
365
366#[cfg(test)]
367mod color_tests {
368    use super::sanitize_color;
369
370    #[test]
371    fn accepts_hex_literals() {
372        assert_eq!(sanitize_color("#abc"), "#abc");
373        assert_eq!(sanitize_color("#1a2B3c"), "#1a2B3c");
374        assert_eq!(sanitize_color("  #ffffff  "), "#ffffff");
375    }
376
377    #[test]
378    fn accepts_css_variables() {
379        assert_eq!(
380            sanitize_color("var(--chart-bullish)"),
381            "var(--chart-bullish)"
382        );
383        assert_eq!(sanitize_color("var(--x_1)"), "var(--x_1)");
384    }
385
386    #[test]
387    fn rejects_everything_else() {
388        // Anything that could carry a second colour decision, an expression, or markup.
389        for hostile in [
390            "red",
391            "rgb(1,2,3)",
392            "var(--x, #fff)",
393            "var(--x);fill:url(#y)",
394            "var(--x)\"onload=\"alert(1)",
395            "url(#gradient)",
396            "#12345",
397            "var(--)",
398            "var(x)",
399        ] {
400            assert_eq!(
401                sanitize_color(hostile),
402                "#29b6f6",
403                "must reject {hostile:?}"
404            );
405        }
406    }
407}