egui_charts/model/timescale.rs
1//! Time-scale coordinate system (model layer).
2//!
3//! [`TimeScale`] manages the mapping between logical bar indices and pixel
4//! coordinates on the horizontal axis. It owns the bar spacing, scroll offset,
5//! and edge constraints that control which bars are visible.
6//!
7//! [`LogicalRange`] represents a floating-point range of bar indices, which
8//! can be converted to a strict integer range for data-array slicing.
9
10/// A floating-point range of bar indices on the horizontal axis.
11///
12/// `left` and `right` may extend beyond `[0, bar_count)` when the chart is
13/// scrolled past the data edges. Use [`to_strict_range`](LogicalRange::to_strict_range)
14/// to clamp to valid array indices.
15#[derive(Debug, Clone, Copy)]
16pub struct LogicalRange {
17 /// Leftmost visible bar index (may be negative when scrolled past the start).
18 pub left: f32,
19 /// Rightmost visible bar index (may exceed `bar_count` when scrolled into the future).
20 pub right: f32,
21}
22
23impl LogicalRange {
24 pub fn new(left: f32, right: f32) -> Self {
25 Self { left, right }
26 }
27
28 /// Convert to strict integer range for data access
29 pub fn to_strict_range(&self) -> (usize, usize) {
30 let start = self.left.floor().max(0.0) as usize;
31 let end = self.right.ceil().max(0.0) as usize;
32 (start, end)
33 }
34
35 /// Length of this range
36 pub fn length(&self) -> f32 {
37 self.right - self.left
38 }
39}
40
41/// Time-scale coordinate engine -- pure logic, no UI dependencies.
42///
43/// Manages the horizontal mapping between logical bar indices and pixel
44/// positions. The chart widget reads from `TimeScale` to lay out candles and
45/// writes back scroll/zoom deltas from user interaction.
46///
47/// # Coordinate model
48///
49/// The rightmost data point (the latest bar) is the *anchor*. The
50/// `right_offset` field shifts the anchor away from the right edge of the
51/// chart to leave whitespace for price labels and visual breathing room.
52///
53/// ```text
54/// bar index: 0 1 2 3 4 5 6 7 8 · ·
55/// │ │ │ │ │ │ │ │ │ │
56/// ◄──────── visible ──────────► │
57/// ◄──────────── width ─────────────►
58/// ↑
59/// right_offset
60/// ```
61#[derive(Debug, Clone)]
62pub struct TimeScale {
63 /// Bar spacing in pixels
64 bar_spacing: f32,
65 /// Right offset in bars from the edge
66 right_offset: f32,
67 /// Width of the chart area in pixels
68 width: f32,
69 /// Total number of bars in dataset
70 bar_cnt: usize,
71 /// Min bar spacing constraint
72 min_bar_spacing: f32,
73 /// Max bar spacing constraint (0 = unlimited)
74 max_bar_spacing: f32,
75 /// Prevent scrolling past left edge
76 fix_left_edge: bool,
77 /// Prevent scrolling past right edge
78 fix_right_edge: bool,
79}
80
81impl TimeScale {
82 /// Create new TimeScale with default settings
83 pub fn new() -> Self {
84 Self {
85 bar_spacing: 8.0,
86 right_offset: 5.0, // Default: 5 bars of whitespace on the right
87 width: 800.0,
88 bar_cnt: 0,
89 min_bar_spacing: 0.5,
90 max_bar_spacing: 0.0, // unlimited
91 fix_left_edge: true,
92 fix_right_edge: false, // Allow scrolling past latest bar
93 }
94 }
95
96 /// Apply options coming from `TimeScaleOptions`
97 /// Note: this does not own `TimeScaleOptions` to avoid a dependency cycle.
98 pub fn apply_options(
99 &mut self,
100 bar_spacing: f32,
101 min_bar_spacing: f32,
102 max_bar_spacing: f32,
103 fix_left_edge: bool,
104 fix_right_edge: bool,
105 right_offset_bars: f32,
106 right_offset_pixels: Option<f32>,
107 ) {
108 // Update constraints first so clamping of spacing uses new bounds
109 self.min_bar_spacing = min_bar_spacing;
110 self.max_bar_spacing = max_bar_spacing;
111 self.fix_left_edge = fix_left_edge;
112 self.fix_right_edge = fix_right_edge;
113
114 // Apply spacing with clamping
115 self.set_bar_spacing(bar_spacing);
116
117 // Pixels option has priority over bars
118 let right_offset = if let Some(px) = right_offset_pixels {
119 // Convert pixels to bars using current spacing
120 px / self.bar_spacing
121 } else {
122 right_offset_bars
123 };
124 self.set_right_offset(right_offset);
125 }
126
127 /// Set the width of the chart area (must be called when chart resizes)
128 pub fn set_width(&mut self, width: f32) {
129 self.width = width;
130 // CRITICAL: Changing width changes visible_bars calculation in constraints
131 self.apply_constraints();
132 }
133
134 /// Set the total number of bars in dataset
135 pub fn set_bar_cnt(&mut self, count: usize) {
136 self.bar_cnt = count;
137 // CRITICAL: Changing bar_cnt affects max_offset calculation in constraints
138 self.apply_constraints();
139 }
140
141 /// Set bar spacing (with clamping to constraints)
142 pub fn set_bar_spacing(&mut self, spacing: f32) {
143 let clamped = if self.max_bar_spacing > 0.0 {
144 spacing.clamp(self.min_bar_spacing, self.max_bar_spacing)
145 } else {
146 spacing.max(self.min_bar_spacing)
147 };
148 self.bar_spacing = clamped;
149 // CRITICAL: Changing bar_spacing changes visible_bars calculation,
150 // which affects edge constraints. Must re-apply constraints!
151 self.apply_constraints();
152 }
153
154 /// Update min bar spacing
155 pub fn set_min_bar_spacing(&mut self, min: f32) {
156 self.min_bar_spacing = min.max(0.0);
157 // Re-apply spacing to respect new constraints
158 self.set_bar_spacing(self.bar_spacing);
159 }
160
161 /// Update max bar spacing (0 = unlimited)
162 pub fn set_max_bar_spacing(&mut self, max: f32) {
163 self.max_bar_spacing = max.max(0.0);
164 // Re-apply spacing to respect new constraints
165 self.set_bar_spacing(self.bar_spacing);
166 }
167
168 /// Configure whether left edge is fixed
169 pub fn set_fix_left_edge(&mut self, fix: bool) {
170 self.fix_left_edge = fix;
171 self.apply_constraints();
172 }
173
174 /// Configure whether right edge is fixed
175 pub fn set_fix_right_edge(&mut self, fix: bool) {
176 self.fix_right_edge = fix;
177 self.apply_constraints();
178 }
179
180 /// Set right offset (with constraint checking)
181 pub fn set_right_offset(&mut self, offset: f32) {
182 self.right_offset = offset;
183 self.apply_constraints();
184 }
185
186 /// Jump to latest bar position
187 /// Resets right_offset to the default sticky offset (2.5 bars)
188 pub fn jump_to_latest(&mut self) {
189 const DEFAULT_RIGHT_OFFSET: f32 = 2.5;
190 self.right_offset = DEFAULT_RIGHT_OFFSET;
191 }
192
193 /// Get current bar spacing
194 pub fn bar_spacing(&self) -> f32 {
195 self.bar_spacing
196 }
197
198 /// Get current right offset
199 pub fn right_offset(&self) -> f32 {
200 self.right_offset
201 }
202
203 /// Get current width
204 pub fn width(&self) -> f32 {
205 self.width
206 }
207
208 /// Get base index (last data point - coord anchor)
209 pub fn base_idx(&self) -> usize {
210 self.bar_cnt.saturating_sub(1)
211 }
212
213 /// Calculate visible logical range
214 pub fn visible_logical_range(&self) -> LogicalRange {
215 let base_idx = self.base_idx() as f32;
216 let bars_len = self.width / self.bar_spacing;
217 let right_border = base_idx + self.right_offset;
218 let left_border = right_border - bars_len + 1.0;
219
220 LogicalRange::new(left_border, right_border)
221 }
222
223 /// Convert logical index to x coord
224 ///
225 /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
226 /// NOT self.width (which may differ from the chart area's rect).
227 pub fn idx_to_coord(&self, index: usize, rect_min_x: f32, rect_width: f32) -> f32 {
228 let base_idx = self.base_idx();
229 let delta_from_right = base_idx as f32 + self.right_offset - index as f32;
230 let relative_x = rect_width - (delta_from_right + 0.5) * self.bar_spacing - 1.0;
231 rect_min_x + relative_x
232 }
233
234 /// Convert fractional bar index to x coord (preserves sub-bar precision)
235 /// Used for drawings which store fractional bar indices for precise positioning
236 ///
237 /// IMPORTANT: rect_width must be the actual width of the rect being drawn in,
238 /// NOT self.width (which may differ from the chart area's rect).
239 pub fn idx_to_coord_precise(&self, index: f32, rect_min_x: f32, rect_width: f32) -> f32 {
240 let base_idx = self.base_idx() as f32;
241 let delta_from_right = base_idx + self.right_offset - index;
242 let relative_x = rect_width - (delta_from_right + 0.5) * self.bar_spacing - 1.0;
243 rect_min_x + relative_x
244 }
245
246 /// Convert x coord to logical index
247 ///
248 /// IMPORTANT: rect_width must be the actual width of the rect being used,
249 /// NOT self.width (which may differ from the chart area's rect).
250 pub fn coord_to_idx(&self, x: f32, rect_min_x: f32, rect_width: f32) -> f32 {
251 let base_idx = self.base_idx() as f32;
252 let relative_x = x - rect_min_x;
253 let delta_from_right = (rect_width - relative_x - 1.0) / self.bar_spacing - 0.5;
254 base_idx + self.right_offset - delta_from_right
255 }
256
257 /// Scroll by number of bars (negative = left, positive = right)
258 pub fn scroll_bars(&mut self, bars: f32) {
259 self.right_offset -= bars;
260 self.apply_constraints();
261 }
262
263 /// Scroll by pixels
264 pub fn scroll_pixels(&mut self, pixels: f32) {
265 let bars = pixels / self.bar_spacing;
266 self.scroll_bars(bars);
267 }
268
269 /// Zoom at a specific point
270 ///
271 /// IMPORTANT: rect_width must be the actual width of the chart rect being zoomed.
272 pub fn zoom(&mut self, delta: f32, anchor_x: f32, rect_min_x: f32, rect_width: f32) {
273 let old_spacing = self.bar_spacing;
274 let _old_right_offset = self.right_offset;
275
276 // Get the bar index at anchor point BEFORE zoom
277 let anchor_bar_idx = self.coord_to_idx(rect_min_x + anchor_x, rect_min_x, rect_width);
278
279 // Calculate new bar spacing
280 let zoom_scale = delta;
281 let new_spacing = old_spacing + zoom_scale * (old_spacing / 10.0);
282
283 // Clamp bar spacing to constraints
284 let clamped = if self.max_bar_spacing > 0.0 {
285 new_spacing.clamp(self.min_bar_spacing, self.max_bar_spacing)
286 } else {
287 new_spacing.max(self.min_bar_spacing)
288 };
289 self.bar_spacing = clamped;
290
291 // DIRECT CALCULATION: Calculate right_offset needed to keep anchor_bar_idx at anchor_x
292 // Formula derived from idx_to_coord_precise:
293 // x = rect_min_x + rect_width - (base_idx + right_offset - bar_idx + 0.5) * bar_spacing - 1
294 // Solving for right_offset:
295 // right_offset = bar_idx - base_idx + (rect_width - (x - rect_min_x) - 1) / bar_spacing - 0.5
296 let base_idx = self.base_idx() as f32;
297 let relative_anchor = anchor_x; // anchor_x is already relative to rect_min_x
298 let delta_from_right = (rect_width - relative_anchor - 1.0) / self.bar_spacing - 0.5;
299 let calculated_offset = anchor_bar_idx - base_idx + delta_from_right;
300 self.right_offset = calculated_offset;
301
302 // Debug logging - check for width mismatch
303 if (rect_width - self.width).abs() > 0.1 {
304 log::warn!(
305 "[ZOOM WIDTH MISMATCH] rect_width={:.1}, self.width={:.1}",
306 rect_width,
307 self.width
308 );
309 }
310
311 // Apply constraints after setting right_offset
312 let before_constraint = self.right_offset;
313 self.apply_constraints();
314
315 // Log if constraints modified right_offset
316 if (self.right_offset - before_constraint).abs() > 0.01 {
317 log::debug!(
318 "[ZOOM CONSTRAINT] right_offset changed: {:.3} -> {:.3} (delta={:.3})",
319 before_constraint,
320 self.right_offset,
321 self.right_offset - before_constraint
322 );
323 }
324 }
325
326 /// Fit all data in view
327 pub fn fit_content(&mut self) {
328 if self.bar_cnt > 0 {
329 let spacing = self.width / self.bar_cnt as f32;
330 self.set_bar_spacing(spacing);
331 self.right_offset = 0.0;
332 }
333 }
334
335 /// Scroll to real-time (latest data)
336 /// Maintains sticky offset - keeps ~2.5 bars of whitespace on the right
337 /// for price labels and visual breathing room
338 pub fn scroll_to_realtime(&mut self) {
339 const REALTIME_OFFSET: f32 = 2.5;
340 self.right_offset = REALTIME_OFFSET;
341 }
342
343 /// Apply edge constraints
344 fn apply_constraints(&mut self) {
345 if self.width <= 0.0 || self.bar_spacing <= 0.0 || self.bar_cnt == 0 {
346 return;
347 }
348
349 // Calculate min and max right offset bounds
350 let min_right = self.calculate_min_right_offset();
351 let max_right = self.calculate_max_right_offset();
352
353 // Ensure well-ordered bounds (min <= max)
354 let (min_right_offset, max_right_offset) = if let Some(min_val) = min_right {
355 if min_val <= max_right {
356 (min_val, max_right)
357 } else {
358 (max_right, min_val)
359 }
360 } else {
361 (f32::NEG_INFINITY, max_right)
362 };
363
364 // Clamp right_offset between bounds
365 self.right_offset = self.right_offset.clamp(min_right_offset, max_right_offset);
366
367 // Near-edge stabilization for fixed right edge
368 if self.fix_right_edge && self.right_offset.abs() < 1e-6 {
369 self.right_offset = 0.0;
370 }
371 }
372
373 /// Calculate min allowed right_offset (most negative value).
374 /// This limits how far we can scroll to the LEFT (showing older data).
375 /// Uses a permissive bound to allow viewing all historical data.
376 fn calculate_min_right_offset(&self) -> Option<f32> {
377 if self.bar_cnt == 0 || self.bar_spacing <= 0.0 || self.width <= 0.0 {
378 return None;
379 }
380
381 // Allow scrolling to view all data plus some buffer
382 Some(-(self.bar_cnt as f32 + 100.0))
383 }
384
385 /// Calculate max allowed right_offset (most positive value).
386 /// This limits how far we can scroll to the RIGHT (into the future).
387 fn calculate_max_right_offset(&self) -> f32 {
388 if self.bar_cnt == 0 {
389 return 0.0;
390 }
391
392 if self.fix_right_edge {
393 0.0
394 } else {
395 // Allow scrolling into future whitespace
396 // Use a generous bound based on visible bars at minimum zoom
397
398 self.width / self.min_bar_spacing
399 }
400 }
401
402 /// Get number of visible candles
403 pub fn visible_candles(&self) -> usize {
404 (self.width / self.bar_spacing).floor() as usize
405 }
406}
407
408impl Default for TimeScale {
409 fn default() -> Self {
410 Self::new()
411 }
412}