1use 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#[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#[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 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
106pub 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 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 #[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 assert!(
307 svg.contains(r##"points="0.0,200.0 400.0,0.0""##),
308 "unexpected mapping: {svg}"
309 );
310 }
311
312 #[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 #[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("<A> & "B""));
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 <me></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}