1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23pub enum AxisKind {
24 X,
26 Y,
28}
29
30#[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 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 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 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#[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 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 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 pub z_order: i32,
143 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 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 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 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#[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 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
373pub 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 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 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 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}