Skip to main content

fret_chart/retained/
canvas.rs

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    /// Normal single-surface chart canvas (owned engine).
371    Full,
372    /// A per-grid view into a shared engine (multi-grid).
373    GridView,
374    /// A global controller surface for a shared engine (multi-grid): legend + tooltip overlays.
375    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    /// Creates a `ChartCanvas` that renders a shared `ChartEngine`.
479    ///
480    /// This is primarily intended for diagnostics harnesses and multi-surface adapters that need to
481    /// hold an engine handle outside the widget tree.
482    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                // No explicit "clear hover" action exists. Instead, hover far outside any plot viewport.
854                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        // Linking actions are applied during paint, but the engine is stepped during prepaint.
861        // Request a follow-up frame so linked changes become visible deterministically.
862        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        // Only apply linked domain windows when the shared model has changed. This prevents stale
874        // shared state from overwriting locally-produced window changes before they can be
875        // published and routed by the linking layer.
876        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        // Same rationale as `sync_linked_axis_pointer`: the engine steps in prepaint, so we need
921        // a follow-up frame after applying linked actions to make the visual update observable.
922        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        // Keep layout in sync for point mapping.
2242        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        // Matches ImPlot's default selection modifiers:
3019        // - Alt: expand selection horizontally to plot edge.
3020        // - Shift: expand selection vertically to plot edge.
3021        //
3022        // Note: when a modifier is required to start the drag gesture (e.g. Shift+LMB alternative),
3023        // treat it as part of the gesture chord and do not implicitly apply edge expansion.
3024        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        // Selector row (ECharts legend.selector-like affordance).
3352        // Keep it non-scrolling so it stays accessible even when the legend overflows.
3353        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                    // Render a lightweight ramp using the bucket palette. This is not ECharts parity yet
3495                    // (no multi-channel mapping), but provides a stable continuous controller affordance.
3496                    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        // X axes: baseline + ticks + labels.
3680        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        // Y axes: baseline + ticks + labels.
3755        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                // Slider interaction: allow left-drag in the slider track.
5090                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                                    // Jump: center the current span around the click and drag as pan.
5134                                    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                                    // Jump: center the current span around the click and drag as pan.
5230                                    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                // Match ImPlot's default feel: zoom factor ~= 2^(delta_y * 0.0025)
5619                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        // P0: run the engine synchronously, but allow multiple internal steps per frame so that
5703        // medium-sized datasets can produce the first set of marks without relying on external
5704        // redraw triggers.
5705        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        // Advance per-frame counters for optional cache pruning.
5768        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        // DataZoom slider: render for the active bottom X axis (if present).
6725        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        // DataZoom slider: render for the active Y axis (if present).
6798        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        // Conservative hygiene: long-lived charts should not grow text caches unbounded.
7201        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}