Skip to main content

egui_charts/drawings/domain/
drawing.rs

1//! Drawing struct - represents a single drawing on the chart
2//!
3//! This is the core data structure for chart drawings. It contains:
4//! - Dual coordinate system (screen coords + chart coords)
5//! - Styling properties (color, stroke, line style)
6//! - Tool-specific configuration (Fibonacci levels, etc.)
7//!
8//! Note: Rendering methods are in the rendering module, not here.
9//! This keeps the domain layer pure and testable.
10
11use super::{
12    ArrowStyle, BarsPatternConfig, ChartPoint, DrawingToolType, ElliottDegree, FibonacciConfig,
13    FontWeight, GANN_SQUARE_UNITS, GannConfig, GhostFeedConfig, ImageConfig, PitchforkConfig,
14    PositionSize, PositionSizing, RegressionTrendConfig, TimeframeVisibility, VwapConfig,
15};
16
17use crate::data::VolumeProfileConfig;
18use crate::model::{AlertSource, LineStyle, PostLink};
19use crate::scales::PriceScaleMode;
20use serde::{Deserialize, Serialize};
21
22/// A drawing on the chart
23///
24/// Drawings use a dual coordinate system:
25/// - `points: Vec<Pos2>` - Screen coordinates, updated each frame
26/// - `chart_points: Vec<ChartPoint>` - Chart coordinates (bar_idx, price), stable across pan/zoom
27///
28/// # Example
29///
30/// ```
31/// use egui_charts::drawings::{Drawing, DrawingToolType};
32///
33/// let mut drawing = Drawing::new(1, DrawingToolType::TrendLine);
34/// drawing.color = [33, 150, 243, 255]; // Blue
35/// drawing.stroke_width = 2.0;
36/// ```
37///
38/// Serde covers the tool and its settings only: the id, the points and the
39/// completion state are left out, since the anchors are stored as
40/// timestamps by [`persistence`](crate::drawings::persistence). A field added
41/// later needs `#[serde(default)]`, so that saved version 2 files still load.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43// The trait impls below wrap the derived ones.
44#[serde(remote = "Self")]
45pub struct Drawing {
46    /// Unique identifier for this drawing
47    #[serde(skip)]
48    pub id: usize,
49    /// Id of the stored record this drawing was restored from; `None` for a drawing never
50    /// stored, which is saved under its `id`
51    #[serde(skip)]
52    pub stored_id: Option<String>,
53    /// Creation time, in Unix milliseconds, of the stored record this drawing was restored
54    /// from; `None` for a drawing never stored, which is saved with the time of the save
55    #[serde(skip)]
56    pub created_at: Option<i64>,
57    /// User notes, saved with the drawing and restored from its stored record
58    #[serde(skip)]
59    pub notes: Option<String>,
60    /// Optional object-tree name, saved with the drawing.
61    #[serde(default)]
62    pub label: Option<String>,
63    /// Owning study id, or `None` for the main price pane. Follows that study when panes move
64    /// or merge; hosts restoring studies must retain or remap their ids. Older files use main.
65    #[serde(default)]
66    pub pane: Option<usize>,
67    /// Whether pointer and programmatic selection can select this drawing.
68    #[serde(default = "enabled")]
69    pub selectable: bool,
70    /// Whether chart saves and persistence notifications include this drawing.
71    #[serde(default = "enabled")]
72    pub saveable: bool,
73    /// Whether host object lists should include this drawing. Rendering is independent.
74    #[serde(default = "enabled")]
75    pub listed: bool,
76    /// Host-defined group identity, retained through saving and undo.
77    #[serde(default)]
78    pub group: Option<String>,
79    /// Named object-tree folder path, from outermost to innermost; empty means the root.
80    /// Independent of host-defined groups. Older saved drawings start at the root.
81    #[serde(default)]
82    pub folder: Vec<String>,
83    /// Stable id of a host-registered tool; `None` uses the built-in renderer.
84    #[serde(default)]
85    pub custom_tool: Option<String>,
86    /// Host-defined settings passed to the registered renderer and saved without interpretation.
87    #[serde(default)]
88    pub custom_settings: serde_json::Value,
89    /// Type of drawing tool
90    pub tool_type: DrawingToolType,
91    /// Screen coordinates (updated each frame from chart_points)
92    #[serde(skip)]
93    pub points: Vec<egui::Pos2>,
94    /// Persistent chart coordinates (bar_idx, price) - stable across pan/zoom
95    #[serde(skip)]
96    pub chart_points: Vec<ChartPoint>,
97    /// Screen y of each visible level of a Fibonacci retracement or extension, in config order,
98    /// or of the far corner of a fixed Gann square (empty for other tools); rebuilt with `points`
99    /// by [`Self::update_screen_coords`]
100    #[serde(skip)]
101    pub level_ys: Vec<f32>,
102    /// Position as fractions of the pane width and height for anchored text.
103    /// `None` adopts the first displayed position, including for older saved drawings.
104    /// A pin saved with one takes the bar and price under it the first time a chart draws it,
105    /// and the chart then clears it.
106    #[serde(default)]
107    pub screen_anchor: Option<[f32; 2]>,
108    /// Drawing colour as RGBA; also the text and border colour of annotations that have none of
109    /// their own.
110    pub color: [u8; 4],
111    /// Stroke width in pixels
112    pub stroke_width: f32,
113    /// Whether the drawing is complete
114    #[serde(skip)]
115    pub completed: bool,
116    /// Optional annotation content, preserved with the drawing. Tables use one row per line
117    /// and tab-separated cells. Images use an image URI resolved by the host's egui image
118    /// loaders. Posts and ideas use the first line as author or title and the rest as body,
119    /// when they have no [`post`](Self::post).
120    pub text: Option<String>,
121    /// The post or idea a post or idea drawing shows: its link and the content the host
122    /// resolved it to through [`Posts`](crate::services::Posts). The content is saved with the
123    /// drawing, so it shows without asking the host again. While it is set, the card shows it
124    /// instead of [`text`](Self::text). Other tools ignore it.
125    #[serde(default)]
126    pub post: Option<PostLink>,
127
128    // === Styling properties ===
129    /// Line style (Solid, Dashed, Dotted)
130    pub line_style: LineStyle,
131    /// Fill colour of shapes and the background colour of annotation boxes. `None` leaves shapes
132    /// unfilled and gives an annotation box its tool's default colour.
133    pub fill_color: Option<[u8; 4]>,
134    /// Whether the drawing is locked (prevents editing/moving)
135    pub locked: bool,
136    /// Whether the drawing is visible
137    pub visible: bool,
138    /// Font size for text annotations
139    pub font_size: f32,
140    /// Font weight for text annotations
141    pub font_weight: FontWeight,
142    /// Whether annotation text is slanted. Older drawings are upright.
143    #[serde(default)]
144    pub italic: bool,
145    /// Text colour of annotations; `None` uses the tool's default, the drawing colour except on
146    /// a solid label, which takes white or black, whichever reads on its fill.
147    #[serde(default)]
148    pub text_color: Option<[u8; 4]>,
149    /// Whether an annotation draws a box behind its text, in `fill_color` or else the tool's
150    /// default: the theme's panel colour, or the drawing colour for a solid label. Older
151    /// drawings have it on.
152    #[serde(default = "enabled")]
153    pub background: bool,
154    /// Whether an annotation's box has a 1 px border. Older drawings have it on.
155    #[serde(default = "enabled")]
156    pub border: bool,
157    /// Border colour of annotation boxes; `None` uses the drawing colour.
158    #[serde(default)]
159    pub border_color: Option<[u8; 4]>,
160    /// Extend line to left edge
161    pub extend_left: bool,
162    /// Extend line to right edge
163    pub extend_right: bool,
164    /// Arrow style at start of line
165    pub arrow_start_style: ArrowStyle,
166    /// Arrow style at end of line
167    pub arrow_end_style: ArrowStyle,
168    /// Per-timeframe visibility
169    pub timeframe_visibility: TimeframeVisibility,
170    /// Whether a horizontal line, horizontal ray or cross line shows its price as a tag on the
171    /// price axis. Other tools ignore it. Drawings saved without it show the tag.
172    #[serde(default = "enabled")]
173    pub price_axis_label: bool,
174    /// Whether a vertical line or cross line shows its time as a tag on the time axis. Other
175    /// tools ignore it. Drawings saved without it show the tag.
176    #[serde(default = "enabled")]
177    pub time_axis_label: bool,
178
179    // === Tool-specific configuration (None uses the defaults) ===
180    /// Quantity of the long and short position tools
181    pub position_size: Option<PositionSize>,
182    /// Instrument economics for long and short position sizing. Older drawings use unit sizing.
183    #[serde(default)]
184    pub position_sizing: PositionSizing,
185    /// Target zone and level colour of position tools; `None` keeps the default green.
186    #[serde(default)]
187    pub position_target_color: Option<[u8; 4]>,
188    /// Stop zone and level colour of position tools; `None` keeps the default red.
189    #[serde(default)]
190    pub position_stop_color: Option<[u8; 4]>,
191    /// Customizable Fibonacci levels for Fib tools
192    pub fib_config: Option<FibonacciConfig>,
193    /// Rows and value area of the volume profile tools
194    pub volume_profile: Option<VolumeProfileConfig>,
195    /// Band width of the regression trend tool
196    pub regression_trend: Option<RegressionTrendConfig>,
197    /// Bands of the anchored VWAP. Older drawings use the defaults.
198    #[serde(default)]
199    pub vwap: Option<VwapConfig>,
200
201    /// Levels, fan lines, arcs and background of the Gann tools; `None` draws
202    /// [`GannConfig::for_tool`].
203    #[serde(default)]
204    pub gann: Option<GannConfig>,
205    /// Average range and noise, in ticks, of the candles a Ghost Feed projects.
206    #[serde(default)]
207    pub ghost_feed: Option<GhostFeedConfig>,
208    /// Whether a rectangle or parallel channel draws its dashed halfway line.
209    #[serde(default)]
210    pub channel_midline: bool,
211    /// Frame size and opacity of the image tool; `None` uses the defaults.
212    #[serde(default)]
213    pub image: Option<ImageConfig>,
214    /// Whether a polyline joins its last anchor back to its first. Clicking the first anchor
215    /// while placing a polyline closes it; older saved drawings are open.
216    #[serde(default)]
217    pub closed: bool,
218    /// Level lines and band fill of the pitchfork tools.
219    #[serde(default)]
220    pub pitchfork: Option<PitchforkConfig>,
221    /// Notation of the wave labels of the Elliott wave tools. Older drawings use the default.
222    #[serde(default)]
223    pub elliott_degree: ElliottDegree,
224
225    /// Draw mode, mirroring and flipping of a bars pattern's copy. `None` uses the defaults,
226    /// as drawings saved before this setting do.
227    #[serde(default)]
228    pub bars_pattern: Option<BarsPatternConfig>,
229
230    // === Z-Order (rendering layer) ===
231    /// Z-order for rendering priority (higher = drawn on top)
232    pub z_order: i32,
233}
234
235fn enabled() -> bool {
236    true
237}
238
239pub(super) const BLUE: [u8; 4] = [41, 98, 255, 255];
240pub(super) const RED: [u8; 4] = [242, 54, 69, 255];
241pub(super) const ORANGE: [u8; 4] = [255, 152, 0, 255];
242pub(super) const GREEN: [u8; 4] = [76, 175, 80, 255];
243pub(super) const TEAL: [u8; 4] = [8, 153, 129, 255];
244pub(super) const CYAN: [u8; 4] = [0, 188, 212, 255];
245pub(super) const PINK: [u8; 4] = [233, 30, 99, 255];
246pub(super) const PURPLE: [u8; 4] = [156, 39, 176, 255];
247const DEEP_PURPLE: [u8; 4] = [103, 58, 183, 255];
248pub(super) const GREY: [u8; 4] = [128, 128, 128, 255];
249const WHITE: [u8; 4] = [255, 255, 255, 255];
250
251/// `color` at `alpha` of 255.
252const fn tint(color: [u8; 4], alpha: u8) -> [u8; 4] {
253    [color[0], color[1], color[2], alpha]
254}
255
256/// Line, fill and text defaults of a new drawing, before any default the user saved for its tool.
257struct DefaultStyle {
258    color: [u8; 4],
259    width: f32,
260    line_style: LineStyle,
261    fill: Option<[u8; 4]>,
262    font_size: f32,
263}
264
265/// A 2 px solid blue line with 14 px text and no fill, the style most tools start from.
266const LINE: DefaultStyle = DefaultStyle {
267    color: BLUE,
268    width: 2.0,
269    line_style: LineStyle::Solid,
270    fill: None,
271    font_size: 14.0,
272};
273
274/// The default style of each tool, chosen for its role: areas start filled with a light tint
275/// (20%, 15% for chart patterns, 40% for date ranges), small labels take 12 px text.
276fn default_style(tool: DrawingToolType) -> DefaultStyle {
277    use DrawingToolType as T;
278    let filled = |color: [u8; 4], alpha: u8| DefaultStyle {
279        color,
280        fill: Some(tint(color, alpha)),
281        ..LINE
282    };
283    let colored = |color: [u8; 4]| DefaultStyle { color, ..LINE };
284    match tool {
285        T::HorizontalLine | T::HorizontalRay | T::CrossLine => DefaultStyle {
286            font_size: 12.0,
287            ..LINE
288        },
289        T::ParallelChannel => filled(BLUE, 51),
290        // The centre line and lower band take the line colour, the upper band the fill.
291        T::RegressionTrend => DefaultStyle {
292            color: RED,
293            fill: Some(tint(BLUE, 51)),
294            ..LINE
295        },
296        T::FlatTopBottom => filled(ORANGE, 51),
297        T::DisjointChannel | T::Triangle => filled(TEAL, 51),
298        T::DoubleCurve | T::ThreeDrivesPattern => colored(DEEP_PURPLE),
299        T::Rect => filled(PURPLE, 51),
300        T::RotatedRect => filled(GREEN, 51),
301        T::Circle | T::Ellipse => filled(RED, 51),
302        // Polylines and arcs start unfilled.
303        T::Polyline | T::Brush => colored(CYAN),
304        T::Arc => colored(PINK),
305        T::Sector => DefaultStyle {
306            color: [156, 156, 156, 255],
307            width: 1.0,
308            fill: Some(tint(BLUE, 51)),
309            ..LINE
310        },
311        T::Highlighter => DefaultStyle {
312            color: tint(RED, 51),
313            width: 20.0,
314            ..LINE
315        },
316        T::ArrowMarker => DefaultStyle {
317            color: [30, 83, 229, 255],
318            font_size: 16.0,
319            ..LINE
320        },
321        T::ArrowMarkUp => colored(TEAL),
322        T::ArrowMarkDown => colored([204, 47, 60, 255]),
323        T::Pitchfork
324        | T::SchiffPitchfork
325        | T::ModifiedSchiffPitchfork
326        | T::InsidePitchfork
327        | T::Pitchfan => colored(RED),
328        // The colour, width and pattern of the trend line; levels have their own.
329        T::FibonacciRetracement
330        | T::FibonacciExtension
331        | T::FibonacciChannel
332        | T::TrendBasedFibTime
333        | T::FibonacciCircles
334        | T::FibonacciSpeedResistanceArcs => DefaultStyle {
335            color: GREY,
336            line_style: LineStyle::Dashed,
337            ..LINE
338        },
339        T::FibonacciTimeZones => DefaultStyle {
340            color: GREY,
341            width: 1.0,
342            line_style: LineStyle::Dashed,
343            ..LINE
344        },
345        T::FibonacciWedge | T::FibonacciSpeedFan => colored(GREY),
346        T::FibonacciSpiral => DefaultStyle {
347            color: CYAN,
348            width: 1.0,
349            ..LINE
350        },
351        T::GhostFeed => DefaultStyle {
352            color: GREY,
353            width: 1.0,
354            ..LINE
355        },
356        T::XABCDPattern | T::CypherPattern => filled(BLUE, 38),
357        T::HeadAndShoulders => filled(TEAL, 38),
358        T::ABCDPattern | T::SineLine => colored(TEAL),
359        T::TrianglePattern => filled(DEEP_PURPLE, 38),
360        T::ElliottImpulse
361        | T::ElliottCorrection
362        | T::ElliottTriangle
363        | T::ElliottDoubleCombo
364        | T::ElliottTripleCombo => colored([61, 133, 198, 255]),
365        T::CyclicLines => colored([144, 202, 249, 255]),
366        T::TimeCycles => DefaultStyle {
367            color: [21, 153, 128, 255],
368            fill: Some([106, 168, 79, 128]),
369            ..LINE
370        },
371        // Measure keeps its fill unset, which tints it by the direction of the move.
372        T::Measure | T::PriceRange => DefaultStyle {
373            font_size: 12.0,
374            ..LINE
375        },
376        T::DateRange | T::DateAndPriceRange => DefaultStyle {
377            font_size: 12.0,
378            ..filled(BLUE, 102)
379        },
380        // Text boxes: the fill is the box colour, used once the background is on.
381        T::TextLabel | T::AnchoredText => DefaultStyle {
382            fill: Some(tint(BLUE, 64)),
383            ..LINE
384        },
385        T::Note => filled(BLUE, 179),
386        T::Callout => filled([0, 151, 167, 255], 179),
387        T::PriceNote | T::Signpost => DefaultStyle {
388            font_size: 12.0,
389            ..LINE
390        },
391        T::Comment => DefaultStyle {
392            font_size: 16.0,
393            ..LINE
394        },
395        T::Table => colored([219, 219, 219, 255]),
396        T::FontIcon => DefaultStyle {
397            font_size: 40.0,
398            ..LINE
399        },
400        T::Sticker => DefaultStyle {
401            font_size: 110.0,
402            ..LINE
403        },
404        _ => LINE,
405    }
406}
407
408impl Drawing {
409    /// Create a new drawing with default properties
410    pub fn new(id: usize, tool_type: DrawingToolType) -> Self {
411        let style = default_style(tool_type);
412        // Text starts without a box; switching one on shows the tinted fill and a grey border.
413        let plain_text = matches!(
414            tool_type,
415            DrawingToolType::TextLabel | DrawingToolType::AnchoredText
416        );
417        Self {
418            id,
419            stored_id: None,
420            created_at: None,
421            notes: None,
422            label: None,
423            selectable: true,
424            saveable: true,
425            listed: true,
426            group: None,
427            folder: Vec::new(),
428            tool_type,
429            custom_tool: None,
430            custom_settings: serde_json::Value::Null,
431            points: Vec::new(),
432            chart_points: Vec::new(),
433            level_ys: Vec::new(),
434            screen_anchor: None,
435            pane: None,
436            color: style.color,
437            stroke_width: style.width,
438            completed: false,
439            text: (tool_type == DrawingToolType::Sticker).then(|| "sticker:bull".into()),
440            post: None,
441            // Styling defaults
442            line_style: style.line_style,
443            fill_color: style.fill,
444            locked: false,
445            visible: true,
446            font_size: style.font_size,
447            font_weight: if tool_type == DrawingToolType::ArrowMarker {
448                FontWeight::Bold
449            } else {
450                FontWeight::Normal
451            },
452            italic: false,
453            text_color: matches!(tool_type, DrawingToolType::Note | DrawingToolType::Callout)
454                .then_some(WHITE),
455            background: !plain_text,
456            border: !plain_text,
457            border_color: plain_text.then_some([112, 112, 112, 255]),
458            extend_left: tool_type == DrawingToolType::ExtendedLine,
459            extend_right: matches!(
460                tool_type,
461                DrawingToolType::Ray | DrawingToolType::ExtendedLine
462            ),
463            arrow_start_style: ArrowStyle::None,
464            arrow_end_style: if matches!(tool_type, DrawingToolType::Arrow | DrawingToolType::Path)
465            {
466                ArrowStyle::Arrow
467            } else {
468                ArrowStyle::None
469            },
470            timeframe_visibility: TimeframeVisibility::all(),
471            price_axis_label: true,
472            time_axis_label: true,
473            position_size: None,
474            position_sizing: PositionSizing::default(),
475            position_target_color: None,
476            position_stop_color: None,
477            fib_config: matches!(
478                tool_type,
479                DrawingToolType::FibonacciRetracement
480                    | DrawingToolType::FibonacciExtension
481                    | DrawingToolType::FibonacciChannel
482                    | DrawingToolType::FibonacciWedge
483                    | DrawingToolType::FibonacciTimeZones
484                    | DrawingToolType::FibonacciSpeedFan
485                    | DrawingToolType::TrendBasedFibTime
486                    | DrawingToolType::FibonacciCircles
487                    | DrawingToolType::FibonacciSpeedResistanceArcs
488            )
489            .then(|| FibonacciConfig::for_tool(tool_type)),
490            volume_profile: matches!(
491                tool_type,
492                DrawingToolType::FixedRangeVolumeProfile | DrawingToolType::AnchoredVolumeProfile
493            )
494            .then(VolumeProfileConfig::drawing_default),
495            regression_trend: None,
496            vwap: None,
497            pitchfork: matches!(
498                tool_type,
499                DrawingToolType::Pitchfork
500                    | DrawingToolType::SchiffPitchfork
501                    | DrawingToolType::ModifiedSchiffPitchfork
502                    | DrawingToolType::InsidePitchfork
503            )
504            .then(PitchforkConfig::default),
505            elliott_degree: ElliottDegree::default(),
506            channel_midline: tool_type == DrawingToolType::ParallelChannel,
507            image: None,
508            closed: false,
509            gann: None,
510            ghost_feed: None,
511            bars_pattern: None,
512            // Z-order (default: 0, higher values render on top)
513            z_order: 0,
514        }
515    }
516
517    /// Creates a host-defined drawing. The manager looks up `tool` in its registry; the
518    /// built-in polyline identity supplies anchor handles and multi-click input only.
519    pub fn custom(tool: impl Into<String>) -> Self {
520        let mut drawing = Self::new(0, DrawingToolType::Polyline);
521        drawing.custom_tool = Some(tool.into());
522        drawing
523    }
524
525    /// Create a new drawing with a specific line colour; a fill tinted from the tool's default
526    /// line colour takes the same tint of `color`.
527    pub fn with_color(id: usize, tool_type: DrawingToolType, color: [u8; 4]) -> Self {
528        let mut drawing = Self::new(id, tool_type);
529        // A fill tinted from the tool's line colour follows the new one; other fills stay.
530        if let Some(fill) = &mut drawing.fill_color
531            && fill[..3] == drawing.color[..3]
532        {
533            fill[..3].copy_from_slice(&color[..3]);
534        }
535        drawing.color = color;
536        drawing
537    }
538
539    /// Create a new text label drawing
540    pub fn with_text(id: usize, text: String) -> Self {
541        let mut drawing = Self::new(id, DrawingToolType::TextLabel);
542        drawing.text = Some(text);
543        drawing.arrow_end_style = ArrowStyle::None;
544        drawing
545    }
546
547    /// Update screen coordinates from chart_points using current transforms
548    ///
549    /// This should be called each frame before rendering.
550    pub fn update_screen_coords<F, G>(&mut self, bar_to_x: F, price_to_y: G)
551    where
552        F: Fn(f32) -> f32,
553        G: Fn(f64) -> f32,
554    {
555        self.points.clear();
556        for cp in &self.chart_points {
557            let x = bar_to_x(cp.bar_idx);
558            let y = price_to_y(cp.price);
559            self.points.push(egui::Pos2::new(x, y));
560        }
561        self.level_ys = match (self.tool_type, &self.chart_points[..]) {
562            // Five of the first units in price, which a logarithmic scale does not keep linear.
563            (DrawingToolType::GannFixed, [a, b, ..]) => {
564                vec![price_to_y(
565                    a.price + (b.price - a.price) * f64::from(GANN_SQUARE_UNITS),
566                )]
567            }
568            _ => self
569                .fib_levels()
570                .map(|(_, prices)| prices.into_iter().map(&price_to_y).collect())
571                .unwrap_or_default(),
572        };
573    }
574
575    /// Rebuilds anchored text coordinates relative to the pane. Call after
576    /// `update_screen_coords`; other tools retain their chart coordinates. Older drawings
577    /// adopt their first displayed position. Resizing preserves the width and height fractions.
578    pub fn update_pane_coords(&mut self, pane: egui::Rect) {
579        if self.tool_type != DrawingToolType::AnchoredText
580            || !pane.is_finite()
581            || pane.width() <= 0.0
582            || pane.height() <= 0.0
583        {
584            return;
585        }
586        if let Some(pos) = self.points.first_mut() {
587            let anchor = self.screen_anchor.get_or_insert_with(|| {
588                [
589                    (pos.x - pane.left()) / pane.width(),
590                    (pos.y - pane.top()) / pane.height(),
591                ]
592            });
593            *pos = pane.min + egui::vec2(anchor[0] * pane.width(), anchor[1] * pane.height());
594        }
595    }
596
597    /// The Fibonacci config in use and the price of each of its visible levels, for a retracement
598    /// or an extension projected from its third anchor (the first anchor for legacy two-point drawings).
599    pub(crate) fn fib_levels(&self) -> Option<(std::borrow::Cow<'_, FibonacciConfig>, Vec<f64>)> {
600        let extension = match self.tool_type {
601            DrawingToolType::FibonacciRetracement => false,
602            DrawingToolType::FibonacciExtension => true,
603            _ => return None,
604        };
605        let [start, end, ..] = &self.chart_points[..] else {
606            return None;
607        };
608        let config = self.fibonacci_config();
609        let origin = self.chart_points.get(2).unwrap_or(start).price;
610        let prices = config
611            .level_prices(start.price, end.price, extension, origin)
612            .collect();
613        Some((config, prices))
614    }
615
616    /// The price levels an alert can follow at the fractional bar index `bar`, each with its
617    /// level number, name and price: a line's price extended through its two anchors, the base and
618    /// second lines of parallel or disjoint channels, the trend and flat lines of a flat
619    /// top/bottom, a horizontal level, a rectangle's top and bottom, a position's entry, target
620    /// and stop, and each visible Fibonacci retracement or extension level, numbered by its place
621    /// in the level settings. Sloping lines are straight on the price axis `scale` paints, so
622    /// they are linear in price except on a logarithmic scale.
623    /// Fork levels use two stable slots per configured level (first and second line), with
624    /// only the first slot used by a median. Regression levels require
625    /// [`Self::alert_levels_with_bars`]. Empty for other drawings and for
626    /// geometry with no price at `bar`, such as a vertical line or a sloping line through a
627    /// non-positive price on a logarithmic scale.
628    pub fn alert_levels(&self, bar: f64, scale: PriceScaleMode) -> Vec<(usize, String, f64)> {
629        use DrawingToolType as T;
630        let named = |names: &[&str], prices: &[f64]| {
631            names
632                .iter()
633                .zip(prices)
634                .enumerate()
635                .map(|(level, (name, price))| (level, (*name).to_owned(), *price))
636                .collect::<Vec<_>>()
637        };
638        let log = scale == PriceScaleMode::Logarithmic;
639        let to_axis = |price: f64| if log { price.ln() } else { price };
640        let from_axis = |value: f64| if log { value.exp() } else { value };
641        // The line through `a` and `b` at `bar`, in axis coordinates.
642        let along = |a: &ChartPoint, b: &ChartPoint, bar: f64| {
643            let (from, to) = (to_axis(a.price), to_axis(b.price));
644            from + (bar - f64::from(a.bar_idx)) * (to - from) / f64::from(b.bar_idx - a.bar_idx)
645        };
646        let levels = match (self.tool_type, &self.chart_points[..]) {
647            (T::HorizontalLine | T::HorizontalRay, [level, ..]) => named(&["Line"], &[level.price]),
648            (
649                T::TrendLine | T::Ray | T::ExtendedLine | T::InfoLine | T::TrendAngle,
650                [start, end, ..],
651            ) => named(&["Line"], &[from_axis(along(start, end, bar))]),
652            // The second line is the base line moved straight up or down through the third anchor.
653            (T::ParallelChannel, [start, end, through, ..]) => {
654                let shift = to_axis(through.price) - along(start, end, f64::from(through.bar_idx));
655                named(
656                    &["Line", "Parallel line"],
657                    &[
658                        from_axis(along(start, end, bar)),
659                        from_axis(along(start, end, bar) + shift),
660                    ],
661                )
662            }
663            (T::FlatTopBottom, [start, end, flat, ..]) => named(
664                &["Line", "Flat line"],
665                &[from_axis(along(start, end, bar)), flat.price],
666            ),
667            // The second line passes through the third anchor with the opposite slope.
668            (T::DisjointChannel, [start, end, from, ..]) => {
669                let rise = to_axis(end.price) - to_axis(start.price);
670                let run = f64::from(end.bar_idx - start.bar_idx);
671                named(
672                    &["Line", "Second line"],
673                    &[
674                        from_axis(along(start, end, bar)),
675                        from_axis(
676                            to_axis(from.price) - (bar - f64::from(from.bar_idx)) * rise / run,
677                        ),
678                    ],
679                )
680            }
681            (
682                T::Pitchfork
683                | T::SchiffPitchfork
684                | T::ModifiedSchiffPitchfork
685                | T::InsidePitchfork
686                | T::Pitchfan,
687                _,
688            ) => self.fork_alert_levels(bar, scale),
689            (T::Rect, [a, b, ..]) => named(
690                &["Top", "Bottom"],
691                &[a.price.max(b.price), a.price.min(b.price)],
692            ),
693            (T::LongPos | T::ShortPos, [entry, target, rest @ ..]) => named(
694                &["Entry", "Target", "Stop"],
695                &[
696                    entry.price,
697                    target.price,
698                    // Until the stop is placed it mirrors the target around the entry.
699                    rest.first()
700                        .map_or(2.0 * entry.price - target.price, |stop| stop.price),
701                ],
702            ),
703            _ => self
704                .fib_levels()
705                .map(|(config, prices)| {
706                    config
707                        .levels
708                        .iter()
709                        .enumerate()
710                        .filter(|(_, level)| level.visible)
711                        .zip(prices)
712                        .map(|((index, level), price)| {
713                            (index, level.label(config.levels_as_percent), price)
714                        })
715                        .collect()
716                })
717                .unwrap_or_default(),
718        };
719        if levels.iter().all(|(_, _, price)| price.is_finite()) {
720            levels
721        } else {
722            Vec::new()
723        }
724    }
725
726    /// The alert source of level `level` of [`Self::alert_levels`]: the drawing's stored id, or
727    /// its id when it was never stored, followed by `#` and the level for levels after the
728    /// first. `%` and `#` in the id are escaped as `%25` and `%23` so distinct drawings cannot
729    /// share a source. It names the same level after the drawing is saved and restored.
730    pub fn alert_source(&self, level: usize) -> AlertSource {
731        let id = self.stable_id();
732        AlertSource::Drawing(if level == 0 {
733            id.into_owned()
734        } else {
735            format!("{id}#{level}")
736        })
737    }
738
739    /// The level of this drawing that `source`, from [`Self::alert_source`], names.
740    pub fn alert_level(&self, source: &AlertSource) -> Option<usize> {
741        let AlertSource::Drawing(source) = source else {
742            return None;
743        };
744        let id = self.stable_id();
745        if *source == id {
746            return Some(0);
747        }
748        source
749            .strip_prefix(id.as_ref())?
750            .strip_prefix('#')?
751            .parse()
752            .ok()
753            .filter(|level| *level > 0)
754    }
755
756    fn stable_id(&self) -> std::borrow::Cow<'_, str> {
757        let id: std::borrow::Cow<'_, str> = self
758            .stored_id
759            .as_deref()
760            .map_or_else(|| self.id.to_string().into(), Into::into);
761        if id.contains('%') || id.contains('#') {
762            id.replace('%', "%25").replace('#', "%23").into()
763        } else {
764            id
765        }
766    }
767
768    /// Add a point with both screen and chart coordinates
769    pub fn add_point_with_chart_coords<F, G>(
770        &mut self,
771        screen_point: egui::Pos2,
772        x_to_bar: F,
773        y_to_price: G,
774    ) where
775        F: Fn(f32) -> f32,
776        G: Fn(f32) -> f64,
777    {
778        let bar_idx = x_to_bar(screen_point.x);
779        let price = y_to_price(screen_point.y);
780        self.chart_points.push(ChartPoint::new(bar_idx, price));
781        self.points.push(screen_point);
782        self.check_completion();
783    }
784
785    /// Update the last point with both screen and chart coordinates
786    pub fn update_last_point_with_chart_coords<F, G>(
787        &mut self,
788        screen_point: egui::Pos2,
789        x_to_bar: F,
790        y_to_price: G,
791    ) where
792        F: Fn(f32) -> f32,
793        G: Fn(f32) -> f64,
794    {
795        if let (Some(last_point), Some(last_chart_point)) =
796            (self.points.last_mut(), self.chart_points.last_mut())
797        {
798            *last_point = screen_point;
799            *last_chart_point =
800                ChartPoint::new(x_to_bar(screen_point.x), y_to_price(screen_point.y));
801        }
802    }
803
804    /// Keeps a second anchor on the first one's price for Time cycles, so it only sets the period,
805    /// and on the first one's bar for a Signpost, so it only sets the label's height.
806    pub(crate) fn align_second_anchor(&mut self) {
807        if self.points.len() < 2 || self.chart_points.len() < 2 {
808            return;
809        }
810        match self.tool_type {
811            DrawingToolType::TimeCycles => {
812                self.points[1].y = self.points[0].y;
813                self.chart_points[1].price = self.chart_points[0].price;
814            }
815            DrawingToolType::Signpost => {
816                self.points[1].x = self.points[0].x;
817                self.chart_points[1].bar_idx = self.chart_points[0].bar_idx;
818            }
819            _ => {}
820        }
821    }
822
823    /// Add a screen point (legacy method)
824    pub fn add_point(&mut self, point: egui::Pos2) {
825        self.points.push(point);
826        self.check_completion();
827    }
828
829    /// Shift the bar indices at or after `from` by a delta (used when bars are inserted in front
830    /// of them)
831    pub fn shift_bar_indices(&mut self, from: f32, delta: f32) {
832        for cp in &mut self.chart_points {
833            if cp.bar_idx >= from {
834                cp.shift_bar_idx(delta);
835            }
836        }
837    }
838
839    /// Index of the first anchor that moving the whole drawing moves. A bars pattern's source
840    /// anchors stay on the bars it copies, so a move takes only its copy's anchor; every other
841    /// drawing, and a bars pattern still without a copy anchor, moves all of its anchors.
842    pub(crate) fn first_moved_anchor(&self) -> usize {
843        if self.tool_type == DrawingToolType::BarsPattern && self.chart_points.len() >= 3 {
844            2
845        } else {
846            0
847        }
848    }
849
850    /// Check and set completion status based on tool type
851    fn check_completion(&mut self) {
852        if let Some(required) = self.tool_type.required_points()
853            && self.points.len() >= required
854        {
855            self.completed = true;
856        }
857    }
858
859    /// Get the egui Color32 from the RGBA array
860    #[inline]
861    pub fn color32(&self) -> egui::Color32 {
862        egui::Color32::from_rgba_unmultiplied(
863            self.color[0],
864            self.color[1],
865            self.color[2],
866            self.color[3],
867        )
868    }
869
870    /// Get the fill color as Color32, if set
871    #[inline]
872    pub fn fill_color32(&self) -> Option<egui::Color32> {
873        self.fill_color
874            .map(|c| egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]))
875    }
876
877    /// Get the stroke for rendering
878    #[inline]
879    pub fn stroke(&self) -> egui::Stroke {
880        egui::Stroke::new(self.stroke_width, self.color32())
881    }
882
883    /// Check if this drawing is visible on the given timeframe
884    #[inline]
885    pub fn is_visible_on_timeframe(&self, timeframe: &str) -> bool {
886        self.visible && self.timeframe_visibility.is_visible_on(timeframe)
887    }
888
889    /// Whether drawings of `tool` can tag their price on the price axis and their time on the
890    /// time axis: horizontal lines and rays their price, vertical lines their time, cross
891    /// lines both.
892    pub(crate) fn axis_labels_of(tool: DrawingToolType) -> (bool, bool) {
893        use DrawingToolType as T;
894        match tool {
895            T::HorizontalLine | T::HorizontalRay => (true, false),
896            T::VerticalLine => (false, true),
897            T::CrossLine => (true, true),
898            _ => (false, false),
899        }
900    }
901
902    /// Get the z-order of this drawing
903    #[inline]
904    pub fn z_order(&self) -> i32 {
905        self.z_order
906    }
907
908    /// Set the z-order of this drawing
909    #[inline]
910    pub fn set_z_order(&mut self, order: i32) {
911        self.z_order = order;
912    }
913
914    /// Get the bounding rect of the drawing's points
915    pub fn bounding_rect(&self) -> Option<egui::Rect> {
916        if self.points.is_empty() {
917            return None;
918        }
919
920        let mut min_x = f32::MAX;
921        let mut min_y = f32::MAX;
922        let mut max_x = f32::MIN;
923        let mut max_y = f32::MIN;
924
925        for p in &self.points {
926            min_x = min_x.min(p.x);
927            min_y = min_y.min(p.y);
928            max_x = max_x.max(p.x);
929            max_y = max_y.max(p.y);
930        }
931
932        Some(egui::Rect::from_min_max(
933            egui::pos2(min_x, min_y),
934            egui::pos2(max_x, max_y),
935        ))
936    }
937
938    /// Quadratic bezier curve interpolation
939    ///
940    /// Returns a point on the bezier curve defined by p0, p1, p2 at parameter t.
941    /// Used for smooth curve rendering (e.g., arcs, spirals).
942    pub fn quadratic_bezier(p0: egui::Pos2, p1: egui::Pos2, p2: egui::Pos2, t: f32) -> egui::Pos2 {
943        let u = 1.0 - t;
944        egui::Pos2::new(
945            u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x,
946            u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y,
947        )
948    }
949}
950
951impl Default for Drawing {
952    fn default() -> Self {
953        Self::new(0, DrawingToolType::TrendLine)
954    }
955}
956
957impl Serialize for Drawing {
958    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
959        Self::serialize(self, serializer)
960    }
961}
962
963impl<'de> Deserialize<'de> for Drawing {
964    /// A saved `gann` object takes the fields it leaves out from [`GannConfig::for_tool`] of the
965    /// drawing's tool, which a drawing without one draws.
966    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
967        GannConfig::forget_left_out();
968        let mut drawing = Self::deserialize(deserializer)?;
969        if let Some(gann) = &mut drawing.gann {
970            gann.fill_left_out(drawing.tool_type);
971        }
972        Ok(drawing)
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    #[test]
981    fn test_shift_bar_indices() {
982        let mut drawing = Drawing::new(1, DrawingToolType::TrendLine);
983        drawing.chart_points.push(ChartPoint::new(100.0, 150.0));
984        drawing.chart_points.push(ChartPoint::new(110.0, 160.0));
985
986        drawing.shift_bar_indices(105.0, 50.0);
987
988        assert_eq!(
989            drawing.chart_points,
990            [ChartPoint::new(100.0, 150.0), ChartPoint::new(160.0, 160.0)]
991        );
992    }
993
994    /// A drawing completes once it holds its tool's required number of
995    /// anchors, and each anchor added from the screen gets its chart
996    /// coordinates.
997    #[test]
998    fn test_completion() {
999        let x_to_bar = |x: f32| x / 10.0;
1000        let y_to_price = |y: f32| 1000.0 - y as f64;
1001        let mut drawing = Drawing::new(1, DrawingToolType::TrendLine);
1002
1003        drawing.add_point_with_chart_coords(egui::pos2(100.0, 300.0), x_to_bar, y_to_price);
1004        assert!(!drawing.completed);
1005
1006        drawing.add_point_with_chart_coords(egui::pos2(200.0, 50.0), x_to_bar, y_to_price);
1007        assert!(drawing.completed); // TrendLine requires 2 points
1008        assert_eq!(
1009            drawing.chart_points,
1010            [ChartPoint::new(10.0, 700.0), ChartPoint::new(20.0, 950.0)]
1011        );
1012    }
1013
1014    /// Alerts follow each level the drawing shows at the evaluated bar, under a source that
1015    /// names the same level once the drawing is stored.
1016    #[test]
1017    fn alert_levels_follow_the_drawing_geometry() {
1018        use DrawingToolType as T;
1019        use PriceScaleMode::{Logarithmic, Normal as Linear};
1020        let drawing = |tool, points: &[(f32, f64)]| {
1021            let mut drawing = Drawing::new(7, tool);
1022            drawing.chart_points = points.iter().map(|&(b, p)| ChartPoint::new(b, p)).collect();
1023            drawing
1024        };
1025        let mut fib = drawing(T::FibonacciRetracement, &[(0.0, 100.0), (10.0, 200.0)]);
1026        let mut config = FibonacciConfig {
1027            levels_as_percent: true,
1028            ..Default::default()
1029        };
1030        config.levels[1].visible = false;
1031        config.levels[2].label = "100%".into();
1032        config.levels[3].label.clear();
1033        fib.fib_config = Some(config);
1034        // (drawing, bar) -> (level, name, price); prices by hand.
1035        for (drawing, bar, scale, expected) in [
1036            // 100 + (25 - 10) * (120 - 100) / (20 - 10).
1037            (
1038                drawing(T::TrendLine, &[(10.0, 100.0), (20.0, 120.0)]),
1039                25.0,
1040                Linear,
1041                vec![(0, "Line", 130.0)],
1042            ),
1043            (
1044                drawing(T::TrendLine, &[(10.0, 100.0), (10.0, 120.0)]),
1045                25.0,
1046                Linear,
1047                vec![],
1048            ),
1049            (
1050                drawing(T::HorizontalRay, &[(3.0, 50.0)]),
1051                9.0,
1052                Linear,
1053                vec![(0, "Line", 50.0)],
1054            ),
1055            (
1056                drawing(T::Rect, &[(5.0, 90.0), (8.0, 110.0)]),
1057                9.0,
1058                Linear,
1059                vec![(0, "Top", 110.0), (1, "Bottom", 90.0)],
1060            ),
1061            (
1062                drawing(T::LongPos, &[(5.0, 100.0), (9.0, 110.0)]),
1063                9.0,
1064                Linear,
1065                vec![(0, "Entry", 100.0), (1, "Target", 110.0), (2, "Stop", 90.0)],
1066            ),
1067            (
1068                drawing(T::ShortPos, &[(5.0, 100.0), (9.0, 90.0), (9.0, 104.0)]),
1069                9.0,
1070                Linear,
1071                vec![(0, "Entry", 100.0), (1, "Target", 90.0), (2, "Stop", 104.0)],
1072            ),
1073            // Level 0 is on the second anchor; the hidden 23.6% level keeps the numbers after it.
1074            (
1075                fib,
1076                9.0,
1077                Linear,
1078                vec![
1079                    (0, "0%", 200.0),
1080                    (2, "38.2%", 161.8),
1081                    (3, "50%", 150.0),
1082                    (4, "61.8%", 138.2),
1083                    (5, "78.6%", 121.4),
1084                    (6, "100%", 100.0),
1085                ],
1086            ),
1087            // Straight on the scale: the geometric mean of 100 and 400 halfway on a log scale.
1088            (
1089                drawing(T::TrendLine, &[(10.0, 100.0), (20.0, 400.0)]),
1090                15.0,
1091                Logarithmic,
1092                vec![(0, "Line", 200.0)],
1093            ),
1094            (
1095                drawing(T::TrendLine, &[(10.0, 100.0), (20.0, 400.0)]),
1096                15.0,
1097                Linear,
1098                vec![(0, "Line", 250.0)],
1099            ),
1100            (
1101                drawing(T::TrendLine, &[(10.0, 0.0), (20.0, 10.0)]),
1102                15.0,
1103                Logarithmic,
1104                vec![],
1105            ),
1106            // The base line is 110 at the third anchor's bar 15, 20 below it: 130 + 20 at bar 25.
1107            // On a log scale the third anchor is twice the base line there, so the parallel is
1108            // twice as high at every bar: 400 and 800 at bar 20.
1109            (
1110                drawing(
1111                    T::ParallelChannel,
1112                    &[(10.0, 100.0), (20.0, 120.0), (15.0, 130.0)],
1113                ),
1114                25.0,
1115                Linear,
1116                vec![(0, "Line", 130.0), (1, "Parallel line", 150.0)],
1117            ),
1118            (
1119                drawing(
1120                    T::ParallelChannel,
1121                    &[(10.0, 100.0), (20.0, 400.0), (15.0, 400.0)],
1122                ),
1123                20.0,
1124                Logarithmic,
1125                vec![(0, "Line", 400.0), (1, "Parallel line", 800.0)],
1126            ),
1127            (
1128                drawing(
1129                    T::ParallelChannel,
1130                    &[(10.0, 100.0), (10.0, 120.0), (15.0, 130.0)],
1131                ),
1132                25.0,
1133                Linear,
1134                vec![],
1135            ),
1136            (
1137                drawing(
1138                    T::FlatTopBottom,
1139                    &[(10.0, 100.0), (20.0, 120.0), (15.0, 90.0)],
1140                ),
1141                25.0,
1142                Linear,
1143                vec![(0, "Line", 130.0), (1, "Flat line", 90.0)],
1144            ),
1145            // Through 50 at bar 12, the mirrored line falls 2 per bar: 24 at bar 25.
1146            // In log price it divides by 4 over 10 bars: 12.5 at bar 22.
1147            (
1148                drawing(
1149                    T::DisjointChannel,
1150                    &[(10.0, 100.0), (20.0, 120.0), (12.0, 50.0)],
1151                ),
1152                25.0,
1153                Linear,
1154                vec![(0, "Line", 130.0), (1, "Second line", 24.0)],
1155            ),
1156            (
1157                drawing(
1158                    T::DisjointChannel,
1159                    &[(10.0, 100.0), (20.0, 400.0), (12.0, 50.0)],
1160                ),
1161                22.0,
1162                Logarithmic,
1163                vec![
1164                    (0, "Line", 100.0 * 4f64.powf(1.2)),
1165                    (1, "Second line", 12.5),
1166                ],
1167            ),
1168            (
1169                drawing(T::Brush, &[(1.0, 1.0), (2.0, 2.0)]),
1170                9.0,
1171                Linear,
1172                vec![],
1173            ),
1174        ] {
1175            let levels = drawing.alert_levels(bar, scale);
1176            assert_eq!(levels.len(), expected.len(), "{:?}", drawing.tool_type);
1177            for ((level, name, price), (want_level, want_name, want_price)) in
1178                levels.into_iter().zip(expected)
1179            {
1180                assert_eq!((level, name.as_str()), (want_level, want_name));
1181                // Fibonacci ratios are stored as `f32`, good to about 1e-5 at these prices.
1182                assert!((price - want_price).abs() < 1e-4, "{name}: {price}");
1183            }
1184        }
1185
1186        let mut line = drawing(T::TrendLine, &[(10.0, 100.0), (20.0, 120.0)]);
1187        for (stored, level, source) in [
1188            (None, 0, "7"),
1189            (None, 2, "7#2"),
1190            (Some("a%231"), 0, "a%25231"),
1191            (Some("a#1"), 3, "a%231#3"),
1192        ] {
1193            line.stored_id = stored.map(Into::into);
1194            assert_eq!(
1195                line.alert_source(level),
1196                AlertSource::Drawing(source.into())
1197            );
1198            assert_eq!(line.alert_level(&line.alert_source(level)), Some(level));
1199        }
1200        for other in ["a", "a#1#0", "a#1#x", "7"] {
1201            assert_eq!(line.alert_level(&AlertSource::Drawing(other.into())), None);
1202        }
1203        assert_eq!(line.alert_level(&AlertSource::Price), None);
1204
1205        // A stored id containing the level separator must not alias another drawing's level.
1206        let mut other = line.clone();
1207        other.stored_id = Some("a".into());
1208        assert_ne!(line.alert_source(0), other.alert_source(1));
1209    }
1210
1211    #[test]
1212    fn test_bounding_rect() {
1213        let mut drawing = Drawing::new(1, DrawingToolType::TrendLine);
1214        assert!(drawing.bounding_rect().is_none());
1215
1216        drawing.points.push(egui::pos2(10.0, 20.0));
1217        drawing.points.push(egui::pos2(30.0, 50.0));
1218
1219        let rect = drawing.bounding_rect().unwrap();
1220        assert_eq!(rect.min, egui::pos2(10.0, 20.0));
1221        assert_eq!(rect.max, egui::pos2(30.0, 50.0));
1222    }
1223}