Skip to main content

egui_charts/drawings/
persistence.rs

1//! Drawing persistence -- JSON serialization and storage.
2//!
3//! This module provides types for serializing drawings to and from JSON. The key
4//! design decision is that anchors are stored as `(timestamp, price)` pairs
5//! (`StoredCoord`) rather than bar indices or screen positions, so drawings
6//! survive pan/zoom, resolution changes and history being prepended.
7//!
8//! # Conversion flow
9//!
10//! ```text
11//! Drawing (in-memory)                    StoredDrawing (serializable)
12//! ─────────────────                      ────────────────────────────
13//! chart_points: Vec<ChartPoint> ──────►  points: Vec<StoredCoord>
14//! (bar index + price)  drawing_to_stored()  (timestamp + price)
15//! tool type and settings        ──────►  drawing: Drawing
16//!
17//! chart_points: Vec<ChartPoint> ◄──────  points: Vec<StoredCoord>
18//!                      stored_to_drawing()
19//! ```
20//!
21//! Screen points are not stored: [`Drawing::update_screen_coords`] rebuilds
22//! them from the anchors on the next frame.
23//!
24//! A restored drawing keeps the id, creation time and notes of its record in
25//! `Drawing::stored_id`, `created_at` and `notes`, and is saved back under
26//! them. Restore drawings through
27//! [`DrawingManager::add_synced_drawing`](crate::drawings::DrawingManager::add_synced_drawing),
28//! which refuses a drawing that saving would refuse; the manager then gives a new drawing an
29//! id that no restored drawing uses. Two managers restoring the same storage can still hand
30//! out the same id: one storage per manager is the supported use.
31//!
32//! # Storage format
33//!
34//! Drawings are grouped by symbol into a `DrawingStorage` container that
35//! includes a format version, metadata, and an optional timeframe filter.
36//! Loading migrates versions 1 to 5, rejects versions it does not know and
37//! skips any drawing it cannot read
38//! (see [`from_json`](crate::drawings::persistence::DrawingStorage::from_json)).
39
40use crate::drawings::{ChartPoint, Drawing, DrawingToolType, ParseToolTypeError, PositionSize};
41use serde::de::IntoDeserializer;
42use serde::{Deserialize, Serialize};
43use std::collections::HashSet;
44use std::fmt;
45
46/// A coordinate stored as a `(timestamp, price)` pair for persistence.
47///
48/// Unlike screen-space coordinates ([`Pos2`](egui::Pos2)) and bar indices,
49/// these values are independent of chart pan/zoom and of the loaded history,
50/// making them suitable for long-term storage and cross-device synchronization.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct StoredCoord {
53    /// Timestamp in Unix epoch milliseconds.
54    pub ts_ms: i64,
55    /// Nanoseconds after `ts_ms`, less than 1,000,000. Older documents default to zero.
56    #[serde(default, skip_serializing_if = "is_zero")]
57    pub subms_ns: u32,
58    /// Price level at this coordinate.
59    pub price: f64,
60}
61
62impl StoredCoord {
63    /// Creates a new stored coordinate from a timestamp and price.
64    pub fn new(ts_ms: i64, price: f64) -> Self {
65        Self {
66            ts_ms,
67            subms_ns: 0,
68            price,
69        }
70    }
71
72    pub(crate) fn from_time(time: chrono::DateTime<chrono::Utc>, price: f64) -> Self {
73        Self {
74            ts_ms: time.timestamp_millis(),
75            subms_ns: time.timestamp_subsec_nanos() % 1_000_000,
76            price,
77        }
78    }
79
80    pub(crate) fn time(&self) -> Option<chrono::DateTime<chrono::Utc>> {
81        if self.subms_ns >= 1_000_000 {
82            return None;
83        }
84        chrono::DateTime::from_timestamp_millis(self.ts_ms)?
85            .checked_add_signed(chrono::Duration::nanoseconds(i64::from(self.subms_ns)))
86    }
87}
88
89fn is_zero(value: &u32) -> bool {
90    *value == 0
91}
92
93/// A fully serializable representation of a drawing with all its properties.
94///
95/// This is the storage/wire format for drawings: the anchors as
96/// `(timestamp, price)` coordinates and the [`Drawing`] itself for the tool
97/// type and every setting. Convert between the two with [`drawing_to_stored`]
98/// and [`stored_to_drawing`].
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct StoredDrawing {
101    /// Unique identifier (string for backend compatibility).
102    pub id: String,
103    /// Anchor points in `(timestamp, price)` coordinates.
104    pub points: Vec<StoredCoord>,
105    /// Tool type and settings; its id, points and completion state are not stored.
106    pub drawing: Drawing,
107    /// Creation ts
108    pub created_at: i64,
109    /// Last modified ts
110    pub modified_at: i64,
111    /// User notes
112    pub notes: Option<String>,
113    /// The study pane holding the drawing, which a chart resolves to its own pane with the same
114    /// studies, or to the price pane when it has none, since study ids are local to each chart.
115    /// `None` for the price pane, and in records written before it was stored, which keep the
116    /// study id in `drawing.pane`.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub pane: Option<StoredPane>,
119}
120
121/// A study pane identified by its studies rather than by a chart's study ids.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct StoredPane {
124    /// The name of each study in the pane, in legend order, with its input ids and their values
125    /// as serialized [`InputValue`](crate::studies::InputValue)s.
126    pub studies: Vec<(String, Vec<(String, serde_json::Value)>)>,
127    /// The pane's rank, from the top, among the chart's panes holding the same studies.
128    pub rank: usize,
129}
130
131impl StoredDrawing {
132    /// Create a new stored drawing without anchor points
133    pub fn new(id: impl Into<String>, drawing: Drawing) -> Self {
134        let now = chrono::Utc::now().timestamp_millis();
135        Self {
136            id: id.into(),
137            points: Vec::new(),
138            drawing,
139            created_at: now,
140            modified_at: now,
141            notes: None,
142            pane: None,
143        }
144    }
145
146    /// Add a point
147    pub fn add_point(&mut self, point: StoredCoord) {
148        self.points.push(point);
149        self.modified_at = chrono::Utc::now().timestamp_millis();
150    }
151
152    /// The anchors the tool needs when this drawing has fewer, as `(needed, held)`.
153    fn missing_anchors(&self) -> Option<(usize, usize)> {
154        let needed = self.drawing.tool_type.anchors_needed();
155        (self.points.len() < needed).then_some((needed, self.points.len()))
156    }
157
158    /// The first field holding NaN or an infinity, which JSON cannot store, or a size or
159    /// pitchfork level outside the range drawings are drawn at.
160    pub(super) fn invalid_field(&self) -> Option<String> {
161        use super::validation::{MAX_IMAGE_SIDE, MAX_PITCHFORK_LEVEL, MAX_STROKE_WIDTH};
162        use crate::styles::typography::FONT_SIZES;
163        let d = &self.drawing;
164        let bad = |value: f64| !value.is_finite();
165        if !(d.stroke_width > 0.0 && d.stroke_width <= MAX_STROKE_WIDTH) {
166            return Some("stroke_width".into());
167        }
168        if let Some(i) = d
169            .pitchfork
170            .iter()
171            .flat_map(|c| &c.levels)
172            .position(|l| l.width.is_some_and(|w| !(w > 0.0 && w <= MAX_STROKE_WIDTH)))
173        {
174            return Some(format!("pitchfork.levels[{i}].width"));
175        }
176        if d.screen_anchor
177            .is_some_and(|anchor| anchor.into_iter().any(|value| !value.is_finite()))
178        {
179            return Some("screen_anchor".into());
180        }
181        if d.image.and_then(|c| c.size).is_some_and(|size| {
182            size.into_iter()
183                .any(|side| !(1.0..=MAX_IMAGE_SIDE).contains(&side))
184        }) {
185            return Some("image.size".into());
186        }
187        if !FONT_SIZES.contains(&d.font_size) {
188            return Some("font_size".into());
189        }
190        if let Some(i) = self.points.iter().position(|p| bad(p.price)) {
191            return Some(format!("points[{i}].price"));
192        }
193        if d.ghost_feed.is_some_and(|c| !c.is_valid()) {
194            return Some("ghost_feed".into());
195        }
196        let sizing = d.position_sizing;
197        if [
198            sizing.point_value,
199            sizing.lot_size,
200            sizing.quantity_step,
201            sizing.leverage,
202        ]
203        .into_iter()
204        .any(bad)
205        {
206            return Some("position_sizing".into());
207        }
208        let size = match d.position_size {
209            Some(PositionSize::Quantity(value) | PositionSize::RiskAmount(value)) => [value, 0.0],
210            Some(PositionSize::RiskPercent {
211                account_size,
212                percent,
213            }) => [account_size, percent],
214            None => [0.0; 2],
215        };
216        if size.into_iter().any(bad) {
217            return Some("position_size".into());
218        }
219        if d.fib_config.as_ref().is_some_and(|c| {
220            !c.level_width.is_finite()
221                || c.level_width <= 0.0
222                || c.level_width > super::validation::MAX_STROKE_WIDTH
223        }) {
224            return Some("fib_config.level_width".into());
225        }
226        for (name, values) in [
227            (
228                "fib_config.levels",
229                d.fib_config
230                    .iter()
231                    .flat_map(|c| c.levels.iter().map(|l| l.value))
232                    .collect::<Vec<_>>(),
233            ),
234            (
235                "pitchfork.levels",
236                d.pitchfork
237                    .iter()
238                    .flat_map(|c| c.levels.iter().map(|l| l.value))
239                    .collect(),
240            ),
241            (
242                "gann.price_levels",
243                d.gann
244                    .iter()
245                    .flat_map(|c| c.price_levels.iter().map(|l| l.value))
246                    .collect(),
247            ),
248            (
249                "gann.time_levels",
250                d.gann
251                    .iter()
252                    .flat_map(|c| c.time_levels.iter().map(|l| l.value))
253                    .collect(),
254            ),
255            (
256                "gann.fan_ratios",
257                d.gann
258                    .iter()
259                    .flat_map(|c| c.fan_ratios.iter().map(|l| l.value))
260                    .collect(),
261            ),
262            (
263                "gann.arc_ratios",
264                d.gann
265                    .iter()
266                    .flat_map(|c| c.arc_ratios.iter().map(|l| l.value))
267                    .collect(),
268            ),
269        ] {
270            // Pitchfork tines are drawn at their multiple of the median's distance.
271            let limit = if name == "pitchfork.levels" {
272                MAX_PITCHFORK_LEVEL
273            } else {
274                f32::MAX
275            };
276            if let Some(i) = values.iter().position(|v| !(-limit..=limit).contains(v)) {
277                return Some(format!("{name}[{i}].value"));
278            }
279        }
280        None
281    }
282}
283
284/// Drawing storage for saving/loading drawings
285///
286/// It is read only through [`Self::from_json`], which checks the version,
287/// migrates older files and loads each drawing on its own.
288#[derive(Debug, Clone, Serialize)]
289pub struct DrawingStorage {
290    /// Format version, [`Self::CURRENT_VERSION`] once loaded
291    pub version: u32,
292    /// Symbol this storage is for
293    pub symbol: String,
294    /// Timeframe (optional)
295    pub timeframe: Option<String>,
296    /// All stored drawings
297    pub drawings: Vec<StoredDrawing>,
298    /// Metadata
299    pub metadata: StorageMetadata,
300}
301
302/// Storage metadata
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct StorageMetadata {
305    /// When the storage was created
306    pub created_at: i64,
307    /// When the storage was last modified
308    pub modified_at: i64,
309    /// Application version that created this
310    pub app_version: Option<String>,
311}
312
313impl Default for StorageMetadata {
314    fn default() -> Self {
315        let now = chrono::Utc::now().timestamp_millis();
316        Self {
317            created_at: now,
318            modified_at: now,
319            app_version: None,
320        }
321    }
322}
323
324impl DrawingStorage {
325    /// Current storage format version. Version 6 draws arrow marks in their own colour; earlier
326    /// versions load in the colour they were drawn in. Version 5 gives Elliott double and triple
327    /// combinations an unlabelled start point; earlier versions load with a start point on their
328    /// first anchor.
329    /// Version 4 draws brushes and highlighters at their stored width and opacity; earlier
330    /// versions load at the width and opacity their strokes were drawn at. Version 3 preserves
331    /// editable ray and extended-line extensions; versions 1 and 2 migrate to their former
332    /// rendered reach when loaded.
333    pub const CURRENT_VERSION: u32 = 6;
334
335    /// Create new storage for a symbol
336    pub fn new(symbol: impl Into<String>) -> Self {
337        Self {
338            version: Self::CURRENT_VERSION,
339            symbol: symbol.into(),
340            timeframe: None,
341            drawings: Vec::new(),
342            metadata: StorageMetadata::default(),
343        }
344    }
345
346    /// Set timeframe
347    pub fn with_timeframe(mut self, timeframe: impl Into<String>) -> Self {
348        self.timeframe = Some(timeframe.into());
349        self
350    }
351
352    /// Add a drawing, or replace the drawing with the same id in place, so a
353    /// drawing saved again after an edit is stored once and the ids stay
354    /// unique, as [`Self::from_json`] requires. The replacement keeps the
355    /// position and the `created_at` of the drawing it replaces and takes
356    /// every other field, `notes` included, from `drawing`.
357    pub fn add_drawing(&mut self, drawing: StoredDrawing) {
358        match self.get_drawing_mut(&drawing.id) {
359            Some(existing) => {
360                *existing = StoredDrawing {
361                    created_at: existing.created_at,
362                    ..drawing
363                }
364            }
365            None => self.drawings.push(drawing),
366        }
367        self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
368    }
369
370    /// Remove a drawing by ID
371    pub fn remove_drawing(&mut self, id: &str) -> bool {
372        let len_before = self.drawings.len();
373        self.drawings.retain(|d| d.id != id);
374        if self.drawings.len() != len_before {
375            self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
376            true
377        } else {
378            false
379        }
380    }
381
382    /// Get a drawing by ID
383    pub fn get_drawing(&self, id: &str) -> Option<&StoredDrawing> {
384        self.drawings.iter().find(|d| d.id == id)
385    }
386
387    /// Get a mutable drawing by ID
388    pub fn get_drawing_mut(&mut self, id: &str) -> Option<&mut StoredDrawing> {
389        self.drawings.iter_mut().find(|d| d.id == id)
390    }
391
392    /// Get all drawings
393    pub fn drawings(&self) -> &[StoredDrawing] {
394        &self.drawings
395    }
396
397    /// Serialize to JSON, refusing what would lose a drawing on the next load:
398    /// a non-finite number, which JSON cannot store and which would otherwise be
399    /// written as `null`, an id an earlier drawing already has, a cursor tool
400    /// such as the eraser, an unsupported timestamp and fewer anchors than the tool draws with.
401    /// [`Self::from_json`] skips these records.
402    pub fn to_json(&self) -> Result<String, SaveError> {
403        let mut ids = HashSet::new();
404        for stored in &self.drawings {
405            if stored.drawing.tool_type.is_cursor() {
406                return Err(SaveError::Cursor {
407                    id: stored.id.clone(),
408                });
409            }
410            if !ids.insert(stored.id.as_str()) {
411                return Err(SaveError::DuplicateId {
412                    id: stored.id.clone(),
413                });
414            }
415            if let Some(field) = stored.invalid_field() {
416                return Err(SaveError::NonFinite {
417                    id: stored.id.clone(),
418                    field,
419                });
420            }
421            if let Some(point) = stored
422                .points
423                .iter()
424                .position(|point| point.time().is_none())
425            {
426                return Err(SaveError::UnmappedAnchor {
427                    id: stored.id.clone(),
428                    point,
429                });
430            }
431            if let Some((needed, anchors)) = stored.missing_anchors() {
432                return Err(SaveError::MissingAnchors {
433                    id: stored.id.clone(),
434                    tool: stored.drawing.tool_type,
435                    anchors,
436                    needed,
437                });
438            }
439        }
440        serde_json::to_string_pretty(self).map_err(SaveError::Json)
441    }
442
443    /// Deserialize from JSON, migrating versions 1 to 5 to the current format.
444    /// Fails as a whole with a [`LoadError`]: [`LoadError::Json`] for invalid JSON or a missing
445    /// or invalid version, symbol, timeframe or metadata, and [`LoadError::UnsupportedVersion`]
446    /// for a version this release does not know. A
447    /// drawing that cannot be read (a missing field, a `null` an earlier release
448    /// wrote for a non-finite number, a number out of the range of its field, a
449    /// tool type from a newer release, a cursor, fewer anchors than the tool
450    /// draws with or a setting out of bounds) is
451    /// left out and returned as a [`SkippedDrawing`] with its [`SkipReason`], so one bad drawing does
452    /// not lose the others. A drawing whose id an
453    /// earlier loaded drawing already has is skipped too, so the loaded ids are
454    /// unique and the first one in the file wins. Saving the returned storage writes
455    /// only the drawings that loaded; keep the original file if the skipped
456    /// ones must be recovered. A drawing whose timeframe filter names no
457    /// timeframe loads as visible on every timeframe.
458    pub fn from_json(json: &str) -> Result<(Self, Vec<SkippedDrawing>), LoadError> {
459        #[derive(Deserialize)]
460        struct Version {
461            version: u32,
462        }
463        #[derive(Deserialize)]
464        struct Container {
465            symbol: String,
466            timeframe: Option<String>,
467            drawings: Vec<serde_json::Value>,
468            metadata: StorageMetadata,
469        }
470        let version = serde_json::from_str::<Version>(json)
471            .map_err(LoadError::Json)?
472            .version;
473        let load: fn(serde_json::Value) -> Result<StoredDrawing, SkipReason> = match version {
474            1 => v1::load,
475            2..=Self::CURRENT_VERSION => |value| {
476                // Checked here, not in `Drawing`'s serde, which other formats share:
477                // serde's own error lists every variant.
478                if let Some(name) = value.pointer("/drawing/tool_type").and_then(|t| t.as_str()) {
479                    DrawingToolType::deserialize(name.into_deserializer()).map_err(
480                        |_: serde::de::value::Error| {
481                            SkipReason::UnknownTool(ParseToolTypeError { input: name.into() })
482                        },
483                    )?;
484                }
485                serde_json::from_value(value).map_err(SkipReason::Invalid)
486            },
487            version => {
488                return Err(LoadError::UnsupportedVersion {
489                    found: version,
490                    supported: 1..=Self::CURRENT_VERSION,
491                });
492            }
493        };
494        let container: Container = serde_json::from_str(json).map_err(LoadError::Json)?;
495        let mut skipped = Vec::new();
496        let mut ids = HashSet::new();
497        let drawings = container
498            .drawings
499            .into_iter()
500            .enumerate()
501            .filter_map(|(index, value)| {
502                let id = value.get("id").and_then(|id| id.as_str()).map(String::from);
503                load(value)
504                    .and_then(|mut drawing| {
505                        // Before the id check, so that a skipped drawing does not claim its id.
506                        let tool = drawing.drawing.tool_type;
507                        if version < 3 {
508                            // Earlier renderers ignored these flags for rays, extended lines
509                            // and pitchforks.
510                            match tool {
511                                DrawingToolType::Ray => {
512                                    let reversed = drawing.points.get(..2).is_some_and(|p| {
513                                        (p[0].ts_ms, p[0].subms_ns) > (p[1].ts_ms, p[1].subms_ns)
514                                    });
515                                    drawing.drawing.extend_left = reversed;
516                                    drawing.drawing.extend_right = !reversed;
517                                }
518                                DrawingToolType::ExtendedLine => {
519                                    drawing.drawing.extend_left = true;
520                                    drawing.drawing.extend_right = true;
521                                }
522                                DrawingToolType::Pitchfork
523                                | DrawingToolType::SchiffPitchfork
524                                | DrawingToolType::ModifiedSchiffPitchfork
525                                | DrawingToolType::InsidePitchfork => {
526                                    drawing.drawing.extend_left = false;
527                                    drawing.drawing.extend_right = false;
528                                }
529                                _ => {}
530                            }
531                        }
532                        if tool.is_cursor() {
533                            return Err(SkipReason::Cursor { tool });
534                        }
535                        if drawing.points.iter().any(|point| point.time().is_none()) {
536                            return Err(SkipReason::Invalid(serde::de::Error::custom(
537                                "anchor timestamp is outside the supported range",
538                            )));
539                        }
540                        if let Some(field) = drawing.invalid_field() {
541                            return Err(SkipReason::Invalid(serde::de::Error::custom(format!(
542                                "non-finite or out-of-range {field}"
543                            ))));
544                        }
545                        if version < 4 {
546                            // Earlier renderers drew brushes at twice and highlighters at eight
547                            // times the stored width, highlighters at alpha 100 whatever the
548                            // colour's.
549                            let settings = &mut drawing.drawing;
550                            let factor = match tool {
551                                DrawingToolType::Brush => 2.0,
552                                DrawingToolType::Highlighter => {
553                                    settings.color[3] = 100;
554                                    8.0
555                                }
556                                _ => 1.0,
557                            };
558                            settings.stroke_width = (settings.stroke_width * factor)
559                                .min(super::validation::MAX_STROKE_WIDTH);
560                        }
561                        // Earlier placements stored fewer anchors. Keep their endpoints and
562                        // supply editable controls before checking the current construction.
563                        use DrawingToolType as T;
564                        // Combinations saved before version 5 had no start point: their first
565                        // anchor ends wave W. A combination finished early since then can hold
566                        // as many anchors, so only the version tells them apart.
567                        if version < 5
568                            && matches!(tool, T::ElliottDoubleCombo | T::ElliottTripleCombo)
569                            && let Some(first) = drawing.points.first().cloned()
570                        {
571                            drawing.points.insert(0, first);
572                        }
573                        if version < 6 {
574                            // Earlier renderers drew up and down marks green and red whatever
575                            // their colour, and every mark's body in its fill when one was set.
576                            let settings = &mut drawing.drawing;
577                            if matches!(
578                                tool,
579                                DrawingToolType::ArrowMarkUp
580                                    | DrawingToolType::ArrowMarkDown
581                                    | DrawingToolType::ArrowMarkLeft
582                                    | DrawingToolType::ArrowMarkRight
583                            ) {
584                                if let Some(fill) = settings.fill_color.take() {
585                                    settings.color = fill;
586                                } else if tool == DrawingToolType::ArrowMarkUp {
587                                    settings.color = [8, 153, 129, 255];
588                                } else if tool == DrawingToolType::ArrowMarkDown {
589                                    settings.color = [242, 54, 69, 255];
590                                }
591                            }
592                        }
593                        match (tool, drawing.points.as_slice()) {
594                            (T::PriceNote | T::ArrowMarker, [a]) => {
595                                drawing.points.push(a.clone());
596                            }
597                            (T::BarsPattern, [a, b]) => {
598                                // Its copy starts over its source, as placement puts it.
599                                let left = if (b.ts_ms, b.subms_ns) < (a.ts_ms, a.subms_ns) {
600                                    b
601                                } else {
602                                    a
603                                };
604                                let copy = StoredCoord {
605                                    price: a.price,
606                                    ..left.clone()
607                                };
608                                drawing.points.push(copy);
609                            }
610                            (T::RotatedRect, [a, b]) => {
611                                drawing.points = vec![
612                                    a.clone(),
613                                    StoredCoord {
614                                        price: a.price,
615                                        ..b.clone()
616                                    },
617                                    b.clone(),
618                                ];
619                            }
620                            (T::DoubleCurve, [a, b]) => {
621                                drawing.points = vec![a.clone(), a.clone(), b.clone(), b.clone()];
622                            }
623                            (T::FibonacciWedge | T::Pitchfan, [a, b]) => {
624                                drawing.points.push(StoredCoord {
625                                    price: a.price,
626                                    ..b.clone()
627                                });
628                            }
629                            _ => {}
630                        }
631                        if let Some((needed, anchors)) = drawing.missing_anchors() {
632                            return Err(SkipReason::MissingAnchors {
633                                tool,
634                                anchors,
635                                needed,
636                            });
637                        }
638                        // A filter naming no interval and holding no range would show the drawing on none.
639                        let visibility = &mut drawing.drawing.timeframe_visibility;
640                        visibility.show_on_all |= visibility.visible_timeframes.is_empty()
641                            && visibility.ranges.is_empty();
642                        if ids.insert(drawing.id.clone()) {
643                            Ok(drawing)
644                        } else {
645                            Err(SkipReason::DuplicateId {
646                                id: drawing.id.clone(),
647                            })
648                        }
649                    })
650                    .map_err(|error| skipped.push(SkippedDrawing { index, id, error }))
651                    .ok()
652            })
653            .collect();
654        let storage = Self {
655            version: Self::CURRENT_VERSION,
656            symbol: container.symbol,
657            timeframe: container.timeframe,
658            drawings,
659            metadata: container.metadata,
660        };
661        Ok((storage, skipped))
662    }
663
664    /// Clear all drawings
665    pub fn clear(&mut self) {
666        self.drawings.clear();
667        self.metadata.modified_at = chrono::Utc::now().timestamp_millis();
668    }
669}
670
671/// Why [`DrawingStorage::to_json`] did not save.
672#[derive(Debug)]
673pub enum SaveError {
674    /// A drawing holds NaN or an infinity, which JSON cannot store, or a size or pitchfork
675    /// level out of the range drawings are drawn at, and loading skips it.
676    NonFinite {
677        /// The id of the drawing.
678        id: String,
679        /// The field, such as `"stroke_width"` or `"points[1].price"`.
680        field: String,
681    },
682    /// Two drawings have the same id, and loading keeps only the first.
683    DuplicateId {
684        /// The id of the drawing.
685        id: String,
686    },
687    /// A drawing's tool is a cursor, such as the eraser, and loading skips it.
688    Cursor {
689        /// The id of the drawing.
690        id: String,
691    },
692    /// A drawing has fewer anchors than its tool draws with, and loading skips it.
693    MissingAnchors {
694        /// The id of the drawing.
695        id: String,
696        /// The tool of the drawing.
697        tool: DrawingToolType,
698        /// The anchors the drawing has.
699        anchors: usize,
700        /// The anchors the tool needs.
701        needed: usize,
702    },
703    /// An anchor has no time mapping or its timestamp is outside the supported range.
704    UnmappedAnchor {
705        /// Drawing id.
706        id: String,
707        /// Anchor index within the drawing.
708        point: usize,
709    },
710    /// Serialization failed.
711    Json(serde_json::Error),
712}
713
714impl fmt::Display for SaveError {
715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716        match self {
717            Self::NonFinite { id, field } => {
718                write!(f, "drawing {id:?} has a non-finite or out-of-range {field}")
719            }
720            Self::DuplicateId { id } => write!(f, "duplicate drawing id {id:?}"),
721            Self::Cursor { id } => write!(f, "drawing {id:?} is a cursor, not a drawing"),
722            Self::MissingAnchors {
723                id,
724                tool,
725                anchors,
726                needed,
727            } => write!(
728                f,
729                "drawing {id:?} has {anchors} anchors, {tool} needs {needed}"
730            ),
731            Self::UnmappedAnchor { id, point } => {
732                write!(f, "drawing {id:?} anchor {point} has no time mapping")
733            }
734            Self::Json(error) => error.fmt(f),
735        }
736    }
737}
738
739impl std::error::Error for SaveError {
740    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
741        match self {
742            Self::NonFinite { .. }
743            | Self::DuplicateId { .. }
744            | Self::Cursor { .. }
745            | Self::MissingAnchors { .. }
746            | Self::UnmappedAnchor { .. } => None,
747            Self::Json(error) => Some(error),
748        }
749    }
750}
751
752/// A drawing [`DrawingStorage::from_json`] left out because it could not be
753/// read or its id was already taken.
754#[derive(Debug)]
755pub struct SkippedDrawing {
756    /// The position of the entry in the file's list of drawings, counted from 0.
757    pub index: usize,
758    /// The stored id, or `None` if the entry has no id or a non-string one.
759    pub id: Option<String>,
760    /// Why the drawing could not be read.
761    pub error: SkipReason,
762}
763
764/// Why [`DrawingStorage::from_json`] left a drawing out.
765///
766/// An [`UnknownTool`](Self::UnknownTool) in a file of version 2 or later was most likely written
767/// by a newer release that added the tool: keep the file. The other reasons describe damaged or
768/// hand-edited data.
769#[derive(Debug)]
770#[non_exhaustive]
771pub enum SkipReason {
772    /// The tool is not one this release knows; [`ParseToolTypeError::input`] holds its name.
773    UnknownTool(ParseToolTypeError),
774    /// The tool is a cursor, such as the eraser, which creates no drawing.
775    Cursor {
776        /// The cursor tool.
777        tool: DrawingToolType,
778    },
779    /// The drawing has fewer anchors than its tool draws with.
780    MissingAnchors {
781        /// The tool of the drawing.
782        tool: DrawingToolType,
783        /// The anchors the drawing has.
784        anchors: usize,
785        /// The anchors the tool needs.
786        needed: usize,
787    },
788    /// An earlier drawing in the file has the same id; the first one is loaded.
789    DuplicateId {
790        /// The repeated id.
791        id: String,
792    },
793    /// A field is missing or has the wrong type, or a value is out of range, such as a `null`
794    /// written for a non-finite number, a timestamp outside the supported range or a setting
795    /// out of bounds.
796    Invalid(serde_json::Error),
797}
798
799impl fmt::Display for SkipReason {
800    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
801        match self {
802            Self::UnknownTool(error) => error.fmt(f),
803            Self::Cursor { tool } => write!(f, "{tool} is a cursor, not a drawing"),
804            Self::MissingAnchors {
805                tool,
806                anchors,
807                needed,
808            } => write!(f, "{tool} needs {needed} anchors, has {anchors}"),
809            Self::DuplicateId { id } => write!(f, "duplicate drawing id {id:?}"),
810            Self::Invalid(error) => error.fmt(f),
811        }
812    }
813}
814
815impl std::error::Error for SkipReason {
816    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
817        match self {
818            Self::UnknownTool(error) => Some(error),
819            Self::Invalid(error) => Some(error),
820            Self::Cursor { .. } | Self::MissingAnchors { .. } | Self::DuplicateId { .. } => None,
821        }
822    }
823}
824
825/// Why drawings were not loaded, by [`DrawingStorage::from_json`],
826/// [`Chart::load_drawings`](crate::widget::Chart::load_drawings) or
827/// [`Chart::paste_drawings`](crate::widget::Chart::paste_drawings), before any drawing changed.
828///
829/// [`UnsupportedVersion`](Self::UnsupportedVersion) above `supported` means a newer release
830/// wrote the data: keep it and update. [`Json`](Self::Json) means damaged data.
831#[derive(Debug)]
832#[non_exhaustive]
833pub enum LoadError {
834    /// The text is not valid JSON, or its version, symbol, timeframe or metadata is missing or
835    /// invalid.
836    Json(serde_json::Error),
837    /// The storage version is not one this release reads.
838    UnsupportedVersion {
839        /// The version the data declares.
840        found: u32,
841        /// The versions this release reads.
842        supported: std::ops::RangeInclusive<u32>,
843    },
844    /// The drawings belong to another symbol than the chart's.
845    OtherSymbol,
846    /// The chart has no bars to place the pasted anchors on; paste once history has loaded.
847    NeedsHistory,
848    /// The pasted text is not drawing clipboard text from
849    /// [`Chart::copy_selected_drawings`](crate::widget::Chart::copy_selected_drawings).
850    NotClipboard,
851    /// Drawings that could not be read, where every drawing is needed: a paste, or a document
852    /// that automatic storage then never writes over.
853    Skipped(Vec<SkippedDrawing>),
854}
855
856impl fmt::Display for LoadError {
857    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
858        match self {
859            Self::Json(error) => error.fmt(f),
860            Self::UnsupportedVersion { found, supported } => write!(
861                f,
862                "unsupported drawing storage version {found}, expected {} to {}",
863                supported.start(),
864                supported.end()
865            ),
866            Self::OtherSymbol => f.write_str("drawing storage belongs to a different symbol"),
867            Self::NeedsHistory => f.write_str("clipboard needs loaded chart history"),
868            Self::NotClipboard => f.write_str("not a drawing clipboard"),
869            Self::Skipped(skipped) if skipped.len() == 1 => {
870                f.write_str("1 drawing could not be read")
871            }
872            Self::Skipped(skipped) => write!(f, "{} drawings could not be read", skipped.len()),
873        }
874    }
875}
876
877impl std::error::Error for LoadError {
878    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
879        match self {
880            Self::Json(error) => Some(error),
881            _ => None,
882        }
883    }
884}
885
886/// A failed read or write of automatic drawing storage, from
887/// [`Chart::take_storage_error`](crate::widget::Chart::take_storage_error).
888#[derive(Debug)]
889#[non_exhaustive]
890pub enum StorageError {
891    /// The stored drawings of `symbol`, or its kept copy, could not all be read. The document
892    /// is kept and never written over; `error` tells a newer release from damaged data.
893    Read {
894        /// The symbol or kept copy whose document was read.
895        symbol: String,
896        /// Why it could not be read.
897        error: LoadError,
898    },
899    /// The drawings of `symbol` could not be saved. They stay on the chart.
900    Save {
901        /// The symbol whose drawings were saved.
902        symbol: String,
903        /// Why they could not be saved.
904        error: SaveError,
905    },
906    /// The storage service or a kept-copy operation failed; the message names it. A failed
907    /// write keeps the drawings on the chart and is retried.
908    Storage(String),
909}
910
911impl fmt::Display for StorageError {
912    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913        match self {
914            Self::Read { symbol, error } => {
915                write!(f, "Could not read drawings of {symbol}: {error}")
916            }
917            Self::Save { symbol, error } => {
918                write!(f, "Could not save drawings of {symbol}: {error}")
919            }
920            Self::Storage(message) => f.write_str(message),
921        }
922    }
923}
924
925impl std::error::Error for StorageError {
926    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
927        match self {
928            Self::Read { error, .. } => Some(error),
929            Self::Save { error, .. } => Some(error),
930            Self::Storage(_) => None,
931        }
932    }
933}
934
935/// The version 1 format, read only to migrate it: the tool by display name,
936/// the colour, width, lock, visibility and text, and nothing else.
937mod v1 {
938    use super::StoredCoord;
939    use crate::drawings::{Drawing, DrawingToolType};
940    use serde::Deserialize;
941    use std::collections::HashMap;
942
943    #[derive(Deserialize)]
944    struct StoredDrawing {
945        id: String,
946        tool_type: String,
947        points: Vec<StoredCoord>,
948        color: Color,
949        line_width: f32,
950        locked: bool,
951        visible: bool,
952        properties: HashMap<String, serde_json::Value>,
953        created_at: i64,
954        modified_at: i64,
955        notes: Option<String>,
956    }
957
958    #[derive(Deserialize)]
959    struct Color {
960        r: u8,
961        g: u8,
962        b: u8,
963        a: u8,
964    }
965
966    /// Reads one version 1 drawing as a current one.
967    pub(super) fn load(
968        value: serde_json::Value,
969    ) -> Result<super::StoredDrawing, super::SkipReason> {
970        let stored: StoredDrawing =
971            serde_json::from_value(value).map_err(super::SkipReason::Invalid)?;
972        // The previous loader also accepted the other names `FromStr` parses,
973        // such as "trendline".
974        let tool_type: DrawingToolType = stored
975            .tool_type
976            .parse()
977            .map_err(super::SkipReason::UnknownTool)?;
978        let Color { r, g, b, a } = stored.color;
979        let mut drawing = Drawing::with_color(0, tool_type, [r, g, b, a]);
980        drawing.fib_config = None;
981        drawing.stroke_width = stored.line_width;
982        drawing.locked = stored.locked;
983        drawing.visible = stored.visible;
984        drawing.text = stored
985            .properties
986            .get("text")
987            .and_then(|text| text.as_str())
988            .map(String::from);
989        Ok(super::StoredDrawing {
990            id: stored.id,
991            points: stored.points,
992            drawing,
993            created_at: stored.created_at,
994            modified_at: stored.modified_at,
995            notes: stored.notes,
996            pane: None,
997        })
998    }
999}
1000
1001/// Convert a Drawing to StoredDrawing, its anchors mapped from bar index to timestamp, under
1002/// the drawing's stored id and creation time when it was restored from storage, and under its
1003/// id and the current time otherwise.
1004///
1005/// Retained timestamps are used for unchanged projected indices. For new or edited indices,
1006/// `bar_to_time` must accept fractional bar indices and extrapolate by the bar
1007/// interval before index 0 and after the last bar instead of clamping: anchors
1008/// can sit between bars, in the history not yet loaded or in the future area,
1009/// and a clamping mapping would move them onto the first or last bar. It must
1010/// be the inverse of the `time_to_bar` passed to [`stored_to_drawing`].
1011pub fn drawing_to_stored(drawing: &Drawing, bar_to_time: impl Fn(f32) -> i64) -> StoredDrawing {
1012    drawing_to_stored_with(drawing, |index, price| {
1013        StoredCoord::new(bar_to_time(index), price)
1014    })
1015}
1016
1017pub(crate) fn drawing_to_stored_with(
1018    drawing: &Drawing,
1019    bar_to_coord: impl Fn(f32, f64) -> StoredCoord,
1020) -> StoredDrawing {
1021    let mut settings = drawing.clone();
1022    settings.points.clear();
1023    settings.chart_points.clear();
1024    let id = drawing.stored_id.clone();
1025    let mut stored = StoredDrawing::new(id.unwrap_or_else(|| drawing.id.to_string()), settings);
1026    stored.created_at = drawing.created_at.unwrap_or(stored.created_at);
1027    stored.notes.clone_from(&drawing.notes);
1028    stored.points = drawing
1029        .chart_points
1030        .iter()
1031        .map(|p| {
1032            p.time_anchor
1033                .filter(|(_, index)| *index == p.bar_idx)
1034                .map_or_else(
1035                    || bar_to_coord(p.bar_idx, p.price),
1036                    |(time, _)| StoredCoord::from_time(time, p.price),
1037                )
1038        })
1039        .collect();
1040    stored
1041}
1042
1043/// Convert StoredDrawing back to a completed Drawing with id `next_id`, its anchors mapped from
1044/// timestamp to bar index and the record's id, creation time and notes kept; the screen points
1045/// follow on the next [`Drawing::update_screen_coords`].
1046///
1047/// `time_to_bar` must be the inverse of the `bar_to_time` passed to
1048/// [`drawing_to_stored`]: it returns fractional bar indices and extrapolates
1049/// before the first and after the last bar instead of clamping, or anchors
1050/// outside the loaded bars collapse onto the edge bar.
1051pub fn stored_to_drawing(
1052    stored: &StoredDrawing,
1053    time_to_bar: impl Fn(i64) -> f32,
1054    next_id: usize,
1055) -> Drawing {
1056    stored_to_drawing_with(stored, |point| time_to_bar(point.ts_ms), next_id)
1057}
1058
1059pub(crate) fn stored_to_drawing_with(
1060    stored: &StoredDrawing,
1061    time_to_bar: impl Fn(&StoredCoord) -> f32,
1062    next_id: usize,
1063) -> Drawing {
1064    let mut drawing = stored.drawing.clone();
1065    drawing.id = next_id;
1066    drawing.stored_id = Some(stored.id.clone());
1067    drawing.created_at = Some(stored.created_at);
1068    drawing.notes.clone_from(&stored.notes);
1069    drawing.chart_points = stored
1070        .points
1071        .iter()
1072        .map(|p| {
1073            let mut point = ChartPoint::new(time_to_bar(p), p.price);
1074            point.time_anchor = p.time().map(|time| (time, point.bar_idx));
1075            point
1076        })
1077        .collect();
1078    drawing.completed = true;
1079    drawing
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::data::VolumeProfileConfig;
1086    use crate::drawings::*;
1087    use crate::model::LineStyle;
1088
1089    const T0: i64 = 1_704_067_200_000;
1090
1091    fn bar_to_time(bar: f32) -> i64 {
1092        T0 + (bar * 60_000.0) as i64
1093    }
1094
1095    fn time_to_bar(ts_ms: i64) -> f32 {
1096        (ts_ms - T0) as f32 / 60_000.0
1097    }
1098
1099    fn bar_to_x(bar: f32) -> f32 {
1100        100.0 + bar * 8.0
1101    }
1102
1103    fn price_to_y(price: f64) -> f32 {
1104        (500.0 - price * 2.0) as f32
1105    }
1106
1107    /// The drawing `stored` restores to, without the record's identity, which
1108    /// `a_later_session_keeps_the_stored_drawings_and_their_ids` checks.
1109    fn restore(stored: &StoredDrawing, id: usize) -> Drawing {
1110        let mut drawing = stored_to_drawing(stored, time_to_bar, id);
1111        drawing.update_screen_coords(bar_to_x, price_to_y);
1112        (drawing.stored_id, drawing.created_at, drawing.notes) = (None, None, None);
1113        drawing
1114    }
1115
1116    /// Every stored setting differs from `Drawing::new`, so a dropped field
1117    /// shows up as a difference.
1118    fn non_default_drawing(tool: DrawingToolType) -> Drawing {
1119        let mut d = Drawing::new(7, tool);
1120        d.chart_points = vec![
1121            ChartPoint::new(3.0, 101.25),
1122            ChartPoint::new(10.5, 99.5),
1123            ChartPoint::new(-2.0, 120.0),
1124        ];
1125        d.color = [1, 2, 3, 4];
1126        d.stroke_width = 3.5;
1127        d.completed = true;
1128        d.text = Some("note".into());
1129        d.label = Some("Support".into());
1130        d.line_style = LineStyle::Dotted;
1131        d.fill_color = Some([5, 6, 7, 8]);
1132        d.locked = true;
1133        d.visible = false;
1134        d.font_size = 18.0;
1135        d.font_weight = FontWeight::Bold;
1136        d.extend_left = !d.extend_left;
1137        d.extend_right = !d.extend_right;
1138        d.arrow_start_style = ArrowStyle::Diamond;
1139        d.arrow_end_style = ArrowStyle::Circle;
1140        d.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
1141        d.price_axis_label = false;
1142        d.time_axis_label = false;
1143        d.position_sizing = PositionSizing {
1144            point_value: 50.0,
1145            lot_size: 10.0,
1146            quantity_step: 0.25,
1147            leverage: 5.0,
1148        };
1149        d.position_size = Some(PositionSize::RiskPercent {
1150            account_size: 25_000.0,
1151            percent: 1.5,
1152        });
1153        d.fib_config = Some(FibonacciConfig {
1154            levels: vec![FibonacciLevel {
1155                value: 1.618,
1156                label: "golden".into(),
1157                color: [9, 10, 11, 12],
1158                visible: false,
1159                show_price: false,
1160            }],
1161            level_width: 3.5,
1162            level_style: LineStyle::Dashed,
1163            show_background: true,
1164            background_opacity: 77,
1165            extend_left: true,
1166            extend_right: true,
1167            reverse: true,
1168            logarithmic: true,
1169            ..FibonacciConfig::default()
1170        });
1171        d.volume_profile = VolumeProfileConfig::new(50, 0.68);
1172        d.regression_trend = RegressionTrendConfig::new(1.5);
1173        let mut vwap = VwapConfig::default();
1174        vwap.bands[0].enabled = false;
1175        vwap.bands[1] = VwapBand::new(true, 2.5).unwrap();
1176        d.vwap = Some(vwap);
1177        if tool == DrawingToolType::DoubleCurve {
1178            d.chart_points.push(ChartPoint::new(12.0, 115.0));
1179        }
1180        if tool == DrawingToolType::ElliottTriangle {
1181            d.chart_points.extend([
1182                ChartPoint::new(12.0, 115.0),
1183                ChartPoint::new(14.0, 104.0),
1184                ChartPoint::new(16.0, 112.0),
1185            ]);
1186        }
1187        d.italic = true;
1188        d.text_color = Some([13, 14, 15, 16]);
1189        d.background = false;
1190        d.border = false;
1191        d.border_color = Some([17, 18, 19, 20]);
1192        d.z_order = -3;
1193        d.update_screen_coords(bar_to_x, price_to_y);
1194        d
1195    }
1196
1197    #[test]
1198    fn every_tool_round_trips_with_all_settings() {
1199        use crate::data::ProfileDisplay::{Delta, Total, UpDown};
1200        for (tool, custom_anchors) in DrawingToolType::all()
1201            .iter()
1202            .copied()
1203            .filter(|t| !t.is_cursor())
1204            .map(|tool| (tool, None))
1205            .chain((1..=3).map(|anchors| (DrawingToolType::Polyline, Some(anchors))))
1206        {
1207            let mut drawing = non_default_drawing(tool);
1208            if let Some(anchors) = custom_anchors {
1209                drawing.custom_tool = Some("host-shape".into());
1210                drawing.custom_settings = serde_json::json!({"radius": 10});
1211                drawing.chart_points.truncate(anchors);
1212                drawing.update_screen_coords(bar_to_x, price_to_y);
1213            }
1214            drawing.gann = Some(crate::drawings::GannConfig {
1215                price_levels: vec![
1216                    GannLevel::new(0.25, [1, 2, 3, 255]),
1217                    GannLevel::new(0.75, [4, 5, 6, 255]),
1218                ],
1219                time_levels: vec![GannLevel::new(0.5, [7, 8, 9, 255])],
1220                show_grid: false,
1221                show_fan: false,
1222                fan_ratios: vec![
1223                    GannLevel::new(0.5, [10, 11, 12, 255]),
1224                    GannLevel {
1225                        visible: false,
1226                        ..GannLevel::new(2.0, [13, 14, 15, 255])
1227                    },
1228                ],
1229                fan_direction: crate::drawings::GannFanDirection::Reverse,
1230                show_arcs: false,
1231                arc_ratios: vec![GannLevel::new(0.75, [16, 17, 18, 255])],
1232                show_background: false,
1233                background_opacity: 7,
1234                show_angles: true,
1235            });
1236            drawing.ghost_feed = Some(crate::drawings::GhostFeedConfig {
1237                average_range_ticks: 2.5,
1238                noise_ticks: 0.5,
1239            });
1240            if tool == DrawingToolType::Image {
1241                drawing.image = Some(crate::drawings::ImageConfig {
1242                    size: Some([240.0, 135.0]),
1243                    opacity: 128,
1244                });
1245            }
1246            drawing.bars_pattern = Some(crate::drawings::BarsPatternConfig {
1247                mode: crate::drawings::BarsPatternMode::Line,
1248                mirrored: true,
1249                flipped: true,
1250            });
1251            drawing.update_screen_coords(bar_to_x, price_to_y);
1252            let mut fork = crate::drawings::PitchforkConfig::default();
1253            fork.levels[0].line_style = LineStyle::Dotted;
1254            fork.levels[1].visible = false;
1255            fork.show_background = false;
1256            drawing.pitchfork = Some(fork);
1257            drawing.channel_midline = true;
1258            drawing.closed = true;
1259            drawing.elliott_degree = ElliottDegree::Minuette;
1260            drawing.selectable = false;
1261            drawing.listed = false;
1262            drawing.group = Some("levels".into());
1263            drawing.folder = vec!["Analysis".into(), "Support / resistance".into()];
1264            if matches!(
1265                tool,
1266                DrawingToolType::AnchoredText | DrawingToolType::AnchoredNote
1267            ) {
1268                drawing.screen_anchor = Some([0.25, 0.75]);
1269            }
1270
1271            let modes = if matches!(
1272                tool,
1273                DrawingToolType::FixedRangeVolumeProfile | DrawingToolType::AnchoredVolumeProfile
1274            ) {
1275                &[Total, UpDown, Delta][..]
1276            } else {
1277                &[Total][..]
1278            };
1279            for &display in modes {
1280                let config = drawing.volume_profile.as_mut().unwrap();
1281                *config = config.with_row_size(Some(0.25)).unwrap();
1282                config.display = display;
1283                config.up_color = [1, 2, 3, 240];
1284                config.down_color = [4, 5, 6, 200];
1285                let mut storage = DrawingStorage::new("AAPL");
1286                storage.add_drawing(drawing_to_stored(&drawing, bar_to_time));
1287                let (loaded, skipped) =
1288                    DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1289                assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1290                let back = restore(&loaded.drawings()[0], drawing.id);
1291                assert_eq!(
1292                    format!("{back:?}"),
1293                    format!("{drawing:?}"),
1294                    "{tool:?} {display:?}"
1295                );
1296            }
1297        }
1298    }
1299
1300    /// A later session that loads the storage, restores its drawings, copies one, adds one and
1301    /// saves them all keeps every stored record as it was, with its layer, and stores the copy
1302    /// and the new drawing under ids of their own, even where a stored id is a number the new
1303    /// session could hand out.
1304    #[test]
1305    fn a_later_session_keeps_the_stored_drawings_and_their_ids() {
1306        let mut storage = DrawingStorage::new("AAPL");
1307        for (id, notes, z_order) in [("a1b2", Some("breakout level"), 1), ("2", None, 0)] {
1308            let mut drawing = Drawing::new(0, DrawingToolType::HorizontalLine);
1309            drawing.z_order = z_order;
1310            let mut stored = StoredDrawing::new(id, drawing);
1311            stored.points = vec![StoredCoord::new(T0, 100.0)];
1312            stored.created_at = 1_700_000_000_000;
1313            stored.notes = notes.map(String::from);
1314            storage.add_drawing(stored);
1315        }
1316        let (mut storage, _) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1317
1318        let mut manager = DrawingManager::new();
1319        for stored in storage.drawings() {
1320            manager
1321                .add_synced_drawing(stored_to_drawing(stored, time_to_bar, 0))
1322                .unwrap();
1323        }
1324        manager
1325            .add_synced_drawing(manager.drawings[0].clone())
1326            .unwrap();
1327        let (x_to_bar, y_to_price) = (|x: f32| x, |y: f32| f64::from(y));
1328        let at = egui::Pos2::new(5.0, 90.0);
1329        manager.start_drawing_with_coords(
1330            DrawingToolType::HorizontalLine,
1331            at,
1332            x_to_bar,
1333            y_to_price,
1334        );
1335        for drawing in &manager.drawings {
1336            storage.add_drawing(drawing_to_stored(drawing, bar_to_time));
1337        }
1338        let (saved, _) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
1339
1340        let records: Vec<_> = saved
1341            .drawings()
1342            .iter()
1343            .map(|d| {
1344                (
1345                    d.id.as_str(),
1346                    d.created_at,
1347                    d.notes.as_deref(),
1348                    d.drawing.z_order,
1349                )
1350            })
1351            .collect();
1352        assert_eq!(records.len(), 4, "{records:?}");
1353        assert_eq!(
1354            records[0],
1355            ("a1b2", 1_700_000_000_000, Some("breakout level"), 1)
1356        );
1357        assert_eq!(records[1], ("2", 1_700_000_000_000, None, 0));
1358        let ids: HashSet<_> = records.iter().map(|r| r.0).collect();
1359        assert_eq!(ids.len(), 4, "{records:?}");
1360    }
1361
1362    /// The version 1 fixture was written by the previous release and stands
1363    /// for files already saved, so do not rewrite it. The version 2 fixture was
1364    /// written by this format's first implementation, which was never released.
1365    #[test]
1366    fn from_json_reads_saved_files_and_skips_the_drawings_it_cannot_read() {
1367        let v1_json = include_str!("fixtures/storage_v1.json");
1368        let (v1, skipped) = DrawingStorage::from_json(v1_json).unwrap();
1369        assert!(skipped.is_empty(), "{skipped:?}");
1370        assert_eq!(v1.version, DrawingStorage::CURRENT_VERSION);
1371        assert_eq!(v1.timeframe.as_deref(), Some("1h"));
1372        let tools = [
1373            (DrawingToolType::TrendLine, None),
1374            (DrawingToolType::FibonacciRetracement, None),
1375            (DrawingToolType::ElliottImpulse, None),
1376            (DrawingToolType::Arrow, None),
1377            (DrawingToolType::TextLabel, Some("breakout")),
1378        ];
1379        assert_eq!(v1.drawings().len(), tools.len());
1380        for (stored, (tool, text)) in v1.drawings().iter().zip(tools) {
1381            let mut want = Drawing::with_color(1, tool, [33, 150, 243, 255]);
1382            // Old files without Fibonacci settings retain their absent config.
1383            want.fib_config = None;
1384            want.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 110.0)];
1385            want.update_screen_coords(bar_to_x, price_to_y);
1386            want.stroke_width = 3.0;
1387            want.completed = true;
1388            want.text = text.map(String::from);
1389            // The fixture's Arrow is the one drawing saved locked and hidden.
1390            want.locked = tool == DrawingToolType::Arrow;
1391            want.visible = tool != DrawingToolType::Arrow;
1392            assert_eq!(format!("{:?}", restore(stored, 1)), format!("{want:?}"));
1393        }
1394
1395        let (v2, skipped) =
1396            DrawingStorage::from_json(include_str!("fixtures/storage_v2.json")).unwrap();
1397        assert!(skipped.is_empty(), "{skipped:?}");
1398        let tools = [
1399            DrawingToolType::TrendLine,
1400            DrawingToolType::LongPos,
1401            DrawingToolType::FixedRangeVolumeProfile,
1402        ];
1403        assert_eq!(v2.drawings().len(), tools.len());
1404        for (stored, tool) in v2.drawings().iter().zip(tools) {
1405            let mut want = non_default_drawing(tool);
1406            want.label = None; // Earlier files have no object name.
1407            want.position_sizing = PositionSizing::default(); // Earlier files use unit sizing.
1408            want.vwap = None; // Earlier files use the default bands.
1409            // Earlier files show their axis labels.
1410            want.price_axis_label = true;
1411            want.time_axis_label = true;
1412            // Earlier files draw text and borders in the drawing colour, boxed and upright.
1413            want.italic = false;
1414            want.text_color = None;
1415            want.background = true;
1416            want.border = true;
1417            want.border_color = None;
1418            let config = want.fib_config.as_mut().unwrap();
1419            config.logarithmic = false;
1420            config.level_width = 2.0;
1421            config.level_style = LineStyle::Solid;
1422            config.levels_as_percent = true;
1423            config.labels_on_left = false;
1424            assert_eq!(
1425                format!("{:?}", restore(stored, want.id)),
1426                format!("{want:?}")
1427            );
1428        }
1429
1430        // New level defaults apply to new drawings, never to either saved format's fixed levels.
1431        for tool in [
1432            DrawingToolType::FibonacciTimeZones,
1433            DrawingToolType::TrendBasedFibTime,
1434            DrawingToolType::FibonacciSpeedFan,
1435            DrawingToolType::FibonacciCircles,
1436            DrawingToolType::FibonacciSpeedResistanceArcs,
1437        ] {
1438            for (fixture, legacy) in [
1439                (v1_json, true),
1440                (include_str!("fixtures/storage_v2.json"), false),
1441            ] {
1442                let mut value: serde_json::Value = serde_json::from_str(fixture).unwrap();
1443                let records = value["drawings"].as_array_mut().unwrap();
1444                records.truncate(1);
1445                let fields = if legacy {
1446                    &mut records[0]
1447                } else {
1448                    &mut records[0]["drawing"]
1449                };
1450                fields["tool_type"] = serde_json::to_value(tool).unwrap();
1451                if !legacy {
1452                    fields["fib_config"] = serde_json::Value::Null;
1453                }
1454                if tool == DrawingToolType::TrendBasedFibTime {
1455                    let points = value["drawings"][0]["points"].as_array_mut().unwrap();
1456                    points.push(points[0].clone());
1457                }
1458                let (loaded, skipped) = DrawingStorage::from_json(&value.to_string()).unwrap();
1459                assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1460                assert!(
1461                    loaded.drawings[0].drawing.fib_config.is_none(),
1462                    "{tool:?}, legacy={legacy}"
1463                );
1464                let (again, skipped) =
1465                    DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1466                assert!(skipped.is_empty());
1467                assert!(
1468                    again.drawings[0].drawing.fib_config.is_none(),
1469                    "{tool:?}: retain fixed levels after saving"
1470                );
1471            }
1472        }
1473
1474        // Earlier rays and extended lines ignored the saved Extend flags. Adopt their old
1475        // reach once, then retain explicit changes on the next save and load.
1476        for (tool, name) in [
1477            (DrawingToolType::Ray, "Ray"),
1478            (DrawingToolType::ExtendedLine, "Extended Line"),
1479        ] {
1480            for (fixture, tool_path, tool_name) in [
1481                (
1482                    include_str!("fixtures/storage_v1.json"),
1483                    "/tool_type",
1484                    name.to_owned(),
1485                ),
1486                (
1487                    include_str!("fixtures/storage_v2.json"),
1488                    "/drawing/tool_type",
1489                    serde_json::to_value(tool)
1490                        .unwrap()
1491                        .as_str()
1492                        .unwrap()
1493                        .to_owned(),
1494                ),
1495            ] {
1496                for reversed in [false, true] {
1497                    let mut saved: serde_json::Value = serde_json::from_str(fixture).unwrap();
1498                    let records = saved["drawings"].as_array_mut().unwrap();
1499                    records.truncate(1);
1500                    *records[0].pointer_mut(tool_path).unwrap() = tool_name.clone().into();
1501                    records[0]["points"].as_array_mut().unwrap().truncate(2);
1502                    if reversed {
1503                        records[0]["points"].as_array_mut().unwrap().reverse();
1504                    }
1505                    if let Some(settings) = records[0].get_mut("drawing") {
1506                        settings["extend_left"] = true.into();
1507                        settings["extend_right"] = false.into();
1508                    }
1509                    let (mut loaded, skipped) =
1510                        DrawingStorage::from_json(&saved.to_string()).unwrap();
1511                    assert!(skipped.is_empty(), "{skipped:?}");
1512                    let drawing = &mut loaded.drawings[0].drawing;
1513                    assert_eq!(
1514                        drawing.extend_left,
1515                        reversed || tool == DrawingToolType::ExtendedLine
1516                    );
1517                    assert_eq!(
1518                        drawing.extend_right,
1519                        !reversed || tool == DrawingToolType::ExtendedLine
1520                    );
1521                    drawing.extend_left = false;
1522                    drawing.extend_right = false;
1523                    let (again, skipped) =
1524                        DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1525                    assert!(skipped.is_empty());
1526                    assert!(!again.drawings[0].drawing.extend_left);
1527                    assert!(!again.drawings[0].drawing.extend_right);
1528                }
1529            }
1530        }
1531
1532        // Before version 6 up and down marks were drawn green and red; a fill replaced
1533        // every mark's body colour. Loading keeps that colour once, including its alpha.
1534        for (version, tool, fill, want) in [
1535            (1, DrawingToolType::ArrowMarkUp, None, [8, 153, 129, 255]),
1536            (3, DrawingToolType::ArrowMarkUp, None, [8, 153, 129, 255]),
1537            (4, DrawingToolType::ArrowMarkDown, None, [242, 54, 69, 255]),
1538            (5, DrawingToolType::ArrowMarkLeft, None, [10, 20, 30, 255]),
1539            (
1540                5,
1541                DrawingToolType::ArrowMarkUp,
1542                Some([1, 2, 3, 90]),
1543                [1, 2, 3, 90],
1544            ),
1545            (
1546                2,
1547                DrawingToolType::ArrowMarkRight,
1548                Some([1, 2, 3, 90]),
1549                [1, 2, 3, 90],
1550            ),
1551            (6, DrawingToolType::ArrowMarkUp, None, [10, 20, 30, 255]),
1552        ] {
1553            let fixture = if version == 1 {
1554                include_str!("fixtures/storage_v1.json")
1555            } else {
1556                include_str!("fixtures/storage_v2.json")
1557            };
1558            let mut saved: serde_json::Value = serde_json::from_str(fixture).unwrap();
1559            saved["version"] = version.into();
1560            let records = saved["drawings"].as_array_mut().unwrap();
1561            records.truncate(1);
1562            records[0]["points"].as_array_mut().unwrap().truncate(1);
1563            if version == 1 {
1564                records[0]["tool_type"] = tool.to_string().into();
1565                records[0]["color"] = serde_json::json!({"r": 10, "g": 20, "b": 30, "a": 255});
1566            } else {
1567                let settings = &mut records[0]["drawing"];
1568                settings["tool_type"] = serde_json::to_value(tool).unwrap();
1569                settings["color"] = serde_json::json!([10, 20, 30, 255]);
1570                settings["fill_color"] = serde_json::to_value(fill).unwrap();
1571            }
1572            let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1573            assert!(skipped.is_empty(), "{tool:?} v{version}: {skipped:?}");
1574            let drawing = &loaded.drawings[0].drawing;
1575            assert_eq!(
1576                (drawing.color, drawing.fill_color),
1577                (want, None),
1578                "{tool:?} v{version}"
1579            );
1580            let (again, skipped) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1581            assert!(skipped.is_empty());
1582            let drawing = &again.drawings[0].drawing;
1583            assert_eq!(
1584                (drawing.color, drawing.fill_color),
1585                (want, None),
1586                "{tool:?} v{version}: converted twice"
1587            );
1588        }
1589
1590        // Old forks ignored Extend, so loading their saved flags must keep that reach.
1591        for tool in [
1592            DrawingToolType::Pitchfork,
1593            DrawingToolType::SchiffPitchfork,
1594            DrawingToolType::ModifiedSchiffPitchfork,
1595            DrawingToolType::InsidePitchfork,
1596        ] {
1597            let mut saved: serde_json::Value =
1598                serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1599            saved["drawings"].as_array_mut().unwrap().truncate(1);
1600            saved["drawings"][0]["drawing"]["tool_type"] = serde_json::to_value(tool).unwrap();
1601            let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1602            assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1603            let drawing = &loaded.drawings[0].drawing;
1604            assert!(!drawing.extend_left && !drawing.extend_right, "{tool:?}");
1605        }
1606
1607        // Strokes saved before version 4 were drawn at twice (brush) and eight times
1608        // (highlighter) their width, the highlighter at alpha 100. They load as drawn, once.
1609        for (version, tool, width, alpha, want_width, want_alpha) in [
1610            (2, DrawingToolType::Brush, 3.0, 200, 6.0, 200),
1611            (3, DrawingToolType::Brush, 3.0, 200, 6.0, 200),
1612            (3, DrawingToolType::Highlighter, 2.0, 255, 16.0, 100),
1613            (3, DrawingToolType::Highlighter, 20.0, 40, 100.0, 100),
1614            (4, DrawingToolType::Highlighter, 2.0, 40, 2.0, 40),
1615        ] {
1616            let mut saved: serde_json::Value =
1617                serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1618            saved["version"] = version.into();
1619            let records = saved["drawings"].as_array_mut().unwrap();
1620            records.truncate(1);
1621            let settings = &mut records[0]["drawing"];
1622            settings["tool_type"] = serde_json::to_value(tool).unwrap();
1623            settings["stroke_width"] = width.into();
1624            settings["color"] = serde_json::json!([10, 20, 30, alpha]);
1625            let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1626            assert!(skipped.is_empty(), "{tool:?} v{version}: {skipped:?}");
1627            let drawing = &loaded.drawings[0].drawing;
1628            assert_eq!(
1629                (drawing.stroke_width, drawing.color),
1630                (want_width, [10, 20, 30, want_alpha]),
1631                "{tool:?} v{version}"
1632            );
1633            let (again, _) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1634            assert_eq!(
1635                again.drawings[0].drawing.stroke_width, want_width,
1636                "{tool:?} v{version}: converted twice"
1637            );
1638        }
1639
1640        // Settings from a file saved by version 4, before the new fields and placement counts.
1641        for (tool, count, origins) in [
1642            (DrawingToolType::Note, 1, &[0][..]),
1643            (DrawingToolType::Signpost, 1, &[0][..]),
1644            (DrawingToolType::PriceNote, 1, &[0, 0][..]),
1645            (DrawingToolType::ArrowMarker, 1, &[0, 0][..]),
1646            (DrawingToolType::RotatedRect, 2, &[0, 1, 1][..]),
1647            (DrawingToolType::DoubleCurve, 2, &[0, 0, 1, 1][..]),
1648            (DrawingToolType::FibonacciWedge, 2, &[0, 1, 1][..]),
1649            (DrawingToolType::Pitchfan, 2, &[0, 1, 1][..]),
1650            (DrawingToolType::BarsPattern, 2, &[0, 1, 0][..]),
1651            (DrawingToolType::ElliottDoubleCombo, 3, &[0, 0, 1, 2][..]),
1652            (
1653                DrawingToolType::ElliottTripleCombo,
1654                5,
1655                &[0, 0, 1, 2, 3, 4][..],
1656            ),
1657            (DrawingToolType::ElliottDoubleCombo, 2, &[0, 0, 1][..]),
1658        ] {
1659            let mut saved: serde_json::Value =
1660                serde_json::from_str(include_str!("fixtures/storage_v2.json")).unwrap();
1661            saved["version"] = 4.into();
1662            let records = saved["drawings"].as_array_mut().unwrap();
1663            records.truncate(1);
1664            records[0]["drawing"]["tool_type"] = serde_json::to_value(tool).unwrap();
1665            let points = records[0]["points"].as_array_mut().unwrap();
1666            let first = points[0].clone();
1667            points.resize(count, first);
1668            for (at, point) in records[0]["points"]
1669                .as_array_mut()
1670                .unwrap()
1671                .iter_mut()
1672                .enumerate()
1673            {
1674                point["subms_ns"] = (at + 1).into();
1675            }
1676            let original = records[0]["points"].as_array().unwrap().clone();
1677            let (loaded, skipped) = DrawingStorage::from_json(&saved.to_string()).unwrap();
1678            assert!(skipped.is_empty(), "{tool:?}: {skipped:?}");
1679            let points = &loaded.drawings[0].points;
1680            assert_eq!(points.len(), origins.len(), "{tool:?}");
1681            for (point, &origin) in points.iter().zip(origins) {
1682                assert_eq!(
1683                    point.ts_ms,
1684                    original[origin]["ts_ms"].as_i64().unwrap(),
1685                    "{tool:?}"
1686                );
1687                assert_eq!(
1688                    u64::from(point.subms_ns),
1689                    original[origin]["subms_ns"].as_u64().unwrap(),
1690                    "{tool:?}"
1691                );
1692            }
1693            let points = serde_json::to_value(points).unwrap();
1694            for anchor in original {
1695                assert!(points.as_array().unwrap().contains(&anchor), "{tool:?}");
1696            }
1697            let (again, skipped) = DrawingStorage::from_json(&loaded.to_json().unwrap()).unwrap();
1698            assert!(skipped.is_empty());
1699            assert_eq!(again.drawings[0].points.len(), origins.len());
1700        }
1701
1702        let mut first = Drawing::new(1, DrawingToolType::FixedRangeVolumeProfile);
1703        first.volume_profile = VolumeProfileConfig::new(24, 0.7);
1704        first.chart_points = vec![ChartPoint::new(0.0, 100.0), ChartPoint::new(10.0, 110.0)];
1705        let mut storage = DrawingStorage::new("AAPL");
1706        storage.add_drawing(drawing_to_stored(&first, bar_to_time));
1707        let mut second = Drawing::new(2, DrawingToolType::TrendLine);
1708        second.chart_points = first.chart_points.clone();
1709        storage.add_drawing(drawing_to_stored(&second, bar_to_time));
1710        let current = storage.to_json().unwrap();
1711        // The first drawing as `tool` with only `anchors` anchors, written as `to_json` would
1712        // without its checks.
1713        let short_of_anchors = |tool, anchors| {
1714            let mut storage = storage.clone();
1715            storage.drawings[0].drawing.tool_type = tool;
1716            storage.drawings[0].points.truncate(anchors);
1717            serde_json::to_string_pretty(&storage).unwrap()
1718        };
1719        let with_level_width = |width| {
1720            let mut value = serde_json::to_value(&storage).unwrap();
1721            value["drawings"][0]["drawing"]["pitchfork"] = serde_json::json!({
1722                "levels":[{"value":1.0,"visible":true,"color":[1,2,3,255],"line_style":"Solid","width":width}],
1723                "show_background":false,"background_opacity":51
1724            });
1725            serde_json::to_string(&value).unwrap()
1726        };
1727        let with_vwap_multiplier = |multiplier| {
1728            let mut value = serde_json::to_value(&storage).unwrap();
1729            let band = |m| serde_json::json!({ "enabled": true, "multiplier": m });
1730            value["drawings"][0]["drawing"]["vwap"] = serde_json::json!({
1731                "bands": [band(multiplier), band(2.0), band(3.0)]
1732            });
1733            serde_json::to_string(&value).unwrap()
1734        };
1735        let unreadable = [
1736            (
1737                with_vwap_multiplier(0.0),
1738                "anchored VWAP band multiplier out of bounds",
1739                &["2"][..],
1740                0,
1741                "invalid",
1742            ),
1743            (
1744                with_vwap_multiplier(10.5),
1745                "anchored VWAP band multiplier out of bounds",
1746                &["2"][..],
1747                0,
1748                "invalid",
1749            ),
1750            (
1751                with_level_width(0.0),
1752                "out-of-range pitchfork.levels[0].width",
1753                &["2"][..],
1754                0,
1755                "invalid",
1756            ),
1757            (
1758                with_level_width(101.0),
1759                "out-of-range pitchfork.levels[0].width",
1760                &["2"][..],
1761                0,
1762                "invalid",
1763            ),
1764            (
1765                current.replacen("\"ts_ms\":", "\"subms_ns\": 1000000, \"ts_ms\":", 1),
1766                "anchor timestamp is outside the supported range",
1767                &["2"][..],
1768                0,
1769                "invalid",
1770            ),
1771            (
1772                current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": null", 1),
1773                "invalid type: null",
1774                &["2"][..],
1775                0,
1776                "invalid",
1777            ),
1778            (
1779                current.replacen("\"rows\": 24", "\"rows\": 0", 1),
1780                "volume profile settings out of bounds",
1781                &["2"],
1782                0,
1783                "invalid",
1784            ),
1785            (
1786                current.replace("FixedRangeVolumeProfile", "NewerTool"),
1787                "unknown drawing tool \"NewerTool\"",
1788                &["2"],
1789                0,
1790                "unknown tool NewerTool",
1791            ),
1792            (
1793                current.replace("FixedRangeVolumeProfile", "Eraser"),
1794                "Eraser is a cursor, not a drawing",
1795                &["2"],
1796                0,
1797                "cursor Eraser",
1798            ),
1799            (
1800                current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": 1e39", 1),
1801                "out-of-range stroke_width",
1802                &["2"],
1803                0,
1804                "invalid",
1805            ),
1806            (
1807                current.replacen("\"stroke_width\": 2.0", "\"stroke_width\": -1.0", 1),
1808                "out-of-range stroke_width",
1809                &["2"],
1810                0,
1811                "invalid",
1812            ),
1813            (
1814                current.replacen("\"font_size\": 14.0", "\"font_size\": 100000.0", 1),
1815                "out-of-range font_size",
1816                &["2"],
1817                0,
1818                "invalid",
1819            ),
1820            (
1821                short_of_anchors(DrawingToolType::TrendLine, 0),
1822                "Trend Line needs 2 anchors, has 0",
1823                &["2"],
1824                0,
1825                "TrendLine has 0 of 2 anchors",
1826            ),
1827            (
1828                short_of_anchors(DrawingToolType::Rect, 1),
1829                "Rectangle needs 2 anchors, has 1",
1830                &["2"],
1831                0,
1832                "Rect has 1 of 2 anchors",
1833            ),
1834            (
1835                short_of_anchors(DrawingToolType::Pitchfork, 2),
1836                "Pitchfork needs 3 anchors, has 2",
1837                &["2"],
1838                0,
1839                "Pitchfork has 2 of 3 anchors",
1840            ),
1841            (
1842                current.replacen("\"id\": \"2\"", "\"id\": \"1\"", 1),
1843                "duplicate drawing id \"1\"",
1844                &["1"],
1845                1,
1846                "duplicate 1",
1847            ),
1848            (
1849                // "fib retracement" is a name the previous loader also accepted.
1850                v1_json.replacen("Trend Line", "Newer Tool", 1).replacen(
1851                    "Fib Retracement",
1852                    "fib retracement",
1853                    1,
1854                ),
1855                "unknown drawing tool \"Newer Tool\"",
1856                &["2", "3", "4", "5"],
1857                0,
1858                "unknown tool Newer Tool",
1859            ),
1860        ];
1861        let reason = |skipped: &SkippedDrawing| match &skipped.error {
1862            SkipReason::UnknownTool(error) => format!("unknown tool {}", error.input),
1863            SkipReason::Cursor { tool } => format!("cursor {tool:?}"),
1864            SkipReason::MissingAnchors {
1865                tool,
1866                anchors,
1867                needed,
1868            } => format!("{tool:?} has {anchors} of {needed} anchors"),
1869            SkipReason::DuplicateId { id } => format!("duplicate {id}"),
1870            SkipReason::Invalid(_) => "invalid".into(),
1871        };
1872        for (json, error, loaded, index, expected) in unreadable {
1873            let (storage, skipped) = DrawingStorage::from_json(&json).unwrap();
1874            let ids: Vec<_> = storage.drawings().iter().map(|d| d.id.as_str()).collect();
1875            assert_eq!(ids, loaded);
1876            assert_eq!(skipped.len(), 1);
1877            assert_eq!(skipped[0].index, index);
1878            assert_eq!(reason(&skipped[0]), expected, "{skipped:?}");
1879            assert_eq!(skipped[0].id.as_deref(), Some("1"));
1880            assert!(skipped[0].error.to_string().contains(error), "{skipped:?}");
1881            assert!(storage.to_json().is_ok(), "{error}");
1882        }
1883
1884        // A drawing saved with the former Paintbrush tool, which drew as a brush, loads as one.
1885        let paintbrush = current.replace("\"TrendLine\"", "\"Paintbrush\"");
1886        let (storage, skipped) = DrawingStorage::from_json(&paintbrush).unwrap();
1887        assert!(skipped.is_empty(), "{skipped:?}");
1888        assert_eq!(
1889            storage.drawings()[1].drawing.tool_type,
1890            DrawingToolType::Brush
1891        );
1892
1893        // A filter naming no timeframe loads as visible on every timeframe.
1894        let hidden = current.replacen("\"show_on_all\": true", "\"show_on_all\": false", 1);
1895        let (storage, skipped) = DrawingStorage::from_json(&hidden).unwrap();
1896        assert!(skipped.is_empty(), "{skipped:?}");
1897        assert!(
1898            storage.drawings()[0]
1899                .drawing
1900                .timeframe_visibility
1901                .show_on_all
1902        );
1903        // A filter holding only a range keeps it.
1904        let ranged = current.replacen(
1905            "\"show_on_all\": true",
1906            "\"show_on_all\": false, \"ranges\": {\"Hour\": {\"start\": 1, \"end\": 4}}",
1907            1,
1908        );
1909        let (storage, skipped) = DrawingStorage::from_json(&ranged).unwrap();
1910        assert!(skipped.is_empty(), "{skipped:?}");
1911        let visibility = &storage.drawings()[0].drawing.timeframe_visibility;
1912        assert!(!visibility.show_on_all);
1913        assert!(visibility.is_visible_on("2h") && !visibility.is_visible_on("1D"));
1914
1915        let error = DrawingStorage::from_json(&current.replace("\"version\": 6", "\"version\": 7"))
1916            .unwrap_err();
1917        assert!(
1918            matches!(&error, LoadError::UnsupportedVersion { found: 7, supported } if *supported == (1..=6)),
1919            "{error}"
1920        );
1921    }
1922
1923    #[test]
1924    fn to_json_refuses_what_the_next_load_would_lose() {
1925        fn first(storage: &mut DrawingStorage) -> &mut StoredDrawing {
1926            &mut storage.drawings[0]
1927        }
1928        type Spoil = fn(&mut DrawingStorage);
1929        let cases: [(Spoil, &str); 26] = [
1930            (
1931                |s| {
1932                    first(s)
1933                        .drawing
1934                        .image
1935                        .get_or_insert_with(Default::default)
1936                        .size = Some([f32::NAN, 1.0])
1937                },
1938                "invalid image.size",
1939            ),
1940            (
1941                |s| {
1942                    first(s)
1943                        .drawing
1944                        .image
1945                        .get_or_insert_with(Default::default)
1946                        .size = Some([0.0, 1.0])
1947                },
1948                "invalid image.size",
1949            ),
1950            (
1951                |s| {
1952                    first(s)
1953                        .drawing
1954                        .pitchfork
1955                        .get_or_insert_with(Default::default)
1956                        .levels[0]
1957                        .width = Some(f32::NAN)
1958                },
1959                "invalid pitchfork.levels[0].width",
1960            ),
1961            (
1962                |s| {
1963                    first(s)
1964                        .drawing
1965                        .pitchfork
1966                        .get_or_insert_with(Default::default)
1967                        .levels[0]
1968                        .width = Some(0.0)
1969                },
1970                "invalid pitchfork.levels[0].width",
1971            ),
1972            (
1973                |s| first(s).drawing.position_sizing.leverage = f64::NAN,
1974                "invalid position_sizing",
1975            ),
1976            (
1977                |s| first(s).drawing.screen_anchor = Some([f32::NAN, 0.5]),
1978                "invalid screen_anchor",
1979            ),
1980            (
1981                |s| first(s).points[0].ts_ms = i64::MAX,
1982                "anchor 0 has no time mapping",
1983            ),
1984            (
1985                |s| first(s).points[0].subms_ns = 1_000_000,
1986                "anchor 0 has no time mapping",
1987            ),
1988            (
1989                |s| first(s).drawing.stroke_width = f32::NAN,
1990                "invalid stroke_width",
1991            ),
1992            (
1993                |s| first(s).drawing.font_size = f32::INFINITY,
1994                "invalid font_size",
1995            ),
1996            (
1997                |s| first(s).drawing.stroke_width = 3e38,
1998                "invalid stroke_width",
1999            ),
2000            (|s| first(s).drawing.font_size = 0.0, "invalid font_size"),
2001            (
2002                |s| first(s).points[1].price = f64::NAN,
2003                "invalid points[1].price",
2004            ),
2005            (
2006                |s| {
2007                    first(s).drawing.position_size = Some(PositionSize::RiskPercent {
2008                        account_size: 25_000.0,
2009                        percent: f64::NEG_INFINITY,
2010                    })
2011                },
2012                "invalid position_size",
2013            ),
2014            (
2015                |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = 0.0,
2016                "invalid fib_config.level_width",
2017            ),
2018            (
2019                |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = f32::NAN,
2020                "invalid fib_config.level_width",
2021            ),
2022            (
2023                |s| first(s).drawing.fib_config.as_mut().unwrap().level_width = 101.0,
2024                "invalid fib_config.level_width",
2025            ),
2026            (
2027                |s| first(s).drawing.fib_config.as_mut().unwrap().levels[0].value = f32::NAN,
2028                "invalid fib_config.levels[0].value",
2029            ),
2030            (
2031                |s| {
2032                    first(s)
2033                        .drawing
2034                        .pitchfork
2035                        .get_or_insert_with(Default::default)
2036                        .levels[0]
2037                        .value = f32::NAN
2038                },
2039                "invalid pitchfork.levels[0].value",
2040            ),
2041            (
2042                |s| {
2043                    first(s)
2044                        .drawing
2045                        .pitchfork
2046                        .get_or_insert_with(Default::default)
2047                        .levels[0]
2048                        .value = 3e38
2049                },
2050                "invalid pitchfork.levels[0].value",
2051            ),
2052            (
2053                |s| {
2054                    first(s)
2055                        .drawing
2056                        .gann
2057                        .get_or_insert_with(Default::default)
2058                        .price_levels[0]
2059                        .value = f32::INFINITY
2060                },
2061                "invalid gann.price_levels[0].value",
2062            ),
2063            (
2064                |s| {
2065                    first(s)
2066                        .drawing
2067                        .gann
2068                        .get_or_insert_with(Default::default)
2069                        .time_levels[0]
2070                        .value = f32::NAN
2071                },
2072                "invalid gann.time_levels[0].value",
2073            ),
2074            (
2075                |s| {
2076                    first(s)
2077                        .drawing
2078                        .ghost_feed
2079                        .get_or_insert_with(Default::default)
2080                        .average_range_ticks = f64::NAN
2081                },
2082                "invalid ghost_feed",
2083            ),
2084            (|s| s.drawings.push(s.drawings[0].clone()), "duplicate id"),
2085            (|s| first(s).points.truncate(1), "Trend Line needs 2"),
2086            (
2087                |s| first(s).drawing.tool_type = DrawingToolType::Eraser,
2088                "a cursor",
2089            ),
2090        ];
2091        for (spoil, expected) in cases {
2092            let mut storage = DrawingStorage::new("AAPL");
2093            storage.add_drawing(drawing_to_stored(
2094                &non_default_drawing(DrawingToolType::TrendLine),
2095                bar_to_time,
2096            ));
2097            spoil(&mut storage);
2098            let got = match storage.to_json() {
2099                Err(SaveError::NonFinite { id, field }) => format!("{id}: invalid {field}"),
2100                Err(SaveError::DuplicateId { id }) => format!("{id}: duplicate id"),
2101                Err(SaveError::Cursor { id }) => format!("{id}: a cursor"),
2102                Err(SaveError::UnmappedAnchor { id, point }) => {
2103                    format!("{id}: anchor {point} has no time mapping")
2104                }
2105                Err(SaveError::MissingAnchors {
2106                    id, tool, needed, ..
2107                }) => {
2108                    format!("{id}: {tool} needs {needed}")
2109                }
2110                other => panic!("{expected}: {:?}", other.map(drop)),
2111            };
2112            assert_eq!(got, format!("7: {expected}"));
2113        }
2114    }
2115
2116    #[test]
2117    fn test_remove_drawing() {
2118        let mut storage = DrawingStorage::new("AAPL");
2119        storage.add_drawing(StoredDrawing::new("1", Drawing::default()));
2120        storage.add_drawing(StoredDrawing::new("2", Drawing::default()));
2121
2122        assert!(storage.remove_drawing("1"));
2123        assert_eq!(storage.drawings().len(), 1);
2124        assert!(!storage.remove_drawing("1")); // Already removed
2125    }
2126
2127    #[test]
2128    fn adding_an_existing_id_replaces_the_drawing_and_round_trips() {
2129        let mut storage = DrawingStorage::new("AAPL");
2130        for (id, tool, created_at) in [
2131            ("1", DrawingToolType::TrendLine, 10),
2132            ("2", DrawingToolType::Ray, 20),
2133            ("1", DrawingToolType::HorizontalLine, 30),
2134        ] {
2135            let mut stored = StoredDrawing::new(id, Drawing::new(0, tool));
2136            stored.points = vec![StoredCoord::new(T0, 100.0), StoredCoord::new(T0, 110.0)];
2137            stored.created_at = created_at;
2138            storage.add_drawing(stored);
2139        }
2140        let (loaded, skipped) = DrawingStorage::from_json(&storage.to_json().unwrap()).unwrap();
2141        assert!(skipped.is_empty(), "{skipped:?}");
2142        let got: Vec<_> = loaded
2143            .drawings()
2144            .iter()
2145            .map(|d| (d.id.as_str(), d.drawing.tool_type, d.created_at))
2146            .collect();
2147        assert_eq!(
2148            got,
2149            [
2150                ("1", DrawingToolType::HorizontalLine, 10),
2151                ("2", DrawingToolType::Ray, 20)
2152            ]
2153        );
2154    }
2155}