1use fret_core::time::Instant;
2use std::collections::BTreeMap;
3use std::ops::Range;
4use std::time::Duration;
5
6use delinea::FilterMode;
7use delinea::engine::EngineError;
8use delinea::engine::model::{ChartPatch, ModelError, PatchMode};
9use delinea::engine::window::{DataWindow, WindowSpanAnchor};
10use delinea::marks::{MarkKind, MarkPayloadRef};
11use delinea::text::{TextMeasurer, TextMetrics};
12use delinea::{Action, BrushSelection2D, ChartEngine, WorkBudget};
13use fret_canvas::cache::{PathCache, SceneOpCache};
14use fret_canvas::diagnostics::{CanvasCacheKey, CanvasCacheStatsRegistry};
15use fret_canvas::scale::effective_scale_factor;
16use fret_core::{
17 Color, Corners, DrawOrder, Edges, Event, FontWeight, KeyCode, Modifiers, MouseButton, Paint,
18 PathCommand, PathConstraints, PathStyle, Point, PointerEvent, PointerType, Px, Rect, SceneOp,
19 Size, StrokeStyle, TextBlobId, TextConstraints, TextOverflow, TextStyle, TextWrap, Transform2D,
20};
21use fret_runtime::Model;
22use fret_ui::Theme;
23use fret_ui::UiHost;
24use fret_ui::retained_bridge::{EventCx, Invalidation, LayoutCx, PaintCx, PrepaintCx, Widget};
25use slotmap::Key;
26use std::cell::{Ref, RefCell, RefMut};
27use std::rc::Rc;
28
29use crate::input_map::{ChartInputMap, ModifierKey, ModifiersMask};
30use crate::linking::{AxisPointerLinkAnchor, BrushSelectionLink2D, ChartLinkRouter, LinkAxisKey};
31use crate::retained::style::ChartStyle;
32use crate::retained::text_cache::{KeyBuilder, TextCacheGroup};
33use crate::retained::tooltip::{DefaultTooltipFormatter, TooltipFormatter};
34use crate::retained::{ChartCanvasOutput, ChartCanvasOutputSnapshot};
35
36fn mark_path_cache_key(mark_id: delinea::ids::MarkId, variant: u8) -> u64 {
37 use std::collections::hash_map::DefaultHasher;
38 use std::hash::{Hash, Hasher};
39
40 let mut hasher = DefaultHasher::new();
41 mark_id.hash(&mut hasher);
42 variant.hash(&mut hasher);
43 hasher.finish()
44}
45
46#[derive(Debug, Default)]
47struct NullTextMeasurer;
48
49impl TextMeasurer for NullTextMeasurer {
50 fn measure(
51 &mut self,
52 _text: delinea::ids::StringId,
53 _style: delinea::text::TextStyleId,
54 ) -> TextMetrics {
55 TextMetrics::default()
56 }
57}
58
59#[derive(Debug)]
60struct CachedPath {
61 fill_alpha: Option<f32>,
62 order: u32,
63 source_series: Option<delinea::SeriesId>,
64}
65
66#[derive(Debug, Clone, Copy)]
67struct CachedRect {
68 rect: Rect,
69 order: u32,
70 source_series: Option<delinea::SeriesId>,
71 fill: Option<delinea::PaintId>,
72 opacity_mul: f32,
73 stroke_width: Option<Px>,
74}
75
76#[derive(Debug, Clone, Copy)]
77struct CachedPoint {
78 point: Point,
79 order: u32,
80 source_series: Option<delinea::SeriesId>,
81 fill: Option<delinea::PaintId>,
82 opacity_mul: f32,
83 radius_mul: f32,
84 stroke_width: Option<Px>,
85}
86
87#[derive(Debug, Clone, Copy)]
88struct PanDrag {
89 x_axis: delinea::AxisId,
90 y_axis: delinea::AxisId,
91 pan_x: bool,
92 pan_y: bool,
93 start_pos: Point,
94 start_x: DataWindow,
95 start_y: DataWindow,
96}
97
98#[derive(Debug, Clone, Copy)]
99struct BoxZoomDrag {
100 x_axis: delinea::AxisId,
101 y_axis: delinea::AxisId,
102 button: MouseButton,
103 required_mods: ModifiersMask,
104 start_pos: Point,
105 current_pos: Point,
106 start_x: DataWindow,
107 start_y: DataWindow,
108}
109
110#[derive(Debug, Clone, Copy)]
111enum SliderDragKind {
112 Pan,
113 HandleMin,
114 HandleMax,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118enum SliderAxisKind {
119 X,
120 Y,
121}
122
123#[derive(Debug, Clone, Copy)]
124struct DataZoomSliderDrag {
125 axis_kind: SliderAxisKind,
126 axis: delinea::AxisId,
127 kind: SliderDragKind,
128 track: Rect,
129 extent: DataWindow,
130 start_pos: Point,
131 start_window: DataWindow,
132}
133
134#[derive(Debug, Clone, Copy)]
135struct VisualMapDrag {
136 visual_map: delinea::VisualMapId,
137 kind: SliderDragKind,
138 track: Rect,
139 domain: DataWindow,
140 start_window: DataWindow,
141 start_value: f64,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum AxisRegion {
146 Plot,
147 XAxis(delinea::AxisId),
148 YAxis(delinea::AxisId),
149}
150
151#[derive(Debug, Clone, Copy)]
152struct AxisBandLayout {
153 axis: delinea::AxisId,
154 position: delinea::AxisPosition,
155 rect: Rect,
156}
157
158#[derive(Debug, Default, Clone)]
159struct ChartLayout {
160 bounds: Rect,
161 plot: Rect,
162 x_axes: Vec<AxisBandLayout>,
163 y_axes: Vec<AxisBandLayout>,
164 visual_map: Option<Rect>,
165}
166
167#[derive(Debug, Default, Clone)]
168struct ChartA11yIndex {
169 point_by_series_and_index: BTreeMap<(delinea::SeriesId, u32), (u32, Point)>,
170 indices_by_series: BTreeMap<delinea::SeriesId, Vec<u32>>,
171 series_by_index: BTreeMap<u32, Vec<delinea::SeriesId>>,
172}
173
174impl ChartA11yIndex {
175 fn clear(&mut self) {
176 self.point_by_series_and_index.clear();
177 self.indices_by_series.clear();
178 self.series_by_index.clear();
179 }
180
181 fn point(&self, series: delinea::SeriesId, data_index: u32) -> Option<Point> {
182 self.point_by_series_and_index
183 .get(&(series, data_index))
184 .map(|(_, point)| *point)
185 }
186
187 fn rebuild(
188 &mut self,
189 marks: &delinea::marks::MarkTree,
190 series_rank_by_id: &BTreeMap<delinea::SeriesId, usize>,
191 ) {
192 self.clear();
193
194 let rect_indices_available = marks.arena.rect_data_indices.len() == marks.arena.rects.len();
195 let point_indices_available = marks.arena.data_indices.len() == marks.arena.points.len();
196
197 for node in &marks.nodes {
198 let series = node
199 .source_series
200 .or_else(|| {
201 let from_layer = delinea::SeriesId::new(node.layer.0);
202 (from_layer.0 != 0).then_some(from_layer)
203 })
204 .or_else(|| {
205 let inferred =
206 delinea::SeriesId::new(node.id.0 >> delinea::ids::MARK_VARIANT_BITS);
207 (inferred.0 != 0).then_some(inferred)
208 })
209 .unwrap_or_else(|| delinea::SeriesId::new(1));
210
211 match &node.payload {
212 delinea::marks::MarkPayloadRef::Polyline(polyline) => {
213 let start = polyline.points.start;
214 let end = polyline.points.end.min(marks.arena.points.len());
215 for i in start..end {
216 let point = marks.arena.points[i];
217 let data_index = if point_indices_available {
218 marks.arena.data_indices[i]
219 } else {
220 u32::try_from(i.saturating_sub(start)).unwrap_or(0)
221 };
222 let entry = self
223 .point_by_series_and_index
224 .entry((series, data_index))
225 .or_insert((node.order.0, point));
226 if node.order.0 > entry.0 {
227 *entry = (node.order.0, point);
228 }
229 }
230 }
231 delinea::marks::MarkPayloadRef::Rect(rects) => {
232 let start = rects.rects.start;
233 let end = rects.rects.end.min(marks.arena.rects.len());
234 for i in start..end {
235 let rect = marks.arena.rects[i];
236 let data_index = if rect_indices_available {
237 marks.arena.rect_data_indices[i]
238 } else {
239 u32::try_from(i.saturating_sub(start)).unwrap_or(0)
240 };
241 let center = Point::new(
242 Px(rect.origin.x.0 + rect.size.width.0 * 0.5),
243 Px(rect.origin.y.0 + rect.size.height.0 * 0.5),
244 );
245
246 let entry = self
247 .point_by_series_and_index
248 .entry((series, data_index))
249 .or_insert((node.order.0, center));
250 if node.order.0 > entry.0 {
251 *entry = (node.order.0, center);
252 }
253 }
254 }
255 delinea::marks::MarkPayloadRef::Points(points) => {
256 let start = points.points.start;
257 let end = points.points.end.min(marks.arena.points.len());
258 for i in start..end {
259 let point = marks.arena.points[i];
260 let data_index = if point_indices_available {
261 marks.arena.data_indices[i]
262 } else {
263 u32::try_from(i.saturating_sub(start)).unwrap_or(0)
264 };
265 let entry = self
266 .point_by_series_and_index
267 .entry((series, data_index))
268 .or_insert((node.order.0, point));
269 if node.order.0 > entry.0 {
270 *entry = (node.order.0, point);
271 }
272 }
273 }
274 _ => {}
275 }
276 }
277
278 for (series, data_index) in self.point_by_series_and_index.keys() {
279 self.indices_by_series
280 .entry(*series)
281 .or_default()
282 .push(*data_index);
283 self.series_by_index
284 .entry(*data_index)
285 .or_default()
286 .push(*series);
287 }
288
289 for indices in self.indices_by_series.values_mut() {
290 indices.sort_unstable();
291 indices.dedup();
292 }
293
294 for series in self.series_by_index.values_mut() {
295 series.sort_by_key(|id| series_rank_by_id.get(id).copied().unwrap_or(usize::MAX));
296 series.dedup();
297 }
298 }
299}
300
301pub struct ChartCanvas {
302 engine: ChartCanvasEngine,
303 grid_override: Option<delinea::GridId>,
304 semantics_test_id: Option<String>,
305 accessibility_layer: bool,
306 a11y_index: ChartA11yIndex,
307 a11y_index_rev: u64,
308 a11y_last_key: Option<(delinea::SeriesId, u32)>,
309 mode: ChartCanvasMode,
310 style: ChartStyle,
311 style_source: ChartStyleSource,
312 last_theme_revision: u64,
313 force_uncached_paint: bool,
314 last_sampling_window_key: u64,
315 text_cache_prune: ChartTextCachePruneTuning,
316 tooltip_formatter: Box<dyn TooltipFormatter>,
317 input_map: ChartInputMap,
318 last_bounds: Rect,
319 last_layout: ChartLayout,
320 last_pointer_pos: Option<Point>,
321 active_x_axis: Option<delinea::AxisId>,
322 active_y_axis: Option<delinea::AxisId>,
323 last_marks_rev: delinea::ids::Revision,
324 last_scale_factor_bits: u32,
325 path_cache: PathCache,
326 cached_paths: BTreeMap<delinea::ids::MarkId, CachedPath>,
327 cached_rects: Vec<CachedRect>,
328 cached_points: Vec<CachedPoint>,
329 cached_rect_scene_ops: SceneOpCache<u64>,
330 cached_point_scene_ops: SceneOpCache<u64>,
331 series_rank_by_id: BTreeMap<delinea::SeriesId, usize>,
332 axis_text: TextCacheGroup,
333 tooltip_text: TextCacheGroup,
334 legend_text: TextCacheGroup,
335 legend_item_rects: Vec<(delinea::SeriesId, Rect)>,
336 legend_selector_rects: Vec<(LegendSelectorAction, Rect)>,
337 legend_panel_rect: Option<Rect>,
338 legend_hover: Option<delinea::SeriesId>,
339 legend_anchor: Option<delinea::SeriesId>,
340 legend_selector_hover: Option<LegendSelectorAction>,
341 legend_scroll_y: Px,
342 legend_content_height: Px,
343 legend_view_height: Px,
344 pan_drag: Option<PanDrag>,
345 box_zoom_drag: Option<BoxZoomDrag>,
346 brush_drag: Option<BoxZoomDrag>,
347 slider_drag: Option<DataZoomSliderDrag>,
348 visual_map_drag: Option<VisualMapDrag>,
349 visual_map_piece_anchor: Option<(delinea::VisualMapId, u32)>,
350 axis_extent_cache: BTreeMap<delinea::AxisId, AxisExtentCacheEntry>,
351 link_router_cache: Option<(delinea::Revision, ChartLinkRouter)>,
352 explicit_link_axis_map: BTreeMap<delinea::AxisId, LinkAxisKey>,
353 linked_brush_model: Option<Model<Option<BrushSelectionLink2D>>>,
354 linked_axis_pointer_model: Option<Model<Option<AxisPointerLinkAnchor>>>,
355 linked_domain_windows_model: Option<Model<BTreeMap<LinkAxisKey, Option<DataWindow>>>>,
356 linked_domain_windows_model_revision: Option<u64>,
357 output_model: Option<Model<ChartCanvasOutput>>,
358 output: ChartCanvasOutput,
359}
360
361type SharedChartEngine = Rc<RefCell<ChartEngine>>;
362
363enum ChartCanvasEngine {
364 Owned(ChartEngine),
365 Shared(SharedChartEngine),
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369enum ChartCanvasMode {
370 Full,
372 GridView,
374 Overlay,
376}
377
378impl ChartCanvasMode {
379 fn renders_legend(self) -> bool {
380 matches!(self, Self::Full | Self::Overlay)
381 }
382
383 fn renders_overlays(self) -> bool {
384 matches!(self, Self::Full | Self::Overlay)
385 }
386
387 fn renders_axes(self) -> bool {
388 matches!(self, Self::Full | Self::GridView)
389 }
390}
391
392enum ChartEngineReadGuard<'a> {
393 Owned(&'a ChartEngine),
394 Shared(Ref<'a, ChartEngine>),
395}
396
397impl core::ops::Deref for ChartEngineReadGuard<'_> {
398 type Target = ChartEngine;
399
400 fn deref(&self) -> &Self::Target {
401 match self {
402 Self::Owned(engine) => engine,
403 Self::Shared(engine) => engine,
404 }
405 }
406}
407
408enum ChartEngineWriteGuard<'a> {
409 Owned(&'a mut ChartEngine),
410 Shared(RefMut<'a, ChartEngine>),
411}
412
413impl core::ops::Deref for ChartEngineWriteGuard<'_> {
414 type Target = ChartEngine;
415
416 fn deref(&self) -> &Self::Target {
417 match self {
418 Self::Owned(engine) => engine,
419 Self::Shared(engine) => engine,
420 }
421 }
422}
423
424impl core::ops::DerefMut for ChartEngineWriteGuard<'_> {
425 fn deref_mut(&mut self) -> &mut Self::Target {
426 match self {
427 Self::Owned(engine) => engine,
428 Self::Shared(engine) => engine,
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy)]
434pub struct ChartTextCachePruneTuning {
435 pub max_age_frames: u64,
436 pub max_entries: usize,
437}
438
439impl Default for ChartTextCachePruneTuning {
440 fn default() -> Self {
441 Self {
442 max_age_frames: 1_200,
443 max_entries: 4_096,
444 }
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum ChartStyleSource {
450 Theme,
451 Fixed,
452}
453
454#[derive(Debug, Clone, Copy)]
455struct AxisExtentCacheEntry {
456 spec_rev: delinea::ids::Revision,
457 visual_rev: delinea::ids::Revision,
458 data_sig: u64,
459 window: DataWindow,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463enum LegendSelectorAction {
464 All,
465 None,
466 Invert,
467}
468
469impl ChartCanvas {
470 pub fn new(spec: delinea::ChartSpec) -> Result<Self, ModelError> {
471 let mut spec = spec;
472 spec.axis_pointer.get_or_insert_with(Default::default);
473 Ok(Self::new_with_engine(ChartCanvasEngine::Owned(
474 ChartEngine::new(spec)?,
475 )))
476 }
477
478 pub fn new_shared(engine: SharedChartEngine) -> Self {
483 Self::new_with_engine(ChartCanvasEngine::Shared(engine))
484 }
485
486 pub fn new_grid_view(engine: SharedChartEngine, grid: delinea::GridId) -> Self {
487 let mut out = Self::new_with_engine(ChartCanvasEngine::Shared(engine));
488 out.grid_override = Some(grid);
489 out.mode = ChartCanvasMode::GridView;
490 out
491 }
492
493 pub fn new_overlay(engine: SharedChartEngine) -> Self {
494 let mut out = Self::new_with_engine(ChartCanvasEngine::Shared(engine));
495 out.mode = ChartCanvasMode::Overlay;
496 out
497 }
498
499 fn new_with_engine(engine: ChartCanvasEngine) -> Self {
500 Self {
501 engine,
502 grid_override: None,
503 semantics_test_id: None,
504 accessibility_layer: false,
505 a11y_index: ChartA11yIndex::default(),
506 a11y_index_rev: u64::MAX,
507 a11y_last_key: None,
508 mode: ChartCanvasMode::Full,
509 style: ChartStyle::default(),
510 style_source: ChartStyleSource::Theme,
511 last_theme_revision: 0,
512 force_uncached_paint: true,
513 last_sampling_window_key: 0,
514 text_cache_prune: ChartTextCachePruneTuning::default(),
515 tooltip_formatter: Box::new(DefaultTooltipFormatter),
516 input_map: ChartInputMap::default(),
517 last_bounds: Rect::default(),
518 last_layout: ChartLayout::default(),
519 last_pointer_pos: None,
520 active_x_axis: None,
521 active_y_axis: None,
522 last_marks_rev: delinea::ids::Revision::default(),
523 last_scale_factor_bits: 0,
524 path_cache: PathCache::default(),
525 cached_paths: BTreeMap::default(),
526 cached_rects: Vec::default(),
527 cached_points: Vec::default(),
528 cached_rect_scene_ops: SceneOpCache::default(),
529 cached_point_scene_ops: SceneOpCache::default(),
530 series_rank_by_id: BTreeMap::default(),
531 axis_text: TextCacheGroup::default(),
532 tooltip_text: TextCacheGroup::default(),
533 legend_text: TextCacheGroup::default(),
534 legend_item_rects: Vec::default(),
535 legend_selector_rects: Vec::default(),
536 legend_panel_rect: None,
537 legend_hover: None,
538 legend_anchor: None,
539 legend_selector_hover: None,
540 legend_scroll_y: Px(0.0),
541 legend_content_height: Px(0.0),
542 legend_view_height: Px(0.0),
543 pan_drag: None,
544 box_zoom_drag: None,
545 brush_drag: None,
546 slider_drag: None,
547 visual_map_drag: None,
548 visual_map_piece_anchor: None,
549 axis_extent_cache: BTreeMap::default(),
550 link_router_cache: None,
551 explicit_link_axis_map: BTreeMap::default(),
552 linked_brush_model: None,
553 linked_axis_pointer_model: None,
554 linked_domain_windows_model: None,
555 linked_domain_windows_model_revision: None,
556 output_model: None,
557 output: ChartCanvasOutput::default(),
558 }
559 }
560
561 fn sampling_window_key(&self, plot: Rect, scale_factor: f32) -> u64 {
562 self.with_engine(|engine| {
563 let output = engine.output();
564 let mut key = KeyBuilder::new();
565
566 key.mix_f32_bits(plot.size.width.0);
567 key.mix_f32_bits(plot.size.height.0);
568 key.mix_f32_bits(scale_factor);
569
570 key.mix_u64(output.axis_windows.len() as u64);
571 for (axis, window) in &output.axis_windows {
572 key.mix_u64(axis.0);
573 key.mix_f64_bits(window.min);
574 key.mix_f64_bits(window.max);
575 }
576
577 key.finish()
578 })
579 }
580
581 pub fn set_text_cache_prune_tuning(&mut self, tuning: ChartTextCachePruneTuning) {
582 self.text_cache_prune = tuning;
583 }
584
585 fn with_engine<R>(&self, f: impl FnOnce(&ChartEngine) -> R) -> R {
586 let engine = self.engine_read();
587 f(&engine)
588 }
589
590 fn with_engine_mut<R>(&mut self, f: impl FnOnce(&mut ChartEngine) -> R) -> R {
591 let mut engine = self.engine_write();
592 f(&mut engine)
593 }
594
595 fn engine_read(&self) -> ChartEngineReadGuard<'_> {
596 match &self.engine {
597 ChartCanvasEngine::Owned(engine) => ChartEngineReadGuard::Owned(engine),
598 ChartCanvasEngine::Shared(engine) => ChartEngineReadGuard::Shared(engine.borrow()),
599 }
600 }
601
602 fn engine_write(&mut self) -> ChartEngineWriteGuard<'_> {
603 match &mut self.engine {
604 ChartCanvasEngine::Owned(engine) => ChartEngineWriteGuard::Owned(engine),
605 ChartCanvasEngine::Shared(engine) => ChartEngineWriteGuard::Shared(engine.borrow_mut()),
606 }
607 }
608
609 pub fn engine(&self) -> &ChartEngine {
610 match &self.engine {
611 ChartCanvasEngine::Owned(engine) => engine,
612 ChartCanvasEngine::Shared(_) => {
613 panic!("ChartCanvas::engine is not available for shared-engine grid views")
614 }
615 }
616 }
617
618 pub fn engine_mut(&mut self) -> &mut ChartEngine {
619 match &mut self.engine {
620 ChartCanvasEngine::Owned(engine) => engine,
621 ChartCanvasEngine::Shared(_) => {
622 panic!("ChartCanvas::engine_mut is not available for shared-engine grid views")
623 }
624 }
625 }
626
627 pub fn set_style(&mut self, style: ChartStyle) {
628 self.style = style;
629 self.style_source = ChartStyleSource::Fixed;
630 }
631
632 pub fn set_style_source(&mut self, source: ChartStyleSource) {
633 self.style_source = source;
634 }
635
636 pub fn set_tooltip_formatter(&mut self, formatter: Box<dyn TooltipFormatter>) {
637 self.tooltip_formatter = formatter;
638 }
639
640 pub fn set_input_map(&mut self, map: ChartInputMap) {
641 self.input_map = map;
642 }
643
644 pub fn set_accessibility_layer(&mut self, enabled: bool) {
645 self.accessibility_layer = enabled;
646 }
647
648 pub fn test_id(mut self, id: impl Into<String>) -> Self {
649 self.semantics_test_id = Some(id.into());
650 self
651 }
652
653 pub fn linked_brush(mut self, brush: Model<Option<BrushSelectionLink2D>>) -> Self {
654 self.linked_brush_model = Some(brush);
655 self
656 }
657
658 pub fn linked_axis_pointer(
659 mut self,
660 axis_pointer: Model<Option<AxisPointerLinkAnchor>>,
661 ) -> Self {
662 self.linked_axis_pointer_model = Some(axis_pointer);
663 self
664 }
665
666 pub fn linked_domain_windows(
667 mut self,
668 windows: Model<BTreeMap<LinkAxisKey, Option<DataWindow>>>,
669 ) -> Self {
670 self.linked_domain_windows_model = Some(windows);
671 self.linked_domain_windows_model_revision = None;
672 self
673 }
674
675 pub fn link_axis_map(mut self, map: BTreeMap<delinea::AxisId, LinkAxisKey>) -> Self {
676 self.explicit_link_axis_map = map;
677 self.link_router_cache = None;
678 self
679 }
680
681 pub fn output_model(mut self, output: Model<ChartCanvasOutput>) -> Self {
682 self.output_model = Some(output);
683 self
684 }
685
686 fn link_router(&mut self) -> &ChartLinkRouter {
687 let spec_rev = self.with_engine(|engine| engine.model().revs.spec);
688 let needs_rebuild = self
689 .link_router_cache
690 .as_ref()
691 .map(|(rev, _router)| *rev != spec_rev)
692 .unwrap_or(true);
693 if needs_rebuild {
694 let router = self.with_engine(|engine| {
695 let mut router = ChartLinkRouter::from_model(engine.model());
696 if !self.explicit_link_axis_map.is_empty() {
697 let mut explicit = BTreeMap::new();
698 for (axis, key) in &self.explicit_link_axis_map {
699 if engine.model().axes.contains_key(axis) {
700 explicit.insert(*axis, *key);
701 }
702 }
703 if !explicit.is_empty() {
704 router = router.with_explicit_axis_map(explicit);
705 }
706 }
707 router
708 });
709 self.link_router_cache = Some((spec_rev, router));
710 }
711 &self
712 .link_router_cache
713 .as_ref()
714 .expect("router cache must be populated")
715 .1
716 }
717
718 fn sync_linked_brush<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
719 let Some(model) = &self.linked_brush_model else {
720 return;
721 };
722 cx.observe_model(model, Invalidation::Paint);
723
724 let Ok(selection) = model.read(cx.app, |_, s| *s) else {
725 return;
726 };
727
728 let router = self.link_router().clone();
729 let current = self
730 .with_engine(|engine| engine.state().brush_selection_2d)
731 .and_then(|sel| {
732 let x_axis = router.axis_key(sel.x_axis)?;
733 let y_axis = router.axis_key(sel.y_axis)?;
734 Some(BrushSelectionLink2D {
735 x_axis,
736 y_axis,
737 x: sel.x,
738 y: sel.y,
739 })
740 });
741 if selection == current {
742 return;
743 }
744
745 match selection {
746 Some(sel) => {
747 let Some(x_axis) = router.axis_for_key(sel.x_axis) else {
748 return;
749 };
750 let Some(y_axis) = router.axis_for_key(sel.y_axis) else {
751 return;
752 };
753 self.with_engine_mut(|engine| {
754 engine.apply_action(Action::SetBrushSelection2D {
755 x_axis,
756 y_axis,
757 x: sel.x,
758 y: sel.y,
759 });
760 });
761 }
762 None => {
763 self.with_engine_mut(|engine| {
764 engine.apply_action(Action::ClearBrushSelection);
765 });
766 }
767 }
768 }
769
770 fn linked_axis_pointer_anchor_for_engine(&mut self) -> Option<AxisPointerLinkAnchor> {
771 let router = self.link_router().clone();
772 let (axis, value) = self.with_engine(|engine| {
773 engine
774 .output()
775 .axis_pointer
776 .as_ref()
777 .map(|o| (o.axis, o.axis_value))
778 })?;
779 if !value.is_finite() {
780 return None;
781 }
782 let axis = router.axis_key(axis)?;
783 Some(AxisPointerLinkAnchor { axis, value })
784 }
785
786 fn hover_point_for_axis_pointer_anchor(
787 &mut self,
788 anchor: AxisPointerLinkAnchor,
789 ) -> Option<Point> {
790 let router = self.link_router().clone();
791 let axis = router.axis_for_key(anchor.axis)?;
792
793 let (plot, axis_window) = self.with_engine(|engine| {
794 let output = engine.output();
795 let grid = engine.model().axes.get(&axis).map(|a| a.grid);
796 let plot = grid
797 .and_then(|grid| output.plot_viewports_by_grid.get(&grid).copied())
798 .or(output.viewport)
799 .or_else(|| output.plot_viewports_by_grid.values().next().copied());
800 let axis_window = output.axis_windows.get(&axis).copied();
801 (plot, axis_window)
802 });
803
804 let plot = plot?;
805 let axis_window = axis_window?;
806
807 let px = match anchor.axis.kind {
808 delinea::AxisKind::X => {
809 let x =
810 delinea::engine::axis::x_px_at_data_in_rect(axis_window, anchor.value, plot);
811 let y = plot.origin.y.0 + 0.5 * plot.size.height.0;
812 Point::new(Px(x), Px(y))
813 }
814 delinea::AxisKind::Y => {
815 let x = plot.origin.x.0 + 0.5 * plot.size.width.0;
816 let y =
817 delinea::engine::axis::y_px_at_data_in_rect(axis_window, anchor.value, plot);
818 Point::new(Px(x), Px(y))
819 }
820 };
821
822 if px.x.0.is_finite() && px.y.0.is_finite() {
823 Some(px)
824 } else {
825 None
826 }
827 }
828
829 fn sync_linked_axis_pointer<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
830 let Some(model) = &self.linked_axis_pointer_model else {
831 return;
832 };
833 cx.observe_model(model, Invalidation::Paint);
834
835 let Ok(anchor) = model.read(cx.app, |_, a| a.clone()) else {
836 return;
837 };
838
839 let current = self.linked_axis_pointer_anchor_for_engine();
840 if anchor == current {
841 return;
842 }
843
844 let mut changed = false;
845 match anchor {
846 Some(anchor) => {
847 if let Some(point) = self.hover_point_for_axis_pointer_anchor(anchor.clone()) {
848 self.with_engine_mut(|engine| engine.apply_action(Action::HoverAt { point }));
849 changed = true;
850 }
851 }
852 None => {
853 let point = Point::new(Px(1.0e9), Px(1.0e9));
855 self.with_engine_mut(|engine| engine.apply_action(Action::HoverAt { point }));
856 changed = true;
857 }
858 }
859
860 if changed {
863 cx.request_animation_frame();
864 }
865 }
866
867 fn sync_linked_domain_windows<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
868 let Some(model) = &self.linked_domain_windows_model else {
869 return;
870 };
871 cx.observe_model(model, Invalidation::Paint);
872
873 let model_rev = model.revision(cx.app);
877 if model_rev == self.linked_domain_windows_model_revision {
878 return;
879 }
880 self.linked_domain_windows_model_revision = model_rev;
881
882 let Ok(windows) = model.read(cx.app, |_, w| w.clone()) else {
883 return;
884 };
885
886 let router = self.link_router().clone();
887 let mut changed = false;
888 for (key, window) in windows {
889 let Some(axis) = router.axis_for_key(key) else {
890 continue;
891 };
892
893 match key.kind {
894 delinea::AxisKind::X => {
895 let current = self.with_engine(|engine| {
896 engine.state().data_zoom_x.get(&axis).and_then(|s| s.window)
897 });
898 if current == window {
899 continue;
900 }
901 self.with_engine_mut(|engine| {
902 engine.apply_action(Action::SetDataWindowX { axis, window });
903 });
904 changed = true;
905 }
906 delinea::AxisKind::Y => {
907 let current =
908 self.with_engine(|engine| engine.state().data_window_y.get(&axis).copied());
909 if current == window {
910 continue;
911 }
912 self.with_engine_mut(|engine| {
913 engine.apply_action(Action::SetDataWindowY { axis, window });
914 });
915 changed = true;
916 }
917 }
918 }
919
920 if changed {
923 cx.request_animation_frame();
924 }
925 }
926
927 fn publish_output<H: UiHost>(&mut self, app: &mut H) -> bool {
928 let drained_link_events = self.with_engine_mut(|engine| engine.drain_link_events());
929 let (link_events_revision, link_events) = if drained_link_events.is_empty() {
930 (
931 self.output.link_events_revision,
932 self.output.snapshot.link_events.clone(),
933 )
934 } else {
935 (
936 self.output.link_events_revision.wrapping_add(1),
937 drained_link_events,
938 )
939 };
940
941 let router = self.link_router().clone();
942 let domain_windows_by_key = self.with_engine(|engine| {
943 let mut out = BTreeMap::new();
944
945 for (axis, st) in &engine.state().data_zoom_x {
946 let Some(window) = st.window else {
947 continue;
948 };
949 let Some(key) = router.axis_key(*axis) else {
950 continue;
951 };
952 if router.axis_for_key(key) != Some(*axis) {
953 continue;
954 }
955 out.insert(key, Some(window));
956 }
957
958 for (axis, window) in &engine.state().data_window_y {
959 let Some(key) = router.axis_key(*axis) else {
960 continue;
961 };
962 if router.axis_for_key(key) != Some(*axis) {
963 continue;
964 }
965 out.insert(key, Some(*window));
966 }
967
968 out
969 });
970 let tooltip_lines = self.with_engine(|engine| {
971 let Some(axis_pointer) = engine.output().axis_pointer.as_ref() else {
972 return Vec::new();
973 };
974
975 self.tooltip_formatter.format_axis_pointer(
976 engine,
977 &engine.output().axis_windows,
978 axis_pointer,
979 )
980 });
981 let snapshot = ChartCanvasOutputSnapshot {
982 brush_selection_2d: self.with_engine(|engine| engine.state().brush_selection_2d),
983 brush_x_row_ranges_by_series: self
984 .with_engine(|engine| engine.output().brush_x_row_ranges_by_series.clone()),
985 link_events,
986 tooltip_lines,
987 domain_windows_by_key,
988 };
989
990 if self.output.snapshot == snapshot
991 && self.output.link_events_revision == link_events_revision
992 {
993 return false;
994 }
995
996 self.output.revision = self.output.revision.wrapping_add(1);
997 self.output.link_events_revision = link_events_revision;
998 self.output.snapshot = snapshot;
999
1000 if let Some(model) = &self.output_model {
1001 let next = self.output.clone();
1002 let _ = model.update(app, |s, _cx| {
1003 *s = next;
1004 });
1005 }
1006
1007 true
1008 }
1009
1010 fn sync_style_from_theme(&mut self, theme: &Theme) -> bool {
1011 if self.style_source != ChartStyleSource::Theme {
1012 return false;
1013 }
1014
1015 let rev = theme.revision();
1016 if self.last_theme_revision == rev {
1017 return false;
1018 }
1019
1020 self.last_theme_revision = rev;
1021 self.style = ChartStyle::from_theme(theme);
1022 true
1023 }
1024
1025 fn compute_layout(&self, bounds: Rect) -> ChartLayout {
1026 self.with_engine(|engine| {
1027 let model = engine.model();
1028
1029 let mut inner = bounds;
1030 inner.origin.x.0 += self.style.padding.left.0;
1031 inner.origin.y.0 += self.style.padding.top.0;
1032 inner.size.width.0 =
1033 (inner.size.width.0 - self.style.padding.left.0 - self.style.padding.right.0)
1034 .max(0.0);
1035 inner.size.height.0 =
1036 (inner.size.height.0 - self.style.padding.top.0 - self.style.padding.bottom.0)
1037 .max(0.0);
1038
1039 let axis_band_x = self.style.axis_band_x.0.max(0.0);
1040 let axis_band_y = self.style.axis_band_y.0.max(0.0);
1041
1042 let active_grid = self.grid_override.or_else(|| {
1043 let primary = model.series_in_order().find(|s| s.visible)?;
1044 model.axes.get(&primary.x_axis).map(|a| a.grid)
1045 });
1046
1047 let has_visual_map = active_grid.is_some_and(|grid| {
1048 model.series_in_order().any(|s| {
1049 s.visible
1050 && model.axes.get(&s.x_axis).is_some_and(|a| a.grid == grid)
1051 && model.visual_map_by_series.contains_key(&s.id)
1052 })
1053 });
1054 let visual_map_band_x = if has_visual_map {
1055 self.style.visual_map_band_x.0.max(0.0)
1056 } else {
1057 0.0
1058 };
1059
1060 let mut x_top: Vec<delinea::AxisId> = Vec::new();
1061 let mut x_bottom: Vec<delinea::AxisId> = Vec::new();
1062 let mut y_left: Vec<delinea::AxisId> = Vec::new();
1063 let mut y_right: Vec<delinea::AxisId> = Vec::new();
1064
1065 if let Some(grid) = active_grid {
1066 for (axis_id, axis) in &model.axes {
1067 if axis.grid != grid {
1068 continue;
1069 }
1070
1071 match (axis.kind, axis.position) {
1072 (delinea::AxisKind::X, delinea::AxisPosition::Top) => x_top.push(*axis_id),
1073 (delinea::AxisKind::X, delinea::AxisPosition::Bottom) => {
1074 x_bottom.push(*axis_id)
1075 }
1076 (delinea::AxisKind::Y, delinea::AxisPosition::Left) => {
1077 y_left.push(*axis_id)
1078 }
1079 (delinea::AxisKind::Y, delinea::AxisPosition::Right) => {
1080 y_right.push(*axis_id)
1081 }
1082 _ => {}
1083 }
1084 }
1085 }
1086
1087 let left_total = axis_band_x * (y_left.len() as f32);
1088 let right_total = axis_band_x * (y_right.len() as f32);
1089 let top_total = axis_band_y * (x_top.len() as f32);
1090 let bottom_total = axis_band_y * (x_bottom.len() as f32);
1091
1092 let plot_w =
1093 (inner.size.width.0 - left_total - right_total - visual_map_band_x).max(0.0);
1094 let plot_h = (inner.size.height.0 - top_total - bottom_total).max(0.0);
1095
1096 let plot = Rect::new(
1097 Point::new(
1098 Px(inner.origin.x.0 + left_total),
1099 Px(inner.origin.y.0 + top_total),
1100 ),
1101 Size::new(Px(plot_w), Px(plot_h)),
1102 );
1103
1104 let mut x_axes: Vec<AxisBandLayout> = Vec::with_capacity(x_top.len() + x_bottom.len());
1105 for (i, axis) in x_top.iter().copied().enumerate() {
1106 let rect = Rect::new(
1107 Point::new(
1108 plot.origin.x,
1109 Px(plot.origin.y.0 - axis_band_y * (i as f32 + 1.0)),
1110 ),
1111 Size::new(plot.size.width, Px(axis_band_y)),
1112 );
1113 x_axes.push(AxisBandLayout {
1114 axis,
1115 position: delinea::AxisPosition::Top,
1116 rect,
1117 });
1118 }
1119 for (i, axis) in x_bottom.iter().copied().enumerate() {
1120 let rect = Rect::new(
1121 Point::new(
1122 plot.origin.x,
1123 Px(plot.origin.y.0 + plot.size.height.0 + axis_band_y * (i as f32)),
1124 ),
1125 Size::new(plot.size.width, Px(axis_band_y)),
1126 );
1127 x_axes.push(AxisBandLayout {
1128 axis,
1129 position: delinea::AxisPosition::Bottom,
1130 rect,
1131 });
1132 }
1133
1134 let mut y_axes: Vec<AxisBandLayout> = Vec::with_capacity(y_left.len() + y_right.len());
1135 for (i, axis) in y_left.iter().copied().enumerate() {
1136 let rect = Rect::new(
1137 Point::new(
1138 Px(plot.origin.x.0 - axis_band_x * (i as f32 + 1.0)),
1139 plot.origin.y,
1140 ),
1141 Size::new(Px(axis_band_x), plot.size.height),
1142 );
1143 y_axes.push(AxisBandLayout {
1144 axis,
1145 position: delinea::AxisPosition::Left,
1146 rect,
1147 });
1148 }
1149 for (i, axis) in y_right.iter().copied().enumerate() {
1150 let rect = Rect::new(
1151 Point::new(
1152 Px(plot.origin.x.0 + plot.size.width.0 + axis_band_x * (i as f32)),
1153 plot.origin.y,
1154 ),
1155 Size::new(Px(axis_band_x), plot.size.height),
1156 );
1157 y_axes.push(AxisBandLayout {
1158 axis,
1159 position: delinea::AxisPosition::Right,
1160 rect,
1161 });
1162 }
1163
1164 let visual_map = (visual_map_band_x > 0.0).then(|| {
1165 let x0 = plot.origin.x.0 + plot.size.width.0 + axis_band_x * (y_right.len() as f32);
1166 Rect::new(
1167 Point::new(Px(x0), plot.origin.y),
1168 Size::new(Px(visual_map_band_x), plot.size.height),
1169 )
1170 });
1171
1172 ChartLayout {
1173 bounds,
1174 plot,
1175 x_axes,
1176 y_axes,
1177 visual_map,
1178 }
1179 })
1180 }
1181
1182 pub fn create_node<H: UiHost>(ui: &mut fret_ui::UiTree<H>, canvas: Self) -> fret_core::NodeId {
1183 use fret_ui::retained_bridge::UiTreeRetainedExt as _;
1184 ui.create_node_retained(canvas)
1185 }
1186
1187 fn sync_viewport(&mut self, viewport: Rect) {
1188 if let Some(grid) = self.grid_override {
1189 let already = self.with_engine(|engine| {
1190 engine.model().plot_viewports_by_grid.get(&grid).copied() == Some(viewport)
1191 });
1192 if already {
1193 return;
1194 }
1195
1196 let mut patch = ChartPatch::default();
1197 patch.plot_viewports_by_grid.insert(grid, Some(viewport));
1198 let _ = self.with_engine_mut(|engine| engine.apply_patch(patch, PatchMode::Merge));
1199 return;
1200 }
1201
1202 let already = self.with_engine(|engine| engine.model().viewport == Some(viewport));
1203 if already {
1204 return;
1205 }
1206 let _ = self.with_engine_mut(|engine| {
1207 engine.apply_patch(
1208 ChartPatch {
1209 viewport: Some(Some(viewport)),
1210 ..ChartPatch::default()
1211 },
1212 PatchMode::Merge,
1213 )
1214 });
1215 }
1216
1217 fn axis_pointer_plot_rect(&self, axis_pointer: &delinea::engine::AxisPointerOutput) -> Rect {
1218 if self.grid_override.is_some() {
1219 return self.last_layout.plot;
1220 }
1221
1222 if let Some(grid) = axis_pointer.grid {
1223 return self
1224 .with_engine(|engine| engine.output().plot_viewports_by_grid.get(&grid).copied())
1225 .unwrap_or(self.last_layout.plot);
1226 }
1227
1228 self.last_layout.plot
1229 }
1230
1231 fn paint_overlay_only<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
1232 let theme = Theme::global(&*cx.app);
1233 let style_changed = self.sync_style_from_theme(theme);
1234 if style_changed || self.last_bounds != cx.bounds {
1235 self.last_bounds = cx.bounds;
1236 self.last_layout = self.compute_layout(cx.bounds);
1237 }
1238
1239 self.tooltip_text.begin_frame();
1240 self.legend_text.begin_frame();
1241
1242 let interaction_idle = self.pan_drag.is_none() && self.box_zoom_drag.is_none();
1243 let axis_pointer = if interaction_idle && self.legend_hover.is_none() {
1244 self.with_engine(|engine| engine.output().axis_pointer.clone())
1245 } else {
1246 None
1247 };
1248
1249 let mut axis_pointer_label_rect: Option<Rect> = None;
1250
1251 if let Some(axis_pointer) = axis_pointer.as_ref() {
1252 let pos = axis_pointer.crosshair_px;
1253 let overlay_order = DrawOrder(self.style.draw_order.0.saturating_add(9_000));
1254 let point_order = DrawOrder(self.style.draw_order.0.saturating_add(9_001));
1255 let shadow_order = DrawOrder(self.style.draw_order.0.saturating_add(8_999));
1256
1257 let (axis_pointer_type, axis_pointer_label_enabled, axis_pointer_label_template) = self
1258 .with_engine(|engine| {
1259 let spec = engine.model().axis_pointer.as_ref();
1260 let axis_pointer_type = spec.map(|p| p.pointer_type).unwrap_or_default();
1261 let axis_pointer_label_enabled = spec.is_some_and(|p| p.label.show);
1262 let axis_pointer_label_template = spec
1263 .map(|p| p.label.template.clone())
1264 .unwrap_or_else(|| "{value}".to_string());
1265 (
1266 axis_pointer_type,
1267 axis_pointer_label_enabled,
1268 axis_pointer_label_template,
1269 )
1270 });
1271 let axis_pointer_label_template = axis_pointer_label_template.as_str();
1272
1273 let plot = self.axis_pointer_plot_rect(axis_pointer);
1274 let crosshair_w = self.style.crosshair_width.0.max(1.0);
1275
1276 let x = pos
1277 .x
1278 .0
1279 .clamp(plot.origin.x.0, plot.origin.x.0 + plot.size.width.0);
1280 let y = pos
1281 .y
1282 .0
1283 .clamp(plot.origin.y.0, plot.origin.y.0 + plot.size.height.0);
1284
1285 let (draw_x, draw_y) = match &axis_pointer.tooltip {
1286 delinea::TooltipOutput::Axis(axis) => match axis.axis_kind {
1287 delinea::AxisKind::X => (true, false),
1288 delinea::AxisKind::Y => (false, true),
1289 },
1290 delinea::TooltipOutput::Item(_) => (true, true),
1291 };
1292
1293 let shadow = matches!(&axis_pointer.tooltip, delinea::TooltipOutput::Axis(_))
1294 && axis_pointer_type == delinea::AxisPointerType::Shadow;
1295
1296 if shadow {
1297 if let Some(rect) = axis_pointer.shadow_rect_px {
1298 let color = Color {
1299 a: 0.08,
1300 ..self.style.selection_fill
1301 };
1302 cx.scene.push(SceneOp::Quad {
1303 order: shadow_order,
1304 rect,
1305 background: Paint::Solid(color).into(),
1306 border: Edges::all(Px(0.0)),
1307 border_paint: Paint::TRANSPARENT.into(),
1308 corner_radii: Corners::all(Px(0.0)),
1309 });
1310 }
1311 } else if draw_x {
1312 cx.scene.push(SceneOp::Quad {
1313 order: overlay_order,
1314 rect: Rect::new(
1315 Point::new(Px(x - 0.5 * crosshair_w), plot.origin.y),
1316 Size::new(Px(crosshair_w), plot.size.height),
1317 ),
1318 background: Paint::Solid(self.style.crosshair_color).into(),
1319 border: Edges::all(Px(0.0)),
1320 border_paint: Paint::TRANSPARENT.into(),
1321 corner_radii: Corners::all(Px(0.0)),
1322 });
1323 }
1324 if !shadow && draw_y {
1325 cx.scene.push(SceneOp::Quad {
1326 order: overlay_order,
1327 rect: Rect::new(
1328 Point::new(plot.origin.x, Px(y - 0.5 * crosshair_w)),
1329 Size::new(plot.size.width, Px(crosshair_w)),
1330 ),
1331 background: Paint::Solid(self.style.crosshair_color).into(),
1332 border: Edges::all(Px(0.0)),
1333 border_paint: Paint::TRANSPARENT.into(),
1334 corner_radii: Corners::all(Px(0.0)),
1335 });
1336 }
1337
1338 if axis_pointer_label_enabled {
1339 let pad_x = 6.0f32;
1340 let pad_y = 3.0f32;
1341 let text_style = TextStyle {
1342 size: Px(11.0),
1343 weight: FontWeight::MEDIUM,
1344 ..TextStyle::default()
1345 };
1346 let constraints = TextConstraints {
1347 max_width: None,
1348 wrap: TextWrap::None,
1349 overflow: TextOverflow::Clip,
1350 align: fret_core::TextAlign::Start,
1351 scale_factor: cx.scale_factor,
1352 };
1353
1354 let rect_union = |a: Rect, b: Rect| {
1355 let x0 = a.origin.x.0.min(b.origin.x.0);
1356 let y0 = a.origin.y.0.min(b.origin.y.0);
1357 let x1 = (a.origin.x.0 + a.size.width.0).max(b.origin.x.0 + b.size.width.0);
1358 let y1 = (a.origin.y.0 + a.size.height.0).max(b.origin.y.0 + b.size.height.0);
1359 Rect::new(
1360 Point::new(Px(x0), Px(y0)),
1361 Size::new(Px((x1 - x0).max(0.0)), Px((y1 - y0).max(0.0))),
1362 )
1363 };
1364
1365 let mut draw_label = |axis_kind: delinea::AxisKind,
1366 axis_id: delinea::AxisId,
1367 axis_value: f64| {
1368 let default_tooltip_spec = delinea::TooltipSpecV1::default();
1369 let (axis_window, axis_name, missing_value) = self.with_engine(|engine| {
1370 let axis_window = engine
1371 .output()
1372 .axis_windows
1373 .get(&axis_id)
1374 .copied()
1375 .unwrap_or_default();
1376 let axis_name = engine
1377 .model()
1378 .axes
1379 .get(&axis_id)
1380 .and_then(|a| a.name.as_deref())
1381 .unwrap_or("")
1382 .to_string();
1383 let missing_value = engine
1384 .model()
1385 .tooltip
1386 .as_ref()
1387 .map(|t| t.missing_value.clone())
1388 .unwrap_or_else(|| default_tooltip_spec.missing_value.clone());
1389 (axis_window, axis_name, missing_value)
1390 });
1391 let value_text = if axis_value.is_finite() {
1392 self.with_engine(|engine| {
1393 delinea::engine::axis::format_value_for(
1394 engine.model(),
1395 axis_id,
1396 axis_window,
1397 axis_value,
1398 )
1399 })
1400 } else {
1401 missing_value
1402 };
1403
1404 let label_text = if axis_pointer_label_template == "{value}" {
1405 value_text
1406 } else {
1407 axis_pointer_label_template
1408 .replace("{value}", &value_text)
1409 .replace("{axis_name}", &axis_name)
1410 };
1411
1412 let prepared = self.tooltip_text.prepare(
1413 cx.services,
1414 &label_text,
1415 &text_style,
1416 constraints,
1417 );
1418 let blob = prepared.blob;
1419 let metrics = prepared.metrics;
1420
1421 let w = (metrics.size.width.0 + 2.0 * pad_x).max(1.0);
1422 let h = (metrics.size.height.0 + 2.0 * pad_y).max(1.0);
1423
1424 let rect = match axis_kind {
1425 delinea::AxisKind::X => {
1426 let box_x = (x - 0.5 * w)
1427 .clamp(plot.origin.x.0, plot.origin.x.0 + plot.size.width.0 - w);
1428 Rect::new(
1429 Point::new(Px(box_x), Px(plot.origin.y.0 + plot.size.height.0)),
1430 Size::new(Px(w), Px(h)),
1431 )
1432 }
1433 delinea::AxisKind::Y => {
1434 let box_y = (y - 0.5 * h)
1435 .clamp(plot.origin.y.0, plot.origin.y.0 + plot.size.height.0 - h);
1436 Rect::new(
1437 Point::new(Px(plot.origin.x.0 - w), Px(box_y)),
1438 Size::new(Px(w), Px(h)),
1439 )
1440 }
1441 };
1442
1443 let kind_key: u32 = match axis_kind {
1444 delinea::AxisKind::X => 0,
1445 delinea::AxisKind::Y => 1,
1446 };
1447 let label_order = DrawOrder(
1448 self.style
1449 .draw_order
1450 .0
1451 .saturating_add(9_020 + kind_key.saturating_mul(4)),
1452 );
1453 cx.scene.push(SceneOp::Quad {
1454 order: label_order,
1455 rect,
1456 background: Paint::Solid(self.style.tooltip_background).into(),
1457 border: Edges::all(self.style.tooltip_border_width),
1458 border_paint: Paint::Solid(self.style.tooltip_border_color).into(),
1459 corner_radii: Corners::all(Px(4.0)),
1460 });
1461 cx.scene.push(SceneOp::Text {
1462 order: DrawOrder(label_order.0.saturating_add(1)),
1463 origin: Point::new(
1464 Px(rect.origin.x.0 + pad_x),
1465 Px(rect.origin.y.0 + pad_y),
1466 ),
1467 text: blob,
1468 paint: (self.style.tooltip_text_color).into(),
1469 outline: None,
1470 shadow: None,
1471 });
1472
1473 axis_pointer_label_rect = Some(match axis_pointer_label_rect {
1474 Some(old) => rect_union(old, rect),
1475 None => rect,
1476 });
1477 };
1478
1479 match &axis_pointer.tooltip {
1480 delinea::TooltipOutput::Axis(axis) => {
1481 draw_label(axis.axis_kind, axis.axis, axis.axis_value);
1482 }
1483 delinea::TooltipOutput::Item(item) => {
1484 draw_label(delinea::AxisKind::X, item.x_axis, item.x_value);
1485 draw_label(delinea::AxisKind::Y, item.y_axis, item.y_value);
1486 }
1487 };
1488 }
1489
1490 if !shadow && let Some(hit) = axis_pointer.hit {
1491 let r = self.style.hover_point_size.0.max(1.0);
1492 cx.scene.push(SceneOp::Quad {
1493 order: point_order,
1494 rect: Rect::new(
1495 Point::new(Px(hit.point_px.x.0 - r), Px(hit.point_px.y.0 - r)),
1496 Size::new(Px(2.0 * r), Px(2.0 * r)),
1497 ),
1498 background: Paint::Solid(self.style.hover_point_color).into(),
1499 border: Edges::all(Px(0.0)),
1500 border_paint: Paint::TRANSPARENT.into(),
1501 corner_radii: Corners::all(Px(0.0)),
1502 });
1503 }
1504 }
1505
1506 if self.mode.renders_legend() {
1507 self.draw_legend(cx);
1508 }
1509
1510 if let Some(axis_pointer) = axis_pointer {
1511 let tooltip_lines = self.with_engine(|engine| {
1512 self.tooltip_formatter.format_axis_pointer(
1513 engine,
1514 &engine.output().axis_windows,
1515 &axis_pointer,
1516 )
1517 });
1518 if !tooltip_lines.is_empty() {
1519 let text_style = TextStyle {
1520 size: Px(12.0),
1521 weight: FontWeight::NORMAL,
1522 ..TextStyle::default()
1523 };
1524 let mut header_text_style = text_style.clone();
1525 header_text_style.weight = FontWeight::BOLD;
1526 let mut value_text_style = text_style.clone();
1527 value_text_style.weight = FontWeight::MEDIUM;
1528 let constraints = TextConstraints {
1529 max_width: None,
1530 wrap: TextWrap::None,
1531 overflow: TextOverflow::Clip,
1532 align: fret_core::TextAlign::Start,
1533 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
1534 };
1535
1536 let pad = self.style.tooltip_padding;
1537 let swatch_w = self.style.tooltip_marker_size.0.max(0.0);
1538 let swatch_gap = self.style.tooltip_marker_gap.0.max(0.0);
1539 let col_gap = self.style.tooltip_column_gap.0.max(0.0);
1540 let reserve_swatch =
1541 swatch_w > 0.0 && tooltip_lines.iter().any(|l| l.source_series.is_some());
1542 let swatch_space = if reserve_swatch {
1543 (swatch_w + swatch_gap).max(0.0)
1544 } else {
1545 0.0
1546 };
1547
1548 enum TooltipLineLayout {
1549 Single {
1550 blob: TextBlobId,
1551 metrics: fret_core::TextMetrics,
1552 },
1553 Columns {
1554 left_blob: TextBlobId,
1555 left_metrics: fret_core::TextMetrics,
1556 right_blob: TextBlobId,
1557 right_metrics: fret_core::TextMetrics,
1558 },
1559 }
1560
1561 struct PreparedTooltipLine {
1562 source_series: Option<delinea::SeriesId>,
1563 is_missing: bool,
1564 layout: TooltipLineLayout,
1565 }
1566
1567 let mut prepared_lines = Vec::with_capacity(tooltip_lines.len());
1568 let mut max_left_w = 0.0f32;
1569 let mut max_right_w = 0.0f32;
1570 let mut max_single_w = 0.0f32;
1571 let mut total_h = 0.0f32;
1572
1573 for line in &tooltip_lines {
1574 let label_style = if line.kind == crate::TooltipTextLineKind::AxisHeader {
1575 &header_text_style
1576 } else {
1577 &text_style
1578 };
1579 let value_style = if line.value_emphasis
1580 && line.kind != crate::TooltipTextLineKind::AxisHeader
1581 {
1582 &value_text_style
1583 } else {
1584 &text_style
1585 };
1586
1587 if let Some((left, right)) = line.columns.as_ref() {
1588 let prepared_left =
1589 self.tooltip_text
1590 .prepare(cx.services, left, label_style, constraints);
1591 let left_blob = prepared_left.blob;
1592 let left_metrics = prepared_left.metrics;
1593 max_left_w = max_left_w.max(left_metrics.size.width.0);
1594
1595 let prepared_right =
1596 self.tooltip_text
1597 .prepare(cx.services, right, value_style, constraints);
1598 let right_blob = prepared_right.blob;
1599 let right_metrics = prepared_right.metrics;
1600 max_right_w = max_right_w.max(right_metrics.size.width.0);
1601
1602 let line_height = left_metrics
1603 .size
1604 .height
1605 .0
1606 .max(right_metrics.size.height.0)
1607 .max(1.0);
1608 total_h += line_height;
1609 prepared_lines.push(PreparedTooltipLine {
1610 source_series: line.source_series,
1611 is_missing: line.is_missing,
1612 layout: TooltipLineLayout::Columns {
1613 left_blob,
1614 left_metrics,
1615 right_blob,
1616 right_metrics,
1617 },
1618 });
1619 } else {
1620 let prepared = self.tooltip_text.prepare(
1621 cx.services,
1622 &line.text,
1623 label_style,
1624 constraints,
1625 );
1626 let blob = prepared.blob;
1627 let metrics = prepared.metrics;
1628 max_single_w = max_single_w.max(metrics.size.width.0);
1629 total_h += metrics.size.height.0.max(1.0);
1630 prepared_lines.push(PreparedTooltipLine {
1631 source_series: line.source_series,
1632 is_missing: line.is_missing,
1633 layout: TooltipLineLayout::Single { blob, metrics },
1634 });
1635 }
1636 }
1637
1638 let mut w = 1.0f32;
1639 if max_left_w > 0.0 || max_right_w > 0.0 {
1640 w = w.max(max_left_w + col_gap + max_right_w);
1641 }
1642 w = w.max(max_single_w);
1643 w = (w + swatch_space + pad.left.0 + pad.right.0).max(1.0);
1644 let h = (total_h + pad.top.0 + pad.bottom.0).max(1.0);
1645
1646 let bounds = self.last_layout.bounds;
1647 let anchor = match &axis_pointer.tooltip {
1648 delinea::TooltipOutput::Axis(_) => axis_pointer.crosshair_px,
1649 delinea::TooltipOutput::Item(_) => axis_pointer
1650 .hit
1651 .map(|h| h.point_px)
1652 .unwrap_or(axis_pointer.crosshair_px),
1653 };
1654
1655 let offset = 10.0f32;
1656 let tooltip_rect = crate::tooltip_layout::place_tooltip_rect(
1657 bounds,
1658 anchor,
1659 Size::new(Px(w), Px(h)),
1660 offset,
1661 axis_pointer_label_rect,
1662 );
1663 let tip_x = tooltip_rect.origin.x.0;
1664 let tip_y = tooltip_rect.origin.y.0;
1665
1666 let tooltip_order = DrawOrder(self.style.draw_order.0.saturating_add(9_100));
1667 cx.scene.push(SceneOp::Quad {
1668 order: tooltip_order,
1669 rect: Rect::new(Point::new(Px(tip_x), Px(tip_y)), Size::new(Px(w), Px(h))),
1670 background: Paint::Solid(self.style.tooltip_background).into(),
1671 border: Edges::all(self.style.tooltip_border_width),
1672 border_paint: Paint::Solid(self.style.tooltip_border_color).into(),
1673 corner_radii: Corners::all(self.style.tooltip_corner_radius),
1674 });
1675
1676 let mut y = tip_y + pad.top.0;
1677 let missing_text_color = Color {
1678 a: (self.style.tooltip_text_color.a * 0.55).clamp(0.0, 1.0),
1679 ..self.style.tooltip_text_color
1680 };
1681 for (i, line) in prepared_lines.into_iter().enumerate() {
1682 let order_base = tooltip_order
1683 .0
1684 .saturating_add(1 + (i as u32).saturating_mul(3));
1685 let swatch_x = tip_x + pad.left.0;
1686 let text_x0 = swatch_x + swatch_space;
1687
1688 let side = self.style.tooltip_marker_size.0.max(0.0);
1689 let line_height = match &line.layout {
1690 TooltipLineLayout::Single { metrics, .. } => metrics.size.height.0.max(1.0),
1691 TooltipLineLayout::Columns {
1692 left_metrics,
1693 right_metrics,
1694 ..
1695 } => left_metrics
1696 .size
1697 .height
1698 .0
1699 .max(right_metrics.size.height.0)
1700 .max(1.0),
1701 };
1702 if side > 0.0
1703 && reserve_swatch
1704 && let Some(series) = line.source_series
1705 {
1706 let marker_y = y + (line_height - side) * 0.5;
1707 cx.scene.push(SceneOp::Quad {
1708 order: DrawOrder(order_base),
1709 rect: Rect::new(
1710 Point::new(Px(swatch_x), Px(marker_y)),
1711 Size::new(Px(side), Px(side)),
1712 ),
1713 background: Paint::Solid(self.series_color(series)).into(),
1714 border: Edges::all(Px(0.0)),
1715 border_paint: Paint::TRANSPARENT.into(),
1716 corner_radii: Corners::all(Px(side * 0.5)),
1717 });
1718 }
1719
1720 match line.layout {
1721 TooltipLineLayout::Single { blob, .. } => {
1722 cx.scene.push(SceneOp::Text {
1723 order: DrawOrder(order_base.saturating_add(1)),
1724 origin: Point::new(Px(text_x0), Px(y)),
1725 text: blob,
1726 paint: (if line.is_missing {
1727 missing_text_color
1728 } else {
1729 self.style.tooltip_text_color
1730 })
1731 .into(),
1732 outline: None,
1733 shadow: None,
1734 });
1735 }
1736 TooltipLineLayout::Columns {
1737 left_blob,
1738 right_blob,
1739 ..
1740 } => {
1741 cx.scene.push(SceneOp::Text {
1742 order: DrawOrder(order_base.saturating_add(1)),
1743 origin: Point::new(Px(text_x0), Px(y)),
1744 text: left_blob,
1745 paint: (self.style.tooltip_text_color).into(),
1746 outline: None,
1747 shadow: None,
1748 });
1749 let value_x = text_x0 + max_left_w + col_gap;
1750 let value_color = if line.is_missing {
1751 missing_text_color
1752 } else {
1753 self.style.tooltip_text_color
1754 };
1755 cx.scene.push(SceneOp::Text {
1756 order: DrawOrder(order_base.saturating_add(2)),
1757 origin: Point::new(Px(value_x), Px(y)),
1758 text: right_blob,
1759 paint: (value_color).into(),
1760 outline: None,
1761 shadow: None,
1762 });
1763 }
1764 }
1765
1766 y += line_height;
1767 }
1768 }
1769 }
1770
1771 let t = self.text_cache_prune;
1772 if t.max_entries > 0 && t.max_age_frames > 0 {
1773 self.tooltip_text
1774 .prune(cx.services, t.max_age_frames, t.max_entries);
1775 self.legend_text
1776 .prune(cx.services, t.max_age_frames, t.max_entries);
1777 }
1778 }
1779
1780 fn primary_axes(&self) -> Option<(delinea::AxisId, delinea::AxisId)> {
1781 self.with_engine(|engine| {
1782 let model = engine.model();
1783 let primary = model.series_in_order().find(|s| {
1784 s.visible
1785 && self.grid_override.is_none_or(|grid| {
1786 model.axes.get(&s.x_axis).is_some_and(|a| a.grid == grid)
1787 })
1788 })?;
1789 Some((primary.x_axis, primary.y_axis))
1790 })
1791 }
1792
1793 fn update_active_axes_for_position(&mut self, layout: &ChartLayout, position: Point) {
1794 match Self::axis_region(layout, position) {
1795 AxisRegion::XAxis(axis) => {
1796 self.active_x_axis = Some(axis);
1797 }
1798 AxisRegion::YAxis(axis) => {
1799 self.active_y_axis = Some(axis);
1800 }
1801 AxisRegion::Plot => {}
1802 }
1803 }
1804
1805 fn x_axis_is_present_in_layout(layout: &ChartLayout, axis: delinea::AxisId) -> bool {
1806 layout.x_axes.iter().any(|a| a.axis == axis)
1807 }
1808
1809 fn y_axis_is_present_in_layout(layout: &ChartLayout, axis: delinea::AxisId) -> bool {
1810 layout.y_axes.iter().any(|a| a.axis == axis)
1811 }
1812
1813 fn active_axes(&self, layout: &ChartLayout) -> Option<(delinea::AxisId, delinea::AxisId)> {
1814 let (primary_x, primary_y) = self.primary_axes()?;
1815
1816 let x_axis = self
1817 .active_x_axis
1818 .filter(|a| Self::x_axis_is_present_in_layout(layout, *a))
1819 .unwrap_or(primary_x);
1820 let y_axis = self
1821 .active_y_axis
1822 .filter(|a| Self::y_axis_is_present_in_layout(layout, *a))
1823 .unwrap_or(primary_y);
1824
1825 Some((x_axis, y_axis))
1826 }
1827
1828 fn axis_range(&self, axis: delinea::AxisId) -> delinea::AxisRange {
1829 self.with_engine(|engine| {
1830 engine
1831 .model()
1832 .axes
1833 .get(&axis)
1834 .map(|a| a.range)
1835 .unwrap_or_default()
1836 })
1837 }
1838
1839 fn axis_is_fixed(&self, axis: delinea::AxisId) -> Option<DataWindow> {
1840 match self.axis_range(axis) {
1841 delinea::AxisRange::Fixed { min, max } => {
1842 let mut w = DataWindow { min, max };
1843 w.clamp_non_degenerate();
1844 Some(w)
1845 }
1846 _ => None,
1847 }
1848 }
1849
1850 fn axis_constraints(&self, axis: delinea::AxisId) -> (Option<f64>, Option<f64>) {
1851 match self.axis_range(axis) {
1852 delinea::AxisRange::Auto => (None, None),
1853 delinea::AxisRange::LockMin { min } => (Some(min), None),
1854 delinea::AxisRange::LockMax { max } => (None, Some(max)),
1855 delinea::AxisRange::Fixed { min, max } => (Some(min), Some(max)),
1856 }
1857 }
1858
1859 fn current_window_x(&mut self, axis: delinea::AxisId) -> DataWindow {
1860 if let Some(fixed) = self.axis_is_fixed(axis) {
1861 return fixed;
1862 }
1863
1864 let zoom_window = self.with_engine(|engine| {
1865 engine
1866 .state()
1867 .data_zoom_x
1868 .get(&axis)
1869 .copied()
1870 .and_then(|z| z.window)
1871 });
1872 if let Some(window) = zoom_window {
1873 return window;
1874 }
1875
1876 let mut window = self.compute_axis_extent(axis, true);
1877 let (locked_min, locked_max) = self.axis_constraints(axis);
1878 window = window.apply_constraints(locked_min, locked_max);
1879 window
1880 }
1881
1882 fn current_window_y(&mut self, axis: delinea::AxisId) -> DataWindow {
1883 if let Some(fixed) = self.axis_is_fixed(axis) {
1884 return fixed;
1885 }
1886
1887 let window = self.with_engine(|engine| engine.state().data_window_y.get(&axis).copied());
1888 if let Some(window) = window {
1889 return window;
1890 }
1891
1892 let mut window = self.compute_axis_extent(axis, false);
1893 let (locked_min, locked_max) = self.axis_constraints(axis);
1894 window = window.apply_constraints(locked_min, locked_max);
1895 window
1896 }
1897
1898 fn compute_axis_extent(&mut self, axis: delinea::AxisId, is_x: bool) -> DataWindow {
1899 self.with_engine_mut(|engine| {
1900 if let Some(window) = engine.output().axis_windows.get(&axis).copied() {
1901 return window;
1902 }
1903
1904 let model = engine.model();
1905 if let Some(axis_model) = model.axes.get(&axis)
1906 && let delinea::AxisScale::Category(scale) = &axis_model.scale
1907 && !scale.categories.is_empty()
1908 {
1909 return DataWindow {
1910 min: -0.5,
1911 max: scale.categories.len() as f64 - 0.5,
1912 };
1913 }
1914
1915 let mut series_cols: Vec<(delinea::DatasetId, usize)> = Vec::new();
1916 for series in model.series.values() {
1917 let axis_id = if is_x { series.x_axis } else { series.y_axis };
1918 if axis_id != axis {
1919 continue;
1920 }
1921
1922 let Some(dataset) = model.datasets.get(&series.dataset) else {
1923 continue;
1924 };
1925
1926 if is_x {
1927 let Some(col) = dataset.fields.get(&series.encode.x).copied() else {
1928 continue;
1929 };
1930 series_cols.push((series.dataset, col));
1931 continue;
1932 }
1933
1934 if let Some(col) = dataset.fields.get(&series.encode.y).copied() {
1935 series_cols.push((series.dataset, col));
1936 }
1937 if series.kind == delinea::SeriesKind::Band
1938 && let Some(y2) = series.encode.y2
1939 && let Some(col) = dataset.fields.get(&y2).copied()
1940 {
1941 series_cols.push((series.dataset, col));
1942 }
1943 }
1944
1945 let store = engine.datasets_mut();
1946 let mut min = f64::INFINITY;
1947 let mut max = f64::NEG_INFINITY;
1948 for (dataset_id, col) in series_cols {
1949 let Some(table) = store.dataset_mut(dataset_id) else {
1950 continue;
1951 };
1952 let Some(values) = table.column_f64(col) else {
1953 continue;
1954 };
1955
1956 for &v in values {
1957 if !v.is_finite() {
1958 continue;
1959 }
1960 min = min.min(v);
1961 max = max.max(v);
1962 }
1963 }
1964
1965 let mut out = if min.is_finite() && max.is_finite() && max > min {
1966 DataWindow { min, max }
1967 } else {
1968 DataWindow { min: 0.0, max: 1.0 }
1969 };
1970 out.clamp_non_degenerate();
1971 out
1972 })
1973 }
1974
1975 fn set_data_window_x(&mut self, axis: delinea::AxisId, window: Option<DataWindow>) {
1976 self.with_engine_mut(|engine| engine.apply_action(Action::SetDataWindowX { axis, window }));
1977 }
1978
1979 fn set_data_window_x_filter_mode(&mut self, axis: delinea::AxisId, mode: Option<FilterMode>) {
1980 self.with_engine_mut(|engine| {
1981 engine.apply_action(Action::SetDataWindowXFilterMode { axis, mode });
1982 });
1983 }
1984
1985 fn toggle_data_window_x_filter_mode(&mut self, axis: delinea::AxisId) {
1986 let current = self.with_engine(|engine| {
1987 engine
1988 .state()
1989 .data_zoom_x
1990 .get(&axis)
1991 .copied()
1992 .unwrap_or_default()
1993 .filter_mode
1994 });
1995
1996 match current {
1997 FilterMode::Filter => self.set_data_window_x_filter_mode(axis, Some(FilterMode::None)),
1998 FilterMode::WeakFilter => {
1999 self.set_data_window_x_filter_mode(axis, Some(FilterMode::None))
2000 }
2001 FilterMode::Empty => self.set_data_window_x_filter_mode(axis, Some(FilterMode::None)),
2002 FilterMode::None => self.set_data_window_x_filter_mode(axis, None),
2003 }
2004 }
2005
2006 fn set_data_window_y(&mut self, axis: delinea::AxisId, window: Option<DataWindow>) {
2007 self.with_engine_mut(|engine| engine.apply_action(Action::SetDataWindowY { axis, window }));
2008 }
2009
2010 fn view_window_2d_action_from_zoom(
2011 x_axis: delinea::AxisId,
2012 y_axis: delinea::AxisId,
2013 base_x: DataWindow,
2014 base_y: DataWindow,
2015 x: Option<DataWindow>,
2016 y: Option<DataWindow>,
2017 ) -> Action {
2018 Action::SetViewWindow2DFromZoom {
2019 x_axis,
2020 y_axis,
2021 base_x,
2022 base_y,
2023 x,
2024 y,
2025 }
2026 }
2027
2028 fn axis_pointer_hover_point(layout: &ChartLayout, position: Point) -> Point {
2029 let plot = layout.plot;
2030 if plot.contains(position) {
2031 return position;
2032 }
2033
2034 let plot_left = plot.origin.x.0;
2035 let plot_top = plot.origin.y.0;
2036 let plot_right = plot.origin.x.0 + plot.size.width.0;
2037 let plot_bottom = plot.origin.y.0 + plot.size.height.0;
2038
2039 let x_in_plot = position.x.0.clamp(plot_left, plot_right);
2040 let y_in_plot = position.y.0.clamp(plot_top, plot_bottom);
2041
2042 if layout.x_axes.iter().any(|a| a.rect.contains(position)) {
2043 let y = (plot_bottom - 1.0).max(plot_top);
2044 return Point::new(Px(x_in_plot), Px(y));
2045 }
2046
2047 if let Some(y_axis) = layout.y_axes.iter().find(|a| a.rect.contains(position)) {
2048 let x = match y_axis.position {
2049 delinea::AxisPosition::Right => (plot_right - 1.0).max(plot_left),
2050 _ => (plot_left + 1.0).min(plot_right),
2051 };
2052 return Point::new(Px(x), Px(y_in_plot));
2053 }
2054
2055 position
2056 }
2057
2058 fn refresh_hover_for_axis_pointer(&mut self, layout: &ChartLayout, position: Point) {
2059 let axis_pointer_enabled = self.with_engine(|engine| {
2060 engine
2061 .model()
2062 .axis_pointer
2063 .as_ref()
2064 .is_some_and(|p| p.enabled)
2065 });
2066 if !axis_pointer_enabled {
2067 return;
2068 }
2069
2070 let region = Self::axis_region(layout, position);
2071 let in_plot = layout.plot.contains(position);
2072 let in_axis = matches!(region, AxisRegion::XAxis(_) | AxisRegion::YAxis(_));
2073 if in_plot || in_axis {
2074 let point = Self::axis_pointer_hover_point(layout, position);
2075 self.with_engine_mut(|engine| engine.apply_action(Action::HoverAt { point }));
2076 }
2077 }
2078
2079 fn ensure_a11y_index(&mut self) {
2080 if !self.accessibility_layer {
2081 return;
2082 }
2083
2084 let (marks, model) =
2085 self.with_engine(|engine| (engine.output().marks.clone(), engine.model().clone()));
2086 let marks_rev = marks.revision.0;
2087 if self.a11y_index_rev == marks_rev && !self.a11y_index.point_by_series_and_index.is_empty()
2088 {
2089 return;
2090 }
2091
2092 self.series_rank_by_id.clear();
2093 for (i, series_id) in model.series_order.iter().enumerate() {
2094 self.series_rank_by_id.insert(*series_id, i);
2095 }
2096
2097 self.a11y_index.rebuild(&marks, &self.series_rank_by_id);
2098 self.a11y_index_rev = marks_rev;
2099 }
2100
2101 fn series_row_count(&mut self, series: delinea::SeriesId) -> Option<u32> {
2102 self.with_engine_mut(|engine| {
2103 let model = engine.model();
2104 let series = model.series.get(&series)?;
2105 let dataset = model.root_dataset_id(series.dataset);
2106 let table = engine.datasets_mut().dataset(dataset)?;
2107 u32::try_from(table.row_count()).ok()
2108 })
2109 }
2110
2111 fn point_for_series_data_index(
2112 &mut self,
2113 series: delinea::SeriesId,
2114 data_index: u32,
2115 ) -> Option<Point> {
2116 let layout = self.compute_layout(self.last_bounds);
2117 let plot = layout.plot;
2118 let plot_w = plot.size.width.0;
2119 let plot_h = plot.size.height.0;
2120 if plot_w <= 0.0 || plot_h <= 0.0 {
2121 return None;
2122 }
2123
2124 let (x_axis, y_axis, x_value, y_value) = self.with_engine_mut(|engine| {
2125 let (dataset, x_axis, y_axis, x_col, y_col) = {
2126 let model = engine.model();
2127 let series = model.series.get(&series)?;
2128 let dataset = model.root_dataset_id(series.dataset);
2129 let dataset_model = model.datasets.get(&series.dataset)?;
2130 let x_col = *dataset_model.fields.get(&series.encode.x)?;
2131 let y_col = *dataset_model.fields.get(&series.encode.y)?;
2132 Some((dataset, series.x_axis, series.y_axis, x_col, y_col))
2133 }?;
2134
2135 let table = engine.datasets_mut().dataset(dataset)?;
2136 let idx = usize::try_from(data_index).ok()?;
2137 let x_value = table.column_f64(x_col)?.get(idx).copied()?;
2138 let y_value = table.column_f64(y_col)?.get(idx).copied()?;
2139
2140 Some((x_axis, y_axis, x_value, y_value))
2141 })?;
2142
2143 let x_window = self.current_window_x(x_axis);
2144 let y_window = self.current_window_y(y_axis);
2145
2146 let x_local = Self::px_at_data(x_window, x_value, 0.0, plot_w);
2147 let y_local = Self::y_local_for_data_value(y_window, y_value, plot_h);
2148 Some(Point::new(
2149 Px(plot.origin.x.0 + x_local),
2150 Px(plot.origin.y.0 + y_local),
2151 ))
2152 }
2153
2154 fn handle_accessibility_navigation_fallback<H: UiHost>(
2155 &mut self,
2156 cx: &mut EventCx<'_, H>,
2157 key: KeyCode,
2158 ) -> bool {
2159 let series_order = self.with_engine(|engine| engine.model().series_order.clone());
2160 if series_order.is_empty() {
2161 return false;
2162 }
2163
2164 let engine_hit = self.with_engine(|engine| {
2165 engine
2166 .output()
2167 .axis_pointer
2168 .as_ref()
2169 .and_then(|o| o.hit)
2170 .map(|hit| (hit.series, hit.data_index))
2171 });
2172
2173 let mut current_series = self
2174 .a11y_last_key
2175 .map(|(s, _)| s)
2176 .or_else(|| engine_hit.map(|(s, _)| s));
2177 let mut current_index = self
2178 .a11y_last_key
2179 .map(|(_, i)| i)
2180 .or_else(|| engine_hit.map(|(_, i)| i))
2181 .unwrap_or(0);
2182
2183 if current_series
2184 .and_then(|s| self.series_row_count(s).filter(|n| *n > 0))
2185 .is_none()
2186 {
2187 current_series = series_order
2188 .iter()
2189 .copied()
2190 .find(|s| self.series_row_count(*s).is_some_and(|n| n > 0));
2191 }
2192
2193 let current_series = match current_series {
2194 Some(s) => s,
2195 None => return false,
2196 };
2197
2198 let current_row_count = match self.series_row_count(current_series).filter(|n| *n > 0) {
2199 Some(n) => n,
2200 None => return false,
2201 };
2202
2203 if current_row_count == 0 {
2204 return false;
2205 }
2206 current_index = current_index.min(current_row_count.saturating_sub(1));
2207
2208 let (next_series, next_index) = match key {
2209 KeyCode::ArrowLeft => (current_series, current_index.saturating_sub(1)),
2210 KeyCode::ArrowRight => (
2211 current_series,
2212 (current_index + 1).min(current_row_count.saturating_sub(1)),
2213 ),
2214 KeyCode::ArrowUp | KeyCode::ArrowDown => {
2215 let pos = series_order
2216 .iter()
2217 .position(|s| *s == current_series)
2218 .unwrap_or(0) as i32;
2219 let step = if key == KeyCode::ArrowUp { -1 } else { 1 };
2220 let mut next_pos = pos + step;
2221 let mut next_series = current_series;
2222 while next_pos >= 0 && (next_pos as usize) < series_order.len() {
2223 let candidate = series_order[next_pos as usize];
2224 if self.series_row_count(candidate).is_some_and(|n| n > 0) {
2225 next_series = candidate;
2226 break;
2227 }
2228 next_pos += step;
2229 }
2230
2231 let next_row_count = self.series_row_count(next_series).unwrap_or(0);
2232 if next_row_count == 0 {
2233 return false;
2234 }
2235 let next_index = current_index.min(next_row_count.saturating_sub(1));
2236 (next_series, next_index)
2237 }
2238 _ => return false,
2239 };
2240
2241 self.last_bounds = cx.bounds;
2243
2244 let point = match self.point_for_series_data_index(next_series, next_index) {
2245 Some(point) => point,
2246 None => return false,
2247 };
2248
2249 let layout = self.compute_layout(cx.bounds);
2250 self.refresh_hover_for_axis_pointer(&layout, point);
2251 self.last_pointer_pos = Some(point);
2252 self.a11y_last_key = Some((next_series, next_index));
2253 cx.invalidate_self(Invalidation::Paint);
2254 cx.request_redraw();
2255 cx.stop_propagation();
2256 true
2257 }
2258
2259 fn handle_accessibility_navigation<H: UiHost>(
2260 &mut self,
2261 cx: &mut EventCx<'_, H>,
2262 key: KeyCode,
2263 ) -> bool {
2264 if !self.accessibility_layer {
2265 return false;
2266 }
2267
2268 if !matches!(
2269 key,
2270 KeyCode::ArrowLeft | KeyCode::ArrowRight | KeyCode::ArrowUp | KeyCode::ArrowDown
2271 ) {
2272 return false;
2273 }
2274
2275 self.ensure_a11y_index();
2276 if self.a11y_index.point_by_series_and_index.is_empty() {
2277 return self.handle_accessibility_navigation_fallback(cx, key);
2278 }
2279
2280 let first = self
2281 .a11y_index
2282 .series_by_index
2283 .iter()
2284 .next()
2285 .and_then(|(data_index, series)| Some((*series.first()?, *data_index)));
2286
2287 let engine_hit = self.with_engine(|engine| {
2288 engine
2289 .output()
2290 .axis_pointer
2291 .as_ref()
2292 .and_then(|o| o.hit)
2293 .map(|hit| (hit.series, hit.data_index))
2294 });
2295
2296 let current = if self.a11y_last_key.is_none() {
2297 first
2298 } else {
2299 self.a11y_last_key.or(engine_hit).or(first)
2300 };
2301
2302 let (series, data_index) = match current {
2303 Some(key) => key,
2304 None => return false,
2305 };
2306
2307 let next = match key {
2308 KeyCode::ArrowLeft => self
2309 .a11y_index
2310 .indices_by_series
2311 .get(&series)
2312 .and_then(|indices| match indices.binary_search(&data_index) {
2313 Ok(pos) | Err(pos) => pos.checked_sub(1).and_then(|i| indices.get(i).copied()),
2314 })
2315 .map(|next_index| (series, next_index)),
2316 KeyCode::ArrowRight => self
2317 .a11y_index
2318 .indices_by_series
2319 .get(&series)
2320 .and_then(|indices| match indices.binary_search(&data_index) {
2321 Ok(pos) => indices.get(pos + 1).copied(),
2322 Err(pos) => indices.get(pos).copied(),
2323 })
2324 .map(|next_index| (series, next_index)),
2325 KeyCode::ArrowUp | KeyCode::ArrowDown => self
2326 .a11y_index
2327 .series_by_index
2328 .get(&data_index)
2329 .and_then(|series_ids| {
2330 let pos = series_ids.iter().position(|s| *s == series).unwrap_or(0);
2331 let next_pos = match key {
2332 KeyCode::ArrowUp => pos.checked_sub(1),
2333 KeyCode::ArrowDown => (pos + 1 < series_ids.len()).then_some(pos + 1),
2334 _ => None,
2335 }?;
2336 series_ids.get(next_pos).copied().map(|s| (s, data_index))
2337 }),
2338 _ => None,
2339 };
2340
2341 let (next_series, next_index) = match next {
2342 Some(next) => next,
2343 None => return false,
2344 };
2345
2346 let point = match self.a11y_index.point(next_series, next_index) {
2347 Some(point) => point,
2348 None => return false,
2349 };
2350
2351 let layout = self.compute_layout(cx.bounds);
2352 self.refresh_hover_for_axis_pointer(&layout, point);
2353 self.last_pointer_pos = Some(point);
2354 self.a11y_last_key = Some((next_series, next_index));
2355 cx.invalidate_self(Invalidation::Paint);
2356 cx.request_redraw();
2357 cx.stop_propagation();
2358 true
2359 }
2360
2361 fn clear_brush(&mut self) {
2362 self.brush_drag = None;
2363 self.with_engine_mut(|engine| engine.apply_action(Action::ClearBrushSelection));
2364 }
2365
2366 fn clear_slider_drag(&mut self) {
2367 self.slider_drag = None;
2368 }
2369
2370 fn selection_windows_for_drag(
2371 &self,
2372 plot: Rect,
2373 start_x: DataWindow,
2374 start_y: DataWindow,
2375 start_pos: Point,
2376 end_pos: Point,
2377 modifiers: Modifiers,
2378 required_mods: ModifiersMask,
2379 ) -> Option<(DataWindow, DataWindow)> {
2380 let width = plot.size.width.0;
2381 let height = plot.size.height.0;
2382 if width <= 0.0 || height <= 0.0 {
2383 return None;
2384 }
2385
2386 let start_local = Point::new(
2387 Px(start_pos.x.0 - plot.origin.x.0),
2388 Px(start_pos.y.0 - plot.origin.y.0),
2389 );
2390 let end_local = Point::new(
2391 Px(end_pos.x.0 - plot.origin.x.0),
2392 Px(end_pos.y.0 - plot.origin.y.0),
2393 );
2394
2395 let (start_local, end_local) = Self::apply_box_select_modifiers(
2396 plot.size,
2397 start_local,
2398 end_local,
2399 modifiers,
2400 self.input_map.box_zoom_expand_x,
2401 self.input_map.box_zoom_expand_y,
2402 required_mods,
2403 );
2404
2405 let w = (start_local.x.0 - end_local.x.0).abs();
2406 let h = (start_local.y.0 - end_local.y.0).abs();
2407 if w < 4.0 || h < 4.0 {
2408 return None;
2409 }
2410
2411 let x0 = start_local.x.0.min(end_local.x.0).clamp(0.0, width);
2412 let x1 = start_local.x.0.max(end_local.x.0).clamp(0.0, width);
2413 let x_min = delinea::engine::axis::data_at_px(start_x, x0, 0.0, width);
2414 let x_max = delinea::engine::axis::data_at_px(start_x, x1, 0.0, width);
2415 let mut x = DataWindow {
2416 min: x_min,
2417 max: x_max,
2418 };
2419 x.clamp_non_degenerate();
2420
2421 let y0 = start_local.y.0.min(end_local.y.0).clamp(0.0, height);
2422 let y1 = start_local.y.0.max(end_local.y.0).clamp(0.0, height);
2423 let y0_from_bottom = height - y1;
2424 let y1_from_bottom = height - y0;
2425 let y_min = delinea::engine::axis::data_at_px(start_y, y0_from_bottom, 0.0, height);
2426 let y_max = delinea::engine::axis::data_at_px(start_y, y1_from_bottom, 0.0, height);
2427 let mut y = DataWindow {
2428 min: y_min,
2429 max: y_max,
2430 };
2431 y.clamp_non_degenerate();
2432
2433 Some((x, y))
2434 }
2435
2436 fn px_at_data(window: DataWindow, value: f64, origin_px: f32, span_px: f32) -> f32 {
2437 let mut window = window;
2438 window.clamp_non_degenerate();
2439 let span = window.span();
2440 if !span.is_finite() || span <= 0.0 {
2441 return origin_px;
2442 }
2443 if !span_px.is_finite() || span_px <= 0.0 {
2444 return origin_px;
2445 }
2446 let t = ((value - window.min) / span).clamp(0.0, 1.0) as f32;
2447 origin_px + t * span_px
2448 }
2449
2450 fn brush_rect_px(&mut self, brush: BrushSelection2D) -> Option<Rect> {
2451 let plot = self.last_layout.plot;
2452 let width = plot.size.width.0;
2453 let height = plot.size.height.0;
2454 if width <= 0.0 || height <= 0.0 {
2455 return None;
2456 }
2457
2458 let x_window = self.current_window_x(brush.x_axis);
2459 let y_window = self.current_window_y(brush.y_axis);
2460
2461 let (xmin, xmax) = if brush.x.min <= brush.x.max {
2462 (brush.x.min, brush.x.max)
2463 } else {
2464 (brush.x.max, brush.x.min)
2465 };
2466 let (ymin, ymax) = if brush.y.min <= brush.y.max {
2467 (brush.y.min, brush.y.max)
2468 } else {
2469 (brush.y.max, brush.y.min)
2470 };
2471
2472 let x0 = Self::px_at_data(x_window, xmin, 0.0, width);
2473 let x1 = Self::px_at_data(x_window, xmax, 0.0, width);
2474
2475 let y0_from_bottom = Self::px_at_data(y_window, ymin, 0.0, height);
2476 let y1_from_bottom = Self::px_at_data(y_window, ymax, 0.0, height);
2477 let y0 = height - y1_from_bottom;
2478 let y1 = height - y0_from_bottom;
2479
2480 let p0 = Point::new(Px(plot.origin.x.0 + x0), Px(plot.origin.y.0 + y0));
2481 let p1 = Point::new(Px(plot.origin.x.0 + x1), Px(plot.origin.y.0 + y1));
2482 Some(rect_from_points_clamped(plot, p0, p1))
2483 }
2484
2485 fn compute_axis_extent_from_data(&mut self, axis: delinea::AxisId, is_x: bool) -> DataWindow {
2486 let (spec_rev, visual_rev) = self.with_engine(|engine| {
2487 let model = engine.model();
2488 (model.revs.spec, model.revs.visual)
2489 });
2490
2491 let data_sig = self.data_signature();
2492 if let Some(entry) = self.axis_extent_cache.get(&axis).copied()
2493 && entry.spec_rev == spec_rev
2494 && entry.visual_rev == visual_rev
2495 && entry.data_sig == data_sig
2496 {
2497 return entry.window;
2498 }
2499
2500 let series_cols = self.with_engine(|engine| {
2501 let model = engine.model();
2502 if let Some(axis_model) = model.axes.get(&axis)
2503 && let delinea::AxisScale::Category(scale) = &axis_model.scale
2504 && !scale.categories.is_empty()
2505 {
2506 return Err(DataWindow {
2507 min: -0.5,
2508 max: scale.categories.len() as f64 - 0.5,
2509 });
2510 }
2511
2512 let mut series_cols: Vec<(delinea::DatasetId, usize)> = Vec::new();
2513 for series_id in &model.series_order {
2514 let Some(series) = model.series.get(series_id) else {
2515 continue;
2516 };
2517 if !series.visible {
2518 continue;
2519 }
2520
2521 let axis_id = if is_x { series.x_axis } else { series.y_axis };
2522 if axis_id != axis {
2523 continue;
2524 }
2525
2526 let Some(dataset) = model.datasets.get(&series.dataset) else {
2527 continue;
2528 };
2529 let field = if is_x {
2530 series.encode.x
2531 } else {
2532 series.encode.y
2533 };
2534 let Some(col) = dataset.fields.get(&field).copied() else {
2535 continue;
2536 };
2537 series_cols.push((series.dataset, col));
2538 }
2539
2540 Ok(series_cols)
2541 });
2542
2543 let series_cols = match series_cols {
2544 Ok(cols) => cols,
2545 Err(window) => return window,
2546 };
2547
2548 let (min, max) = self.with_engine_mut(|engine| {
2549 let mut min = f64::INFINITY;
2550 let mut max = f64::NEG_INFINITY;
2551
2552 let datasets = engine.datasets_mut();
2553 for (dataset_id, col) in &series_cols {
2554 let Some(table) = datasets.dataset_mut(*dataset_id) else {
2555 continue;
2556 };
2557 let Some(values) = table.column_f64(*col) else {
2558 continue;
2559 };
2560
2561 for &v in values {
2562 if !v.is_finite() {
2563 continue;
2564 }
2565 min = min.min(v);
2566 max = max.max(v);
2567 }
2568 }
2569
2570 (min, max)
2571 });
2572
2573 let mut out = if min.is_finite() && max.is_finite() && max > min {
2574 DataWindow { min, max }
2575 } else {
2576 DataWindow { min: 0.0, max: 1.0 }
2577 };
2578
2579 let (locked_min, locked_max) = self.axis_constraints(axis);
2580 out = out.apply_constraints(locked_min, locked_max);
2581 out.clamp_non_degenerate();
2582
2583 self.axis_extent_cache.insert(
2584 axis,
2585 AxisExtentCacheEntry {
2586 spec_rev,
2587 visual_rev,
2588 data_sig,
2589 window: out,
2590 },
2591 );
2592 out
2593 }
2594
2595 fn data_signature(&mut self) -> u64 {
2596 use std::hash::{Hash, Hasher};
2597
2598 self.with_engine_mut(|engine| {
2599 let dataset_ids: Vec<delinea::DatasetId> =
2600 engine.model().datasets.keys().copied().collect();
2601
2602 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2603 let datasets = engine.datasets_mut();
2604 for dataset_id in dataset_ids {
2605 dataset_id.0.hash(&mut hasher);
2606 if let Some(table) = datasets.dataset_mut(dataset_id) {
2607 table.revision().0.hash(&mut hasher);
2608 table.row_count().hash(&mut hasher);
2609 }
2610 }
2611 hasher.finish()
2612 })
2613 }
2614
2615 fn x_slider_track_for_axis(&self, axis: delinea::AxisId) -> Option<Rect> {
2616 let plot = self.last_layout.plot;
2617 if plot.size.width.0 <= 0.0 || plot.size.height.0 <= 0.0 {
2618 return None;
2619 }
2620
2621 let band = self
2622 .last_layout
2623 .x_axes
2624 .iter()
2625 .find(|b| b.axis == axis && b.position == delinea::AxisPosition::Bottom)?;
2626
2627 let h = 9.0f32;
2628 let pad = 4.0f32;
2629 let y = band.rect.origin.y.0 + band.rect.size.height.0 - h - pad;
2630 let track = Rect::new(
2631 Point::new(plot.origin.x, Px(y)),
2632 Size::new(plot.size.width, Px(h)),
2633 );
2634
2635 Some(track)
2636 }
2637
2638 fn current_window_x_for_slider(
2639 &mut self,
2640 axis: delinea::AxisId,
2641 extent: DataWindow,
2642 ) -> DataWindow {
2643 if let Some(fixed) = self.axis_is_fixed(axis) {
2644 return fixed;
2645 }
2646
2647 let zoom_window = self.with_engine(|engine| {
2648 engine
2649 .state()
2650 .data_zoom_x
2651 .get(&axis)
2652 .copied()
2653 .and_then(|z| z.window)
2654 });
2655 if let Some(window) = zoom_window {
2656 return window;
2657 }
2658
2659 extent
2660 }
2661
2662 fn slider_norm(extent: DataWindow, v: f64) -> f32 {
2663 let span = extent.span();
2664 if !span.is_finite() || span <= 0.0 {
2665 return 0.0;
2666 }
2667 (((v - extent.min) / span) as f32).clamp(0.0, 1.0)
2668 }
2669
2670 fn slider_value_at(track: Rect, extent: DataWindow, px_x: f32) -> f64 {
2671 delinea::engine::axis::data_at_px(extent, px_x, track.origin.x.0, track.size.width.0)
2672 }
2673
2674 fn slider_window_after_delta(
2675 extent: DataWindow,
2676 start_window: DataWindow,
2677 delta_value: f64,
2678 kind: SliderDragKind,
2679 ) -> DataWindow {
2680 let extent_span = extent.span();
2681 if !extent_span.is_finite() || extent_span <= 0.0 {
2682 return start_window;
2683 }
2684
2685 let mut min = start_window.min;
2686 let mut max = start_window.max;
2687
2688 if !delta_value.is_finite() || !min.is_finite() || !max.is_finite() {
2689 return start_window;
2690 }
2691
2692 match kind {
2693 SliderDragKind::Pan => {
2694 min += delta_value;
2695 max += delta_value;
2696 }
2697 SliderDragKind::HandleMin => {
2698 min += delta_value;
2699 }
2700 SliderDragKind::HandleMax => {
2701 max += delta_value;
2702 }
2703 }
2704
2705 let eps = (extent_span.abs() * 1e-12).max(1e-9).max(f64::MIN_POSITIVE);
2706
2707 match kind {
2708 SliderDragKind::Pan => {
2709 let mut span = (max - min).abs();
2710 if !span.is_finite() || span <= eps {
2711 span = start_window.span().abs();
2712 }
2713 if !span.is_finite() || span <= eps {
2714 span = eps;
2715 }
2716
2717 if span >= extent_span {
2718 return extent;
2719 }
2720
2721 if max <= min {
2722 max = min + span;
2723 } else {
2724 span = max - min;
2725 }
2726
2727 if min < extent.min {
2728 let d = extent.min - min;
2729 min += d;
2730 max += d;
2731 }
2732 if max > extent.max {
2733 let d = max - extent.max;
2734 min -= d;
2735 max -= d;
2736 }
2737
2738 min = min.max(extent.min);
2739 max = max.min(extent.max);
2740
2741 if max - min < eps {
2742 min = extent.min;
2743 max = (extent.min + span).min(extent.max);
2744 if max - min < eps {
2745 max = (min + eps).min(extent.max);
2746 }
2747 }
2748
2749 if !(max > min) {
2750 return extent;
2751 }
2752
2753 DataWindow { min, max }
2754 }
2755 SliderDragKind::HandleMin => {
2756 let mut out_max = max.clamp(extent.min + eps, extent.max);
2757 let mut out_min = min.clamp(extent.min, out_max - eps);
2758 if !(out_max > out_min) {
2759 out_min = (out_max - eps).max(extent.min);
2760 if !(out_max > out_min) {
2761 out_max = (out_min + eps).min(extent.max);
2762 }
2763 }
2764 DataWindow {
2765 min: out_min,
2766 max: out_max,
2767 }
2768 }
2769 SliderDragKind::HandleMax => {
2770 let mut out_min = min.clamp(extent.min, extent.max - eps);
2771 let mut out_max = max.clamp(out_min + eps, extent.max);
2772 if !(out_max > out_min) {
2773 out_max = (out_min + eps).min(extent.max);
2774 if !(out_max > out_min) {
2775 out_min = (out_max - eps).max(extent.min);
2776 }
2777 }
2778 DataWindow {
2779 min: out_min,
2780 max: out_max,
2781 }
2782 }
2783 }
2784 }
2785
2786 fn y_slider_track_for_axis(&self, axis: delinea::AxisId) -> Option<Rect> {
2787 let plot = self.last_layout.plot;
2788 if plot.size.width.0 <= 0.0 || plot.size.height.0 <= 0.0 {
2789 return None;
2790 }
2791
2792 let band = self.last_layout.y_axes.iter().find(|b| b.axis == axis)?;
2793
2794 let w = 9.0f32;
2795 let pad = 4.0f32;
2796 let x = match band.position {
2797 delinea::AxisPosition::Right => band.rect.origin.x.0 + band.rect.size.width.0 - w - pad,
2798 _ => band.rect.origin.x.0 + pad,
2799 };
2800
2801 Some(Rect::new(
2802 Point::new(Px(x), plot.origin.y),
2803 Size::new(Px(w), plot.size.height),
2804 ))
2805 }
2806
2807 fn current_window_y_for_slider(
2808 &mut self,
2809 axis: delinea::AxisId,
2810 extent: DataWindow,
2811 ) -> DataWindow {
2812 if let Some(fixed) = self.axis_is_fixed(axis) {
2813 return fixed;
2814 }
2815
2816 let window = self.with_engine(|engine| engine.state().data_window_y.get(&axis).copied());
2817 if let Some(window) = window {
2818 return window;
2819 }
2820
2821 extent
2822 }
2823
2824 fn slider_value_at_y(track: Rect, extent: DataWindow, px_y: f32) -> f64 {
2825 let height = track.size.height.0.max(1.0);
2826 let bottom = track.origin.y.0 + height;
2827 let y = px_y.clamp(track.origin.y.0, bottom);
2828 let y_from_bottom = bottom - y;
2829 delinea::engine::axis::data_at_px(extent, y_from_bottom, 0.0, height)
2830 }
2831
2832 fn visual_map_tracks(
2833 &self,
2834 ) -> Vec<(
2835 delinea::VisualMapId,
2836 delinea::engine::model::VisualMapModel,
2837 Rect,
2838 )> {
2839 let Some(band) = self.last_layout.visual_map else {
2840 return Vec::new();
2841 };
2842 if band.size.width.0 <= 0.0 || band.size.height.0 <= 0.0 {
2843 return Vec::new();
2844 }
2845
2846 let maps: Vec<(delinea::VisualMapId, delinea::engine::model::VisualMapModel)> = self
2847 .with_engine(|engine| {
2848 engine
2849 .model()
2850 .visual_maps
2851 .iter()
2852 .map(|(id, vm)| (*id, *vm))
2853 .collect()
2854 });
2855 if maps.is_empty() {
2856 return Vec::new();
2857 }
2858
2859 let gap = self.style.visual_map_item_gap.0.max(0.0);
2860 let pad = self.style.visual_map_padding.0.max(0.0);
2861
2862 let total_gap = gap * (maps.len().saturating_sub(1) as f32);
2863 let item_h = ((band.size.height.0 - total_gap) / (maps.len() as f32)).max(1.0);
2864
2865 let mut y = band.origin.y.0;
2866 let mut out = Vec::with_capacity(maps.len());
2867 for (id, vm) in maps {
2868 let item = Rect::new(
2869 Point::new(band.origin.x, Px(y)),
2870 Size::new(band.size.width, Px(item_h)),
2871 );
2872 y += item_h + gap;
2873
2874 let track = Rect::new(
2875 Point::new(Px(item.origin.x.0 + pad), Px(item.origin.y.0 + pad)),
2876 Size::new(
2877 Px((item.size.width.0 - 2.0 * pad).max(1.0)),
2878 Px((item.size.height.0 - 2.0 * pad).max(1.0)),
2879 ),
2880 );
2881 if track.size.width.0 > 0.0 && track.size.height.0 > 0.0 {
2882 out.push((id, vm, track));
2883 }
2884 }
2885 out
2886 }
2887
2888 fn visual_map_track_at(
2889 &self,
2890 position: Point,
2891 ) -> Option<(
2892 delinea::VisualMapId,
2893 delinea::engine::model::VisualMapModel,
2894 Rect,
2895 )> {
2896 self.visual_map_tracks()
2897 .into_iter()
2898 .find(|(_, _, track)| track.contains(position))
2899 }
2900
2901 fn visual_map_domain_window(vm: delinea::engine::model::VisualMapModel) -> DataWindow {
2902 DataWindow {
2903 min: vm.domain.min,
2904 max: vm.domain.max,
2905 }
2906 }
2907
2908 fn current_visual_map_window(
2909 &self,
2910 id: delinea::VisualMapId,
2911 vm: delinea::engine::model::VisualMapModel,
2912 ) -> DataWindow {
2913 let domain = Self::visual_map_domain_window(vm);
2914 let range =
2915 self.with_engine(|engine| engine.state().visual_map_range.get(&id).copied().flatten());
2916 match range {
2917 Some(r) => DataWindow {
2918 min: r.min,
2919 max: r.max,
2920 },
2921 None => domain,
2922 }
2923 }
2924
2925 fn current_visual_map_piece_mask(
2926 &self,
2927 id: delinea::VisualMapId,
2928 vm: delinea::engine::model::VisualMapModel,
2929 ) -> u64 {
2930 let buckets = vm.buckets.clamp(1, 64) as u32;
2931 let full_mask = if buckets >= 64 {
2932 u64::MAX
2933 } else {
2934 (1u64 << buckets) - 1
2935 };
2936 let piece_mask = self.with_engine(|engine| {
2937 engine
2938 .state()
2939 .visual_map_piece_mask
2940 .get(&id)
2941 .copied()
2942 .flatten()
2943 });
2944 piece_mask.or(vm.initial_piece_mask).unwrap_or(full_mask) & full_mask
2945 }
2946
2947 fn visual_map_y_at_value(track: Rect, domain: DataWindow, value: f64) -> f32 {
2948 let mut domain = domain;
2949 domain.clamp_non_degenerate();
2950 let span = domain.span();
2951 if !span.is_finite() || span <= 0.0 {
2952 return track.origin.y.0 + track.size.height.0;
2953 }
2954 let t = ((value - domain.min) / span).clamp(0.0, 1.0) as f32;
2955 track.origin.y.0 + (1.0 - t) * track.size.height.0
2956 }
2957
2958 fn reset_view_for_axes(&mut self, x_axis: delinea::AxisId, y_axis: delinea::AxisId) {
2959 if self.axis_is_fixed(x_axis).is_none() {
2960 self.set_data_window_x(x_axis, None);
2961 }
2962 if self.axis_is_fixed(y_axis).is_none() {
2963 self.set_data_window_y(y_axis, None);
2964 }
2965 }
2966
2967 fn fit_view_to_data_for_axes(&mut self, x_axis: delinea::AxisId, y_axis: delinea::AxisId) {
2968 if self.axis_is_fixed(x_axis).is_none() {
2969 let mut w = self.compute_axis_extent(x_axis, true);
2970 let (locked_min, locked_max) = self.axis_constraints(x_axis);
2971 w = w.apply_constraints(locked_min, locked_max);
2972 self.set_data_window_x(x_axis, Some(w));
2973 }
2974
2975 if self.axis_is_fixed(y_axis).is_none() {
2976 let mut w = self.compute_axis_extent(y_axis, false);
2977 let (locked_min, locked_max) = self.axis_constraints(y_axis);
2978 w = w.apply_constraints(locked_min, locked_max);
2979 self.set_data_window_y(y_axis, Some(w));
2980 }
2981 }
2982
2983 fn axis_region(layout: &ChartLayout, position: Point) -> AxisRegion {
2984 for axis in &layout.x_axes {
2985 if axis.rect.contains(position) {
2986 return AxisRegion::XAxis(axis.axis);
2987 }
2988 }
2989 for axis in &layout.y_axes {
2990 if axis.rect.contains(position) {
2991 return AxisRegion::YAxis(axis.axis);
2992 }
2993 }
2994 AxisRegion::Plot
2995 }
2996
2997 fn is_button_held(button: MouseButton, buttons: fret_core::MouseButtons) -> bool {
2998 match button {
2999 MouseButton::Left => buttons.left,
3000 MouseButton::Right => buttons.right,
3001 MouseButton::Middle => buttons.middle,
3002 _ => false,
3003 }
3004 }
3005
3006 fn apply_box_select_modifiers(
3007 plot_size: Size,
3008 start: Point,
3009 end: Point,
3010 modifiers: Modifiers,
3011 expand_x: Option<ModifierKey>,
3012 expand_y: Option<ModifierKey>,
3013 required: ModifiersMask,
3014 ) -> (Point, Point) {
3015 let mut start = start;
3016 let mut end = end;
3017
3018 if expand_x.is_some_and(|k| k.is_pressed(modifiers) && !k.is_required_by(required)) {
3025 start.x = Px(0.0);
3026 end.x = plot_size.width;
3027 }
3028 if expand_y.is_some_and(|k| k.is_pressed(modifiers) && !k.is_required_by(required)) {
3029 start.y = Px(0.0);
3030 end.y = plot_size.height;
3031 }
3032
3033 (start, end)
3034 }
3035
3036 fn axis_ticks_with_labels(
3037 model: &delinea::engine::model::ChartModel,
3038 axis: delinea::AxisId,
3039 window: DataWindow,
3040 count: usize,
3041 ) -> Vec<(f64, String)> {
3042 delinea::engine::axis::axis_ticks_with_labels_for(model, axis, window, count)
3043 }
3044
3045 fn y_local_for_data_value(window: DataWindow, value: f64, plot_height_px: f32) -> f32 {
3046 let mut window = window;
3047 window.clamp_non_degenerate();
3048
3049 let span = window.span();
3050 if !span.is_finite() || span <= 0.0 || !value.is_finite() {
3051 return plot_height_px;
3052 }
3053
3054 let t = ((value - window.min) / span).clamp(0.0, 1.0) as f32;
3055 plot_height_px * (1.0 - t)
3056 }
3057
3058 fn clear_tooltip_text_cache(&mut self, services: &mut dyn fret_core::UiServices) {
3059 self.tooltip_text.clear(services);
3060 }
3061
3062 fn series_color(&self, series: delinea::SeriesId) -> Color {
3063 let order_idx = self
3064 .series_rank_by_id
3065 .get(&series)
3066 .copied()
3067 .unwrap_or_else(|| {
3068 self.with_engine(|engine| {
3069 engine
3070 .model()
3071 .series_order
3072 .iter()
3073 .position(|id| *id == series)
3074 .unwrap_or(0)
3075 })
3076 });
3077 let palette = &self.style.series_palette;
3078 palette[order_idx % palette.len()]
3079 }
3080
3081 fn series_is_in_view_grid(
3082 &self,
3083 model: &delinea::engine::model::ChartModel,
3084 series: delinea::SeriesId,
3085 ) -> bool {
3086 let Some(grid) = self.grid_override else {
3087 return true;
3088 };
3089 let Some(series) = model.series.get(&series) else {
3090 return false;
3091 };
3092 model
3093 .axes
3094 .get(&series.x_axis)
3095 .is_some_and(|a| a.grid == grid)
3096 }
3097
3098 fn paint_color(&self, paint: delinea::PaintId) -> Color {
3099 let palette = &self.style.series_palette;
3100 palette[(paint.0 as usize) % palette.len()]
3101 }
3102
3103 fn legend_series_at(&self, pos: Point) -> Option<delinea::SeriesId> {
3104 self.legend_item_rects
3105 .iter()
3106 .find_map(|(id, r)| r.contains(pos).then_some(*id))
3107 }
3108
3109 fn legend_selector_at(&self, pos: Point) -> Option<LegendSelectorAction> {
3110 self.legend_selector_rects
3111 .iter()
3112 .find_map(|(action, r)| r.contains(pos).then_some(*action))
3113 }
3114
3115 fn legend_max_scroll_y(&self) -> Px {
3116 if self.legend_content_height.0 <= self.legend_view_height.0 {
3117 return Px(0.0);
3118 }
3119 Px(self.legend_content_height.0 - self.legend_view_height.0)
3120 }
3121
3122 fn apply_legend_wheel_scroll(&mut self, wheel_delta_y: Px) -> bool {
3123 let max_scroll = self.legend_max_scroll_y();
3124 if max_scroll.0 <= 0.0 {
3125 return false;
3126 }
3127
3128 let prev = self.legend_scroll_y;
3129 let speed = 0.75f32;
3130 let next = (self.legend_scroll_y.0 - wheel_delta_y.0 * speed).clamp(0.0, max_scroll.0);
3131 self.legend_scroll_y = Px(next);
3132 self.legend_scroll_y.0 != prev.0
3133 }
3134
3135 fn apply_legend_select_all(&mut self) -> bool {
3136 let updates = self
3137 .with_engine(|engine| crate::legend_logic::legend_select_all_updates(engine.model()));
3138 if updates.is_empty() {
3139 return false;
3140 }
3141 self.with_engine_mut(|engine| engine.apply_action(Action::SetSeriesVisibility { updates }));
3142 true
3143 }
3144
3145 fn apply_legend_select_none(&mut self) -> bool {
3146 let updates = self
3147 .with_engine(|engine| crate::legend_logic::legend_select_none_updates(engine.model()));
3148 if updates.is_empty() {
3149 return false;
3150 }
3151 self.with_engine_mut(|engine| engine.apply_action(Action::SetSeriesVisibility { updates }));
3152 true
3153 }
3154
3155 fn apply_legend_invert(&mut self) -> bool {
3156 let updates =
3157 self.with_engine(|engine| crate::legend_logic::legend_invert_updates(engine.model()));
3158 if updates.is_empty() {
3159 return false;
3160 }
3161 self.with_engine_mut(|engine| engine.apply_action(Action::SetSeriesVisibility { updates }));
3162 true
3163 }
3164
3165 fn apply_legend_double_click(&mut self, clicked: delinea::SeriesId) {
3166 let updates = self.with_engine(|engine| {
3167 crate::legend_logic::legend_double_click_updates(engine.model(), clicked)
3168 });
3169 if !updates.is_empty() {
3170 self.with_engine_mut(|engine| {
3171 engine.apply_action(Action::SetSeriesVisibility { updates });
3172 });
3173 }
3174 }
3175
3176 fn apply_legend_reset(&mut self) {
3177 let updates =
3178 self.with_engine(|engine| crate::legend_logic::legend_reset_updates(engine.model()));
3179 if !updates.is_empty() {
3180 self.with_engine_mut(|engine| {
3181 engine.apply_action(Action::SetSeriesVisibility { updates })
3182 });
3183 }
3184 }
3185
3186 fn apply_legend_shift_range_toggle(
3187 &mut self,
3188 anchor: delinea::SeriesId,
3189 clicked: delinea::SeriesId,
3190 ) {
3191 let updates = self.with_engine(|engine| {
3192 crate::legend_logic::legend_shift_range_toggle_updates(engine.model(), anchor, clicked)
3193 });
3194 if !updates.is_empty() {
3195 self.with_engine_mut(|engine| {
3196 engine.apply_action(Action::SetSeriesVisibility { updates })
3197 });
3198 }
3199 }
3200
3201 fn draw_legend<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
3202 self.legend_item_rects.clear();
3203 self.legend_selector_rects.clear();
3204 self.legend_panel_rect = None;
3205 self.legend_content_height = Px(0.0);
3206 self.legend_view_height = Px(0.0);
3207
3208 let plot = self.last_layout.plot;
3209 if plot.size.width.0 <= 0.0 || plot.size.height.0 <= 0.0 {
3210 return;
3211 }
3212
3213 let series: Vec<delinea::engine::model::SeriesModel> = self.with_engine(|engine| {
3214 let model = engine.model();
3215 model
3216 .series_in_order()
3217 .filter(|s| self.series_is_in_view_grid(model, s.id))
3218 .cloned()
3219 .collect()
3220 });
3221 if series.is_empty() {
3222 return;
3223 }
3224
3225 let mut key = KeyBuilder::new();
3226 key.mix_u64(self.last_theme_revision);
3227 key.mix_u64(u64::from(cx.scale_factor.to_bits()));
3228 key.mix_u64(u64::from(series.len() as u32));
3229 for s in &series {
3230 key.mix_u64(s.id.0);
3231 key.mix_bool(s.visible);
3232 if let Some(name) = s.name.as_deref() {
3233 key.mix_str(name);
3234 }
3235 }
3236 self.legend_text
3237 .reset_if_key_changed(cx.services, key.finish());
3238
3239 let text_style = TextStyle {
3240 size: Px(12.0),
3241 weight: FontWeight::NORMAL,
3242 ..TextStyle::default()
3243 };
3244 let constraints = TextConstraints {
3245 max_width: None,
3246 wrap: TextWrap::None,
3247 overflow: TextOverflow::Clip,
3248 align: fret_core::TextAlign::Start,
3249 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
3250 };
3251
3252 let mut blobs: Vec<(delinea::SeriesId, TextBlobId, fret_core::TextMetrics, bool)> =
3253 Vec::with_capacity(series.len());
3254
3255 let mut max_text_w = 1.0f32;
3256 let mut row_h = 1.0f32;
3257 for s in &series {
3258 let label = s
3259 .name
3260 .as_deref()
3261 .map(|n| n.to_string())
3262 .unwrap_or_else(|| format!("Series {}", s.id.0));
3263 let prepared = self
3264 .legend_text
3265 .prepare(cx.services, &label, &text_style, constraints);
3266 let blob = prepared.blob;
3267 let metrics = prepared.metrics;
3268 max_text_w = max_text_w.max(metrics.size.width.0.max(1.0));
3269 row_h = row_h.max(metrics.size.height.0.max(1.0));
3270 blobs.push((s.id, blob, metrics, s.visible));
3271 }
3272
3273 let selector_text_style = TextStyle {
3274 size: Px(11.0),
3275 weight: FontWeight::MEDIUM,
3276 ..TextStyle::default()
3277 };
3278
3279 let pad = self.style.legend_padding;
3280 let sw = self.style.legend_swatch_size.0.max(1.0);
3281 let sw_gap = self.style.legend_swatch_gap.0.max(0.0);
3282 let gap = self.style.legend_item_gap.0.max(0.0);
3283 let selector_gap = 8.0f32;
3284
3285 let row_h = row_h.max(sw);
3286 let legend_w = (pad.left.0 + sw + sw_gap + max_text_w + pad.right.0).max(1.0);
3287 let selector_labels: [(LegendSelectorAction, &str); 3] = [
3288 (LegendSelectorAction::All, "All"),
3289 (LegendSelectorAction::None, "None"),
3290 (LegendSelectorAction::Invert, "Invert"),
3291 ];
3292
3293 let mut selector_blobs: Vec<(LegendSelectorAction, TextBlobId, fret_core::TextMetrics)> =
3294 Vec::with_capacity(selector_labels.len());
3295 let mut selector_total_w = 0.0f32;
3296 let mut selector_h = 0.0f32;
3297 for (action, label) in selector_labels {
3298 let prepared =
3299 self.legend_text
3300 .prepare(cx.services, label, &selector_text_style, constraints);
3301 let blob = prepared.blob;
3302 let metrics = prepared.metrics;
3303 selector_total_w += metrics.size.width.0.max(1.0);
3304 selector_h = selector_h.max(metrics.size.height.0.max(1.0));
3305 selector_blobs.push((action, blob, metrics));
3306 }
3307 if !selector_blobs.is_empty() {
3308 selector_total_w += selector_gap * (selector_blobs.len().saturating_sub(1) as f32);
3309 }
3310 let selector_h = selector_h.max(1.0);
3311 let selector_row_h = (selector_h + 4.0).max(1.0);
3312
3313 let items_h = ((row_h + gap) * (series.len().saturating_sub(1) as f32) + row_h).max(1.0);
3314 let full_h = (pad.top.0 + selector_row_h + items_h + pad.bottom.0).max(1.0);
3315
3316 let margin = 8.0f32;
3317 let min_h = (pad.top.0 + row_h + pad.bottom.0).max(1.0);
3318 let max_h = (plot.size.height.0 - 2.0 * margin).max(min_h);
3319 let legend_h = full_h.min(max_h);
3320 let view_h = (legend_h - selector_row_h - pad.top.0 - pad.bottom.0).max(1.0);
3321 self.legend_content_height = Px(items_h);
3322 self.legend_view_height = Px(view_h);
3323 self.legend_scroll_y = Px(self
3324 .legend_scroll_y
3325 .0
3326 .clamp(0.0, self.legend_max_scroll_y().0));
3327
3328 let x0 =
3329 (plot.origin.x.0 + plot.size.width.0 - legend_w - margin).max(plot.origin.x.0 + margin);
3330 let y0 = (plot.origin.y.0 + margin).max(plot.origin.y.0 + margin);
3331 let legend_rect = Rect::new(
3332 Point::new(Px(x0), Px(y0)),
3333 Size::new(Px(legend_w), Px(legend_h)),
3334 );
3335 self.legend_panel_rect = Some(legend_rect);
3336
3337 let legend_order = DrawOrder(self.style.draw_order.0.saturating_add(8_900));
3338 cx.scene.push(SceneOp::Quad {
3339 order: legend_order,
3340 rect: legend_rect,
3341 background: fret_core::Paint::Solid(self.style.legend_background).into(),
3342
3343 border: Edges::all(self.style.legend_border_width),
3344 border_paint: fret_core::Paint::Solid(self.style.legend_border_color).into(),
3345
3346 corner_radii: Corners::all(self.style.legend_corner_radius),
3347 });
3348
3349 cx.scene.push(SceneOp::PushClipRect { rect: legend_rect });
3350
3351 let selector_y = y0 + pad.top.0;
3354 let selector_x0 = x0 + legend_w - pad.right.0 - selector_total_w;
3355 let mut sx = selector_x0;
3356 for (action, blob, metrics) in selector_blobs.into_iter() {
3357 let w = metrics.size.width.0.max(1.0);
3358 let rect = Rect::new(
3359 Point::new(Px(sx), Px(selector_y)),
3360 Size::new(Px(w), Px(selector_row_h)),
3361 );
3362 self.legend_selector_rects.push((action, rect));
3363
3364 if self.legend_selector_hover == Some(action) {
3365 cx.scene.push(SceneOp::Quad {
3366 order: DrawOrder(legend_order.0.saturating_add(1)),
3367 rect,
3368 background: fret_core::Paint::Solid(self.style.legend_hover_background).into(),
3369
3370 border: Edges::all(Px(0.0)),
3371 border_paint: fret_core::Paint::TRANSPARENT.into(),
3372
3373 corner_radii: Corners::all(Px(4.0)),
3374 });
3375 }
3376
3377 let text_y = selector_y + 0.5 * (selector_row_h - metrics.size.height.0.max(1.0));
3378 cx.scene.push(SceneOp::Text {
3379 order: DrawOrder(legend_order.0.saturating_add(2)),
3380 origin: Point::new(Px(sx), Px(text_y)),
3381 text: blob,
3382 paint: (self.style.legend_text_color).into(),
3383 outline: None,
3384 shadow: None,
3385 });
3386
3387 sx += w + selector_gap;
3388 }
3389
3390 let items_clip = Rect::new(
3391 Point::new(Px(x0), Px(y0 + pad.top.0 + selector_row_h)),
3392 Size::new(Px(legend_w), Px(view_h)),
3393 );
3394 cx.scene.push(SceneOp::PushClipRect { rect: items_clip });
3395
3396 let mut y = items_clip.origin.y.0 - self.legend_scroll_y.0;
3397 for (i, (series_id, blob, metrics, visible)) in blobs.into_iter().enumerate() {
3398 let item_rect = Rect::new(
3399 Point::new(Px(x0), Px(y)),
3400 Size::new(Px(legend_w), Px(row_h)),
3401 );
3402 self.legend_item_rects.push((series_id, item_rect));
3403
3404 if self.legend_hover == Some(series_id) {
3405 cx.scene.push(SceneOp::Quad {
3406 order: DrawOrder(legend_order.0.saturating_add(1 + i as u32 * 3)),
3407 rect: item_rect,
3408 background: fret_core::Paint::Solid(self.style.legend_hover_background).into(),
3409
3410 border: Edges::all(Px(0.0)),
3411 border_paint: fret_core::Paint::TRANSPARENT.into(),
3412
3413 corner_radii: Corners::all(Px(0.0)),
3414 });
3415 }
3416
3417 let mut swatch = self.series_color(series_id);
3418 swatch.a = if visible { 0.9 } else { 0.25 };
3419 let sw_x = x0 + pad.left.0;
3420 let sw_y = y + 0.5 * (row_h - sw);
3421 cx.scene.push(SceneOp::Quad {
3422 order: DrawOrder(legend_order.0.saturating_add(2 + i as u32 * 3)),
3423 rect: Rect::new(Point::new(Px(sw_x), Px(sw_y)), Size::new(Px(sw), Px(sw))),
3424 background: fret_core::Paint::Solid(swatch).into(),
3425
3426 border: Edges::all(Px(0.0)),
3427 border_paint: fret_core::Paint::TRANSPARENT.into(),
3428
3429 corner_radii: Corners::all(Px(2.0)),
3430 });
3431
3432 let text_x = sw_x + sw + sw_gap;
3433 let text_y = y + 0.5 * (row_h - metrics.size.height.0.max(1.0));
3434 let mut text_color = self.style.legend_text_color;
3435 if !visible {
3436 text_color.a *= 0.55;
3437 }
3438 cx.scene.push(SceneOp::Text {
3439 order: DrawOrder(legend_order.0.saturating_add(3 + i as u32 * 3)),
3440 origin: Point::new(Px(text_x), Px(text_y)),
3441 text: blob,
3442 paint: (text_color).into(),
3443 outline: None,
3444 shadow: None,
3445 });
3446
3447 y += row_h + gap;
3448 }
3449
3450 cx.scene.push(SceneOp::PopClip);
3451 cx.scene.push(SceneOp::PopClip);
3452 }
3453
3454 fn draw_visual_map<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
3455 let tracks = self.visual_map_tracks();
3456 if tracks.is_empty() {
3457 return;
3458 }
3459
3460 for (i, (vm_id, vm, track)) in tracks.into_iter().enumerate() {
3461 let order = DrawOrder(
3462 self.style
3463 .draw_order
3464 .0
3465 .saturating_add(8_600)
3466 .saturating_add((i as u32).saturating_mul(20)),
3467 );
3468
3469 cx.scene.push(SceneOp::Quad {
3470 order,
3471 rect: track,
3472 background: fret_core::Paint::Solid(self.style.visual_map_track_color).into(),
3473
3474 border: Edges::all(Px(0.0)),
3475 border_paint: fret_core::Paint::TRANSPARENT.into(),
3476
3477 corner_radii: Corners::all(self.style.visual_map_corner_radius),
3478 });
3479
3480 let buckets = vm.buckets.max(1) as u32;
3481 let inset = 1.0f32;
3482 let ramp_rect = Rect::new(
3483 Point::new(Px(track.origin.x.0 + inset), Px(track.origin.y.0 + inset)),
3484 Size::new(
3485 Px((track.size.width.0 - 2.0 * inset).max(1.0)),
3486 Px((track.size.height.0 - 2.0 * inset).max(1.0)),
3487 ),
3488 );
3489 let ramp_h = ramp_rect.size.height.0.max(1.0);
3490 let segment_h = (ramp_h / buckets as f32).max(1.0);
3491
3492 match vm.mode {
3493 delinea::VisualMapMode::Continuous => {
3494 let ramp_alpha = 0.35f32;
3497 for bucket in 0..buckets {
3498 let y1 = ramp_rect.origin.y.0 + ramp_h - (bucket as f32) * segment_h;
3499 let y0 = (y1 - segment_h).max(ramp_rect.origin.y.0);
3500 let h = (y1 - y0).max(1.0);
3501
3502 let mut c = self.paint_color(delinea::PaintId(bucket as u64));
3503 c.a *= ramp_alpha;
3504 cx.scene.push(SceneOp::Quad {
3505 order: DrawOrder(order.0.saturating_add(1)),
3506 rect: Rect::new(
3507 Point::new(ramp_rect.origin.x, Px(y0)),
3508 Size::new(ramp_rect.size.width, Px(h)),
3509 ),
3510 background: fret_core::Paint::Solid(c).into(),
3511
3512 border: Edges::all(Px(0.0)),
3513 border_paint: fret_core::Paint::TRANSPARENT.into(),
3514
3515 corner_radii: Corners::all(Px(0.0)),
3516 });
3517 }
3518
3519 let domain = Self::visual_map_domain_window(vm);
3520 let window = self.current_visual_map_window(vm_id, vm);
3521
3522 let y_min = Self::visual_map_y_at_value(track, domain, window.min);
3523 let y_max = Self::visual_map_y_at_value(track, domain, window.max);
3524 let top = y_max.min(y_min);
3525 let bottom = y_max.max(y_min);
3526
3527 let win_rect = Rect::new(
3528 Point::new(track.origin.x, Px(top)),
3529 Size::new(track.size.width, Px((bottom - top).max(1.0))),
3530 );
3531 cx.scene.push(SceneOp::Quad {
3532 order: DrawOrder(order.0.saturating_add(2)),
3533 rect: win_rect,
3534 background: fret_core::Paint::Solid(self.style.visual_map_range_fill)
3535 .into(),
3536
3537 border: Edges::all(self.style.selection_stroke_width),
3538 border_paint: fret_core::Paint::Solid(self.style.visual_map_range_stroke)
3539 .into(),
3540
3541 corner_radii: Corners::all(self.style.visual_map_corner_radius),
3542 });
3543
3544 let handle_h = 2.0f32.max(self.style.selection_stroke_width.0);
3545 let handle_color = self.style.visual_map_handle_color;
3546 cx.scene.push(SceneOp::Quad {
3547 order: DrawOrder(order.0.saturating_add(3)),
3548 rect: Rect::new(
3549 Point::new(track.origin.x, Px(y_min - 0.5 * handle_h)),
3550 Size::new(track.size.width, Px(handle_h)),
3551 ),
3552 background: fret_core::Paint::Solid(handle_color).into(),
3553
3554 border: Edges::all(Px(0.0)),
3555 border_paint: fret_core::Paint::TRANSPARENT.into(),
3556
3557 corner_radii: Corners::all(Px(0.0)),
3558 });
3559 cx.scene.push(SceneOp::Quad {
3560 order: DrawOrder(order.0.saturating_add(4)),
3561 rect: Rect::new(
3562 Point::new(track.origin.x, Px(y_max - 0.5 * handle_h)),
3563 Size::new(track.size.width, Px(handle_h)),
3564 ),
3565 background: fret_core::Paint::Solid(handle_color).into(),
3566
3567 border: Edges::all(Px(0.0)),
3568 border_paint: fret_core::Paint::TRANSPARENT.into(),
3569
3570 corner_radii: Corners::all(Px(0.0)),
3571 });
3572 }
3573 delinea::VisualMapMode::Piecewise => {
3574 let mask = self.current_visual_map_piece_mask(vm_id, vm);
3575 let ramp_alpha_selected = 0.55f32;
3576 let ramp_alpha_unselected = 0.12f32;
3577 for bucket in 0..buckets {
3578 let y1 = ramp_rect.origin.y.0 + ramp_h - (bucket as f32) * segment_h;
3579 let y0 = (y1 - segment_h).max(ramp_rect.origin.y.0);
3580 let h = (y1 - y0).max(1.0);
3581
3582 let selected = ((mask >> bucket) & 1) == 1;
3583 let alpha = if selected {
3584 ramp_alpha_selected
3585 } else {
3586 ramp_alpha_unselected
3587 };
3588
3589 let mut c = self.paint_color(delinea::PaintId(bucket as u64));
3590 c.a *= alpha;
3591 cx.scene.push(SceneOp::Quad {
3592 order: DrawOrder(order.0.saturating_add(1)),
3593 rect: Rect::new(
3594 Point::new(ramp_rect.origin.x, Px(y0)),
3595 Size::new(ramp_rect.size.width, Px(h)),
3596 ),
3597 background: fret_core::Paint::Solid(c).into(),
3598
3599 border: Edges::all(Px(0.0)),
3600 border_paint: fret_core::Paint::TRANSPARENT.into(),
3601
3602 corner_radii: Corners::all(Px(0.0)),
3603 });
3604 }
3605 }
3606 }
3607 }
3608 }
3609
3610 fn draw_axes<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
3611 let plot = self.last_layout.plot;
3612 let x_axes = self.last_layout.x_axes.clone();
3613 let y_axes = self.last_layout.y_axes.clone();
3614 if plot.size.width.0 <= 0.0 || plot.size.height.0 <= 0.0 {
3615 return;
3616 }
3617
3618 let model = self.with_engine(|engine| engine.model().clone());
3619
3620 let x_bands: Vec<(AxisBandLayout, DataWindow)> = x_axes
3621 .iter()
3622 .map(|band| (*band, self.current_window_x(band.axis)))
3623 .collect();
3624 let y_bands: Vec<(AxisBandLayout, DataWindow)> = y_axes
3625 .iter()
3626 .map(|band| (*band, self.current_window_y(band.axis)))
3627 .collect();
3628
3629 let mut key = KeyBuilder::new();
3630 key.mix_u64(self.last_theme_revision);
3631 key.mix_u64(u64::from(cx.scale_factor.to_bits()));
3632 key.mix_f32_bits(plot.size.width.0);
3633 key.mix_f32_bits(plot.size.height.0);
3634 key.mix_u64(u64::from(x_bands.len() as u32));
3635 for (band, window) in &x_bands {
3636 key.mix_u64(band.axis.0);
3637 key.mix_f32_bits(band.rect.origin.x.0);
3638 key.mix_f32_bits(band.rect.origin.y.0);
3639 key.mix_f32_bits(band.rect.size.width.0);
3640 key.mix_f32_bits(band.rect.size.height.0);
3641 key.mix_f64_bits(window.min);
3642 key.mix_f64_bits(window.max);
3643 }
3644 key.mix_u64(u64::from(y_bands.len() as u32));
3645 for (band, window) in &y_bands {
3646 key.mix_u64(band.axis.0);
3647 key.mix_f32_bits(band.rect.origin.x.0);
3648 key.mix_f32_bits(band.rect.origin.y.0);
3649 key.mix_f32_bits(band.rect.size.width.0);
3650 key.mix_f32_bits(band.rect.size.height.0);
3651 key.mix_f64_bits(window.min);
3652 key.mix_f64_bits(window.max);
3653 }
3654 self.axis_text
3655 .reset_if_key_changed(cx.services, key.finish());
3656
3657 let axis_order = DrawOrder(self.style.draw_order.0.saturating_add(8_500));
3658 let label_order = DrawOrder(self.style.draw_order.0.saturating_add(8_501));
3659
3660 let line_w = self.style.axis_line_width.0.max(1.0);
3661 let tick_len = self.style.axis_tick_length.0.max(0.0);
3662
3663 let text_style = TextStyle {
3664 size: Px(12.0),
3665 weight: FontWeight::NORMAL,
3666 ..TextStyle::default()
3667 };
3668 let constraints = TextConstraints {
3669 max_width: None,
3670 wrap: TextWrap::None,
3671 overflow: TextOverflow::Clip,
3672 align: fret_core::TextAlign::Start,
3673 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
3674 };
3675
3676 let x_tick_count = (plot.size.width.0 / 80.0).round().clamp(2.0, 12.0) as usize;
3677 let y_tick_count = (plot.size.height.0 / 56.0).round().clamp(2.0, 12.0) as usize;
3678
3679 for (band, window) in &x_bands {
3681 let baseline_y = match band.position {
3682 delinea::AxisPosition::Bottom => band.rect.origin.y.0,
3683 delinea::AxisPosition::Top => band.rect.origin.y.0 + band.rect.size.height.0,
3684 _ => continue,
3685 };
3686
3687 cx.scene.push(SceneOp::Quad {
3688 order: axis_order,
3689 rect: Rect::new(
3690 Point::new(band.rect.origin.x, Px(baseline_y - line_w * 0.5)),
3691 Size::new(plot.size.width, Px(line_w)),
3692 ),
3693 background: fret_core::Paint::Solid(self.style.axis_line_color).into(),
3694
3695 border: Edges::all(Px(0.0)),
3696 border_paint: fret_core::Paint::TRANSPARENT.into(),
3697
3698 corner_radii: Corners::all(Px(0.0)),
3699 });
3700
3701 let mut last_right = f32::NEG_INFINITY;
3702 for (value, label) in
3703 Self::axis_ticks_with_labels(&model, band.axis, *window, x_tick_count)
3704 {
3705 let t = ((value - window.min) / window.span()).clamp(0.0, 1.0) as f32;
3706 let x_px = plot.origin.x.0 + t * plot.size.width.0;
3707
3708 let tick_y = match band.position {
3709 delinea::AxisPosition::Bottom => baseline_y,
3710 delinea::AxisPosition::Top => baseline_y - tick_len,
3711 _ => baseline_y,
3712 };
3713
3714 cx.scene.push(SceneOp::Quad {
3715 order: axis_order,
3716 rect: Rect::new(
3717 Point::new(Px(x_px - 0.5 * line_w), Px(tick_y)),
3718 Size::new(Px(line_w), Px(tick_len)),
3719 ),
3720 background: fret_core::Paint::Solid(self.style.axis_tick_color).into(),
3721
3722 border: Edges::all(Px(0.0)),
3723 border_paint: fret_core::Paint::TRANSPARENT.into(),
3724
3725 corner_radii: Corners::all(Px(0.0)),
3726 });
3727
3728 let prepared =
3729 self.axis_text
3730 .prepare(cx.services, &label, &text_style, constraints);
3731 let blob = prepared.blob;
3732 let metrics = prepared.metrics;
3733
3734 let label_x = x_px - metrics.size.width.0 * 0.5;
3735 let label_y =
3736 band.rect.origin.y.0 + (band.rect.size.height.0 - metrics.size.height.0) * 0.5;
3737
3738 let gap = 4.0;
3739 let right = label_x + metrics.size.width.0;
3740 if label_x >= last_right + gap {
3741 cx.scene.push(SceneOp::Text {
3742 order: label_order,
3743 origin: Point::new(Px(label_x), Px(label_y)),
3744 text: blob,
3745 paint: (self.style.axis_label_color).into(),
3746 outline: None,
3747 shadow: None,
3748 });
3749 last_right = right;
3750 }
3751 }
3752 }
3753
3754 for (band, window) in &y_bands {
3756 let baseline_x = match band.position {
3757 delinea::AxisPosition::Left => band.rect.origin.x.0 + band.rect.size.width.0,
3758 delinea::AxisPosition::Right => band.rect.origin.x.0,
3759 _ => continue,
3760 };
3761
3762 cx.scene.push(SceneOp::Quad {
3763 order: axis_order,
3764 rect: Rect::new(
3765 Point::new(Px(baseline_x - line_w * 0.5), band.rect.origin.y),
3766 Size::new(Px(line_w), plot.size.height),
3767 ),
3768 background: fret_core::Paint::Solid(self.style.axis_line_color).into(),
3769
3770 border: Edges::all(Px(0.0)),
3771 border_paint: fret_core::Paint::TRANSPARENT.into(),
3772
3773 corner_radii: Corners::all(Px(0.0)),
3774 });
3775
3776 let mut last_bottom = f32::NEG_INFINITY;
3777 for (value, label) in
3778 Self::axis_ticks_with_labels(&model, band.axis, *window, y_tick_count)
3779 {
3780 let t = ((value - window.min) / window.span()).clamp(0.0, 1.0) as f32;
3781 let y_px = plot.origin.y.0 + (1.0 - t) * plot.size.height.0;
3782
3783 let (tick_x, tick_w) = match band.position {
3784 delinea::AxisPosition::Left => (baseline_x - tick_len, tick_len),
3785 delinea::AxisPosition::Right => (baseline_x, tick_len),
3786 _ => (baseline_x, tick_len),
3787 };
3788
3789 cx.scene.push(SceneOp::Quad {
3790 order: axis_order,
3791 rect: Rect::new(
3792 Point::new(Px(tick_x), Px(y_px - 0.5 * line_w)),
3793 Size::new(Px(tick_w), Px(line_w)),
3794 ),
3795 background: fret_core::Paint::Solid(self.style.axis_tick_color).into(),
3796
3797 border: Edges::all(Px(0.0)),
3798 border_paint: fret_core::Paint::TRANSPARENT.into(),
3799
3800 corner_radii: Corners::all(Px(0.0)),
3801 });
3802
3803 let prepared =
3804 self.axis_text
3805 .prepare(cx.services, &label, &text_style, constraints);
3806 let blob = prepared.blob;
3807 let metrics = prepared.metrics;
3808
3809 let label_x = match band.position {
3810 delinea::AxisPosition::Left => {
3811 band.rect.origin.x.0
3812 + (band.rect.size.width.0 - metrics.size.width.0 - 4.0).max(0.0)
3813 }
3814 delinea::AxisPosition::Right => band.rect.origin.x.0 + 4.0,
3815 _ => band.rect.origin.x.0 + 4.0,
3816 };
3817 let label_y = y_px - metrics.size.height.0 * 0.5;
3818
3819 let gap = 2.0;
3820 let bottom = label_y + metrics.size.height.0;
3821 if label_y >= last_bottom + gap {
3822 cx.scene.push(SceneOp::Text {
3823 order: label_order,
3824 origin: Point::new(Px(label_x), Px(label_y)),
3825 text: blob,
3826 paint: (self.style.axis_label_color).into(),
3827 outline: None,
3828 shadow: None,
3829 });
3830 last_bottom = bottom;
3831 }
3832 }
3833 }
3834 }
3835
3836 fn rebuild_paths_if_needed<H: UiHost>(&mut self, cx: &mut PaintCx<'_, H>) {
3837 let marks_rev = self.with_engine(|engine| engine.output().marks.revision);
3838 let scale_factor_bits = cx.scale_factor.to_bits();
3839
3840 if marks_rev == self.last_marks_rev && scale_factor_bits == self.last_scale_factor_bits {
3841 return;
3842 }
3843 self.last_marks_rev = marks_rev;
3844 self.last_scale_factor_bits = scale_factor_bits;
3845
3846 self.path_cache.clear(cx.services);
3847 self.cached_paths.clear();
3848 self.cached_rects.clear();
3849 self.cached_points.clear();
3850 self.cached_rect_scene_ops.clear();
3851 self.cached_point_scene_ops.clear();
3852
3853 let plot_h = self.last_layout.plot.size.height.0;
3854 let area_series: Vec<(delinea::SeriesId, delinea::AxisId, delinea::AreaBaseline)> = self
3855 .with_engine(|engine| {
3856 let model = engine.model();
3857 model
3858 .series_in_order()
3859 .filter(|s| s.kind == delinea::SeriesKind::Area && s.visible)
3860 .filter(|s| self.series_is_in_view_grid(model, s.id))
3861 .map(|s| (s.id, s.y_axis, s.area_baseline))
3862 .collect()
3863 });
3864
3865 let mut area_baseline_y_local: BTreeMap<delinea::SeriesId, f32> = BTreeMap::new();
3866 for (series_id, y_axis, baseline) in area_series {
3867 let y = match baseline {
3868 delinea::AreaBaseline::AxisMin => plot_h,
3869 delinea::AreaBaseline::Zero => {
3870 let y_window = self.current_window_y(y_axis);
3871 Self::y_local_for_data_value(y_window, 0.0, plot_h)
3872 }
3873 delinea::AreaBaseline::Value(value) => {
3874 let y_window = self.current_window_y(y_axis);
3875 Self::y_local_for_data_value(y_window, value, plot_h)
3876 }
3877 };
3878 area_baseline_y_local.insert(series_id, y);
3879 }
3880
3881 let origin = self.last_layout.plot.origin;
3882 let (marks, model) =
3883 self.with_engine(|engine| (engine.output().marks.clone(), engine.model().clone()));
3884
3885 self.series_rank_by_id.clear();
3886 for (i, series_id) in model.series_order.iter().enumerate() {
3887 self.series_rank_by_id.insert(*series_id, i);
3888 }
3889
3890 #[derive(Default)]
3891 struct BandSegment {
3892 lower: Option<Range<usize>>,
3893 upper: Option<Range<usize>>,
3894 lower_id: Option<delinea::ids::MarkId>,
3895 }
3896
3897 let mut band_segments: BTreeMap<delinea::SeriesId, Vec<BandSegment>> = BTreeMap::new();
3898
3899 for node in &marks.nodes {
3900 if let Some(series_id) = node.source_series
3901 && !self.series_is_in_view_grid(&model, series_id)
3902 {
3903 continue;
3904 }
3905
3906 if node.kind != MarkKind::Polyline {
3907 continue;
3908 }
3909
3910 let MarkPayloadRef::Polyline(poly) = &node.payload else {
3911 continue;
3912 };
3913
3914 let series_kind = node
3915 .source_series
3916 .and_then(|id| model.series.get(&id).map(|s| s.kind));
3917
3918 let is_stacked_area = series_kind == Some(delinea::SeriesKind::Area)
3919 && node
3920 .source_series
3921 .is_some_and(|id| model.series.get(&id).is_some_and(|s| s.stack.is_some()));
3922
3923 if (series_kind == Some(delinea::SeriesKind::Band) || is_stacked_area)
3924 && let Some(series_id) = node.source_series
3925 {
3926 let variant = delinea::ids::mark_variant(node.id);
3927 if variant < 1 {
3928 continue;
3929 }
3930 let segment = ((variant - 1) / 2) as usize;
3931 let role = ((variant - 1) % 2) as u8;
3932
3933 let segments = band_segments.entry(series_id).or_default();
3934 if segments.len() <= segment {
3935 segments.resize_with(segment + 1, BandSegment::default);
3936 }
3937 let entry = &mut segments[segment];
3938 if role == 0 {
3939 entry.lower = Some(poly.points.clone());
3940 entry.lower_id = Some(node.id);
3941 } else {
3942 entry.upper = Some(poly.points.clone());
3943 }
3944 }
3945
3946 let baseline_y_local = node.source_series.and_then(|id| {
3947 let series = model.series.get(&id)?;
3948 if series.kind == delinea::SeriesKind::Area && series.stack.is_some() {
3949 return None;
3950 }
3951 area_baseline_y_local.get(&id).copied()
3952 });
3953
3954 let start = poly.points.start;
3955 let end = poly.points.end;
3956 if end <= start || end > marks.arena.points.len() {
3957 continue;
3958 }
3959
3960 let mut commands: Vec<PathCommand> =
3961 Vec::with_capacity((end - start).saturating_add(1));
3962 for (i, p) in marks.arena.points[start..end].iter().enumerate() {
3963 let local = fret_core::Point::new(Px(p.x.0 - origin.x.0), Px(p.y.0 - origin.y.0));
3964 if i == 0 {
3965 commands.push(PathCommand::MoveTo(local));
3966 } else {
3967 commands.push(PathCommand::LineTo(local));
3968 }
3969 }
3970
3971 if commands.len() < 2 {
3972 continue;
3973 }
3974
3975 let stroke_width = poly
3976 .stroke
3977 .as_ref()
3978 .map(|(_, s)| s.width)
3979 .unwrap_or(self.style.stroke_width);
3980
3981 let constraints = PathConstraints {
3982 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
3983 };
3984
3985 let _ = self.path_cache.prepare(
3986 cx.services,
3987 mark_path_cache_key(node.id, 0),
3988 &commands,
3989 PathStyle::Stroke(StrokeStyle {
3990 width: stroke_width,
3991 }),
3992 constraints,
3993 );
3994
3995 let mut fill_prepared = false;
3996 if let Some(baseline_y_local) = baseline_y_local {
3997 let mut fill_commands: Vec<PathCommand> = Vec::with_capacity(commands.len() + 4);
3998 fill_commands.extend_from_slice(&commands);
3999
4000 if let (Some(first), Some(last)) = (
4001 marks.arena.points.get(start),
4002 marks.arena.points.get(end.saturating_sub(1)),
4003 ) {
4004 let last_x_local = last.x.0 - origin.x.0;
4005 let first_x_local = first.x.0 - origin.x.0;
4006 fill_commands.push(PathCommand::LineTo(fret_core::Point::new(
4007 Px(last_x_local),
4008 Px(baseline_y_local),
4009 )));
4010 fill_commands.push(PathCommand::LineTo(fret_core::Point::new(
4011 Px(first_x_local),
4012 Px(baseline_y_local),
4013 )));
4014 fill_commands.push(PathCommand::Close);
4015
4016 let _ = self.path_cache.prepare(
4017 cx.services,
4018 mark_path_cache_key(node.id, 1),
4019 &fill_commands,
4020 PathStyle::Fill(fret_core::FillStyle::default()),
4021 constraints,
4022 );
4023 fill_prepared = true;
4024 }
4025 };
4026 let fill_alpha = fill_prepared.then_some(self.style.area_fill_color.a);
4027
4028 let mark_id = node.id;
4029 self.cached_paths.insert(
4030 mark_id,
4031 CachedPath {
4032 fill_alpha,
4033 order: node.order.0,
4034 source_series: node.source_series,
4035 },
4036 );
4037 }
4038
4039 for (series_id, segments) in band_segments {
4040 for seg in segments {
4041 let (Some(lower_range), Some(upper_range), Some(lower_id)) =
4042 (seg.lower, seg.upper, seg.lower_id)
4043 else {
4044 continue;
4045 };
4046
4047 if upper_range.end <= upper_range.start || lower_range.end <= lower_range.start {
4048 continue;
4049 }
4050 if upper_range.end > marks.arena.points.len()
4051 || lower_range.end > marks.arena.points.len()
4052 {
4053 continue;
4054 }
4055
4056 let upper_points = &marks.arena.points[upper_range.start..upper_range.end];
4057 let lower_points = &marks.arena.points[lower_range.start..lower_range.end];
4058 if upper_points.len() < 2 || lower_points.len() < 2 {
4059 continue;
4060 }
4061
4062 let mut fill_commands: Vec<PathCommand> =
4063 Vec::with_capacity(upper_points.len() + lower_points.len() + 1);
4064 let first = upper_points[0];
4065 fill_commands.push(PathCommand::MoveTo(fret_core::Point::new(
4066 Px(first.x.0 - origin.x.0),
4067 Px(first.y.0 - origin.y.0),
4068 )));
4069 for p in &upper_points[1..] {
4070 fill_commands.push(PathCommand::LineTo(fret_core::Point::new(
4071 Px(p.x.0 - origin.x.0),
4072 Px(p.y.0 - origin.y.0),
4073 )));
4074 }
4075 for p in lower_points.iter().rev() {
4076 fill_commands.push(PathCommand::LineTo(fret_core::Point::new(
4077 Px(p.x.0 - origin.x.0),
4078 Px(p.y.0 - origin.y.0),
4079 )));
4080 }
4081 fill_commands.push(PathCommand::Close);
4082
4083 let fill_alpha = match model.series.get(&series_id).map(|s| s.kind) {
4084 Some(delinea::SeriesKind::Band) => self.style.band_fill_color.a,
4085 Some(delinea::SeriesKind::Area) => self.style.area_fill_color.a,
4086 _ => self.style.area_fill_color.a,
4087 };
4088
4089 let constraints = PathConstraints {
4090 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
4091 };
4092
4093 if self.cached_paths.contains_key(&lower_id) {
4094 let _ = self.path_cache.prepare(
4095 cx.services,
4096 mark_path_cache_key(lower_id, 1),
4097 &fill_commands,
4098 PathStyle::Fill(fret_core::FillStyle::default()),
4099 constraints,
4100 );
4101
4102 if let Some(cached) = self.cached_paths.get_mut(&lower_id) {
4103 cached.fill_alpha = Some(fill_alpha);
4104 }
4105 }
4106 }
4107 }
4108
4109 for node in &marks.nodes {
4110 if let Some(series_id) = node.source_series
4111 && !self.series_is_in_view_grid(&model, series_id)
4112 {
4113 continue;
4114 }
4115
4116 if node.kind != MarkKind::Rect {
4117 continue;
4118 }
4119 let MarkPayloadRef::Rect(rects) = &node.payload else {
4120 continue;
4121 };
4122 let start = rects.rects.start;
4123 let end = rects.rects.end;
4124 if end <= start || end > marks.arena.rects.len() {
4125 continue;
4126 }
4127 self.cached_rects.reserve(end - start);
4128 let stroke_width = rects
4129 .stroke
4130 .as_ref()
4131 .map(|(_, s)| s.width)
4132 .filter(|w| w.0.is_finite() && w.0 > 0.0);
4133 for rect in &marks.arena.rects[start..end] {
4134 self.cached_rects.push(CachedRect {
4135 rect: *rect,
4136 order: node.order.0,
4137 source_series: node.source_series,
4138 fill: rects.fill,
4139 opacity_mul: rects.opacity_mul.unwrap_or(1.0),
4140 stroke_width,
4141 });
4142 }
4143 }
4144
4145 for node in &marks.nodes {
4146 if let Some(series_id) = node.source_series
4147 && !self.series_is_in_view_grid(&model, series_id)
4148 {
4149 continue;
4150 }
4151
4152 if node.kind != MarkKind::Points {
4153 continue;
4154 }
4155 let MarkPayloadRef::Points(points) = &node.payload else {
4156 continue;
4157 };
4158 let start = points.points.start;
4159 let end = points.points.end;
4160 if end <= start || end > marks.arena.points.len() {
4161 continue;
4162 }
4163 self.cached_points.reserve(end - start);
4164 let stroke_width = points
4165 .stroke
4166 .as_ref()
4167 .map(|(_, s)| s.width)
4168 .filter(|w| w.0.is_finite() && w.0 > 0.0);
4169 for p in &marks.arena.points[start..end] {
4170 let radius_mul = points
4171 .radius_mul
4172 .filter(|v| v.is_finite() && *v > 0.0)
4173 .unwrap_or(1.0);
4174 self.cached_points.push(CachedPoint {
4175 point: *p,
4176 order: node.order.0,
4177 source_series: node.source_series,
4178 fill: points.fill,
4179 opacity_mul: points.opacity_mul.unwrap_or(1.0),
4180 radius_mul,
4181 stroke_width,
4182 });
4183 }
4184 }
4185
4186 self.a11y_index.rebuild(&marks, &self.series_rank_by_id);
4187 self.a11y_index_rev = marks.revision.0;
4188 }
4189}
4190
4191impl<H: UiHost> Widget<H> for ChartCanvas {
4192 fn semantics(&mut self, cx: &mut fret_ui::retained_bridge::SemanticsCx<'_, H>) {
4193 cx.set_role(fret_core::SemanticsRole::Viewport);
4194
4195 if let Some(id) = self.semantics_test_id.as_deref() {
4196 cx.set_test_id(id);
4197 } else {
4198 match (self.mode, self.grid_override) {
4199 (ChartCanvasMode::GridView, Some(grid)) => {
4200 cx.set_test_id(format!("fret-chart-grid-{}", grid.0));
4201 }
4202 (ChartCanvasMode::GridView, None) => {}
4203 (ChartCanvasMode::Overlay, _) => {
4204 cx.set_test_id("fret-chart-overlay");
4205 }
4206 (ChartCanvasMode::Full, _) => {}
4207 }
4208 }
4209
4210 if self.accessibility_layer {
4211 self.ensure_a11y_index();
4212 cx.set_focusable(true);
4213 cx.set_label("Chart");
4214
4215 let first = self
4216 .a11y_index
4217 .series_by_index
4218 .iter()
4219 .next()
4220 .and_then(|(data_index, series)| Some((*series.first()?, *data_index)));
4221
4222 let engine_hit = self.with_engine(|engine| {
4223 engine
4224 .output()
4225 .axis_pointer
4226 .as_ref()
4227 .and_then(|o| o.hit)
4228 .map(|hit| (hit.series, hit.data_index))
4229 });
4230
4231 let series_order = self.with_engine(|engine| engine.model().series_order.clone());
4232 let fallback_first = series_order
4233 .into_iter()
4234 .find(|s| self.series_row_count(*s).is_some_and(|n| n > 0))
4235 .map(|s| (s, 0));
4236
4237 let current = self
4238 .a11y_last_key
4239 .or(engine_hit)
4240 .or(first)
4241 .or(fallback_first);
4242 if let Some((series, data_index)) = current {
4243 if let Some(indices) = self.a11y_index.indices_by_series.get(&series) {
4244 let set_size = u32::try_from(indices.len()).ok().filter(|n| *n > 0);
4245 let pos_in_set = indices
4246 .binary_search(&data_index)
4247 .ok()
4248 .and_then(|pos| u32::try_from(pos + 1).ok());
4249
4250 if let (Some(pos_in_set), Some(set_size)) = (pos_in_set, set_size) {
4251 cx.set_collection_position(Some(pos_in_set), Some(set_size));
4252 }
4253 } else if let Some(set_size) = self.series_row_count(series).filter(|n| *n > 0) {
4254 let clamped = data_index.min(set_size.saturating_sub(1));
4255 let pos_in_set = clamped.saturating_add(1);
4256 cx.set_collection_position(Some(pos_in_set), Some(set_size));
4257 }
4258 }
4259
4260 let tooltip_text = {
4261 let formatter = &self.tooltip_formatter;
4262 self.with_engine(|engine| {
4263 let output = engine.output();
4264 let axis_pointer = output.axis_pointer.as_ref()?;
4265
4266 let mut parts: Vec<String> = Vec::new();
4267 match &axis_pointer.tooltip {
4268 delinea::TooltipOutput::Item(item) => {
4269 let x_window = output
4270 .axis_windows
4271 .get(&item.x_axis)
4272 .copied()
4273 .unwrap_or_default();
4274 let x_label = engine
4275 .model()
4276 .axes
4277 .get(&item.x_axis)
4278 .and_then(|axis| axis.name.as_deref())
4279 .unwrap_or("X");
4280 let x_value = delinea::engine::axis::format_value_for(
4281 engine.model(),
4282 item.x_axis,
4283 x_window,
4284 item.x_value,
4285 );
4286 parts.push(format!("{x_label}: {x_value}"));
4287 }
4288 delinea::TooltipOutput::Axis(axis) => {
4289 let axis_window = output
4290 .axis_windows
4291 .get(&axis.axis)
4292 .copied()
4293 .unwrap_or_default();
4294 let axis_label = engine
4295 .model()
4296 .axes
4297 .get(&axis.axis)
4298 .and_then(|axis| axis.name.as_deref())
4299 .unwrap_or("Axis");
4300 let axis_value = delinea::engine::axis::format_value_for(
4301 engine.model(),
4302 axis.axis,
4303 axis_window,
4304 axis.axis_value,
4305 );
4306 parts.push(format!("{axis_label}: {axis_value}"));
4307 }
4308 }
4309
4310 let lines =
4311 formatter.format_axis_pointer(engine, &output.axis_windows, axis_pointer);
4312 for line in lines {
4313 parts.push(if let Some((left, right)) = line.columns {
4314 format!("{left}: {right}")
4315 } else {
4316 line.text
4317 });
4318 }
4319
4320 if parts.is_empty() {
4321 return None;
4322 }
4323
4324 Some(parts.join(" | "))
4325 })
4326 };
4327
4328 if let Some(value) = tooltip_text {
4329 cx.set_value(value);
4330 }
4331 }
4332 }
4333
4334 fn render_transform(&self, _bounds: Rect) -> Option<Transform2D> {
4335 self.force_uncached_paint.then_some(Transform2D::IDENTITY)
4336 }
4337
4338 fn hit_test(&self, _bounds: Rect, position: Point) -> bool {
4339 if self.mode != ChartCanvasMode::Overlay {
4340 return true;
4341 }
4342 self.legend_panel_rect
4343 .is_some_and(|rect| rect.contains(position))
4344 }
4345
4346 fn event(&mut self, cx: &mut EventCx<'_, H>, event: &Event) {
4347 match event {
4348 Event::KeyDown { key, modifiers, .. } => {
4349 let plain = !modifiers.shift
4350 && !modifiers.ctrl
4351 && !modifiers.alt
4352 && !modifiers.alt_gr
4353 && !modifiers.meta;
4354
4355 if plain && self.handle_accessibility_navigation(cx, *key) {
4356 return;
4357 }
4358
4359 let lock_mods_ok = !modifiers.alt && !modifiers.alt_gr && !modifiers.meta;
4360 let legend_mods_ok =
4361 modifiers.ctrl && !modifiers.alt && !modifiers.alt_gr && !modifiers.meta;
4362 let legend_pos = self.last_pointer_pos;
4363 let in_legend = legend_pos.is_some_and(|pos| {
4364 self.legend_panel_rect
4365 .is_some_and(|rect| rect.contains(pos))
4366 }) || self.legend_hover.is_some()
4367 || self.legend_selector_hover.is_some();
4368
4369 if lock_mods_ok && *key == KeyCode::KeyL {
4370 let Some(pos) = self.last_pointer_pos else {
4371 return;
4372 };
4373
4374 let toggle_pan = modifiers.shift && !modifiers.ctrl;
4375 let toggle_zoom = modifiers.ctrl && !modifiers.shift;
4376 let toggle_both = !toggle_pan && !toggle_zoom;
4377
4378 let layout = self.compute_layout(cx.bounds);
4379 self.update_active_axes_for_position(&layout, pos);
4380 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
4381 return;
4382 };
4383 match Self::axis_region(&layout, pos) {
4384 AxisRegion::XAxis(axis) => {
4385 if toggle_both || toggle_pan {
4386 self.with_engine_mut(|engine| {
4387 engine.apply_action(Action::ToggleAxisPanLock { axis });
4388 });
4389 }
4390 if toggle_both || toggle_zoom {
4391 self.with_engine_mut(|engine| {
4392 engine.apply_action(Action::ToggleAxisZoomLock { axis });
4393 });
4394 }
4395 }
4396 AxisRegion::YAxis(axis) => {
4397 if toggle_both || toggle_pan {
4398 self.with_engine_mut(|engine| {
4399 engine.apply_action(Action::ToggleAxisPanLock { axis });
4400 });
4401 }
4402 if toggle_both || toggle_zoom {
4403 self.with_engine_mut(|engine| {
4404 engine.apply_action(Action::ToggleAxisZoomLock { axis });
4405 });
4406 }
4407 }
4408 AxisRegion::Plot => {
4409 if toggle_both || toggle_pan {
4410 self.with_engine_mut(|engine| {
4411 engine.apply_action(Action::ToggleAxisPanLock { axis: x_axis });
4412 engine.apply_action(Action::ToggleAxisPanLock { axis: y_axis });
4413 });
4414 }
4415 if toggle_both || toggle_zoom {
4416 self.with_engine_mut(|engine| {
4417 engine
4418 .apply_action(Action::ToggleAxisZoomLock { axis: x_axis });
4419 engine
4420 .apply_action(Action::ToggleAxisZoomLock { axis: y_axis });
4421 });
4422 }
4423 }
4424 }
4425
4426 self.pan_drag = None;
4427 self.box_zoom_drag = None;
4428 self.clear_brush();
4429 self.clear_slider_drag();
4430 if cx.captured == Some(cx.node) {
4431 cx.release_pointer_capture();
4432 }
4433 cx.invalidate_self(Invalidation::Paint);
4434 cx.request_redraw();
4435 cx.stop_propagation();
4436 return;
4437 }
4438
4439 if legend_mods_ok && in_legend {
4440 let mut changed = false;
4441 if modifiers.shift && *key == KeyCode::KeyA {
4442 changed = self.apply_legend_select_none();
4443 } else if !modifiers.shift && *key == KeyCode::KeyA {
4444 changed = self.apply_legend_select_all();
4445 } else if !modifiers.shift && *key == KeyCode::KeyI {
4446 changed = self.apply_legend_invert();
4447 }
4448
4449 if changed {
4450 self.legend_anchor = None;
4451 cx.invalidate_self(Invalidation::Paint);
4452 cx.request_redraw();
4453 cx.stop_propagation();
4454 return;
4455 }
4456 }
4457
4458 if plain && *key == KeyCode::KeyR {
4459 let layout = self.compute_layout(cx.bounds);
4460 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
4461 return;
4462 };
4463 self.reset_view_for_axes(x_axis, y_axis);
4464 self.pan_drag = None;
4465 self.box_zoom_drag = None;
4466 self.clear_brush();
4467 self.clear_slider_drag();
4468 if cx.captured == Some(cx.node) {
4469 cx.release_pointer_capture();
4470 }
4471 cx.invalidate_self(Invalidation::Paint);
4472 cx.request_redraw();
4473 cx.stop_propagation();
4474 return;
4475 }
4476
4477 if plain && *key == KeyCode::KeyF {
4478 let layout = self.compute_layout(cx.bounds);
4479 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
4480 return;
4481 };
4482 self.fit_view_to_data_for_axes(x_axis, y_axis);
4483 self.pan_drag = None;
4484 self.box_zoom_drag = None;
4485 self.clear_brush();
4486 self.clear_slider_drag();
4487 if cx.captured == Some(cx.node) {
4488 cx.release_pointer_capture();
4489 }
4490 cx.invalidate_self(Invalidation::Paint);
4491 cx.request_redraw();
4492 cx.stop_propagation();
4493 return;
4494 }
4495
4496 if plain && *key == KeyCode::KeyM {
4497 let layout = self.compute_layout(cx.bounds);
4498 let Some((x_axis, _y_axis)) = self.active_axes(&layout) else {
4499 return;
4500 };
4501
4502 self.toggle_data_window_x_filter_mode(x_axis);
4503 self.pan_drag = None;
4504 self.box_zoom_drag = None;
4505 self.clear_brush();
4506 self.clear_slider_drag();
4507 if cx.captured == Some(cx.node) {
4508 cx.release_pointer_capture();
4509 }
4510 cx.invalidate_self(Invalidation::Paint);
4511 cx.request_redraw();
4512 cx.stop_propagation();
4513 return;
4514 }
4515
4516 if plain && *key == KeyCode::KeyA {
4517 self.clear_brush();
4518 self.clear_slider_drag();
4519 cx.invalidate_self(Invalidation::Paint);
4520 cx.request_redraw();
4521 cx.stop_propagation();
4522 }
4523 }
4524 Event::Pointer(PointerEvent::Move {
4525 position, buttons, ..
4526 }) => {
4527 self.last_pointer_pos = Some(*position);
4528 let layout = self.compute_layout(cx.bounds);
4529 self.update_active_axes_for_position(&layout, *position);
4530
4531 let prev_series_hover = self.legend_hover;
4532 let prev_selector_hover = self.legend_selector_hover;
4533 self.legend_selector_hover = self.legend_selector_at(*position);
4534 self.legend_hover = if self.legend_selector_hover.is_some() {
4535 None
4536 } else {
4537 self.legend_series_at(*position)
4538 };
4539 if self.legend_hover != prev_series_hover
4540 || self.legend_selector_hover != prev_selector_hover
4541 {
4542 cx.invalidate_self(Invalidation::Paint);
4543 cx.request_redraw();
4544 }
4545
4546 if cx.captured == Some(cx.node) {
4547 if let Some(drag) = self.visual_map_drag
4548 && Self::is_button_held(MouseButton::Left, *buttons)
4549 {
4550 let current_value = delinea::engine::axis::data_at_y_in_rect(
4551 drag.domain,
4552 position.y.0,
4553 drag.track,
4554 );
4555 let delta_value = current_value - drag.start_value;
4556 let window = Self::slider_window_after_delta(
4557 drag.domain,
4558 drag.start_window,
4559 delta_value,
4560 drag.kind,
4561 );
4562 self.with_engine_mut(|engine| {
4563 engine.apply_action(Action::SetVisualMapRange {
4564 visual_map: drag.visual_map,
4565 range: Some((window.min, window.max)),
4566 });
4567 });
4568 cx.invalidate_self(Invalidation::Paint);
4569 cx.request_redraw();
4570 cx.stop_propagation();
4571 return;
4572 }
4573
4574 if let Some(drag) = self.slider_drag
4575 && Self::is_button_held(MouseButton::Left, *buttons)
4576 {
4577 let track = drag.track;
4578 let extent = drag.extent;
4579 let span = extent.span();
4580 match drag.axis_kind {
4581 SliderAxisKind::X => {
4582 if track.size.width.0 > 0.0 && span.is_finite() && span > 0.0 {
4583 let x = position.x.0.clamp(
4584 track.origin.x.0,
4585 track.origin.x.0 + track.size.width.0,
4586 );
4587 let start_x = drag.start_pos.x.0.clamp(
4588 track.origin.x.0,
4589 track.origin.x.0 + track.size.width.0,
4590 );
4591 let delta_px = x - start_x;
4592 let delta_value = (delta_px / track.size.width.0) as f64 * span;
4593
4594 let window = Self::slider_window_after_delta(
4595 extent,
4596 drag.start_window,
4597 delta_value,
4598 drag.kind,
4599 );
4600 let anchor = match drag.kind {
4601 SliderDragKind::HandleMin => WindowSpanAnchor::LockMax,
4602 SliderDragKind::HandleMax => WindowSpanAnchor::LockMin,
4603 SliderDragKind::Pan => WindowSpanAnchor::Center,
4604 };
4605 self.with_engine_mut(|engine| {
4606 engine.apply_action(Action::SetDataWindowXFromZoom {
4607 axis: drag.axis,
4608 base: drag.start_window,
4609 window,
4610 anchor,
4611 });
4612 });
4613
4614 self.slider_drag = Some(DataZoomSliderDrag {
4615 start_pos: *position,
4616 start_window: window,
4617 ..drag
4618 });
4619 cx.invalidate_self(Invalidation::Paint);
4620 cx.request_redraw();
4621 cx.stop_propagation();
4622 return;
4623 }
4624 }
4625 SliderAxisKind::Y => {
4626 if track.size.height.0 > 0.0 && span.is_finite() && span > 0.0 {
4627 let height = track.size.height.0;
4628 let bottom = track.origin.y.0 + height;
4629
4630 let y = position.y.0.clamp(track.origin.y.0, bottom);
4631 let start_y =
4632 drag.start_pos.y.0.clamp(track.origin.y.0, bottom);
4633
4634 let y_from_bottom = bottom - y;
4635 let start_from_bottom = bottom - start_y;
4636 let delta_px = y_from_bottom - start_from_bottom;
4637 let delta_value = (delta_px / height) as f64 * span;
4638
4639 let window = Self::slider_window_after_delta(
4640 extent,
4641 drag.start_window,
4642 delta_value,
4643 drag.kind,
4644 );
4645 let anchor = match drag.kind {
4646 SliderDragKind::HandleMin => WindowSpanAnchor::LockMax,
4647 SliderDragKind::HandleMax => WindowSpanAnchor::LockMin,
4648 SliderDragKind::Pan => WindowSpanAnchor::Center,
4649 };
4650 self.with_engine_mut(|engine| {
4651 engine.apply_action(Action::SetDataWindowYFromZoom {
4652 axis: drag.axis,
4653 base: drag.start_window,
4654 window,
4655 anchor,
4656 });
4657 });
4658
4659 self.slider_drag = Some(DataZoomSliderDrag {
4660 start_pos: *position,
4661 start_window: window,
4662 ..drag
4663 });
4664 cx.invalidate_self(Invalidation::Paint);
4665 cx.request_redraw();
4666 cx.stop_propagation();
4667 return;
4668 }
4669 }
4670 }
4671 return;
4672 }
4673
4674 if let Some(mut drag) = self.box_zoom_drag
4675 && Self::is_button_held(drag.button, *buttons)
4676 {
4677 drag.current_pos = *position;
4678 self.box_zoom_drag = Some(drag);
4679 cx.invalidate_self(Invalidation::Paint);
4680 cx.request_redraw();
4681 cx.stop_propagation();
4682 return;
4683 }
4684
4685 if let Some(mut drag) = self.brush_drag
4686 && Self::is_button_held(drag.button, *buttons)
4687 {
4688 drag.current_pos = *position;
4689 self.brush_drag = Some(drag);
4690 cx.invalidate_self(Invalidation::Paint);
4691 cx.request_redraw();
4692 cx.stop_propagation();
4693 return;
4694 }
4695
4696 if let Some(drag) = self.pan_drag
4697 && buttons.left
4698 {
4699 let layout = self.compute_layout(cx.bounds);
4700 let width = layout.plot.size.width.0;
4701 let height = layout.plot.size.height.0;
4702 if width <= 0.0 || height <= 0.0 {
4703 return;
4704 }
4705
4706 let dx = position.x.0 - drag.start_pos.x.0;
4707 let dy = position.y.0 - drag.start_pos.y.0;
4708
4709 let (x_pan_locked, y_pan_locked) = self.with_engine(|engine| {
4710 let x_pan_locked = engine
4711 .state()
4712 .axis_locks
4713 .get(&drag.x_axis)
4714 .copied()
4715 .unwrap_or_default()
4716 .pan_locked;
4717 let y_pan_locked = engine
4718 .state()
4719 .axis_locks
4720 .get(&drag.y_axis)
4721 .copied()
4722 .unwrap_or_default()
4723 .pan_locked;
4724 (x_pan_locked, y_pan_locked)
4725 });
4726
4727 if drag.pan_x && self.axis_is_fixed(drag.x_axis).is_none() && !x_pan_locked
4728 {
4729 self.with_engine_mut(|engine| {
4730 engine.apply_action(Action::PanDataWindowXFromBase {
4731 axis: drag.x_axis,
4732 base: drag.start_x,
4733 delta_px: dx,
4734 viewport_span_px: width,
4735 });
4736 });
4737 }
4738 if drag.pan_y && self.axis_is_fixed(drag.y_axis).is_none() && !y_pan_locked
4739 {
4740 self.with_engine_mut(|engine| {
4741 engine.apply_action(Action::PanDataWindowYFromBase {
4742 axis: drag.y_axis,
4743 base: drag.start_y,
4744 delta_px: -dy,
4745 viewport_span_px: height,
4746 });
4747 });
4748 }
4749
4750 self.refresh_hover_for_axis_pointer(&layout, *position);
4751 cx.invalidate_self(Invalidation::Paint);
4752 cx.request_redraw();
4753 cx.stop_propagation();
4754 return;
4755 }
4756 }
4757
4758 let hover_point = Self::axis_pointer_hover_point(&layout, *position);
4759 self.with_engine_mut(|engine| {
4760 engine.apply_action(Action::HoverAt { point: hover_point });
4761 });
4762 cx.invalidate_self(Invalidation::Paint);
4763 cx.request_redraw();
4764 }
4765 Event::Pointer(PointerEvent::Down {
4766 position,
4767 button,
4768 modifiers,
4769 click_count,
4770 pointer_type,
4771 ..
4772 }) => {
4773 self.last_pointer_pos = Some(*position);
4774 let layout = self.compute_layout(cx.bounds);
4775 self.update_active_axes_for_position(&layout, *position);
4776
4777 if *button == MouseButton::Left
4778 && self.pan_drag.is_none()
4779 && self.box_zoom_drag.is_none()
4780 && let Some(action) = self.legend_selector_at(*position)
4781 {
4782 let _changed = match action {
4783 LegendSelectorAction::All => self.apply_legend_select_all(),
4784 LegendSelectorAction::None => self.apply_legend_select_none(),
4785 LegendSelectorAction::Invert => self.apply_legend_invert(),
4786 };
4787 self.legend_anchor = None;
4788 self.legend_hover = None;
4789 self.legend_selector_hover = Some(action);
4790 cx.invalidate_self(Invalidation::Paint);
4791 cx.request_redraw();
4792 cx.stop_propagation();
4793 return;
4794 }
4795
4796 if *button == MouseButton::Left
4797 && self.pan_drag.is_none()
4798 && self.box_zoom_drag.is_none()
4799 && let Some(series) = self.legend_series_at(*position)
4800 {
4801 if *click_count >= 2 {
4802 self.apply_legend_double_click(series);
4803 } else if modifiers.shift
4804 && let Some(anchor) = self.legend_anchor
4805 {
4806 self.apply_legend_shift_range_toggle(anchor, series);
4807 } else {
4808 let visible = self.with_engine(|engine| {
4809 engine
4810 .model()
4811 .series
4812 .get(&series)
4813 .map(|s| s.visible)
4814 .unwrap_or(true)
4815 });
4816 self.with_engine_mut(|engine| {
4817 engine.apply_action(Action::SetSeriesVisible {
4818 series,
4819 visible: !visible,
4820 });
4821 });
4822 }
4823 self.legend_anchor = Some(series);
4824 self.legend_hover = Some(series);
4825 cx.invalidate_self(Invalidation::Paint);
4826 cx.request_redraw();
4827 cx.stop_propagation();
4828 return;
4829 }
4830
4831 if *button == MouseButton::Right
4832 && self.pan_drag.is_none()
4833 && self.box_zoom_drag.is_none()
4834 && self
4835 .legend_panel_rect
4836 .is_some_and(|r| r.contains(*position))
4837 {
4838 self.apply_legend_reset();
4839 self.legend_anchor = None;
4840 cx.invalidate_self(Invalidation::Paint);
4841 cx.request_redraw();
4842 cx.stop_propagation();
4843 return;
4844 }
4845
4846 if *pointer_type == PointerType::Mouse
4847 && cx.captured.is_none()
4848 && self.pan_drag.is_none()
4849 && self.box_zoom_drag.is_none()
4850 && self.brush_drag.is_none()
4851 && self.slider_drag.is_none()
4852 && let Some((vm_id, vm, track)) = self.visual_map_track_at(*position)
4853 && (*button == MouseButton::Left
4854 || (vm.mode == delinea::VisualMapMode::Piecewise
4855 && *button == MouseButton::Right))
4856 {
4857 let domain = Self::visual_map_domain_window(vm);
4858 let click_value =
4859 delinea::engine::axis::data_at_y_in_rect(domain, position.y.0, track);
4860
4861 if vm.mode == delinea::VisualMapMode::Piecewise {
4862 let buckets = vm.buckets.clamp(1, 64) as u32;
4863 let full_mask = if buckets >= 64 {
4864 u64::MAX
4865 } else {
4866 (1u64 << buckets) - 1
4867 };
4868
4869 let bucket =
4870 delinea::visual_map::bucket_index_for_value(&vm, click_value) as u32;
4871 let bit = 1u64 << bucket.min(63);
4872 let current = self.current_visual_map_piece_mask(vm_id, vm);
4873
4874 let wants_reset = (*button == MouseButton::Right
4875 && !modifiers.alt
4876 && !modifiers.ctrl
4877 && !modifiers.meta
4878 && !modifiers.alt_gr)
4879 || (*button == MouseButton::Left && *click_count == 2);
4880 if wants_reset {
4881 self.with_engine_mut(|engine| {
4882 engine.apply_action(Action::SetVisualMapPieceMask {
4883 visual_map: vm_id,
4884 mask: None,
4885 });
4886 });
4887 self.visual_map_piece_anchor = None;
4888 cx.invalidate_self(Invalidation::Paint);
4889 cx.request_redraw();
4890 cx.stop_propagation();
4891 return;
4892 }
4893
4894 let is_selected = ((current >> bucket) & 1) == 1;
4895
4896 let mut next = current;
4897 if modifiers.shift {
4898 if let Some((anchor_vm, anchor_bucket)) = self.visual_map_piece_anchor
4899 && anchor_vm == vm_id
4900 {
4901 let lo = anchor_bucket.min(bucket);
4902 let hi = anchor_bucket.max(bucket).min(buckets.saturating_sub(1));
4903 let width = hi.saturating_sub(lo).saturating_add(1);
4904 let range_mask = if width >= 64 {
4905 u64::MAX
4906 } else {
4907 ((1u64 << width) - 1) << lo
4908 } & full_mask;
4909
4910 if is_selected {
4911 next &= !range_mask;
4912 } else {
4913 next |= range_mask;
4914 }
4915 } else {
4916 next ^= bit;
4917 }
4918 } else {
4919 next ^= bit;
4920 }
4921 next &= full_mask;
4922 let mask = (next != full_mask).then_some(next);
4923 self.with_engine_mut(|engine| {
4924 engine.apply_action(Action::SetVisualMapPieceMask {
4925 visual_map: vm_id,
4926 mask,
4927 });
4928 });
4929 self.visual_map_piece_anchor = Some((vm_id, bucket));
4930 cx.invalidate_self(Invalidation::Paint);
4931 cx.request_redraw();
4932 cx.stop_propagation();
4933 return;
4934 }
4935
4936 let current_window = self.current_visual_map_window(vm_id, vm);
4937
4938 let handle_hit_px = 8.0f32;
4939 let y_min = Self::visual_map_y_at_value(track, domain, current_window.min);
4940 let y_max = Self::visual_map_y_at_value(track, domain, current_window.max);
4941 let (top, bottom) = (y_max.min(y_min), y_max.max(y_min));
4942
4943 let (kind, start_window) = if (position.y.0 - y_min).abs() <= handle_hit_px {
4944 (SliderDragKind::HandleMin, current_window)
4945 } else if (position.y.0 - y_max).abs() <= handle_hit_px {
4946 (SliderDragKind::HandleMax, current_window)
4947 } else if position.y.0 >= top && position.y.0 <= bottom {
4948 (SliderDragKind::Pan, current_window)
4949 } else {
4950 let center = (current_window.min + current_window.max) * 0.5;
4951 let delta = click_value - center;
4952 (
4953 SliderDragKind::Pan,
4954 Self::slider_window_after_delta(
4955 domain,
4956 current_window,
4957 delta,
4958 SliderDragKind::Pan,
4959 ),
4960 )
4961 };
4962
4963 self.with_engine_mut(|engine| {
4964 engine.apply_action(Action::SetVisualMapRange {
4965 visual_map: vm_id,
4966 range: Some((start_window.min, start_window.max)),
4967 });
4968 });
4969 self.visual_map_drag = Some(VisualMapDrag {
4970 visual_map: vm_id,
4971 kind,
4972 track,
4973 domain,
4974 start_window,
4975 start_value: click_value,
4976 });
4977
4978 cx.capture_pointer(cx.node);
4979 cx.invalidate_self(Invalidation::Paint);
4980 cx.request_redraw();
4981 cx.stop_propagation();
4982 return;
4983 }
4984
4985 if *pointer_type == PointerType::Mouse
4986 && *button == MouseButton::Left
4987 && *click_count == 2
4988 && !modifiers.shift
4989 && !modifiers.ctrl
4990 && !modifiers.alt
4991 && !modifiers.alt_gr
4992 && !modifiers.meta
4993 {
4994 let layout = self.compute_layout(cx.bounds);
4995 match Self::axis_region(&layout, *position) {
4996 AxisRegion::XAxis(axis) => {
4997 self.active_x_axis = Some(axis);
4998 if self.axis_is_fixed(axis).is_none() {
4999 self.set_data_window_x(axis, None);
5000 }
5001 }
5002 AxisRegion::YAxis(axis) => {
5003 self.active_y_axis = Some(axis);
5004 if self.axis_is_fixed(axis).is_none() {
5005 self.set_data_window_y(axis, None);
5006 }
5007 }
5008 AxisRegion::Plot => {
5009 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
5010 return;
5011 };
5012 self.reset_view_for_axes(x_axis, y_axis);
5013 }
5014 }
5015
5016 self.pan_drag = None;
5017 self.box_zoom_drag = None;
5018 if cx.captured == Some(cx.node) {
5019 cx.release_pointer_capture();
5020 }
5021 cx.invalidate_self(Invalidation::Paint);
5022 cx.request_redraw();
5023 cx.stop_propagation();
5024 return;
5025 }
5026
5027 if let Some(cancel) = self.input_map.box_zoom_cancel
5028 && self.box_zoom_drag.is_some()
5029 && cancel.matches(*button, *modifiers)
5030 {
5031 self.box_zoom_drag = None;
5032 if cx.captured == Some(cx.node) {
5033 cx.release_pointer_capture();
5034 }
5035 cx.invalidate_self(Invalidation::Paint);
5036 cx.request_redraw();
5037 cx.stop_propagation();
5038 return;
5039 }
5040
5041 if self.input_map.axis_lock_toggle.matches(*button, *modifiers) {
5042 let layout = self.compute_layout(cx.bounds);
5043 self.update_active_axes_for_position(&layout, *position);
5044 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
5045 return;
5046 };
5047 match Self::axis_region(&layout, *position) {
5048 AxisRegion::XAxis(axis) => {
5049 self.active_x_axis = Some(axis);
5050 self.with_engine_mut(|engine| {
5051 engine.apply_action(Action::ToggleAxisPanLock { axis });
5052 engine.apply_action(Action::ToggleAxisZoomLock { axis });
5053 });
5054 }
5055 AxisRegion::YAxis(axis) => {
5056 self.active_y_axis = Some(axis);
5057 self.with_engine_mut(|engine| {
5058 engine.apply_action(Action::ToggleAxisPanLock { axis });
5059 engine.apply_action(Action::ToggleAxisZoomLock { axis });
5060 });
5061 }
5062 AxisRegion::Plot => {
5063 self.with_engine_mut(|engine| {
5064 engine.apply_action(Action::ToggleAxisPanLock { axis: x_axis });
5065 engine.apply_action(Action::ToggleAxisZoomLock { axis: x_axis });
5066 engine.apply_action(Action::ToggleAxisPanLock { axis: y_axis });
5067 engine.apply_action(Action::ToggleAxisZoomLock { axis: y_axis });
5068 });
5069 }
5070 }
5071
5072 cx.request_focus(cx.node);
5073 cx.invalidate_self(Invalidation::Paint);
5074 cx.request_redraw();
5075 cx.stop_propagation();
5076 return;
5077 }
5078
5079 if self.pan_drag.is_some() || self.box_zoom_drag.is_some() {
5080 return;
5081 }
5082 if self.brush_drag.is_some() {
5083 return;
5084 }
5085 if self.slider_drag.is_some() {
5086 return;
5087 }
5088
5089 if *button == MouseButton::Left {
5091 let layout = self.compute_layout(cx.bounds);
5092 let region = Self::axis_region(&layout, *position);
5093 match region {
5094 AxisRegion::XAxis(axis) => {
5095 let zoom_locked = self.with_engine(|engine| {
5096 engine
5097 .state()
5098 .axis_locks
5099 .get(&axis)
5100 .copied()
5101 .unwrap_or_default()
5102 .zoom_locked
5103 });
5104 if zoom_locked || self.axis_is_fixed(axis).is_some() {
5105 return;
5106 }
5107
5108 let (locked_min, locked_max) = self.axis_constraints(axis);
5109 let can_pan = locked_min.is_none() && locked_max.is_none();
5110 let can_handle_min = locked_min.is_none();
5111 let can_handle_max = locked_max.is_none();
5112
5113 if let Some(track) = self.x_slider_track_for_axis(axis)
5114 && track.contains(*position)
5115 {
5116 let extent = self.compute_axis_extent_from_data(axis, true);
5117 let window = self.current_window_x_for_slider(axis, extent);
5118
5119 let t0 = Self::slider_norm(extent, window.min);
5120 let t1 = Self::slider_norm(extent, window.max);
5121 let left = track.origin.x.0 + t0 * track.size.width.0;
5122 let right = track.origin.x.0 + t1 * track.size.width.0;
5123
5124 let handle_hit_px = 7.0f32;
5125 let x = position.x.0;
5126 let kind = if (x - left).abs() <= handle_hit_px {
5127 SliderDragKind::HandleMin
5128 } else if (x - right).abs() <= handle_hit_px {
5129 SliderDragKind::HandleMax
5130 } else if x >= left && x <= right {
5131 SliderDragKind::Pan
5132 } else {
5133 SliderDragKind::Pan
5135 };
5136
5137 if matches!(kind, SliderDragKind::Pan) && !can_pan {
5138 return;
5139 }
5140 if matches!(kind, SliderDragKind::HandleMin) && !can_handle_min {
5141 return;
5142 }
5143 if matches!(kind, SliderDragKind::HandleMax) && !can_handle_max {
5144 return;
5145 }
5146
5147 let start_window = if matches!(kind, SliderDragKind::Pan)
5148 && !(x >= left && x <= right)
5149 {
5150 let click_value = Self::slider_value_at(track, extent, x);
5151 let half = 0.5 * window.span();
5152 let start_window = DataWindow {
5153 min: click_value - half,
5154 max: click_value + half,
5155 };
5156 Self::slider_window_after_delta(
5157 extent,
5158 start_window,
5159 0.0,
5160 SliderDragKind::Pan,
5161 )
5162 } else {
5163 window
5164 };
5165
5166 self.slider_drag = Some(DataZoomSliderDrag {
5167 axis_kind: SliderAxisKind::X,
5168 axis,
5169 kind,
5170 track,
5171 extent,
5172 start_pos: *position,
5173 start_window,
5174 });
5175
5176 cx.request_focus(cx.node);
5177 cx.capture_pointer(cx.node);
5178 cx.invalidate_self(Invalidation::Paint);
5179 cx.request_redraw();
5180 cx.stop_propagation();
5181 return;
5182 }
5183 }
5184 AxisRegion::YAxis(axis) => {
5185 let zoom_locked = self.with_engine(|engine| {
5186 engine
5187 .state()
5188 .axis_locks
5189 .get(&axis)
5190 .copied()
5191 .unwrap_or_default()
5192 .zoom_locked
5193 });
5194 if zoom_locked || self.axis_is_fixed(axis).is_some() {
5195 return;
5196 }
5197
5198 let (locked_min, locked_max) = self.axis_constraints(axis);
5199 let can_pan = locked_min.is_none() && locked_max.is_none();
5200 let can_handle_min = locked_min.is_none();
5201 let can_handle_max = locked_max.is_none();
5202
5203 if let Some(track) = self.y_slider_track_for_axis(axis)
5204 && track.contains(*position)
5205 {
5206 let extent = self.compute_axis_extent_from_data(axis, false);
5207 let window = self.current_window_y_for_slider(axis, extent);
5208
5209 let t0 = Self::slider_norm(extent, window.min);
5210 let t1 = Self::slider_norm(extent, window.max);
5211
5212 let handle_hit_px = 7.0f32;
5213 let height = track.size.height.0;
5214 let bottom = track.origin.y.0 + height;
5215 let y_from_bottom =
5216 (bottom - position.y.0).clamp(0.0, height.max(1.0));
5217
5218 let min_handle = t0 * height;
5219 let max_handle = t1 * height;
5220
5221 let kind = if (y_from_bottom - min_handle).abs() <= handle_hit_px {
5222 SliderDragKind::HandleMin
5223 } else if (y_from_bottom - max_handle).abs() <= handle_hit_px {
5224 SliderDragKind::HandleMax
5225 } else if y_from_bottom >= min_handle && y_from_bottom <= max_handle
5226 {
5227 SliderDragKind::Pan
5228 } else {
5229 SliderDragKind::Pan
5231 };
5232
5233 if matches!(kind, SliderDragKind::Pan) && !can_pan {
5234 return;
5235 }
5236 if matches!(kind, SliderDragKind::HandleMin) && !can_handle_min {
5237 return;
5238 }
5239 if matches!(kind, SliderDragKind::HandleMax) && !can_handle_max {
5240 return;
5241 }
5242
5243 let start_window = if matches!(kind, SliderDragKind::Pan)
5244 && !(y_from_bottom >= min_handle && y_from_bottom <= max_handle)
5245 {
5246 let click_value =
5247 Self::slider_value_at_y(track, extent, position.y.0);
5248 let half = 0.5 * window.span();
5249 let start_window = DataWindow {
5250 min: click_value - half,
5251 max: click_value + half,
5252 };
5253 Self::slider_window_after_delta(
5254 extent,
5255 start_window,
5256 0.0,
5257 SliderDragKind::Pan,
5258 )
5259 } else {
5260 window
5261 };
5262
5263 self.slider_drag = Some(DataZoomSliderDrag {
5264 axis_kind: SliderAxisKind::Y,
5265 axis,
5266 kind,
5267 track,
5268 extent,
5269 start_pos: *position,
5270 start_window,
5271 });
5272
5273 cx.request_focus(cx.node);
5274 cx.capture_pointer(cx.node);
5275 cx.invalidate_self(Invalidation::Paint);
5276 cx.request_redraw();
5277 cx.stop_propagation();
5278 return;
5279 }
5280 }
5281 AxisRegion::Plot => {}
5282 }
5283 }
5284
5285 let start_box_primary = self.input_map.box_zoom.matches(*button, *modifiers);
5286 let start_box_alt = self
5287 .input_map
5288 .box_zoom_alt
5289 .is_some_and(|chord| chord.matches(*button, *modifiers));
5290 if start_box_primary || start_box_alt {
5291 let layout = self.compute_layout(cx.bounds);
5292 if !layout.plot.contains(*position) {
5293 return;
5294 }
5295
5296 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
5297 return;
5298 };
5299
5300 let (x_zoom_locked, y_zoom_locked) = self.with_engine(|engine| {
5301 let x_zoom_locked = engine
5302 .state()
5303 .axis_locks
5304 .get(&x_axis)
5305 .copied()
5306 .unwrap_or_default()
5307 .zoom_locked;
5308 let y_zoom_locked = engine
5309 .state()
5310 .axis_locks
5311 .get(&y_axis)
5312 .copied()
5313 .unwrap_or_default()
5314 .zoom_locked;
5315 (x_zoom_locked, y_zoom_locked)
5316 });
5317 if x_zoom_locked || y_zoom_locked {
5318 return;
5319 }
5320
5321 if self.axis_is_fixed(x_axis).is_some() || self.axis_is_fixed(y_axis).is_some()
5322 {
5323 return;
5324 }
5325
5326 let required_mods = if start_box_primary {
5327 self.input_map.box_zoom.modifiers
5328 } else {
5329 self.input_map
5330 .box_zoom_alt
5331 .unwrap_or(self.input_map.box_zoom)
5332 .modifiers
5333 };
5334
5335 let start_x = self.current_window_x(x_axis);
5336 let start_y = self.current_window_y(y_axis);
5337
5338 self.box_zoom_drag = Some(BoxZoomDrag {
5339 x_axis,
5340 y_axis,
5341 button: *button,
5342 required_mods,
5343 start_pos: *position,
5344 current_pos: *position,
5345 start_x,
5346 start_y,
5347 });
5348
5349 cx.request_focus(cx.node);
5350 cx.capture_pointer(cx.node);
5351 cx.invalidate_self(Invalidation::Paint);
5352 cx.request_redraw();
5353 cx.stop_propagation();
5354 return;
5355 }
5356
5357 if self.input_map.brush_select.matches(*button, *modifiers) {
5358 let layout = self.compute_layout(cx.bounds);
5359 if !layout.plot.contains(*position) {
5360 return;
5361 }
5362
5363 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
5364 return;
5365 };
5366
5367 let start_x = self.current_window_x(x_axis);
5368 let start_y = self.current_window_y(y_axis);
5369
5370 self.brush_drag = Some(BoxZoomDrag {
5371 x_axis,
5372 y_axis,
5373 button: *button,
5374 required_mods: self.input_map.brush_select.modifiers,
5375 start_pos: *position,
5376 current_pos: *position,
5377 start_x,
5378 start_y,
5379 });
5380
5381 cx.request_focus(cx.node);
5382 cx.capture_pointer(cx.node);
5383 cx.invalidate_self(Invalidation::Paint);
5384 cx.request_redraw();
5385 cx.stop_propagation();
5386 return;
5387 }
5388
5389 if !self.input_map.pan.matches(*button, *modifiers) {
5390 return;
5391 }
5392
5393 let layout = self.compute_layout(cx.bounds);
5394 let region = Self::axis_region(&layout, *position);
5395 let in_plot = layout.plot.contains(*position);
5396 let in_axis = matches!(region, AxisRegion::XAxis(_) | AxisRegion::YAxis(_));
5397 if !in_plot && !in_axis {
5398 return;
5399 }
5400
5401 let Some((x_axis, y_axis)) = self.active_axes(&layout) else {
5402 return;
5403 };
5404 let (x_axis, y_axis, mut pan_x, mut pan_y) = match region {
5405 AxisRegion::Plot => (x_axis, y_axis, !modifiers.ctrl, !modifiers.shift),
5406 AxisRegion::XAxis(axis) => (axis, y_axis, true, false),
5407 AxisRegion::YAxis(axis) => (x_axis, axis, false, true),
5408 };
5409
5410 if pan_x && self.axis_is_fixed(x_axis).is_some() {
5411 pan_x = false;
5412 }
5413 if pan_y && self.axis_is_fixed(y_axis).is_some() {
5414 pan_y = false;
5415 }
5416
5417 let (x_pan_locked, y_pan_locked) = self.with_engine(|engine| {
5418 let x_pan_locked = engine
5419 .state()
5420 .axis_locks
5421 .get(&x_axis)
5422 .copied()
5423 .unwrap_or_default()
5424 .pan_locked;
5425 let y_pan_locked = engine
5426 .state()
5427 .axis_locks
5428 .get(&y_axis)
5429 .copied()
5430 .unwrap_or_default()
5431 .pan_locked;
5432 (x_pan_locked, y_pan_locked)
5433 });
5434 if pan_x && x_pan_locked {
5435 pan_x = false;
5436 }
5437 if pan_y && y_pan_locked {
5438 pan_y = false;
5439 }
5440 if !pan_x && !pan_y {
5441 return;
5442 }
5443
5444 let start_x = self.current_window_x(x_axis);
5445 let start_y = self.current_window_y(y_axis);
5446
5447 self.pan_drag = Some(PanDrag {
5448 x_axis,
5449 y_axis,
5450 pan_x,
5451 pan_y,
5452 start_pos: *position,
5453 start_x,
5454 start_y,
5455 });
5456
5457 cx.request_focus(cx.node);
5458 cx.capture_pointer(cx.node);
5459 cx.stop_propagation();
5460 }
5461 Event::Pointer(PointerEvent::Up {
5462 position,
5463 button,
5464 modifiers,
5465 ..
5466 }) => {
5467 self.last_pointer_pos = Some(*position);
5468 if let Some(drag) = self.box_zoom_drag
5469 && drag.button == *button
5470 {
5471 self.box_zoom_drag = None;
5472 if cx.captured == Some(cx.node) {
5473 cx.release_pointer_capture();
5474 }
5475
5476 let layout = self.compute_layout(cx.bounds);
5477 let plot = layout.plot;
5478 if let Some((x, y)) = self.selection_windows_for_drag(
5479 plot,
5480 drag.start_x,
5481 drag.start_y,
5482 drag.start_pos,
5483 drag.current_pos,
5484 *modifiers,
5485 drag.required_mods,
5486 ) {
5487 let x_window = (self.axis_is_fixed(drag.x_axis).is_none()).then_some(x);
5488 let y_window = (self.axis_is_fixed(drag.y_axis).is_none()).then_some(y);
5489 self.with_engine_mut(|engine| {
5490 engine.apply_action(Self::view_window_2d_action_from_zoom(
5491 drag.x_axis,
5492 drag.y_axis,
5493 drag.start_x,
5494 drag.start_y,
5495 x_window,
5496 y_window,
5497 ));
5498 });
5499 self.refresh_hover_for_axis_pointer(&layout, *position);
5500 }
5501
5502 cx.invalidate_self(Invalidation::Paint);
5503 cx.request_redraw();
5504 cx.stop_propagation();
5505 return;
5506 }
5507
5508 if let Some(drag) = self.brush_drag
5509 && drag.button == *button
5510 {
5511 self.brush_drag = None;
5512 if cx.captured == Some(cx.node) {
5513 cx.release_pointer_capture();
5514 }
5515
5516 let layout = self.compute_layout(cx.bounds);
5517 let plot = layout.plot;
5518 if let Some((x, y)) = self.selection_windows_for_drag(
5519 plot,
5520 drag.start_x,
5521 drag.start_y,
5522 drag.start_pos,
5523 drag.current_pos,
5524 *modifiers,
5525 drag.required_mods,
5526 ) {
5527 self.with_engine_mut(|engine| {
5528 engine.apply_action(Action::SetBrushSelection2D {
5529 x_axis: drag.x_axis,
5530 y_axis: drag.y_axis,
5531 x,
5532 y,
5533 });
5534 });
5535 } else {
5536 self.with_engine_mut(|engine| {
5537 engine.apply_action(Action::ClearBrushSelection);
5538 });
5539 }
5540
5541 cx.invalidate_self(Invalidation::Paint);
5542 cx.request_redraw();
5543 cx.stop_propagation();
5544 return;
5545 }
5546
5547 if self.visual_map_drag.is_some() && *button == MouseButton::Left {
5548 self.visual_map_drag = None;
5549 if cx.captured == Some(cx.node) {
5550 cx.release_pointer_capture();
5551 }
5552 cx.invalidate_self(Invalidation::Paint);
5553 cx.request_redraw();
5554 cx.stop_propagation();
5555 return;
5556 }
5557
5558 if self.slider_drag.is_some() && *button == MouseButton::Left {
5559 self.slider_drag = None;
5560 if cx.captured == Some(cx.node) {
5561 cx.release_pointer_capture();
5562 }
5563 cx.invalidate_self(Invalidation::Paint);
5564 cx.request_redraw();
5565 cx.stop_propagation();
5566 return;
5567 }
5568
5569 if self.pan_drag.is_some() && *button == MouseButton::Left {
5570 self.pan_drag = None;
5571 if cx.captured == Some(cx.node) {
5572 cx.release_pointer_capture();
5573 }
5574 cx.invalidate_self(Invalidation::Paint);
5575 cx.request_redraw();
5576 cx.stop_propagation();
5577 }
5578 }
5579 Event::Pointer(PointerEvent::Wheel {
5580 position,
5581 delta,
5582 modifiers,
5583 ..
5584 }) => {
5585 self.last_pointer_pos = Some(*position);
5586
5587 if self
5588 .legend_panel_rect
5589 .is_some_and(|rect| rect.contains(*position))
5590 && self.apply_legend_wheel_scroll(delta.y)
5591 {
5592 cx.invalidate_self(Invalidation::Paint);
5593 cx.request_redraw();
5594 cx.stop_propagation();
5595 return;
5596 }
5597
5598 let layout = self.compute_layout(cx.bounds);
5599 self.update_active_axes_for_position(&layout, *position);
5600 let plot = layout.plot;
5601 let width = plot.size.width.0;
5602 let height = plot.size.height.0;
5603 if width <= 0.0 || height <= 0.0 {
5604 return;
5605 }
5606
5607 let delta_y = delta.y.0;
5608 if !delta_y.is_finite() {
5609 return;
5610 }
5611
5612 if let Some(required) = self.input_map.wheel_zoom_mod
5613 && !required.is_pressed(*modifiers)
5614 {
5615 return;
5616 }
5617
5618 let log2_scale = delta_y * 0.0025;
5620
5621 let region = Self::axis_region(&layout, *position);
5622 let in_plot = plot.contains(*position);
5623 let in_axis = matches!(region, AxisRegion::XAxis(_) | AxisRegion::YAxis(_));
5624 if !in_plot && !in_axis {
5625 return;
5626 }
5627
5628 let local_x = (position.x.0 - plot.origin.x.0).clamp(0.0, width);
5629 let local_y = (position.y.0 - plot.origin.y.0).clamp(0.0, height);
5630 let center_x = local_x;
5631 let center_y_from_bottom = height - local_y;
5632
5633 let Some((primary_x_axis, primary_y_axis)) = self.active_axes(&layout) else {
5634 return;
5635 };
5636
5637 let (x_axis, y_axis) = match region {
5638 AxisRegion::XAxis(axis) => (axis, primary_y_axis),
5639 AxisRegion::YAxis(axis) => (primary_x_axis, axis),
5640 AxisRegion::Plot => (primary_x_axis, primary_y_axis),
5641 };
5642
5643 let (zoom_x, zoom_y) = match region {
5644 AxisRegion::XAxis(_) => (true, false),
5645 AxisRegion::YAxis(_) => (false, true),
5646 AxisRegion::Plot => (!modifiers.ctrl, !modifiers.shift),
5647 };
5648
5649 if zoom_x && self.axis_is_fixed(x_axis).is_none() {
5650 let w = self.current_window_x(x_axis);
5651 self.with_engine_mut(|engine| {
5652 engine.apply_action(Action::ZoomDataWindowXFromBase {
5653 axis: x_axis,
5654 base: w,
5655 center_px: center_x,
5656 log2_scale,
5657 viewport_span_px: width,
5658 });
5659 });
5660 }
5661 if zoom_y && self.axis_is_fixed(y_axis).is_none() {
5662 let w = self.current_window_y(y_axis);
5663 self.with_engine_mut(|engine| {
5664 engine.apply_action(Action::ZoomDataWindowYFromBase {
5665 axis: y_axis,
5666 base: w,
5667 center_px: center_y_from_bottom,
5668 log2_scale,
5669 viewport_span_px: height,
5670 });
5671 });
5672 }
5673
5674 self.refresh_hover_for_axis_pointer(&layout, *position);
5675 cx.invalidate_self(Invalidation::Paint);
5676 cx.request_redraw();
5677 cx.stop_propagation();
5678 }
5679 _ => {}
5680 }
5681 }
5682
5683 fn layout(&mut self, cx: &mut LayoutCx<'_, H>) -> fret_core::Size {
5684 let theme = Theme::global(&*cx.app);
5685 self.sync_style_from_theme(theme);
5686
5687 self.last_bounds = cx.bounds;
5688 self.last_layout = self.compute_layout(cx.bounds);
5689 if self.mode != ChartCanvasMode::Overlay {
5690 self.sync_viewport(self.last_layout.plot);
5691 }
5692 cx.available
5693 }
5694
5695 fn prepaint(&mut self, cx: &mut PrepaintCx<'_, H>) {
5696 if self.mode == ChartCanvasMode::Overlay {
5697 return;
5698 }
5699
5700 let mut measurer = NullTextMeasurer;
5701
5702 let start = Instant::now();
5706 let mut unfinished = true;
5707 let mut steps_ran = 0u32;
5708 while unfinished && steps_ran < 8 && start.elapsed() < Duration::from_millis(4) {
5709 let budget = if self.cached_paths.is_empty() && self.cached_rects.is_empty() {
5710 WorkBudget::new(262_144, 0, 32)
5711 } else {
5712 WorkBudget::new(32_768, 0, 8)
5713 };
5714
5715 let step = self.with_engine_mut(|engine| engine.step(&mut measurer, budget));
5716 match step {
5717 Ok(step) => {
5718 unfinished = step.unfinished;
5719 }
5720 Err(EngineError::MissingViewport | EngineError::MissingPlotViewport { .. }) => {
5721 unfinished = false;
5722 }
5723 }
5724 steps_ran = steps_ran.saturating_add(1);
5725 }
5726
5727 self.force_uncached_paint = unfinished;
5728 if unfinished {
5729 cx.request_animation_frame();
5730 }
5731
5732 let next_key = self.sampling_window_key(self.last_layout.plot, cx.scale_factor);
5733 if next_key != self.last_sampling_window_key {
5734 cx.debug_record_chart_sampling_window_shift(next_key);
5735 self.last_sampling_window_key = next_key;
5736 }
5737 }
5738
5739 fn paint(&mut self, cx: &mut PaintCx<'_, H>) {
5740 if self.mode == ChartCanvasMode::Overlay {
5741 self.paint_overlay_only(cx);
5742 return;
5743 }
5744
5745 let theme = Theme::global(&*cx.app);
5746 let style_changed = self.sync_style_from_theme(theme);
5747 if style_changed {
5748 self.last_bounds = cx.bounds;
5749 self.last_layout = self.compute_layout(cx.bounds);
5750 self.sync_viewport(self.last_layout.plot);
5751 }
5752
5753 self.sync_linked_brush(cx);
5754
5755 if self.last_bounds != cx.bounds
5756 || self.last_layout.plot.size.width.0 <= 0.0
5757 || self.last_layout.plot.size.height.0 <= 0.0
5758 {
5759 self.last_bounds = cx.bounds;
5760 self.last_layout = self.compute_layout(cx.bounds);
5761 self.sync_viewport(self.last_layout.plot);
5762 }
5763
5764 self.sync_linked_domain_windows(cx);
5765 self.sync_linked_axis_pointer(cx);
5766
5767 self.axis_text.begin_frame();
5769 self.tooltip_text.begin_frame();
5770 self.legend_text.begin_frame();
5771 self.path_cache.begin_frame();
5772 if let Some(window) = cx.window {
5773 let frame_id = cx.app.frame_id().0;
5774 let path_entries = self.path_cache.len();
5775 let path_stats = self.path_cache.stats();
5776 let path_key = CanvasCacheKey {
5777 window: window.data().as_ffi(),
5778 node: cx.node.data().as_ffi(),
5779 name: "fret-chart.canvas.paths",
5780 };
5781
5782 let axis_text_entries = self.axis_text.len();
5783 let axis_text_stats = self.axis_text.stats();
5784 let axis_text_key = CanvasCacheKey {
5785 window: window.data().as_ffi(),
5786 node: cx.node.data().as_ffi(),
5787 name: "fret-chart.canvas.text.axis",
5788 };
5789
5790 let tooltip_text_entries = self.tooltip_text.len();
5791 let tooltip_text_stats = self.tooltip_text.stats();
5792 let tooltip_text_key = CanvasCacheKey {
5793 window: window.data().as_ffi(),
5794 node: cx.node.data().as_ffi(),
5795 name: "fret-chart.canvas.text.tooltip",
5796 };
5797
5798 let legend_text_entries = self.legend_text.len();
5799 let legend_text_stats = self.legend_text.stats();
5800 let legend_text_key = CanvasCacheKey {
5801 window: window.data().as_ffi(),
5802 node: cx.node.data().as_ffi(),
5803 name: "fret-chart.canvas.text.legend",
5804 };
5805 cx.app
5806 .with_global_mut(CanvasCacheStatsRegistry::default, |registry, _app| {
5807 registry.record_path_cache(path_key, frame_id, path_entries, path_stats);
5808 registry.record_text_cache(
5809 axis_text_key,
5810 frame_id,
5811 axis_text_entries,
5812 axis_text_stats,
5813 );
5814 registry.record_text_cache(
5815 tooltip_text_key,
5816 frame_id,
5817 tooltip_text_entries,
5818 tooltip_text_stats,
5819 );
5820 registry.record_text_cache(
5821 legend_text_key,
5822 frame_id,
5823 legend_text_entries,
5824 legend_text_stats,
5825 );
5826 });
5827 }
5828
5829 self.rebuild_paths_if_needed(cx);
5830 self.clear_tooltip_text_cache(cx.services);
5831 let output_changed = self.publish_output(cx.app);
5832 if output_changed {
5833 cx.request_animation_frame();
5834 }
5835
5836 if let Some(background) = self.style.background {
5837 cx.scene.push(SceneOp::Quad {
5838 order: DrawOrder(self.style.draw_order.0.saturating_sub(1)),
5839 rect: self.last_layout.bounds,
5840 background: fret_core::Paint::Solid(background).into(),
5841 border: Edges::all(Px(0.0)),
5842 border_paint: fret_core::Paint::TRANSPARENT.into(),
5843
5844 corner_radii: Corners::all(Px(0.0)),
5845 });
5846 }
5847
5848 cx.scene.push(SceneOp::PushClipRect {
5849 rect: self.last_layout.plot,
5850 });
5851
5852 let brush = self
5853 .with_engine(|engine| engine.state().brush_selection_2d)
5854 .and_then(|brush| {
5855 if let Some(grid) = self.grid_override
5856 && brush.grid != Some(grid)
5857 {
5858 return None;
5859 }
5860 Some(brush)
5861 });
5862 let brush_rect_px = if let Some(brush) = brush {
5863 self.brush_rect_px(brush)
5864 .filter(|rect| rect.size.width.0 >= 1.0 && rect.size.height.0 >= 1.0)
5865 } else {
5866 None
5867 };
5868
5869 #[derive(Clone, Copy)]
5870 struct SeriesSnapshot {
5871 x_axis: delinea::AxisId,
5872 y_axis: delinea::AxisId,
5873 kind: delinea::SeriesKind,
5874 stack: Option<delinea::StackId>,
5875 }
5876
5877 let series_by_id: BTreeMap<delinea::SeriesId, SeriesSnapshot> =
5878 self.with_engine(|engine| {
5879 engine
5880 .model()
5881 .series
5882 .iter()
5883 .map(|(id, s)| {
5884 (
5885 *id,
5886 SeriesSnapshot {
5887 x_axis: s.x_axis,
5888 y_axis: s.y_axis,
5889 kind: s.kind,
5890 stack: s.stack,
5891 },
5892 )
5893 })
5894 .collect()
5895 });
5896
5897 let mut style_sig = KeyBuilder::new();
5898 style_sig.mix_f32_bits(self.style.stroke_color.r);
5899 style_sig.mix_f32_bits(self.style.stroke_color.g);
5900 style_sig.mix_f32_bits(self.style.stroke_color.b);
5901 style_sig.mix_f32_bits(self.style.stroke_color.a);
5902 style_sig.mix_f32_bits(self.style.bar_fill_alpha);
5903 style_sig.mix_f32_bits(self.style.scatter_fill_alpha);
5904 style_sig.mix_f32_bits(self.style.scatter_point_radius.0);
5905 for c in &self.style.series_palette {
5906 style_sig.mix_f32_bits(c.r);
5907 style_sig.mix_f32_bits(c.g);
5908 style_sig.mix_f32_bits(c.b);
5909 style_sig.mix_f32_bits(c.a);
5910 }
5911 let style_sig = style_sig.finish();
5912
5913 let mut rect_key = KeyBuilder::new();
5914 rect_key.mix_u64(self.last_marks_rev.0);
5915 rect_key.mix_u64(u64::from(self.last_scale_factor_bits));
5916 rect_key.mix_u64(style_sig);
5917 rect_key.mix_u64(self.legend_hover.map(|v| v.0).unwrap_or(0));
5918 if let Some(brush) = brush {
5919 rect_key.mix_u64(1);
5920 rect_key.mix_u64(brush.x_axis.0);
5921 rect_key.mix_u64(brush.y_axis.0);
5922 } else {
5923 rect_key.mix_u64(0);
5924 }
5925 let rect_key = rect_key.finish();
5926
5927 if !self.cached_rect_scene_ops.try_replay_with(
5928 rect_key,
5929 cx.scene,
5930 Point::new(Px(0.0), Px(0.0)),
5931 |_ops| {},
5932 ) {
5933 let mut ops: Vec<SceneOp> = Vec::with_capacity(self.cached_rects.len());
5934 for cached in &self.cached_rects {
5935 let base_order = self
5936 .style
5937 .draw_order
5938 .0
5939 .saturating_add(cached.order.saturating_mul(4));
5940
5941 let mut fill_color = self.style.stroke_color;
5942 if let Some(paint) = cached.fill {
5943 fill_color = self.paint_color(paint);
5944 fill_color.a *= self.style.stroke_color.a;
5945 } else if let Some(series) = cached.source_series {
5946 fill_color = self.series_color(series);
5947 fill_color.a *= self.style.stroke_color.a;
5948 }
5949 fill_color.a *= cached.opacity_mul;
5950 if let Some(series_id) = cached.source_series {
5951 let brush_dim = if brush.is_some() && series_by_id.contains_key(&series_id) {
5952 0.25
5953 } else {
5954 1.0
5955 };
5956 fill_color.a *= brush_dim;
5957 if let Some(hover) = self.legend_hover
5958 && cached.source_series.is_some()
5959 && cached.source_series != Some(hover)
5960 {
5961 fill_color.a *= 0.25;
5962 }
5963 }
5964 fill_color.a *= self.style.bar_fill_alpha;
5965
5966 let stroke_width = cached.stroke_width.unwrap_or(Px(0.0));
5967 let border_color = if stroke_width.0 > 0.0 {
5968 fill_color
5969 } else {
5970 Color::TRANSPARENT
5971 };
5972
5973 ops.push(SceneOp::Quad {
5974 order: DrawOrder(base_order),
5975 rect: cached.rect,
5976 background: fret_core::Paint::Solid(fill_color).into(),
5977
5978 border: Edges::all(stroke_width),
5979 border_paint: fret_core::Paint::Solid(border_color).into(),
5980 corner_radii: Corners::all(Px(0.0)),
5981 });
5982 }
5983
5984 #[cfg(debug_assertions)]
5985 {
5986 debug_assert!(
5987 ops.iter().all(|op| {
5988 !matches!(
5989 op,
5990 SceneOp::Text { .. }
5991 | SceneOp::Path { .. }
5992 | SceneOp::SvgMaskIcon { .. }
5993 | SceneOp::SvgImage { .. }
5994 )
5995 }),
5996 "Cached rect scene ops must not include hosted resources without touching their caches on replay"
5997 );
5998 }
5999
6000 cx.scene.replay_ops(&ops);
6001 self.cached_rect_scene_ops.store_ops(rect_key, ops);
6002 }
6003
6004 let path_constraints = PathConstraints {
6005 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
6006 };
6007
6008 for (mark_id, cached) in &self.cached_paths {
6009 let base_order = self
6010 .style
6011 .draw_order
6012 .0
6013 .saturating_add(cached.order.saturating_mul(4));
6014
6015 let mut stroke_color = self.style.stroke_color;
6016 if let Some(series) = cached.source_series {
6017 stroke_color = self.series_color(series);
6018 stroke_color.a *= self.style.stroke_color.a;
6019 }
6020 if let Some(series_id) = cached.source_series {
6021 let brush_dim = if let Some(brush) = brush
6022 && let Some(series) = series_by_id.get(&series_id)
6023 {
6024 if series.x_axis == brush.x_axis && series.y_axis == brush.y_axis {
6025 0.25
6026 } else {
6027 0.25
6028 }
6029 } else {
6030 1.0
6031 };
6032 stroke_color.a *= brush_dim;
6033 if let Some(hover) = self.legend_hover
6034 && cached.source_series.is_some()
6035 && cached.source_series != Some(hover)
6036 {
6037 stroke_color.a *= 0.25;
6038 }
6039 }
6040
6041 if let Some(fill_alpha) = cached.fill_alpha
6042 && let Some((fill, _metrics)) = self
6043 .path_cache
6044 .get(mark_path_cache_key(*mark_id, 1), path_constraints)
6045 {
6046 let mut fill_color = stroke_color;
6047 fill_color.a = fill_alpha;
6048 cx.scene.push(SceneOp::Path {
6049 order: DrawOrder(base_order),
6050 origin: self.last_layout.plot.origin,
6051 path: fill,
6052 paint: fill_color.into(),
6053 });
6054 }
6055
6056 let suppress_stroke = cached.source_series.is_some_and(|series_id| {
6057 series_by_id
6058 .get(&series_id)
6059 .is_some_and(|s| s.kind == delinea::SeriesKind::Area && s.stack.is_some())
6060 && delinea::ids::mark_variant(*mark_id) == 1
6061 });
6062 if !suppress_stroke
6063 && let Some((stroke, _metrics)) = self
6064 .path_cache
6065 .get(mark_path_cache_key(*mark_id, 0), path_constraints)
6066 {
6067 cx.scene.push(SceneOp::Path {
6068 order: DrawOrder(base_order.saturating_add(1)),
6069 origin: self.last_layout.plot.origin,
6070 path: stroke,
6071 paint: stroke_color.into(),
6072 });
6073 }
6074 }
6075
6076 let mut point_key = KeyBuilder::new();
6077 point_key.mix_u64(self.last_marks_rev.0);
6078 point_key.mix_u64(u64::from(self.last_scale_factor_bits));
6079 point_key.mix_u64(style_sig);
6080 point_key.mix_u64(self.legend_hover.map(|v| v.0).unwrap_or(0));
6081 if let Some(brush) = brush {
6082 point_key.mix_u64(1);
6083 point_key.mix_u64(brush.x_axis.0);
6084 point_key.mix_u64(brush.y_axis.0);
6085 } else {
6086 point_key.mix_u64(0);
6087 }
6088 let point_key = point_key.finish();
6089
6090 if !self.cached_point_scene_ops.try_replay_with(
6091 point_key,
6092 cx.scene,
6093 Point::new(Px(0.0), Px(0.0)),
6094 |_ops| {},
6095 ) {
6096 let base_point_r = self.style.scatter_point_radius.0.max(1.0);
6097 let point_order_bias = 2u32;
6098 let mut ops: Vec<SceneOp> = Vec::with_capacity(self.cached_points.len());
6099 for cached in &self.cached_points {
6100 let point_r = (base_point_r * cached.radius_mul).max(1.0);
6101 let base_order = self
6102 .style
6103 .draw_order
6104 .0
6105 .saturating_add(cached.order.saturating_mul(4))
6106 .saturating_add(point_order_bias);
6107
6108 let mut fill_color = self.style.stroke_color;
6109 if let Some(paint) = cached.fill {
6110 fill_color = self.paint_color(paint);
6111 fill_color.a *= self.style.scatter_fill_alpha;
6112 } else if let Some(series) = cached.source_series {
6113 fill_color = self.series_color(series);
6114 fill_color.a *= self.style.scatter_fill_alpha;
6115 }
6116 fill_color.a *= cached.opacity_mul;
6117 if let Some(series_id) = cached.source_series {
6118 let brush_dim = if brush.is_some() && series_by_id.contains_key(&series_id) {
6119 0.25
6120 } else {
6121 1.0
6122 };
6123 fill_color.a *= brush_dim;
6124 if let Some(hover) = self.legend_hover
6125 && cached.source_series.is_some()
6126 && cached.source_series != Some(hover)
6127 {
6128 fill_color.a *= 0.25;
6129 }
6130 }
6131
6132 let stroke_width = cached.stroke_width.unwrap_or(Px(0.0));
6133 let border_color = if stroke_width.0 > 0.0 {
6134 fill_color
6135 } else {
6136 Color::TRANSPARENT
6137 };
6138
6139 ops.push(SceneOp::Quad {
6140 order: DrawOrder(base_order),
6141 rect: Rect::new(
6142 Point::new(
6143 Px(cached.point.x.0 - point_r),
6144 Px(cached.point.y.0 - point_r),
6145 ),
6146 Size::new(Px(2.0 * point_r), Px(2.0 * point_r)),
6147 ),
6148 background: fret_core::Paint::Solid(fill_color).into(),
6149
6150 border: Edges::all(stroke_width),
6151 border_paint: fret_core::Paint::Solid(border_color).into(),
6152 corner_radii: Corners::all(Px(point_r)),
6153 });
6154 }
6155
6156 #[cfg(debug_assertions)]
6157 {
6158 debug_assert!(
6159 ops.iter().all(|op| {
6160 !matches!(
6161 op,
6162 SceneOp::Text { .. }
6163 | SceneOp::Path { .. }
6164 | SceneOp::SvgMaskIcon { .. }
6165 | SceneOp::SvgImage { .. }
6166 )
6167 }),
6168 "Cached point scene ops must not include hosted resources without touching their caches on replay"
6169 );
6170 }
6171
6172 cx.scene.replay_ops(&ops);
6173 self.cached_point_scene_ops.store_ops(point_key, ops);
6174 }
6175
6176 if let Some(brush) = brush
6177 && let Some(brush_rect_px) = brush_rect_px
6178 {
6179 cx.scene.push(SceneOp::PushClipRect {
6180 rect: brush_rect_px,
6181 });
6182
6183 let highlight_bias = 2u32;
6184
6185 for cached in &self.cached_rects {
6186 let Some(series_id) = cached.source_series else {
6187 continue;
6188 };
6189 let Some(series) = series_by_id.get(&series_id) else {
6190 continue;
6191 };
6192 if series.x_axis != brush.x_axis || series.y_axis != brush.y_axis {
6193 continue;
6194 }
6195
6196 let base_order = self
6197 .style
6198 .draw_order
6199 .0
6200 .saturating_add(cached.order.saturating_mul(4));
6201
6202 let mut fill_color = self.series_color(series_id);
6203 if let Some(paint) = cached.fill {
6204 fill_color = self.paint_color(paint);
6205 }
6206 fill_color.a *= self.style.stroke_color.a;
6207 fill_color.a *= cached.opacity_mul;
6208 if let Some(hover) = self.legend_hover
6209 && cached.source_series.is_some()
6210 && cached.source_series != Some(hover)
6211 {
6212 fill_color.a *= 0.25;
6213 }
6214 fill_color.a *= self.style.bar_fill_alpha;
6215
6216 let stroke_width = cached.stroke_width.unwrap_or(Px(0.0));
6217 let border_color = if stroke_width.0 > 0.0 {
6218 fill_color
6219 } else {
6220 Color::TRANSPARENT
6221 };
6222
6223 cx.scene.push(SceneOp::Quad {
6224 order: DrawOrder(base_order.saturating_add(highlight_bias)),
6225 rect: cached.rect,
6226 background: fret_core::Paint::Solid(fill_color).into(),
6227
6228 border: Edges::all(stroke_width),
6229 border_paint: fret_core::Paint::Solid(border_color).into(),
6230
6231 corner_radii: Corners::all(Px(0.0)),
6232 });
6233 }
6234
6235 let path_constraints = PathConstraints {
6236 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
6237 };
6238
6239 for (mark_id, cached) in &self.cached_paths {
6240 let Some(series_id) = cached.source_series else {
6241 continue;
6242 };
6243 let Some(series) = series_by_id.get(&series_id) else {
6244 continue;
6245 };
6246 if series.x_axis != brush.x_axis || series.y_axis != brush.y_axis {
6247 continue;
6248 }
6249
6250 let base_order = self
6251 .style
6252 .draw_order
6253 .0
6254 .saturating_add(cached.order.saturating_mul(4));
6255
6256 let mut stroke_color = self.series_color(series_id);
6257 stroke_color.a *= self.style.stroke_color.a;
6258 if let Some(hover) = self.legend_hover
6259 && cached.source_series.is_some()
6260 && cached.source_series != Some(hover)
6261 {
6262 stroke_color.a *= 0.25;
6263 }
6264
6265 if let Some(fill_alpha) = cached.fill_alpha
6266 && let Some((fill, _metrics)) = self
6267 .path_cache
6268 .get(mark_path_cache_key(*mark_id, 1), path_constraints)
6269 {
6270 let mut fill_color = stroke_color;
6271 fill_color.a = fill_alpha;
6272 cx.scene.push(SceneOp::Path {
6273 order: DrawOrder(base_order.saturating_add(highlight_bias)),
6274 origin: self.last_layout.plot.origin,
6275 path: fill,
6276 paint: fill_color.into(),
6277 });
6278 }
6279
6280 let suppress_stroke = cached.source_series.is_some_and(|series_id| {
6281 series_by_id
6282 .get(&series_id)
6283 .is_some_and(|s| s.kind == delinea::SeriesKind::Area && s.stack.is_some())
6284 && delinea::ids::mark_variant(*mark_id) == 1
6285 });
6286 if !suppress_stroke
6287 && let Some((stroke, _metrics)) = self
6288 .path_cache
6289 .get(mark_path_cache_key(*mark_id, 0), path_constraints)
6290 {
6291 cx.scene.push(SceneOp::Path {
6292 order: DrawOrder(base_order.saturating_add(highlight_bias + 1)),
6293 origin: self.last_layout.plot.origin,
6294 path: stroke,
6295 paint: stroke_color.into(),
6296 });
6297 }
6298 }
6299
6300 let base_point_r = self.style.scatter_point_radius.0.max(1.0);
6301 let point_order_bias = 2u32;
6302 for cached in &self.cached_points {
6303 let Some(series_id) = cached.source_series else {
6304 continue;
6305 };
6306 let Some(series) = series_by_id.get(&series_id) else {
6307 continue;
6308 };
6309 if series.x_axis != brush.x_axis || series.y_axis != brush.y_axis {
6310 continue;
6311 }
6312
6313 let point_r = (base_point_r * cached.radius_mul).max(1.0);
6314 let base_order = self
6315 .style
6316 .draw_order
6317 .0
6318 .saturating_add(cached.order.saturating_mul(4))
6319 .saturating_add(point_order_bias);
6320
6321 let mut fill_color = self.series_color(series_id);
6322 if let Some(paint) = cached.fill {
6323 fill_color = self.paint_color(paint);
6324 }
6325 fill_color.a *= self.style.scatter_fill_alpha;
6326 fill_color.a *= cached.opacity_mul;
6327 if let Some(hover) = self.legend_hover
6328 && cached.source_series.is_some()
6329 && cached.source_series != Some(hover)
6330 {
6331 fill_color.a *= 0.25;
6332 }
6333
6334 cx.scene.push(SceneOp::Quad {
6335 order: DrawOrder(base_order.saturating_add(highlight_bias)),
6336 rect: Rect::new(
6337 Point::new(
6338 Px(cached.point.x.0 - point_r),
6339 Px(cached.point.y.0 - point_r),
6340 ),
6341 Size::new(Px(2.0 * point_r), Px(2.0 * point_r)),
6342 ),
6343 background: fret_core::Paint::Solid(fill_color).into(),
6344
6345 border: Edges::all(Px(0.0)),
6346 border_paint: fret_core::Paint::TRANSPARENT.into(),
6347
6348 corner_radii: Corners::all(Px(point_r)),
6349 });
6350 }
6351
6352 cx.scene.push(SceneOp::PopClip);
6353 }
6354
6355 if let Some((x_axis, _y_axis)) = self.active_axes(&self.last_layout)
6356 && self.with_engine(|engine| {
6357 let dz = engine
6358 .state()
6359 .data_zoom_x
6360 .get(&x_axis)
6361 .copied()
6362 .unwrap_or_default();
6363 dz.window.is_some() && dz.filter_mode == FilterMode::None
6364 })
6365 {
6366 let label = "Y bounds: global (M)";
6367 let text_style = TextStyle {
6368 size: Px(11.0),
6369 weight: FontWeight::NORMAL,
6370 ..TextStyle::default()
6371 };
6372 let constraints = TextConstraints {
6373 max_width: None,
6374 wrap: TextWrap::None,
6375 overflow: TextOverflow::Clip,
6376 align: fret_core::TextAlign::Start,
6377 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
6378 };
6379 let prepared = self
6380 .tooltip_text
6381 .prepare(cx.services, label, &text_style, constraints);
6382 let blob = prepared.blob;
6383
6384 let plot = self.last_layout.plot;
6385 let pad = 6.0f32;
6386 let order = DrawOrder(self.style.draw_order.0.saturating_add(9_050));
6387 cx.scene.push(SceneOp::Text {
6388 order,
6389 origin: Point::new(Px(plot.origin.x.0 + pad), Px(plot.origin.y.0 + pad)),
6390 text: blob,
6391 paint: (self.style.axis_tick_color).into(),
6392 outline: None,
6393 shadow: None,
6394 });
6395 }
6396
6397 let interaction_idle = self.pan_drag.is_none() && self.box_zoom_drag.is_none();
6398 let axis_pointer =
6399 if self.mode.renders_overlays() && interaction_idle && self.legend_hover.is_none() {
6400 self.with_engine(|engine| engine.output().axis_pointer.clone())
6401 .and_then(|axis_pointer| {
6402 if let Some(grid) = self.grid_override
6403 && axis_pointer.grid != Some(grid)
6404 {
6405 return None;
6406 }
6407 Some(axis_pointer)
6408 })
6409 } else {
6410 None
6411 };
6412 let mut axis_pointer_label_rect: Option<Rect> = None;
6413
6414 if let Some(axis_pointer) = axis_pointer.as_ref() {
6415 let pos = axis_pointer.crosshair_px;
6416 let overlay_order = DrawOrder(self.style.draw_order.0.saturating_add(9_000));
6417 let point_order = DrawOrder(self.style.draw_order.0.saturating_add(9_001));
6418 let shadow_order = DrawOrder(self.style.draw_order.0.saturating_add(8_999));
6419
6420 let (axis_pointer_type, axis_pointer_label_enabled, axis_pointer_label_template) = self
6421 .with_engine(|engine| {
6422 let spec = engine.model().axis_pointer.as_ref();
6423 let axis_pointer_type = spec.map(|p| p.pointer_type).unwrap_or_default();
6424 let axis_pointer_label_enabled = spec.is_some_and(|p| p.label.show);
6425 let axis_pointer_label_template = spec
6426 .map(|p| p.label.template.clone())
6427 .unwrap_or_else(|| "{value}".to_string());
6428 (
6429 axis_pointer_type,
6430 axis_pointer_label_enabled,
6431 axis_pointer_label_template,
6432 )
6433 });
6434 let axis_pointer_label_template = axis_pointer_label_template.as_str();
6435
6436 let plot = self.axis_pointer_plot_rect(axis_pointer);
6437 let crosshair_w = self.style.crosshair_width.0.max(1.0);
6438
6439 let x = pos
6440 .x
6441 .0
6442 .clamp(plot.origin.x.0, plot.origin.x.0 + plot.size.width.0);
6443 let y = pos
6444 .y
6445 .0
6446 .clamp(plot.origin.y.0, plot.origin.y.0 + plot.size.height.0);
6447
6448 let (draw_x, draw_y) = match &axis_pointer.tooltip {
6449 delinea::TooltipOutput::Axis(axis) => match axis.axis_kind {
6450 delinea::AxisKind::X => (true, false),
6451 delinea::AxisKind::Y => (false, true),
6452 },
6453 delinea::TooltipOutput::Item(_) => (true, true),
6454 };
6455
6456 let shadow = matches!(&axis_pointer.tooltip, delinea::TooltipOutput::Axis(_))
6457 && axis_pointer_type == delinea::AxisPointerType::Shadow;
6458
6459 if shadow {
6460 if let Some(rect) = axis_pointer.shadow_rect_px {
6461 let color = Color {
6462 a: 0.08,
6463 ..self.style.selection_fill
6464 };
6465 cx.scene.push(SceneOp::Quad {
6466 order: shadow_order,
6467 rect,
6468 background: fret_core::Paint::Solid(color).into(),
6469
6470 border: Edges::all(Px(0.0)),
6471 border_paint: fret_core::Paint::TRANSPARENT.into(),
6472
6473 corner_radii: Corners::all(Px(0.0)),
6474 });
6475 }
6476 } else if draw_x {
6477 cx.scene.push(SceneOp::Quad {
6478 order: overlay_order,
6479 rect: Rect::new(
6480 Point::new(Px(x - 0.5 * crosshair_w), plot.origin.y),
6481 Size::new(Px(crosshair_w), plot.size.height),
6482 ),
6483 background: fret_core::Paint::Solid(self.style.crosshair_color).into(),
6484
6485 border: Edges::all(Px(0.0)),
6486 border_paint: fret_core::Paint::TRANSPARENT.into(),
6487
6488 corner_radii: Corners::all(Px(0.0)),
6489 });
6490 }
6491 if !shadow && draw_y {
6492 cx.scene.push(SceneOp::Quad {
6493 order: overlay_order,
6494 rect: Rect::new(
6495 Point::new(plot.origin.x, Px(y - 0.5 * crosshair_w)),
6496 Size::new(plot.size.width, Px(crosshair_w)),
6497 ),
6498 background: fret_core::Paint::Solid(self.style.crosshair_color).into(),
6499
6500 border: Edges::all(Px(0.0)),
6501 border_paint: fret_core::Paint::TRANSPARENT.into(),
6502
6503 corner_radii: Corners::all(Px(0.0)),
6504 });
6505 }
6506
6507 if axis_pointer_label_enabled {
6508 let union = |a: Rect, b: Rect| -> Rect {
6509 let ax0 = a.origin.x.0;
6510 let ay0 = a.origin.y.0;
6511 let ax1 = ax0 + a.size.width.0;
6512 let ay1 = ay0 + a.size.height.0;
6513
6514 let bx0 = b.origin.x.0;
6515 let by0 = b.origin.y.0;
6516 let bx1 = bx0 + b.size.width.0;
6517 let by1 = by0 + b.size.height.0;
6518
6519 let x0 = ax0.min(bx0);
6520 let y0 = ay0.min(by0);
6521 let x1 = ax1.max(bx1);
6522 let y1 = ay1.max(by1);
6523
6524 Rect::new(
6525 Point::new(Px(x0), Px(y0)),
6526 Size::new(Px((x1 - x0).max(0.0)), Px((y1 - y0).max(0.0))),
6527 )
6528 };
6529
6530 let mut draw_label_for_axis =
6531 |axis_id: delinea::AxisId, axis_kind: delinea::AxisKind, axis_value: f64| {
6532 let band = match axis_kind {
6533 delinea::AxisKind::X => self
6534 .last_layout
6535 .x_axes
6536 .iter()
6537 .find(|b| b.axis == axis_id)
6538 .copied(),
6539 delinea::AxisKind::Y => self
6540 .last_layout
6541 .y_axes
6542 .iter()
6543 .find(|b| b.axis == axis_id)
6544 .copied(),
6545 };
6546 let Some(band) = band else {
6547 return;
6548 };
6549
6550 let default_tooltip_spec = delinea::TooltipSpecV1::default();
6551 let (axis_window, axis_name, missing_value) = self.with_engine(|engine| {
6552 let axis_window = engine
6553 .output()
6554 .axis_windows
6555 .get(&axis_id)
6556 .copied()
6557 .unwrap_or_default();
6558 let axis_name = engine
6559 .model()
6560 .axes
6561 .get(&axis_id)
6562 .and_then(|a| a.name.as_deref())
6563 .unwrap_or("")
6564 .to_string();
6565 let missing_value = engine
6566 .model()
6567 .tooltip
6568 .as_ref()
6569 .map(|t| t.missing_value.clone())
6570 .unwrap_or_else(|| default_tooltip_spec.missing_value.clone());
6571 (axis_window, axis_name, missing_value)
6572 });
6573 let value_text = if axis_value.is_finite() {
6574 self.with_engine(|engine| {
6575 delinea::engine::axis::format_value_for(
6576 engine.model(),
6577 axis_id,
6578 axis_window,
6579 axis_value,
6580 )
6581 })
6582 } else {
6583 missing_value
6584 };
6585
6586 let label_text = if axis_pointer_label_template == "{value}" {
6587 value_text
6588 } else {
6589 axis_pointer_label_template
6590 .replace("{value}", &value_text)
6591 .replace("{axis_name}", &axis_name)
6592 };
6593
6594 let text_style = TextStyle {
6595 size: Px(11.0),
6596 weight: FontWeight::MEDIUM,
6597 ..TextStyle::default()
6598 };
6599 let constraints = TextConstraints {
6600 max_width: None,
6601 wrap: TextWrap::None,
6602 overflow: TextOverflow::Clip,
6603 align: fret_core::TextAlign::Start,
6604 scale_factor: cx.scale_factor,
6605 };
6606 let prepared = self.tooltip_text.prepare(
6607 cx.services,
6608 &label_text,
6609 &text_style,
6610 constraints,
6611 );
6612 let blob = prepared.blob;
6613 let metrics = prepared.metrics;
6614
6615 let pad_x = 6.0f32;
6616 let pad_y = 3.0f32;
6617 let w = (metrics.size.width.0 + 2.0 * pad_x).max(1.0);
6618 let h = (metrics.size.height.0 + 2.0 * pad_y).max(1.0);
6619
6620 let (mut box_x, mut box_y) = match axis_kind {
6621 delinea::AxisKind::X => (
6622 x - 0.5 * w,
6623 band.rect.origin.y.0 + 0.5 * (band.rect.size.height.0 - h),
6624 ),
6625 delinea::AxisKind::Y => (
6626 band.rect.origin.x.0 + 0.5 * (band.rect.size.width.0 - w),
6627 y - 0.5 * h,
6628 ),
6629 };
6630
6631 let bx0 = band.rect.origin.x.0;
6632 let by0 = band.rect.origin.y.0;
6633 let bx1 = bx0 + band.rect.size.width.0;
6634 let by1 = by0 + band.rect.size.height.0;
6635 box_x = box_x.clamp(bx0, (bx1 - w).max(bx0));
6636 box_y = box_y.clamp(by0, (by1 - h).max(by0));
6637 let rect =
6638 Rect::new(Point::new(Px(box_x), Px(box_y)), Size::new(Px(w), Px(h)));
6639 axis_pointer_label_rect = Some(match axis_pointer_label_rect {
6640 Some(prev) => union(prev, rect),
6641 None => rect,
6642 });
6643
6644 let kind_key: u32 = match axis_kind {
6645 delinea::AxisKind::X => 0,
6646 delinea::AxisKind::Y => 1,
6647 };
6648 let label_order = DrawOrder(
6649 self.style
6650 .draw_order
6651 .0
6652 .saturating_add(9_020 + kind_key.saturating_mul(4)),
6653 );
6654 cx.scene.push(SceneOp::Quad {
6655 order: label_order,
6656 rect,
6657 background: fret_core::Paint::Solid(self.style.tooltip_background)
6658 .into(),
6659
6660 border: Edges::all(self.style.tooltip_border_width),
6661 border_paint: fret_core::Paint::Solid(self.style.tooltip_border_color)
6662 .into(),
6663
6664 corner_radii: Corners::all(Px(4.0)),
6665 });
6666 cx.scene.push(SceneOp::Text {
6667 order: DrawOrder(label_order.0.saturating_add(1)),
6668 origin: Point::new(Px(box_x + pad_x), Px(box_y + pad_y)),
6669 text: blob,
6670 paint: (self.style.tooltip_text_color).into(),
6671 outline: None,
6672 shadow: None,
6673 });
6674 };
6675
6676 match &axis_pointer.tooltip {
6677 delinea::TooltipOutput::Axis(axis) => {
6678 draw_label_for_axis(axis.axis, axis.axis_kind, axis.axis_value);
6679 }
6680 delinea::TooltipOutput::Item(item) => {
6681 draw_label_for_axis(item.x_axis, delinea::AxisKind::X, item.x_value);
6682 draw_label_for_axis(item.y_axis, delinea::AxisKind::Y, item.y_value);
6683 }
6684 }
6685 }
6686
6687 if !shadow && let Some(hit) = axis_pointer.hit {
6688 let r = self.style.hover_point_size.0.max(1.0);
6689 cx.scene.push(SceneOp::Quad {
6690 order: point_order,
6691 rect: Rect::new(
6692 Point::new(Px(hit.point_px.x.0 - r), Px(hit.point_px.y.0 - r)),
6693 Size::new(Px(2.0 * r), Px(2.0 * r)),
6694 ),
6695 background: fret_core::Paint::Solid(self.style.hover_point_color).into(),
6696
6697 border: Edges::all(Px(0.0)),
6698 border_paint: fret_core::Paint::TRANSPARENT.into(),
6699
6700 corner_radii: Corners::all(Px(0.0)),
6701 });
6702 }
6703 }
6704
6705 self.draw_legend(cx);
6706
6707 if let Some(drag) = self.box_zoom_drag {
6708 let rect =
6709 rect_from_points_clamped(self.last_layout.plot, drag.start_pos, drag.current_pos);
6710 if rect.size.width.0 >= 1.0 && rect.size.height.0 >= 1.0 {
6711 cx.scene.push(SceneOp::Quad {
6712 order: DrawOrder(self.style.draw_order.0.saturating_add(8_800)),
6713 rect,
6714 background: fret_core::Paint::Solid(self.style.selection_fill).into(),
6715
6716 border: Edges::all(self.style.selection_stroke_width),
6717 border_paint: fret_core::Paint::Solid(self.style.selection_stroke).into(),
6718
6719 corner_radii: Corners::all(Px(0.0)),
6720 });
6721 }
6722 }
6723
6724 if let Some((x_axis, _y_axis)) = self.active_axes(&self.last_layout)
6726 && let Some(track) = self.x_slider_track_for_axis(x_axis)
6727 {
6728 let extent = self.compute_axis_extent_from_data(x_axis, true);
6729 let window = self.current_window_x_for_slider(x_axis, extent);
6730
6731 let t0 = Self::slider_norm(extent, window.min);
6732 let t1 = Self::slider_norm(extent, window.max);
6733 let left = track.origin.x.0 + t0 * track.size.width.0;
6734 let right = track.origin.x.0 + t1 * track.size.width.0;
6735
6736 let order = DrawOrder(self.style.draw_order.0.saturating_add(8_650));
6737 let track_color = Color {
6738 a: 0.18,
6739 ..self.style.axis_line_color
6740 };
6741 cx.scene.push(SceneOp::Quad {
6742 order,
6743 rect: track,
6744 background: fret_core::Paint::Solid(track_color).into(),
6745
6746 border: Edges::all(Px(0.0)),
6747 border_paint: fret_core::Paint::TRANSPARENT.into(),
6748
6749 corner_radii: Corners::all(Px(4.0)),
6750 });
6751
6752 let win_rect = Rect::new(
6753 Point::new(Px(left.min(right)), track.origin.y),
6754 Size::new(Px((right - left).abs().max(1.0)), track.size.height),
6755 );
6756 cx.scene.push(SceneOp::Quad {
6757 order: DrawOrder(order.0.saturating_add(1)),
6758 rect: win_rect,
6759 background: fret_core::Paint::Solid(self.style.selection_fill).into(),
6760
6761 border: Edges::all(self.style.selection_stroke_width),
6762 border_paint: fret_core::Paint::Solid(self.style.selection_stroke).into(),
6763
6764 corner_radii: Corners::all(Px(4.0)),
6765 });
6766
6767 let handle_w = 2.0f32.max(self.style.selection_stroke_width.0);
6768 let handle_color = self.style.selection_stroke;
6769 cx.scene.push(SceneOp::Quad {
6770 order: DrawOrder(order.0.saturating_add(2)),
6771 rect: Rect::new(
6772 Point::new(Px(left - 0.5 * handle_w), track.origin.y),
6773 Size::new(Px(handle_w), track.size.height),
6774 ),
6775 background: fret_core::Paint::Solid(handle_color).into(),
6776
6777 border: Edges::all(Px(0.0)),
6778 border_paint: fret_core::Paint::TRANSPARENT.into(),
6779
6780 corner_radii: Corners::all(Px(0.0)),
6781 });
6782 cx.scene.push(SceneOp::Quad {
6783 order: DrawOrder(order.0.saturating_add(3)),
6784 rect: Rect::new(
6785 Point::new(Px(right - 0.5 * handle_w), track.origin.y),
6786 Size::new(Px(handle_w), track.size.height),
6787 ),
6788 background: fret_core::Paint::Solid(handle_color).into(),
6789
6790 border: Edges::all(Px(0.0)),
6791 border_paint: fret_core::Paint::TRANSPARENT.into(),
6792
6793 corner_radii: Corners::all(Px(0.0)),
6794 });
6795 }
6796
6797 if let Some((_x_axis, y_axis)) = self.active_axes(&self.last_layout)
6799 && let Some(track) = self.y_slider_track_for_axis(y_axis)
6800 {
6801 let extent = self.compute_axis_extent_from_data(y_axis, false);
6802 let window = self.current_window_y_for_slider(y_axis, extent);
6803
6804 let t0 = Self::slider_norm(extent, window.min);
6805 let t1 = Self::slider_norm(extent, window.max);
6806
6807 let height = track.size.height.0;
6808 let bottom = track.origin.y.0 + height;
6809 let y0 = bottom - t0 * height;
6810 let y1 = bottom - t1 * height;
6811
6812 let order = DrawOrder(self.style.draw_order.0.saturating_add(8_650));
6813 let track_color = Color {
6814 a: 0.18,
6815 ..self.style.axis_line_color
6816 };
6817 cx.scene.push(SceneOp::Quad {
6818 order,
6819 rect: track,
6820 background: fret_core::Paint::Solid(track_color).into(),
6821
6822 border: Edges::all(Px(0.0)),
6823 border_paint: fret_core::Paint::TRANSPARENT.into(),
6824
6825 corner_radii: Corners::all(Px(4.0)),
6826 });
6827
6828 let top = y0.min(y1);
6829 let bottom = y0.max(y1);
6830 let win_rect = Rect::new(
6831 Point::new(track.origin.x, Px(top)),
6832 Size::new(track.size.width, Px((bottom - top).abs().max(1.0))),
6833 );
6834 cx.scene.push(SceneOp::Quad {
6835 order: DrawOrder(order.0.saturating_add(1)),
6836 rect: win_rect,
6837 background: fret_core::Paint::Solid(self.style.selection_fill).into(),
6838
6839 border: Edges::all(self.style.selection_stroke_width),
6840 border_paint: fret_core::Paint::Solid(self.style.selection_stroke).into(),
6841
6842 corner_radii: Corners::all(Px(4.0)),
6843 });
6844
6845 let handle_h = 2.0f32.max(self.style.selection_stroke_width.0);
6846 let handle_color = self.style.selection_stroke;
6847 cx.scene.push(SceneOp::Quad {
6848 order: DrawOrder(order.0.saturating_add(2)),
6849 rect: Rect::new(
6850 Point::new(track.origin.x, Px(y0 - 0.5 * handle_h)),
6851 Size::new(track.size.width, Px(handle_h)),
6852 ),
6853 background: fret_core::Paint::Solid(handle_color).into(),
6854
6855 border: Edges::all(Px(0.0)),
6856 border_paint: fret_core::Paint::TRANSPARENT.into(),
6857
6858 corner_radii: Corners::all(Px(0.0)),
6859 });
6860 cx.scene.push(SceneOp::Quad {
6861 order: DrawOrder(order.0.saturating_add(3)),
6862 rect: Rect::new(
6863 Point::new(track.origin.x, Px(y1 - 0.5 * handle_h)),
6864 Size::new(track.size.width, Px(handle_h)),
6865 ),
6866 background: fret_core::Paint::Solid(handle_color).into(),
6867
6868 border: Edges::all(Px(0.0)),
6869 border_paint: fret_core::Paint::TRANSPARENT.into(),
6870
6871 corner_radii: Corners::all(Px(0.0)),
6872 });
6873 }
6874
6875 if let Some(rect) = brush_rect_px {
6876 cx.scene.push(SceneOp::Quad {
6877 order: DrawOrder(self.style.draw_order.0.saturating_add(8_700)),
6878 rect,
6879 background: fret_core::Paint::Solid(self.style.selection_fill).into(),
6880
6881 border: Edges::all(self.style.selection_stroke_width),
6882 border_paint: fret_core::Paint::Solid(self.style.selection_stroke).into(),
6883
6884 corner_radii: Corners::all(Px(0.0)),
6885 });
6886 }
6887
6888 if let Some(drag) = self.brush_drag {
6889 let rect =
6890 rect_from_points_clamped(self.last_layout.plot, drag.start_pos, drag.current_pos);
6891 if rect.size.width.0 >= 1.0 && rect.size.height.0 >= 1.0 {
6892 cx.scene.push(SceneOp::Quad {
6893 order: DrawOrder(self.style.draw_order.0.saturating_add(8_750)),
6894 rect,
6895 background: fret_core::Paint::Solid(self.style.selection_fill).into(),
6896
6897 border: Edges::all(self.style.selection_stroke_width),
6898 border_paint: fret_core::Paint::Solid(self.style.selection_stroke).into(),
6899
6900 corner_radii: Corners::all(Px(0.0)),
6901 });
6902 }
6903 }
6904
6905 cx.scene.push(SceneOp::PopClip);
6906
6907 self.draw_visual_map(cx);
6908
6909 if let Some(axis_pointer) = axis_pointer {
6910 let tooltip_lines = self.with_engine(|engine| {
6911 self.tooltip_formatter.format_axis_pointer(
6912 engine,
6913 &engine.output().axis_windows,
6914 &axis_pointer,
6915 )
6916 });
6917 if tooltip_lines.is_empty() {
6918 if self.mode.renders_axes() {
6919 self.draw_axes(cx);
6920 }
6921 return;
6922 }
6923
6924 let text_style = TextStyle {
6925 size: Px(12.0),
6926 weight: FontWeight::NORMAL,
6927 ..TextStyle::default()
6928 };
6929 let mut header_text_style = text_style.clone();
6930 header_text_style.weight = FontWeight::BOLD;
6931 let mut value_text_style = text_style.clone();
6932 value_text_style.weight = FontWeight::MEDIUM;
6933 let constraints = TextConstraints {
6934 max_width: None,
6935 wrap: TextWrap::None,
6936 overflow: TextOverflow::Clip,
6937 align: fret_core::TextAlign::Start,
6938 scale_factor: effective_scale_factor(cx.scale_factor, 1.0),
6939 };
6940
6941 let pad = self.style.tooltip_padding;
6942 let swatch_w = self.style.tooltip_marker_size.0.max(0.0);
6943 let swatch_gap = self.style.tooltip_marker_gap.0.max(0.0);
6944 let col_gap = self.style.tooltip_column_gap.0.max(0.0);
6945 let reserve_swatch =
6946 swatch_w > 0.0 && tooltip_lines.iter().any(|l| l.source_series.is_some());
6947 let swatch_space = if reserve_swatch {
6948 (swatch_w + swatch_gap).max(0.0)
6949 } else {
6950 0.0
6951 };
6952
6953 enum TooltipLineLayout {
6954 Single {
6955 blob: TextBlobId,
6956 metrics: fret_core::TextMetrics,
6957 },
6958 Columns {
6959 left_blob: TextBlobId,
6960 left_metrics: fret_core::TextMetrics,
6961 right_blob: TextBlobId,
6962 right_metrics: fret_core::TextMetrics,
6963 },
6964 }
6965
6966 struct PreparedTooltipLine {
6967 source_series: Option<delinea::SeriesId>,
6968 is_missing: bool,
6969 layout: TooltipLineLayout,
6970 }
6971
6972 let mut prepared_lines = Vec::with_capacity(tooltip_lines.len());
6973 let mut max_left_w = 0.0f32;
6974 let mut max_right_w = 0.0f32;
6975 let mut max_single_w = 0.0f32;
6976 let mut total_h = 0.0f32;
6977
6978 for line in &tooltip_lines {
6979 let label_style = if line.kind == crate::TooltipTextLineKind::AxisHeader {
6980 &header_text_style
6981 } else {
6982 &text_style
6983 };
6984 let value_style =
6985 if line.value_emphasis && line.kind != crate::TooltipTextLineKind::AxisHeader {
6986 &value_text_style
6987 } else {
6988 label_style
6989 };
6990
6991 let columns = line
6992 .columns
6993 .as_ref()
6994 .map(|(left, right)| (left.as_str(), right.as_str()))
6995 .or_else(|| crate::tooltip_layout::split_tooltip_text_for_columns(&line.text));
6996
6997 if let Some((left, right)) = columns {
6998 let left_prepared =
6999 self.tooltip_text
7000 .prepare(cx.services, left, label_style, constraints);
7001 let right_prepared =
7002 self.tooltip_text
7003 .prepare(cx.services, right, value_style, constraints);
7004 let left_blob = left_prepared.blob;
7005 let left_metrics = left_prepared.metrics;
7006 let right_blob = right_prepared.blob;
7007 let right_metrics = right_prepared.metrics;
7008
7009 max_left_w = max_left_w.max(left_metrics.size.width.0);
7010 max_right_w = max_right_w.max(right_metrics.size.width.0);
7011 total_h += left_metrics
7012 .size
7013 .height
7014 .0
7015 .max(right_metrics.size.height.0)
7016 .max(1.0);
7017
7018 prepared_lines.push(PreparedTooltipLine {
7019 source_series: line.source_series,
7020 is_missing: line.is_missing,
7021 layout: TooltipLineLayout::Columns {
7022 left_blob,
7023 left_metrics,
7024 right_blob,
7025 right_metrics,
7026 },
7027 });
7028 } else {
7029 let prepared = self.tooltip_text.prepare(
7030 cx.services,
7031 &line.text,
7032 label_style,
7033 constraints,
7034 );
7035 let blob = prepared.blob;
7036 let metrics = prepared.metrics;
7037 max_single_w = max_single_w.max(metrics.size.width.0);
7038 total_h += metrics.size.height.0.max(1.0);
7039 prepared_lines.push(PreparedTooltipLine {
7040 source_series: line.source_series,
7041 is_missing: line.is_missing,
7042 layout: TooltipLineLayout::Single { blob, metrics },
7043 });
7044 }
7045 }
7046
7047 let mut w = 1.0f32;
7048 if max_left_w > 0.0 || max_right_w > 0.0 {
7049 w = w.max(max_left_w + col_gap + max_right_w);
7050 }
7051 w = w.max(max_single_w);
7052 w = (w + swatch_space + pad.left.0 + pad.right.0).max(1.0);
7053 let h = (total_h + pad.top.0 + pad.bottom.0).max(1.0);
7054
7055 let bounds = self.last_layout.bounds;
7056
7057 let anchor = match &axis_pointer.tooltip {
7058 delinea::TooltipOutput::Axis(_) => axis_pointer.crosshair_px,
7059 delinea::TooltipOutput::Item(_) => axis_pointer
7060 .hit
7061 .map(|h| h.point_px)
7062 .unwrap_or(axis_pointer.crosshair_px),
7063 };
7064
7065 let offset = 10.0f32;
7066 let tooltip_rect = crate::tooltip_layout::place_tooltip_rect(
7067 bounds,
7068 anchor,
7069 Size::new(Px(w), Px(h)),
7070 offset,
7071 axis_pointer_label_rect,
7072 );
7073 let tip_x = tooltip_rect.origin.x.0;
7074 let tip_y = tooltip_rect.origin.y.0;
7075
7076 let tooltip_order = DrawOrder(self.style.draw_order.0.saturating_add(9_100));
7077 cx.scene.push(SceneOp::Quad {
7078 order: tooltip_order,
7079 rect: Rect::new(Point::new(Px(tip_x), Px(tip_y)), Size::new(Px(w), Px(h))),
7080 background: fret_core::Paint::Solid(self.style.tooltip_background).into(),
7081
7082 border: Edges::all(self.style.tooltip_border_width),
7083 border_paint: fret_core::Paint::Solid(self.style.tooltip_border_color).into(),
7084
7085 corner_radii: Corners::all(self.style.tooltip_corner_radius),
7086 });
7087
7088 let mut y = tip_y + pad.top.0;
7089 let missing_text_color = Color {
7090 a: (self.style.tooltip_text_color.a * 0.55).clamp(0.0, 1.0),
7091 ..self.style.tooltip_text_color
7092 };
7093 for (i, line) in prepared_lines.into_iter().enumerate() {
7094 let order_base = tooltip_order
7095 .0
7096 .saturating_add(1 + (i as u32).saturating_mul(3));
7097 let swatch_x = tip_x + pad.left.0;
7098 let text_x0 = swatch_x + swatch_space;
7099
7100 let side = self.style.tooltip_marker_size.0.max(0.0);
7101 if side > 0.0
7102 && reserve_swatch
7103 && let Some(series) = line.source_series
7104 {
7105 let line_height = match &line.layout {
7106 TooltipLineLayout::Single { metrics, .. } => metrics.size.height.0.max(1.0),
7107 TooltipLineLayout::Columns {
7108 left_metrics,
7109 right_metrics,
7110 ..
7111 } => left_metrics
7112 .size
7113 .height
7114 .0
7115 .max(right_metrics.size.height.0)
7116 .max(1.0),
7117 };
7118 let marker_y = y + (line_height - side) * 0.5;
7119 cx.scene.push(SceneOp::Quad {
7120 order: DrawOrder(order_base),
7121 rect: Rect::new(
7122 Point::new(Px(swatch_x), Px(marker_y)),
7123 Size::new(Px(side), Px(side)),
7124 ),
7125 background: fret_core::Paint::Solid(self.series_color(series)).into(),
7126
7127 border: Edges::all(Px(0.0)),
7128 border_paint: fret_core::Paint::TRANSPARENT.into(),
7129
7130 corner_radii: Corners::all(Px((side * 0.25).max(0.0))),
7131 });
7132 }
7133
7134 match line.layout {
7135 TooltipLineLayout::Single { blob, metrics } => {
7136 let color = if line.is_missing {
7137 missing_text_color
7138 } else {
7139 self.style.tooltip_text_color
7140 };
7141 cx.scene.push(SceneOp::Text {
7142 order: DrawOrder(order_base.saturating_add(1)),
7143 origin: Point::new(Px(text_x0), Px(y)),
7144 text: blob,
7145 paint: (color).into(),
7146 outline: None,
7147 shadow: None,
7148 });
7149 y += metrics.size.height.0.max(1.0);
7150 }
7151 TooltipLineLayout::Columns {
7152 left_blob,
7153 left_metrics,
7154 right_blob,
7155 right_metrics,
7156 } => {
7157 let line_height = left_metrics
7158 .size
7159 .height
7160 .0
7161 .max(right_metrics.size.height.0)
7162 .max(1.0);
7163 let value_x = text_x0
7164 + max_left_w
7165 + col_gap
7166 + (max_right_w - right_metrics.size.width.0).max(0.0);
7167
7168 cx.scene.push(SceneOp::Text {
7169 order: DrawOrder(order_base.saturating_add(1)),
7170 origin: Point::new(Px(text_x0), Px(y)),
7171 text: left_blob,
7172 paint: (self.style.tooltip_text_color).into(),
7173 outline: None,
7174 shadow: None,
7175 });
7176 let value_color = if line.is_missing {
7177 missing_text_color
7178 } else {
7179 self.style.tooltip_text_color
7180 };
7181 cx.scene.push(SceneOp::Text {
7182 order: DrawOrder(order_base.saturating_add(2)),
7183 origin: Point::new(Px(value_x), Px(y)),
7184 text: right_blob,
7185 paint: (value_color).into(),
7186 outline: None,
7187 shadow: None,
7188 });
7189
7190 y += line_height;
7191 }
7192 }
7193 }
7194 }
7195
7196 if self.mode.renders_axes() {
7197 self.draw_axes(cx);
7198 }
7199
7200 let t = self.text_cache_prune;
7202 if t.max_entries > 0 && t.max_age_frames > 0 {
7203 self.axis_text
7204 .prune(cx.services, t.max_age_frames, t.max_entries);
7205 self.legend_text
7206 .prune(cx.services, t.max_age_frames, t.max_entries);
7207 }
7208 }
7209
7210 fn cleanup_resources(&mut self, services: &mut dyn fret_core::UiServices) {
7211 self.path_cache.clear(services);
7212 self.cached_paths.clear();
7213
7214 self.axis_text.clear(services);
7215 self.tooltip_text.clear(services);
7216 self.legend_text.clear(services);
7217 }
7218}
7219
7220fn rect_from_points_clamped(bounds: Rect, a: Point, b: Point) -> Rect {
7221 let x0 =
7222 a.x.0
7223 .min(b.x.0)
7224 .clamp(bounds.origin.x.0, bounds.origin.x.0 + bounds.size.width.0);
7225 let x1 =
7226 a.x.0
7227 .max(b.x.0)
7228 .clamp(bounds.origin.x.0, bounds.origin.x.0 + bounds.size.width.0);
7229 let y0 =
7230 a.y.0
7231 .min(b.y.0)
7232 .clamp(bounds.origin.y.0, bounds.origin.y.0 + bounds.size.height.0);
7233 let y1 =
7234 a.y.0
7235 .max(b.y.0)
7236 .clamp(bounds.origin.y.0, bounds.origin.y.0 + bounds.size.height.0);
7237
7238 Rect::new(
7239 Point::new(Px(x0), Px(y0)),
7240 Size::new(Px((x1 - x0).max(0.0)), Px((y1 - y0).max(0.0))),
7241 )
7242}
7243
7244#[cfg(test)]
7245mod tests {
7246 use super::*;
7247 use crate::TooltipTextLineKind;
7248 use delinea::ids::{AxisId, ChartId, DatasetId, FieldId, GridId, SeriesId, VisualMapId};
7249 use delinea::{
7250 AxisKind, AxisPosition, AxisRange, AxisScale, ChartSpec, DatasetSpec, FieldSpec, GridSpec,
7251 SeriesEncode, SeriesKind, SeriesSpec, VisualMapSpec,
7252 };
7253 use fret_app::App;
7254 use fret_core::{
7255 AppWindowId, Event, KeyCode, Modifiers, PathCommand, PathConstraints, PathId, PathMetrics,
7256 PathService, PathStyle, Scene, SvgId, SvgService, TextBlobId, TextConstraints, TextMetrics,
7257 TextService,
7258 };
7259 use fret_runtime::{FrameId, Model};
7260 use fret_ui::retained_bridge::UiTreeRetainedExt as _;
7261 use fret_ui::tree::UiTree;
7262
7263 fn first_chart_bar_spec() -> (ChartSpec, DatasetId, SeriesId, Vec<f64>, Vec<f64>, Vec<f64>) {
7264 use delinea::CategoryAxisScale;
7265
7266 let dataset_id = DatasetId::new(1);
7267 let grid_id = GridId::new(1);
7268 let x_axis = AxisId::new(1);
7269 let y_axis = AxisId::new(2);
7270 let x_field = FieldId::new(1);
7271 let desktop_field = FieldId::new(2);
7272 let mobile_field = FieldId::new(3);
7273 let desktop_series = SeriesId::new(1);
7274 let mobile_series = SeriesId::new(2);
7275
7276 let categories = vec![
7277 "January".to_string(),
7278 "February".to_string(),
7279 "March".to_string(),
7280 "April".to_string(),
7281 "May".to_string(),
7282 "June".to_string(),
7283 ];
7284 let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
7285 let desktop = vec![186.0, 305.0, 237.0, 73.0, 209.0, 214.0];
7286 let mobile = vec![80.0, 200.0, 120.0, 190.0, 130.0, 140.0];
7287
7288 let spec = ChartSpec {
7289 id: ChartId::new(1),
7290 viewport: None,
7291 datasets: vec![DatasetSpec {
7292 id: dataset_id,
7293 fields: vec![
7294 FieldSpec {
7295 id: x_field,
7296 column: 0,
7297 },
7298 FieldSpec {
7299 id: desktop_field,
7300 column: 1,
7301 },
7302 FieldSpec {
7303 id: mobile_field,
7304 column: 2,
7305 },
7306 ],
7307 ..Default::default()
7308 }],
7309 grids: vec![GridSpec { id: grid_id }],
7310 axes: vec![
7311 delinea::AxisSpec {
7312 id: x_axis,
7313 name: Some("Month".to_string()),
7314 kind: AxisKind::X,
7315 grid: grid_id,
7316 position: None,
7317 scale: AxisScale::Category(CategoryAxisScale { categories }),
7318 range: Default::default(),
7319 },
7320 delinea::AxisSpec {
7321 id: y_axis,
7322 name: Some("Visitors".to_string()),
7323 kind: AxisKind::Y,
7324 grid: grid_id,
7325 position: None,
7326 scale: Default::default(),
7327 range: Default::default(),
7328 },
7329 ],
7330 data_zoom_x: vec![],
7331 data_zoom_y: vec![],
7332 tooltip: None,
7333 axis_pointer: Some(delinea::AxisPointerSpec::default()),
7334 visual_maps: vec![],
7335 series: vec![
7336 SeriesSpec {
7337 id: desktop_series,
7338 name: Some("Desktop".to_string()),
7339 kind: SeriesKind::Bar,
7340 dataset: dataset_id,
7341 encode: SeriesEncode {
7342 x: x_field,
7343 y: desktop_field,
7344 y2: None,
7345 },
7346 x_axis,
7347 y_axis,
7348 stack: None,
7349 stack_strategy: Default::default(),
7350 bar_layout: Default::default(),
7351 area_baseline: None,
7352 lod: None,
7353 },
7354 SeriesSpec {
7355 id: mobile_series,
7356 name: Some("Mobile".to_string()),
7357 kind: SeriesKind::Bar,
7358 dataset: dataset_id,
7359 encode: SeriesEncode {
7360 x: x_field,
7361 y: mobile_field,
7362 y2: None,
7363 },
7364 x_axis,
7365 y_axis,
7366 stack: None,
7367 stack_strategy: Default::default(),
7368 bar_layout: Default::default(),
7369 area_baseline: None,
7370 lod: None,
7371 },
7372 ],
7373 };
7374
7375 (spec, dataset_id, desktop_series, x, desktop, mobile)
7376 }
7377
7378 fn seed_first_chart_dataset(
7379 canvas: &mut ChartCanvas,
7380 dataset_id: DatasetId,
7381 x: Vec<f64>,
7382 desktop: Vec<f64>,
7383 mobile: Vec<f64>,
7384 ) {
7385 use delinea::data::{Column, DataTable};
7386
7387 let mut table = DataTable::default();
7388 table.push_column(Column::F64(x));
7389 table.push_column(Column::F64(desktop));
7390 table.push_column(Column::F64(mobile));
7391 canvas.engine_mut().datasets_mut().insert(dataset_id, table);
7392 }
7393
7394 fn step_chart_engine(canvas: &mut ChartCanvas) {
7395 let mut measurer = NullTextMeasurer;
7396 for _ in 0..8 {
7397 let step = canvas
7398 .with_engine_mut(|engine| {
7399 engine.step(&mut measurer, WorkBudget::new(262_144, 0, 32))
7400 })
7401 .expect("chart engine step should succeed");
7402 if !step.unfinished {
7403 return;
7404 }
7405 }
7406
7407 panic!("chart engine should settle within the test work budget");
7408 }
7409
7410 #[derive(Default)]
7411 struct FakeServices;
7412
7413 impl TextService for FakeServices {
7414 fn prepare(
7415 &mut self,
7416 _input: &fret_core::TextInput,
7417 _constraints: TextConstraints,
7418 ) -> (TextBlobId, TextMetrics) {
7419 (
7420 TextBlobId::default(),
7421 TextMetrics {
7422 size: Size::new(Px(10.0), Px(10.0)),
7423 baseline: Px(8.0),
7424 },
7425 )
7426 }
7427
7428 fn release(&mut self, _blob: TextBlobId) {}
7429 }
7430
7431 impl PathService for FakeServices {
7432 fn prepare(
7433 &mut self,
7434 _commands: &[PathCommand],
7435 _style: PathStyle,
7436 _constraints: PathConstraints,
7437 ) -> (PathId, PathMetrics) {
7438 (PathId::default(), PathMetrics::default())
7439 }
7440
7441 fn release(&mut self, _path: PathId) {}
7442 }
7443
7444 impl SvgService for FakeServices {
7445 fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
7446 SvgId::default()
7447 }
7448
7449 fn unregister_svg(&mut self, _svg: SvgId) -> bool {
7450 true
7451 }
7452 }
7453
7454 impl fret_core::MaterialService for FakeServices {
7455 fn register_material(
7456 &mut self,
7457 _desc: fret_core::MaterialDescriptor,
7458 ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
7459 Ok(fret_core::MaterialId::default())
7460 }
7461
7462 fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
7463 true
7464 }
7465 }
7466
7467 fn pump_chart_frame(
7468 ui: &mut UiTree<App>,
7469 app: &mut App,
7470 services: &mut FakeServices,
7471 bounds: Rect,
7472 ) {
7473 ui.layout_all(app, services, bounds, 1.0);
7474 let mut scene = Scene::default();
7475 ui.paint_all(app, services, bounds, &mut scene, 1.0);
7476 app.set_frame_id(FrameId(app.frame_id().0.saturating_add(1)));
7477 }
7478
7479 #[test]
7480 fn legend_double_click_isolates_and_restores_all_series() {
7481 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7482
7483 let a = delinea::SeriesId::new(1);
7484 let b = delinea::SeriesId::new(2);
7485
7486 assert!(canvas.engine().model().series.get(&a).unwrap().visible);
7487 assert!(canvas.engine().model().series.get(&b).unwrap().visible);
7488
7489 canvas.apply_legend_double_click(b);
7490 assert!(!canvas.engine().model().series.get(&a).unwrap().visible);
7491 assert!(canvas.engine().model().series.get(&b).unwrap().visible);
7492
7493 canvas.apply_legend_double_click(b);
7494 assert!(canvas.engine().model().series.get(&a).unwrap().visible);
7495 assert!(canvas.engine().model().series.get(&b).unwrap().visible);
7496 }
7497
7498 #[test]
7499 fn legend_scroll_clamps_to_content_height() {
7500 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7501 canvas.legend_content_height = Px(500.0);
7502 canvas.legend_view_height = Px(120.0);
7503
7504 assert_eq!(canvas.legend_max_scroll_y().0, 380.0);
7505
7506 assert!(canvas.apply_legend_wheel_scroll(Px(-200.0)));
7507 assert!(canvas.legend_scroll_y.0 > 0.0);
7508
7509 canvas.apply_legend_wheel_scroll(Px(-10_000.0));
7510 assert_eq!(canvas.legend_scroll_y.0, 380.0);
7511
7512 canvas.apply_legend_wheel_scroll(Px(10_000.0));
7513 assert_eq!(canvas.legend_scroll_y.0, 0.0);
7514 }
7515
7516 #[test]
7517 fn legend_select_all_none_invert_update_series_visibility() {
7518 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7519
7520 let ids: Vec<_> = canvas.engine().model().series_order.clone();
7521 assert!(ids.len() >= 2);
7522
7523 canvas.apply_legend_select_none();
7524 for id in &ids {
7525 assert!(!canvas.engine().model().series.get(id).unwrap().visible);
7526 }
7527
7528 canvas.apply_legend_select_all();
7529 for id in &ids {
7530 assert!(canvas.engine().model().series.get(id).unwrap().visible);
7531 }
7532
7533 canvas.with_engine_mut(|engine| {
7534 engine.apply_action(Action::SetSeriesVisible {
7535 series: ids[0],
7536 visible: false,
7537 });
7538 });
7539 canvas.apply_legend_invert();
7540 assert!(canvas.engine().model().series.get(&ids[0]).unwrap().visible);
7541 assert!(!canvas.engine().model().series.get(&ids[1]).unwrap().visible);
7542 }
7543
7544 #[test]
7545 fn legend_selector_hit_test_returns_action() {
7546 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7547 canvas.legend_selector_rects = vec![(
7548 LegendSelectorAction::Invert,
7549 Rect::new(
7550 Point::new(Px(10.0), Px(10.0)),
7551 Size::new(Px(20.0), Px(12.0)),
7552 ),
7553 )];
7554
7555 assert_eq!(
7556 canvas.legend_selector_at(Point::new(Px(15.0), Px(15.0))),
7557 Some(LegendSelectorAction::Invert)
7558 );
7559 assert_eq!(
7560 canvas.legend_selector_at(Point::new(Px(1.0), Px(1.0))),
7561 None
7562 );
7563 }
7564
7565 #[test]
7566 fn axis_pointer_hover_point_clamps_axis_band_into_plot() {
7567 let plot = Rect::new(
7568 Point::new(Px(0.0), Px(0.0)),
7569 Size::new(Px(100.0), Px(100.0)),
7570 );
7571 let layout = ChartLayout {
7572 bounds: plot,
7573 plot,
7574 x_axes: vec![AxisBandLayout {
7575 axis: AxisId::new(1),
7576 position: AxisPosition::Bottom,
7577 rect: Rect::new(
7578 Point::new(Px(0.0), Px(100.0)),
7579 Size::new(Px(100.0), Px(20.0)),
7580 ),
7581 }],
7582 y_axes: vec![
7583 AxisBandLayout {
7584 axis: AxisId::new(2),
7585 position: AxisPosition::Left,
7586 rect: Rect::new(
7587 Point::new(Px(-20.0), Px(0.0)),
7588 Size::new(Px(20.0), Px(100.0)),
7589 ),
7590 },
7591 AxisBandLayout {
7592 axis: AxisId::new(3),
7593 position: AxisPosition::Right,
7594 rect: Rect::new(
7595 Point::new(Px(100.0), Px(0.0)),
7596 Size::new(Px(20.0), Px(100.0)),
7597 ),
7598 },
7599 ],
7600 visual_map: None,
7601 };
7602
7603 let p = ChartCanvas::axis_pointer_hover_point(&layout, Point::new(Px(50.0), Px(110.0)));
7604 assert!(plot.contains(p));
7605 assert_eq!(p.x.0, 50.0);
7606 assert_eq!(p.y.0, 99.0);
7607
7608 let p = ChartCanvas::axis_pointer_hover_point(&layout, Point::new(Px(-10.0), Px(25.0)));
7609 assert!(plot.contains(p));
7610 assert_eq!(p.x.0, 1.0);
7611 assert_eq!(p.y.0, 25.0);
7612
7613 let p = ChartCanvas::axis_pointer_hover_point(&layout, Point::new(Px(110.0), Px(75.0)));
7614 assert!(plot.contains(p));
7615 assert_eq!(p.x.0, 99.0);
7616 assert_eq!(p.y.0, 75.0);
7617 }
7618
7619 #[test]
7620 fn data_mapping_is_monotonic() {
7621 let window = DataWindow {
7622 min: 10.0,
7623 max: 20.0,
7624 };
7625 let a = delinea::engine::axis::data_at_px(window, 0.0, 0.0, 100.0);
7626 let b = delinea::engine::axis::data_at_px(window, 50.0, 0.0, 100.0);
7627 let c = delinea::engine::axis::data_at_px(window, 100.0, 0.0, 100.0);
7628 assert!(a < b && b < c);
7629 assert_eq!(a, 10.0);
7630 assert_eq!(c, 20.0);
7631
7632 let d = delinea::engine::axis::data_at_px(window, 0.0, 0.0, 100.0);
7633 let e = delinea::engine::axis::data_at_px(window, 100.0, 0.0, 100.0);
7634 assert_eq!(d, 10.0);
7635 assert_eq!(e, 20.0);
7636 }
7637
7638 #[test]
7639 fn rect_from_points_is_clamped_to_bounds() {
7640 let bounds = Rect::new(
7641 Point::new(Px(10.0), Px(20.0)),
7642 Size::new(Px(100.0), Px(200.0)),
7643 );
7644 let a = Point::new(Px(0.0), Px(0.0));
7645 let b = Point::new(Px(999.0), Px(999.0));
7646 let rect = rect_from_points_clamped(bounds, a, b);
7647 assert_eq!(rect.origin, bounds.origin);
7648 assert_eq!(rect.size, bounds.size);
7649 }
7650
7651 #[test]
7652 fn nice_ticks_include_endpoints() {
7653 let window = DataWindow { min: 0.2, max: 9.7 };
7654 let ticks = delinea::format::nice_ticks(window, 5);
7655 assert!(!ticks.is_empty());
7656 assert_eq!(*ticks.first().unwrap(), window.min);
7657 assert_eq!(*ticks.last().unwrap(), window.max);
7658 }
7659
7660 #[test]
7661 fn series_color_is_stable() {
7662 let canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7663 let a = canvas.series_color(delinea::SeriesId::new(1));
7664 let b = canvas.series_color(delinea::SeriesId::new(2));
7665 assert_ne!(a, b);
7666 assert_eq!(a, canvas.series_color(delinea::SeriesId::new(1)));
7667 }
7668
7669 #[test]
7670 fn series_color_respects_theme_palette_when_style_is_fixed() {
7671 let mut app = fret_app::App::new();
7672 let mut cfg = fret_ui::ThemeConfig::default();
7673 cfg.colors
7674 .insert("chart.palette.0".to_string(), "#FF0000".to_string());
7675 cfg.colors
7676 .insert("chart.palette.1".to_string(), "#00FF00".to_string());
7677 Theme::with_global_mut(&mut app, |theme| theme.apply_config(&cfg));
7678
7679 let theme = Theme::global(&app);
7680 let style = ChartStyle::from_theme(theme);
7681 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7682 canvas.set_style(style);
7683
7684 assert_eq!(
7685 canvas.series_color(delinea::SeriesId::new(1)),
7686 theme.color_token("chart.palette.0")
7687 );
7688 assert_eq!(
7689 canvas.series_color(delinea::SeriesId::new(2)),
7690 theme.color_token("chart.palette.1")
7691 );
7692 }
7693
7694 #[test]
7695 fn series_color_follows_series_order_not_series_id() {
7696 let mut app = fret_app::App::new();
7697 let mut cfg = fret_ui::ThemeConfig::default();
7698 cfg.colors
7699 .insert("chart.palette.0".to_string(), "#FF0000".to_string());
7700 cfg.colors
7701 .insert("chart.palette.1".to_string(), "#00FF00".to_string());
7702 Theme::with_global_mut(&mut app, |theme| theme.apply_config(&cfg));
7703
7704 let theme = Theme::global(&app);
7705 let style = ChartStyle::from_theme(theme);
7706
7707 let mut spec = multi_axis_spec();
7708 spec.series[0].id = delinea::SeriesId::new(42);
7709 spec.series[1].id = delinea::SeriesId::new(1);
7710
7711 let mut canvas = ChartCanvas::new(spec).expect("spec should be valid");
7712 canvas.set_style(style);
7713
7714 assert_eq!(
7715 canvas.series_color(delinea::SeriesId::new(42)),
7716 theme.color_token("chart.palette.0")
7717 );
7718 assert_eq!(
7719 canvas.series_color(delinea::SeriesId::new(1)),
7720 theme.color_token("chart.palette.1")
7721 );
7722 }
7723
7724 fn multi_axis_spec() -> ChartSpec {
7725 let dataset_id = DatasetId::new(1);
7726 let grid_id = GridId::new(1);
7727 let x_axis = AxisId::new(1);
7728 let y_left = AxisId::new(2);
7729 let y_right = AxisId::new(3);
7730 let x_field = FieldId::new(1);
7731 let y_field = FieldId::new(2);
7732
7733 ChartSpec {
7734 id: ChartId::new(1),
7735 viewport: None,
7736 datasets: vec![DatasetSpec {
7737 id: dataset_id,
7738 fields: vec![
7739 FieldSpec {
7740 id: x_field,
7741 column: 0,
7742 },
7743 FieldSpec {
7744 id: y_field,
7745 column: 1,
7746 },
7747 ],
7748
7749 from: None,
7750 transforms: Vec::new(),
7751 }],
7752 grids: vec![GridSpec { id: grid_id }],
7753 axes: vec![
7754 delinea::AxisSpec {
7755 id: x_axis,
7756 name: None,
7757 kind: AxisKind::X,
7758 grid: grid_id,
7759 position: Some(AxisPosition::Bottom),
7760 scale: AxisScale::default(),
7761 range: Some(AxisRange::Auto),
7762 },
7763 delinea::AxisSpec {
7764 id: y_left,
7765 name: None,
7766 kind: AxisKind::Y,
7767 grid: grid_id,
7768 position: Some(AxisPosition::Left),
7769 scale: AxisScale::default(),
7770 range: Some(AxisRange::Auto),
7771 },
7772 delinea::AxisSpec {
7773 id: y_right,
7774 name: None,
7775 kind: AxisKind::Y,
7776 grid: grid_id,
7777 position: Some(AxisPosition::Right),
7778 scale: AxisScale::default(),
7779 range: Some(AxisRange::Auto),
7780 },
7781 ],
7782 data_zoom_x: vec![],
7783 data_zoom_y: vec![],
7784 tooltip: None,
7785 axis_pointer: None,
7786 visual_maps: vec![],
7787 series: vec![
7788 SeriesSpec {
7789 id: SeriesId::new(1),
7790 name: None,
7791 kind: SeriesKind::Line,
7792 dataset: dataset_id,
7793 encode: SeriesEncode {
7794 x: x_field,
7795 y: y_field,
7796 y2: None,
7797 },
7798 x_axis,
7799 y_axis: y_left,
7800 stack: None,
7801 stack_strategy: Default::default(),
7802 bar_layout: Default::default(),
7803 area_baseline: None,
7804 lod: None,
7805 },
7806 SeriesSpec {
7807 id: SeriesId::new(2),
7808 name: None,
7809 kind: SeriesKind::Line,
7810 dataset: dataset_id,
7811 encode: SeriesEncode {
7812 x: x_field,
7813 y: y_field,
7814 y2: None,
7815 },
7816 x_axis,
7817 y_axis: y_right,
7818 stack: None,
7819 stack_strategy: Default::default(),
7820 bar_layout: Default::default(),
7821 area_baseline: None,
7822 lod: None,
7823 },
7824 ],
7825 }
7826 }
7827
7828 fn multi_axis_visual_map_spec() -> ChartSpec {
7829 let mut spec = multi_axis_spec();
7830 let y_field = spec.series[0].encode.y;
7831 let series_id = spec.series[0].id;
7832 spec.visual_maps.push(VisualMapSpec {
7833 id: VisualMapId::new(1),
7834 mode: delinea::VisualMapMode::Continuous,
7835 dataset: None,
7836 series: vec![series_id],
7837 field: y_field,
7838 domain: (-1.0, 1.0),
7839 initial_range: Some((-0.25, 0.75)),
7840 initial_piece_mask: None,
7841 point_radius_mul_range: None,
7842 stroke_width_range: None,
7843 opacity_mul_range: None,
7844 buckets: 8,
7845 out_of_range_opacity: 0.25,
7846 });
7847 spec
7848 }
7849
7850 #[test]
7851 fn primary_axes_skip_hidden_series() {
7852 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7853 canvas
7854 .engine_mut()
7855 .apply_action(delinea::action::Action::SetSeriesVisible {
7856 series: delinea::SeriesId::new(1),
7857 visible: false,
7858 });
7859
7860 let (_x, y) = canvas.primary_axes().expect("expected primary axes");
7861 assert_eq!(y, AxisId::new(3));
7862 }
7863
7864 #[test]
7865 fn active_axes_prefer_last_hovered_band() {
7866 let mut canvas = ChartCanvas::new(multi_axis_spec()).expect("spec should be valid");
7867 let layout = canvas.compute_layout(Rect::new(
7868 Point::new(Px(0.0), Px(0.0)),
7869 Size::new(Px(800.0), Px(400.0)),
7870 ));
7871
7872 let right_band = layout
7873 .y_axes
7874 .iter()
7875 .find(|b| b.position == AxisPosition::Right)
7876 .expect("expected a right y axis band");
7877 let p = Point::new(
7878 Px(right_band.rect.origin.x.0 + 1.0),
7879 Px(right_band.rect.origin.y.0 + 1.0),
7880 );
7881 canvas.update_active_axes_for_position(&layout, p);
7882
7883 let (x, y) = canvas.active_axes(&layout).expect("expected active axes");
7884 assert_eq!(x, AxisId::new(1));
7885 assert_eq!(y, AxisId::new(3));
7886 }
7887
7888 #[test]
7889 fn first_chart_bar_hover_publishes_tooltip_lines_to_output_model() {
7890 let mut app = App::new();
7891 let output: Model<ChartCanvasOutput> =
7892 app.models_mut().insert(ChartCanvasOutput::default());
7893
7894 let (spec, dataset_id, desktop_series, x, desktop, mobile) = first_chart_bar_spec();
7895 let mut canvas = ChartCanvas::new(spec).expect("spec should be valid");
7896 canvas = canvas.output_model(output.clone());
7897 seed_first_chart_dataset(&mut canvas, dataset_id, x, desktop, mobile);
7898
7899 let bounds = Rect::new(
7900 Point::new(Px(0.0), Px(0.0)),
7901 Size::new(Px(560.0), Px(208.0)),
7902 );
7903 canvas.last_bounds = bounds;
7904 canvas.last_layout = canvas.compute_layout(bounds);
7905 canvas.sync_viewport(canvas.last_layout.plot);
7906
7907 step_chart_engine(&mut canvas);
7908
7909 let point = canvas
7910 .point_for_series_data_index(desktop_series, 0)
7911 .expect("expected a point for the first desktop bar");
7912 assert!(
7913 canvas.last_layout.plot.contains(point),
7914 "expected the derived hover point to land inside the plot"
7915 );
7916
7917 let layout = canvas.last_layout.clone();
7918 canvas.refresh_hover_for_axis_pointer(&layout, point);
7919 step_chart_engine(&mut canvas);
7920
7921 let axis_pointer_present =
7922 canvas.with_engine(|engine| engine.output().axis_pointer.is_some());
7923 assert!(
7924 axis_pointer_present,
7925 "expected axis pointer output after applying hover to the first-chart bar spec"
7926 );
7927
7928 let output_changed = canvas.publish_output(&mut app);
7929 assert!(
7930 output_changed,
7931 "expected publish_output to detect tooltip payload changes"
7932 );
7933
7934 let published = output
7935 .read(&mut app, |_app, state| state.clone())
7936 .expect("expected output model to be readable");
7937 assert!(
7938 published.revision > 0,
7939 "expected output revision to advance after tooltip publish"
7940 );
7941 assert!(
7942 !published.snapshot.tooltip_lines.is_empty(),
7943 "expected tooltip lines to be published into the shared output model"
7944 );
7945 assert_eq!(
7946 published.snapshot.tooltip_lines[0].kind,
7947 TooltipTextLineKind::AxisHeader
7948 );
7949 }
7950
7951 #[test]
7952 fn first_chart_bar_hover_publishes_tooltip_lines_with_nonzero_bounds_origin() {
7953 let mut app = App::new();
7954 let output: Model<ChartCanvasOutput> =
7955 app.models_mut().insert(ChartCanvasOutput::default());
7956
7957 let (spec, dataset_id, desktop_series, x, desktop, mobile) = first_chart_bar_spec();
7958 let mut canvas = ChartCanvas::new(spec).expect("spec should be valid");
7959 canvas = canvas.output_model(output.clone());
7960 seed_first_chart_dataset(&mut canvas, dataset_id, x, desktop, mobile);
7961
7962 let bounds = Rect::new(
7963 Point::new(Px(293.5), Px(296.5)),
7964 Size::new(Px(560.0), Px(208.0)),
7965 );
7966 canvas.last_bounds = bounds;
7967 canvas.last_layout = canvas.compute_layout(bounds);
7968 canvas.sync_viewport(canvas.last_layout.plot);
7969
7970 step_chart_engine(&mut canvas);
7971
7972 let point = canvas
7973 .point_for_series_data_index(desktop_series, 0)
7974 .expect("expected a point for the first desktop bar");
7975 assert!(
7976 canvas.last_layout.plot.contains(point),
7977 "expected the derived hover point to land inside the plot"
7978 );
7979
7980 let layout = canvas.last_layout.clone();
7981 canvas.refresh_hover_for_axis_pointer(&layout, point);
7982 step_chart_engine(&mut canvas);
7983
7984 let axis_pointer_present =
7985 canvas.with_engine(|engine| engine.output().axis_pointer.is_some());
7986 assert!(
7987 axis_pointer_present,
7988 "expected axis pointer output after applying hover with non-zero canvas bounds"
7989 );
7990
7991 let output_changed = canvas.publish_output(&mut app);
7992 assert!(
7993 output_changed,
7994 "expected publish_output to detect tooltip payload changes with non-zero canvas bounds"
7995 );
7996
7997 let published = output
7998 .read(&mut app, |_app, state| state.clone())
7999 .expect("expected output model to be readable");
8000 assert!(
8001 !published.snapshot.tooltip_lines.is_empty(),
8002 "expected tooltip lines to be published for non-zero canvas bounds"
8003 );
8004 }
8005
8006 #[test]
8007 fn ui_tree_keyboard_navigation_publishes_tooltip_lines_to_output_model() {
8008 let window = AppWindowId::default();
8009 let mut app = App::new();
8010 let mut ui: UiTree<App> = UiTree::new();
8011 ui.set_window(window);
8012
8013 let output: Model<ChartCanvasOutput> =
8014 app.models_mut().insert(ChartCanvasOutput::default());
8015
8016 let (spec, dataset_id, _desktop_series, x, desktop, mobile) = first_chart_bar_spec();
8017 let mut canvas = ChartCanvas::new(spec).expect("spec should be valid");
8018 canvas.set_accessibility_layer(true);
8019 canvas.set_input_map(crate::input_map::ChartInputMap::default());
8020 canvas = canvas.output_model(output.clone());
8021 seed_first_chart_dataset(&mut canvas, dataset_id, x, desktop, mobile);
8022
8023 let root = ui.create_node_retained(canvas.test_id("chart-keyboard-canvas"));
8024 ui.set_node_view_cache_flags(root, true, true, false);
8025 ui.set_root(root);
8026
8027 let bounds = Rect::new(
8028 Point::new(Px(293.5), Px(296.5)),
8029 Size::new(Px(560.0), Px(208.0)),
8030 );
8031 let mut services = FakeServices;
8032
8033 ui.request_semantics_snapshot();
8034 pump_chart_frame(&mut ui, &mut app, &mut services, bounds);
8035 ui.request_semantics_snapshot();
8036 pump_chart_frame(&mut ui, &mut app, &mut services, bounds);
8037
8038 let before_pos_in_set = ui
8039 .semantics_snapshot()
8040 .expect("expected semantics snapshot before keyboard navigation")
8041 .nodes
8042 .iter()
8043 .find(|node| node.test_id.as_deref() == Some("chart-keyboard-canvas"))
8044 .and_then(|node| node.pos_in_set);
8045
8046 ui.set_focus(Some(root));
8047 ui.dispatch_event(
8048 &mut app,
8049 &mut services,
8050 &Event::KeyDown {
8051 key: KeyCode::ArrowRight,
8052 modifiers: Modifiers::default(),
8053 repeat: false,
8054 },
8055 );
8056
8057 ui.request_semantics_snapshot();
8058 pump_chart_frame(&mut ui, &mut app, &mut services, bounds);
8059 ui.request_semantics_snapshot();
8060 pump_chart_frame(&mut ui, &mut app, &mut services, bounds);
8061
8062 let after_pos_in_set = ui
8063 .semantics_snapshot()
8064 .expect("expected semantics snapshot after keyboard navigation")
8065 .nodes
8066 .iter()
8067 .find(|node| node.test_id.as_deref() == Some("chart-keyboard-canvas"))
8068 .and_then(|node| node.pos_in_set);
8069 let after_value = ui
8070 .semantics_snapshot()
8071 .expect("expected semantics snapshot after keyboard navigation")
8072 .nodes
8073 .iter()
8074 .find(|node| node.test_id.as_deref() == Some("chart-keyboard-canvas"))
8075 .and_then(|node| node.value.clone());
8076
8077 let published = output
8078 .read(&mut app, |_app, state| state.clone())
8079 .expect("expected output model to be readable");
8080 assert_eq!(
8081 before_pos_in_set,
8082 Some(1),
8083 "expected initial chart semantics collection position to point at the first item"
8084 );
8085 assert_eq!(
8086 after_pos_in_set,
8087 Some(2),
8088 "expected keyboard accessibility navigation to update chart semantics collection position"
8089 );
8090 assert!(
8091 published.revision > 0,
8092 "expected keyboard accessibility navigation to advance the shared output model revision; after_pos_in_set={after_pos_in_set:?} after_value={after_value:?} tooltip_lines={}",
8093 published.snapshot.tooltip_lines.len()
8094 );
8095 assert!(
8096 !published.snapshot.tooltip_lines.is_empty(),
8097 "expected keyboard accessibility navigation to publish tooltip lines"
8098 );
8099 }
8100
8101 #[test]
8102 fn visual_map_y_mapping_respects_domain_endpoints() {
8103 let track = Rect::new(
8104 Point::new(Px(10.0), Px(20.0)),
8105 Size::new(Px(8.0), Px(100.0)),
8106 );
8107 let domain = DataWindow {
8108 min: 0.0,
8109 max: 10.0,
8110 };
8111
8112 let bottom = track.origin.y.0 + track.size.height.0;
8113 assert_eq!(
8114 ChartCanvas::visual_map_y_at_value(track, domain, 0.0),
8115 bottom
8116 );
8117 assert_eq!(
8118 ChartCanvas::visual_map_y_at_value(track, domain, 10.0),
8119 track.origin.y.0
8120 );
8121 }
8122
8123 #[test]
8124 fn visual_map_track_applies_style_padding() {
8125 let mut canvas =
8126 ChartCanvas::new(multi_axis_visual_map_spec()).expect("spec should be valid");
8127 let mut style = ChartStyle::default();
8128 style.visual_map_band_x = Px(80.0);
8129 style.visual_map_padding = Px(10.0);
8130 canvas.set_style(style);
8131
8132 let bounds = Rect::new(
8133 Point::new(Px(0.0), Px(0.0)),
8134 Size::new(Px(800.0), Px(400.0)),
8135 );
8136 let layout = canvas.compute_layout(bounds);
8137 canvas.last_layout = layout;
8138
8139 let tracks = canvas.visual_map_tracks();
8140 assert_eq!(tracks.len(), 1);
8141 let (_id, _vm, track) = tracks[0];
8142 let outer = canvas
8143 .last_layout
8144 .visual_map
8145 .expect("expected a visual map band rect");
8146 assert_eq!(track.origin.x.0, outer.origin.x.0 + 10.0);
8147 assert_eq!(track.origin.y.0, outer.origin.y.0 + 10.0);
8148 }
8149
8150 #[test]
8151 fn view_window_2d_action_is_atomic() {
8152 let x_axis = AxisId::new(1);
8153 let y_axis = AxisId::new(2);
8154 let x = DataWindow {
8155 min: 10.0,
8156 max: 20.0,
8157 };
8158 let y = DataWindow {
8159 min: -5.0,
8160 max: 5.0,
8161 };
8162
8163 let action = Action::SetViewWindow2D {
8164 x_axis,
8165 y_axis,
8166 x: Some(x),
8167 y: Some(y),
8168 };
8169 match action {
8170 Action::SetViewWindow2D {
8171 x_axis: ax,
8172 y_axis: ay,
8173 x: Some(wx),
8174 y: Some(wy),
8175 } => {
8176 assert_eq!(ax, x_axis);
8177 assert_eq!(ay, y_axis);
8178 assert_eq!(wx, x);
8179 assert_eq!(wy, y);
8180 }
8181 _ => panic!("expected SetViewWindow2D"),
8182 }
8183 }
8184
8185 #[test]
8186 fn slider_window_after_delta_clamps_and_never_inverts() {
8187 let extent = DataWindow {
8188 min: 0.0,
8189 max: 100.0,
8190 };
8191 let start = DataWindow {
8192 min: 20.0,
8193 max: 30.0,
8194 };
8195
8196 let left =
8197 ChartCanvas::slider_window_after_delta(extent, start, -999.0, SliderDragKind::Pan);
8198 assert_eq!(
8199 left,
8200 DataWindow {
8201 min: 0.0,
8202 max: 10.0
8203 }
8204 );
8205
8206 let right =
8207 ChartCanvas::slider_window_after_delta(extent, start, 999.0, SliderDragKind::Pan);
8208 assert_eq!(
8209 right,
8210 DataWindow {
8211 min: 90.0,
8212 max: 100.0
8213 }
8214 );
8215
8216 let inverted_min =
8217 ChartCanvas::slider_window_after_delta(extent, start, 999.0, SliderDragKind::HandleMin);
8218 assert!(inverted_min.max > inverted_min.min);
8219 assert_eq!(inverted_min.max, start.max);
8220 assert!(inverted_min.min >= extent.min && inverted_min.max <= extent.max);
8221
8222 let inverted_max = ChartCanvas::slider_window_after_delta(
8223 extent,
8224 start,
8225 -999.0,
8226 SliderDragKind::HandleMax,
8227 );
8228 assert!(inverted_max.max > inverted_max.min);
8229 assert_eq!(inverted_max.min, start.min);
8230 assert!(inverted_max.min >= extent.min && inverted_max.max <= extent.max);
8231 }
8232}