Skip to main content

fret_chart/declarative/
panel.rs

1use fret_core::time::{Duration, Instant};
2use std::collections::BTreeMap;
3use std::sync::{Arc, Mutex};
4
5use delinea::engine::model::{ChartPatch, PatchMode};
6use delinea::engine::window::DataWindow;
7use delinea::marks::{MarkKind, MarkPayloadRef, MarkTree};
8use delinea::tooltip::TooltipOutput;
9use delinea::{Action, ChartEngine, WorkBudget};
10use fret_canvas::ui::{
11    CanvasToolDownResult, CanvasToolEntry, CanvasToolHandlers, CanvasToolId, CanvasToolRouterProps,
12    OnCanvasToolPinch, OnCanvasToolPointerDown, OnCanvasToolPointerMove, OnCanvasToolPointerUp,
13    OnCanvasToolWheel, PanZoomCanvasPaintCx, PanZoomCanvasSurfacePanelProps,
14    canvas_tool_router_panel,
15};
16use fret_core::{
17    Color, Corners, DrawOrder, Edges, KeyCode, MouseButton, PathCommand, PathStyle, Point, Px,
18    Rect, Size, StrokeStyle,
19};
20use fret_runtime::Model;
21use fret_ui::action::OnKeyDown;
22use fret_ui::canvas::CanvasPainter;
23use fret_ui::element::{AnyElement, CanvasProps, FocusScopeProps, Length, PointerRegionProps};
24use fret_ui::{ElementContext, UiHost};
25
26use crate::input_map::{ChartInputMap, ModifierKey};
27use crate::retained::ChartStyle;
28use crate::{DefaultTooltipFormatter, TooltipFormatter, TooltipTextLine};
29
30use super::legend_overlay::{LegendOverlayState, LegendSeriesEntry, legend_overlay_tool};
31use super::tooltip_overlay::{AxisPointerLabelOverlay, TooltipOverlayState, tooltip_overlay_tool};
32
33#[derive(Debug, Default)]
34struct NullTextMeasurer;
35
36impl delinea::text::TextMeasurer for NullTextMeasurer {
37    fn measure(
38        &mut self,
39        _text: delinea::ids::StringId,
40        _style: delinea::text::TextStyleId,
41    ) -> delinea::text::TextMetrics {
42        delinea::text::TextMetrics::default()
43    }
44}
45
46#[derive(Debug, Clone, Copy)]
47struct ChartPanDrag {
48    start_pos: Point,
49    x_axis: delinea::AxisId,
50    y_axis: delinea::AxisId,
51    start_x: DataWindow,
52    start_y: DataWindow,
53}
54
55fn default_chart_input_map_safe() -> ChartInputMap {
56    let mut map = ChartInputMap::default();
57    map.wheel_zoom_mod = Some(ModifierKey::Ctrl);
58    map
59}
60
61fn primary_axes(engine: &ChartEngine) -> Option<(delinea::AxisId, delinea::AxisId)> {
62    let model = engine.model();
63    for id in &model.series_order {
64        let s = model.series.get(id)?;
65        if s.visible {
66            return Some((s.x_axis, s.y_axis));
67        }
68    }
69    None
70}
71
72fn fallback_window() -> DataWindow {
73    DataWindow { min: 0.0, max: 1.0 }
74}
75
76fn window_for_axis_x(engine: &ChartEngine, axis: delinea::AxisId) -> DataWindow {
77    engine
78        .output()
79        .axis_windows
80        .get(&axis)
81        .copied()
82        .unwrap_or_else(fallback_window)
83}
84
85fn window_for_axis_y(engine: &ChartEngine, axis: delinea::AxisId) -> DataWindow {
86    engine
87        .output()
88        .axis_windows
89        .get(&axis)
90        .copied()
91        .unwrap_or_else(fallback_window)
92}
93
94fn paint_color(style: ChartStyle, paint: delinea::PaintId) -> Color {
95    let palette = &style.series_palette;
96    palette[(paint.0 as usize) % palette.len()]
97}
98
99fn series_color(style: ChartStyle, series: delinea::SeriesId) -> Color {
100    let palette = &style.series_palette;
101    palette[(series.0 as usize) % palette.len()]
102}
103
104#[track_caller]
105fn ensure_engine_model<H: UiHost>(
106    cx: &mut ElementContext<'_, H>,
107    controlled: Option<Model<ChartEngine>>,
108    spec: delinea::ChartSpec,
109) -> Model<ChartEngine> {
110    if let Some(model) = controlled {
111        return model;
112    }
113
114    let mut spec = spec;
115    spec.axis_pointer.get_or_insert_with(Default::default);
116    cx.local_model(|| ChartEngine::new(spec).expect("chart spec should be valid"))
117}
118
119#[derive(Debug, Clone)]
120struct MarksCache {
121    marks_rev: delinea::ids::Revision,
122    output_rev: delinea::ids::Revision,
123    marks: Arc<MarkTree>,
124    axis_pointer: Option<AxisPointerPaintData>,
125    hover_point_px: Option<Point>,
126}
127
128impl Default for MarksCache {
129    fn default() -> Self {
130        Self {
131            marks_rev: delinea::ids::Revision::default(),
132            output_rev: delinea::ids::Revision::default(),
133            marks: Arc::new(MarkTree::default()),
134            axis_pointer: None,
135            hover_point_px: None,
136        }
137    }
138}
139
140#[derive(Debug, Clone, Copy)]
141struct AxisPointerPaintData {
142    crosshair_px: Point,
143    shadow_rect_px: Option<Rect>,
144    draw_x: bool,
145    draw_y: bool,
146}
147
148#[derive(Clone)]
149pub struct ChartCanvasPanelProps {
150    pub pointer_region: PointerRegionProps,
151    pub canvas: CanvasProps,
152
153    /// When `None`, an internal engine model is created once from `spec`.
154    pub engine: Option<Model<ChartEngine>>,
155    pub spec: delinea::ChartSpec,
156
157    /// Optional formatter hook for axis-trigger tooltips (ADR 0209).
158    ///
159    /// When `None`, `DefaultTooltipFormatter` is used.
160    pub tooltip_formatter: Option<Arc<dyn TooltipFormatter>>,
161
162    /// Chart interaction mapping (ImPlot-aligned). Defaults to a "safe" wheel mapping
163    /// (zoom requires Ctrl), because charts are often embedded inside scroll containers.
164    pub input_map: ChartInputMap,
165
166    pub style: ChartStyle,
167}
168
169impl ChartCanvasPanelProps {
170    pub fn new(spec: delinea::ChartSpec) -> Self {
171        Self {
172            pointer_region: PointerRegionProps::default(),
173            canvas: CanvasProps::default(),
174            engine: None,
175            spec,
176            tooltip_formatter: None,
177            input_map: default_chart_input_map_safe(),
178            style: ChartStyle::default(),
179        }
180    }
181}
182
183#[track_caller]
184pub fn chart_canvas_panel<H: UiHost>(
185    cx: &mut ElementContext<'_, H>,
186    mut props: ChartCanvasPanelProps,
187) -> AnyElement {
188    props.pointer_region.layout.size.width = Length::Fill;
189    props.pointer_region.layout.size.height = Length::Fill;
190    props.canvas.layout.size.width = Length::Fill;
191    props.canvas.layout.size.height = Length::Fill;
192
193    let engine = ensure_engine_model(cx, props.engine.clone(), props.spec.clone());
194
195    // Tool-local drag model.
196    let pan_drag: Model<Option<ChartPanDrag>> = cx.local_model(|| None::<ChartPanDrag>);
197
198    let legend_state: Arc<Mutex<LegendOverlayState>> = cx.slot_state(
199        || Arc::new(Mutex::new(LegendOverlayState::default())),
200        |st| st.clone(),
201    );
202    let tooltip_state: Arc<Mutex<TooltipOverlayState>> = cx.slot_state(
203        || Arc::new(Mutex::new(TooltipOverlayState::default())),
204        |st| st.clone(),
205    );
206
207    let default_tooltip_formatter: Arc<dyn TooltipFormatter> = cx.slot_state(
208        || Arc::new(DefaultTooltipFormatter) as Arc<dyn TooltipFormatter>,
209        |st| st.clone(),
210    );
211    let tooltip_formatter: Arc<dyn TooltipFormatter> = props
212        .tooltip_formatter
213        .clone()
214        .unwrap_or(default_tooltip_formatter);
215
216    // Step the engine during declarative render and cache the current marks snapshot.
217    let bounds = cx.bounds;
218    let mut unfinished = false;
219
220    let marks_cache_slot = cx.slot_id();
221    let (prev_marks_rev, prev_output_rev) =
222        cx.state_for(marks_cache_slot, MarksCache::default, |cache| {
223            (cache.marks_rev, cache.output_rev)
224        });
225
226    let mut marks_rev = prev_marks_rev;
227    let mut output_rev = prev_output_rev;
228    let mut output_marks: Option<Arc<MarkTree>> = None;
229
230    let mut legend_series: Vec<LegendSeriesEntry> = Vec::new();
231    let mut series_rank_by_id: BTreeMap<delinea::SeriesId, usize> = BTreeMap::default();
232    let mut axis_pointer_output: Option<delinea::engine::AxisPointerOutput> = None;
233    let mut axis_pointer_labels: Vec<AxisPointerLabelOverlay> = Vec::new();
234    let mut tooltip_lines: Vec<TooltipTextLine> = Vec::new();
235
236    let mut axis_pointer: Option<AxisPointerPaintData> = None;
237    let mut hover_point_px: Option<Point> = None;
238
239    let tooltip_formatter_c = tooltip_formatter.clone();
240    let _ = engine.update(cx.app, |engine, _cx| {
241        if engine.model().viewport != Some(bounds) {
242            let _ = engine.apply_patch(
243                ChartPatch {
244                    viewport: Some(Some(bounds)),
245                    ..ChartPatch::default()
246                },
247                PatchMode::Merge,
248            );
249        }
250
251        let mut measurer = NullTextMeasurer;
252        let start = Instant::now();
253        let mut steps_ran = 0u32;
254        let mut still_unfinished = true;
255        while still_unfinished && steps_ran < 8 && start.elapsed() < Duration::from_millis(4) {
256            let budget = WorkBudget::new(262_144, 0, 32);
257            let step = engine.step(&mut measurer, budget);
258            match step {
259                Ok(step) => {
260                    still_unfinished = step.unfinished;
261                }
262                Err(_) => {
263                    still_unfinished = false;
264                }
265            }
266            steps_ran = steps_ran.saturating_add(1);
267        }
268
269        unfinished = still_unfinished;
270
271        let output = engine.output();
272        output_rev = output.revision;
273        marks_rev = output.marks.revision;
274
275        if marks_rev != prev_marks_rev {
276            output_marks = Some(Arc::new(output.marks.clone()));
277        }
278
279        let model = engine.model();
280        series_rank_by_id.clear();
281        legend_series = model
282            .series_in_order()
283            .enumerate()
284            .map(|(order, s)| LegendSeriesEntry {
285                id: s.id,
286                order,
287                label: s
288                    .name
289                    .clone()
290                    .unwrap_or_else(|| format!("Series {}", s.id.0))
291                    .into(),
292                visible: s.visible,
293            })
294            .collect();
295        for s in &legend_series {
296            series_rank_by_id.insert(s.id, s.order);
297        }
298
299        axis_pointer_output = output.axis_pointer.clone();
300        axis_pointer_labels.clear();
301        tooltip_lines.clear();
302        if let Some(axis_pointer) = axis_pointer_output.as_ref() {
303            tooltip_lines =
304                tooltip_formatter_c.format_axis_pointer(engine, &output.axis_windows, axis_pointer);
305
306            if let Some(pointer_model) = model.axis_pointer.as_ref()
307                && pointer_model.label.show
308            {
309                let default_tooltip_spec = delinea::TooltipSpecV1::default();
310                let tooltip_spec = model.tooltip.as_ref().unwrap_or(&default_tooltip_spec);
311                let template = pointer_model.label.template.as_str();
312
313                let mut push_label_for_axis =
314                    |axis_id: delinea::AxisId, axis_kind: delinea::AxisKind, axis_value: f64| {
315                        let axis_window = output
316                            .axis_windows
317                            .get(&axis_id)
318                            .copied()
319                            .unwrap_or_default();
320                        let axis_name = model
321                            .axes
322                            .get(&axis_id)
323                            .and_then(|a| a.name.as_deref())
324                            .unwrap_or("");
325                        let value_text = if axis_value.is_finite() {
326                            delinea::engine::axis::format_value_for(
327                                model,
328                                axis_id,
329                                axis_window,
330                                axis_value,
331                            )
332                        } else {
333                            tooltip_spec.missing_value.clone()
334                        };
335                        let label_text = if template == "{value}" {
336                            value_text
337                        } else {
338                            template
339                                .replace("{value}", &value_text)
340                                .replace("{axis_name}", axis_name)
341                        };
342                        axis_pointer_labels.push(AxisPointerLabelOverlay {
343                            axis_kind,
344                            text: label_text.into(),
345                        });
346                    };
347
348                match &axis_pointer.tooltip {
349                    delinea::TooltipOutput::Axis(axis) => {
350                        push_label_for_axis(axis.axis, axis.axis_kind, axis.axis_value);
351                    }
352                    delinea::TooltipOutput::Item(item) => {
353                        push_label_for_axis(item.x_axis, delinea::AxisKind::X, item.x_value);
354                        push_label_for_axis(item.y_axis, delinea::AxisKind::Y, item.y_value);
355                    }
356                }
357            }
358        }
359
360        if output_rev != prev_output_rev {
361            hover_point_px = output.hover.map(|hit| hit.point_px);
362
363            if let Some(axis_pointer_out) = output.axis_pointer.as_ref() {
364                let (draw_x, draw_y) = match &axis_pointer_out.tooltip {
365                    TooltipOutput::Axis(axis) => match axis.axis_kind {
366                        delinea::AxisKind::X => (true, false),
367                        delinea::AxisKind::Y => (false, true),
368                    },
369                    TooltipOutput::Item(_) => (true, true),
370                };
371
372                axis_pointer = Some(AxisPointerPaintData {
373                    crosshair_px: axis_pointer_out.crosshair_px,
374                    shadow_rect_px: axis_pointer_out.shadow_rect_px,
375                    draw_x,
376                    draw_y,
377                });
378            } else {
379                axis_pointer = None;
380            }
381        }
382    });
383
384    if let Ok(mut st) = legend_state.lock() {
385        st.sync_series(legend_series);
386    }
387    if let Ok(mut st) = tooltip_state.lock() {
388        st.axis_pointer = axis_pointer_output;
389        st.axis_pointer_labels = std::mem::take(&mut axis_pointer_labels);
390        st.lines = tooltip_lines;
391        st.series_rank_by_id = series_rank_by_id;
392    }
393
394    let (cache, axis_pointer, hover_point_px) =
395        cx.state_for(marks_cache_slot, MarksCache::default, |cache| {
396            if cache.marks_rev != marks_rev
397                && let Some(marks) = output_marks.clone()
398            {
399                cache.marks_rev = marks_rev;
400                cache.marks = marks;
401            }
402
403            if cache.output_rev != output_rev {
404                cache.output_rev = output_rev;
405                cache.axis_pointer = axis_pointer;
406                cache.hover_point_px = hover_point_px;
407            }
408
409            (
410                cache.marks.clone(),
411                cache.axis_pointer,
412                cache.hover_point_px,
413            )
414        });
415
416    let style = props.style;
417    let engine_c = engine.clone();
418    let input_map = props.input_map;
419
420    let pan_drag_down = pan_drag.clone();
421    let on_pan_down: OnCanvasToolPointerDown = Arc::new(move |host, _action_cx, tool_cx, down| {
422        if !input_map.pan.matches(down.button, down.modifiers) {
423            return CanvasToolDownResult::unhandled();
424        }
425        if !tool_cx.bounds.contains(down.position) {
426            return CanvasToolDownResult::unhandled();
427        }
428
429        let Some((x_axis, y_axis)) = host
430            .models_mut()
431            .read(&engine_c, primary_axes)
432            .ok()
433            .flatten()
434        else {
435            return CanvasToolDownResult::unhandled();
436        };
437
438        let (start_x, start_y) = host
439            .models_mut()
440            .read(&engine_c, |engine| {
441                (
442                    window_for_axis_x(engine, x_axis),
443                    window_for_axis_y(engine, y_axis),
444                )
445            })
446            .ok()
447            .unwrap_or((fallback_window(), fallback_window()));
448
449        let _ = host.models_mut().update(&pan_drag_down, |st| {
450            *st = Some(ChartPanDrag {
451                start_pos: down.position,
452                x_axis,
453                y_axis,
454                start_x,
455                start_y,
456            });
457        });
458
459        CanvasToolDownResult::activate_and_capture()
460    });
461
462    let pan_drag_move = pan_drag.clone();
463    let engine_c = engine.clone();
464    let on_pan_move: OnCanvasToolPointerMove = Arc::new(move |host, action_cx, tool_cx, mv| {
465        let Some(drag) = host
466            .models_mut()
467            .read(&pan_drag_move, |st| *st)
468            .ok()
469            .flatten()
470        else {
471            return false;
472        };
473
474        let width = tool_cx.bounds.size.width.0;
475        let height = tool_cx.bounds.size.height.0;
476        if width <= 0.0 || height <= 0.0 {
477            return false;
478        }
479
480        let dx = mv.position.x.0 - drag.start_pos.x.0;
481        let dy = mv.position.y.0 - drag.start_pos.y.0;
482
483        let _ = host.models_mut().update(&engine_c, |engine| {
484            engine.apply_action(Action::PanDataWindowXFromBase {
485                axis: drag.x_axis,
486                base: drag.start_x,
487                delta_px: dx,
488                viewport_span_px: width,
489            });
490            engine.apply_action(Action::PanDataWindowYFromBase {
491                axis: drag.y_axis,
492                base: drag.start_y,
493                delta_px: -dy,
494                viewport_span_px: height,
495            });
496        });
497
498        host.request_redraw(action_cx.window);
499        true
500    });
501
502    let pan_drag_up = pan_drag.clone();
503    let on_pan_up: OnCanvasToolPointerUp = Arc::new(move |host, _action_cx, _tool_cx, _up| {
504        let _ = host.models_mut().update(&pan_drag_up, |st| *st = None);
505        true
506    });
507
508    let engine_c = engine.clone();
509    let on_hover_move: OnCanvasToolPointerMove = Arc::new(move |host, action_cx, _tool_cx, mv| {
510        let _ = host.models_mut().update(&engine_c, |engine| {
511            engine.apply_action(Action::HoverAt { point: mv.position });
512        });
513        host.request_redraw(action_cx.window);
514        true
515    });
516
517    let engine_c = engine.clone();
518    let input_map_c = input_map;
519    let on_wheel_zoom: OnCanvasToolWheel = Arc::new(move |host, action_cx, tool_cx, wheel| {
520        let delta_y = wheel.delta.y.0;
521        if !delta_y.is_finite() {
522            return false;
523        }
524
525        if let Some(required) = input_map_c.wheel_zoom_mod
526            && !required.is_pressed(wheel.modifiers)
527        {
528            return false;
529        }
530
531        let width = tool_cx.bounds.size.width.0;
532        let height = tool_cx.bounds.size.height.0;
533        if width <= 0.0 || height <= 0.0 {
534            return false;
535        }
536
537        let Some((x_axis, y_axis)) = host
538            .models_mut()
539            .read(&engine_c, primary_axes)
540            .ok()
541            .flatten()
542        else {
543            return false;
544        };
545
546        let (base_x, base_y) = host
547            .models_mut()
548            .read(&engine_c, |engine| {
549                (
550                    window_for_axis_x(engine, x_axis),
551                    window_for_axis_y(engine, y_axis),
552                )
553            })
554            .ok()
555            .unwrap_or((fallback_window(), fallback_window()));
556
557        // Match ImPlot's default feel: zoom factor ~= 2^(delta_y * 0.0025)
558        let log2_scale = delta_y * 0.0025;
559
560        let local_x = (wheel.position.x.0 - tool_cx.bounds.origin.x.0).clamp(0.0, width);
561        let local_y = (wheel.position.y.0 - tool_cx.bounds.origin.y.0).clamp(0.0, height);
562        let center_x = local_x;
563        let center_y_from_bottom = height - local_y;
564
565        let _ = host.models_mut().update(&engine_c, |engine| {
566            engine.apply_action(Action::ZoomDataWindowXFromBase {
567                axis: x_axis,
568                base: base_x,
569                center_px: center_x,
570                log2_scale,
571                viewport_span_px: width,
572            });
573            engine.apply_action(Action::ZoomDataWindowYFromBase {
574                axis: y_axis,
575                base: base_y,
576                center_px: center_y_from_bottom,
577                log2_scale,
578                viewport_span_px: height,
579            });
580        });
581
582        host.request_redraw(action_cx.window);
583        true
584    });
585
586    let legend_tool = legend_overlay_tool(engine.clone(), legend_state.clone(), style);
587    let tooltip_tool = tooltip_overlay_tool(tooltip_state.clone(), style);
588
589    let engine_c = engine.clone();
590    let on_pinch_zoom: OnCanvasToolPinch = Arc::new(move |host, action_cx, tool_cx, pinch| {
591        if !pinch.delta.is_finite() {
592            return false;
593        }
594
595        let width = tool_cx.bounds.size.width.0;
596        let height = tool_cx.bounds.size.height.0;
597        if width <= 0.0 || height <= 0.0 {
598            return false;
599        }
600
601        let Some((x_axis, y_axis)) = host
602            .models_mut()
603            .read(&engine_c, primary_axes)
604            .ok()
605            .flatten()
606        else {
607            return false;
608        };
609
610        let (base_x, base_y) = host
611            .models_mut()
612            .read(&engine_c, |engine| {
613                (
614                    window_for_axis_x(engine, x_axis),
615                    window_for_axis_y(engine, y_axis),
616                )
617            })
618            .ok()
619            .unwrap_or((fallback_window(), fallback_window()));
620
621        // Match `fret-ui-kit`'s pinch mapping: factor = 1 + delta.
622        let delta = pinch.delta.clamp(-0.95, 10.0);
623        let factor = (1.0 + delta).max(0.01);
624        let log2_scale = factor.log2();
625        if !log2_scale.is_finite() || log2_scale.abs() <= 1.0e-9 {
626            return false;
627        }
628
629        let local_x = (pinch.position.x.0 - tool_cx.bounds.origin.x.0).clamp(0.0, width);
630        let local_y = (pinch.position.y.0 - tool_cx.bounds.origin.y.0).clamp(0.0, height);
631        let center_x = local_x;
632        let center_y_from_bottom = height - local_y;
633
634        let _ = host.models_mut().update(&engine_c, |engine| {
635            engine.apply_action(Action::ZoomDataWindowXFromBase {
636                axis: x_axis,
637                base: base_x,
638                center_px: center_x,
639                log2_scale,
640                viewport_span_px: width,
641            });
642            engine.apply_action(Action::ZoomDataWindowYFromBase {
643                axis: y_axis,
644                base: base_y,
645                center_px: center_y_from_bottom,
646                log2_scale,
647                viewport_span_px: height,
648            });
649        });
650
651        host.request_redraw(action_cx.window);
652        true
653    });
654
655    let tools = vec![
656        legend_tool,
657        tooltip_tool,
658        CanvasToolEntry {
659            id: CanvasToolId::new(1),
660            priority: 100,
661            handlers: CanvasToolHandlers {
662                on_pointer_down: Some(on_pan_down),
663                on_pointer_move: Some(on_pan_move),
664                on_pointer_up: Some(on_pan_up),
665                ..Default::default()
666            },
667        },
668        CanvasToolEntry {
669            id: CanvasToolId::new(2),
670            priority: 50,
671            handlers: CanvasToolHandlers {
672                on_wheel: Some(on_wheel_zoom),
673                ..Default::default()
674            },
675        },
676        CanvasToolEntry {
677            id: CanvasToolId::new(4),
678            priority: 50,
679            handlers: CanvasToolHandlers {
680                on_pinch: Some(on_pinch_zoom),
681                ..Default::default()
682            },
683        },
684        CanvasToolEntry {
685            id: CanvasToolId::new(3),
686            priority: -10,
687            handlers: CanvasToolHandlers {
688                on_pointer_move: Some(on_hover_move),
689                ..Default::default()
690            },
691        },
692    ];
693
694    let mut pan_zoom = PanZoomCanvasSurfacePanelProps::default();
695    pan_zoom.pointer_region = props.pointer_region;
696    pan_zoom.canvas = props.canvas;
697
698    // Disable built-in infinite-canvas pan/zoom: chart interactions are routed via tools.
699    pan_zoom.pan_button = MouseButton::Other(999);
700    pan_zoom.min_zoom = 1.0;
701    pan_zoom.max_zoom = 1.0;
702
703    let router_props = CanvasToolRouterProps {
704        pan_zoom,
705        active_tool: None,
706    };
707
708    let marks = cache;
709    let paint = move |painter: &mut CanvasPainter<'_>, paint_cx: PanZoomCanvasPaintCx| {
710        if unfinished {
711            painter.request_animation_frame();
712        }
713
714        let bounds = painter.bounds();
715
716        // Basic background.
717        if let Some(background) = style.background {
718            painter.scene().push(fret_core::SceneOp::Quad {
719                order: DrawOrder(style.draw_order.0.saturating_sub(1)),
720                rect: bounds,
721                background: fret_core::Paint::Solid(background).into(),
722                border: Edges::all(Px(0.0)),
723                border_paint: fret_core::Paint::TRANSPARENT.into(),
724
725                corner_radii: Corners::all(Px(0.0)),
726            });
727        }
728
729        let viewport = bounds;
730        painter.with_clip_rect(viewport, |painter| {
731            let marks = &*marks;
732            let arena = &marks.arena;
733
734            for node in &marks.nodes {
735                match (node.kind, &node.payload) {
736                    (MarkKind::Polyline, MarkPayloadRef::Polyline(poly)) => {
737                        let start = poly.points.start;
738                        let end = poly.points.end;
739                        if end <= start || end > arena.points.len() {
740                            continue;
741                        }
742
743                        let mut commands: Vec<PathCommand> =
744                            Vec::with_capacity((end - start).saturating_add(1));
745                        for (i, p) in arena.points[start..end].iter().enumerate() {
746                            if i == 0 {
747                                commands.push(PathCommand::MoveTo(*p));
748                            } else {
749                                commands.push(PathCommand::LineTo(*p));
750                            }
751                        }
752                        if commands.len() < 2 {
753                            continue;
754                        }
755
756                        let stroke_width = poly
757                            .stroke
758                            .as_ref()
759                            .map(|(_, s)| s.width)
760                            .unwrap_or(style.stroke_width);
761                        let stroke_color = if let Some((paint, _)) = &poly.stroke {
762                            paint_color(style, *paint)
763                        } else if let Some(series) = node.source_series {
764                            series_color(style, series)
765                        } else {
766                            style.stroke_color
767                        };
768
769                        let key = node.id.0;
770                        painter.path(
771                            key,
772                            DrawOrder(style.draw_order.0.saturating_add(node.order.0)),
773                            Point::new(Px(0.0), Px(0.0)),
774                            &commands,
775                            PathStyle::Stroke(StrokeStyle {
776                                width: stroke_width,
777                            }),
778                            stroke_color,
779                            paint_cx.raster_scale_factor,
780                        );
781                    }
782                    (MarkKind::Rect, MarkPayloadRef::Rect(rects)) => {
783                        let start = rects.rects.start;
784                        let end = rects.rects.end;
785                        if end <= start || end > arena.rects.len() {
786                            continue;
787                        }
788
789                        let stroke_width = rects
790                            .stroke
791                            .as_ref()
792                            .map(|(_, s)| s.width)
793                            .filter(|w| w.0.is_finite() && w.0 > 0.0)
794                            .unwrap_or(Px(0.0));
795
796                        for rect in &arena.rects[start..end] {
797                            let mut background = Color::TRANSPARENT;
798                            if let Some(paint) = rects.fill {
799                                background = paint_color(style, paint);
800                            } else if let Some(series) = node.source_series {
801                                background = series_color(style, series);
802                            }
803                            background.a *= rects.opacity_mul.unwrap_or(1.0);
804
805                            let border_color = if stroke_width.0 > 0.0 {
806                                background
807                            } else {
808                                Color::TRANSPARENT
809                            };
810
811                            painter.scene().push(fret_core::SceneOp::Quad {
812                                order: DrawOrder(style.draw_order.0.saturating_add(node.order.0)),
813                                rect: *rect,
814                                background: fret_core::Paint::Solid(background).into(),
815                                border: Edges::all(stroke_width),
816                                border_paint: fret_core::Paint::Solid(border_color).into(),
817                                corner_radii: Corners::all(Px(0.0)),
818                            });
819                        }
820                    }
821                    (MarkKind::Points, MarkPayloadRef::Points(points)) => {
822                        let start = points.points.start;
823                        let end = points.points.end;
824                        if end <= start || end > arena.points.len() {
825                            continue;
826                        }
827
828                        let base_point_r = style.scatter_point_radius.0.max(1.0);
829                        let stroke_width = points
830                            .stroke
831                            .as_ref()
832                            .map(|(_, s)| s.width)
833                            .filter(|w| w.0.is_finite() && w.0 > 0.0)
834                            .unwrap_or(Px(0.0));
835
836                        for p in &arena.points[start..end] {
837                            let radius_mul = points
838                                .radius_mul
839                                .filter(|v| v.is_finite() && *v > 0.0)
840                                .unwrap_or(1.0);
841                            let point_r = (base_point_r * radius_mul).max(1.0);
842
843                            let mut fill = style.stroke_color;
844                            if let Some(paint) = points.fill {
845                                fill = paint_color(style, paint);
846                                fill.a *= style.scatter_fill_alpha;
847                            } else if let Some(series) = node.source_series {
848                                fill = series_color(style, series);
849                                fill.a *= style.scatter_fill_alpha;
850                            }
851                            fill.a *= points.opacity_mul.unwrap_or(1.0);
852
853                            let border_color = if stroke_width.0 > 0.0 {
854                                fill
855                            } else {
856                                Color::TRANSPARENT
857                            };
858
859                            painter.scene().push(fret_core::SceneOp::Quad {
860                                order: DrawOrder(style.draw_order.0.saturating_add(node.order.0)),
861                                rect: Rect::new(
862                                    Point::new(Px(p.x.0 - point_r), Px(p.y.0 - point_r)),
863                                    Size::new(Px(2.0 * point_r), Px(2.0 * point_r)),
864                                ),
865                                background: fret_core::Paint::Solid(fill).into(),
866
867                                border: Edges::all(stroke_width),
868                                border_paint: fret_core::Paint::Solid(border_color).into(),
869                                corner_radii: Corners::all(Px(point_r)),
870                            });
871                        }
872                    }
873                    _ => {}
874                }
875            }
876
877            let overlay_order = DrawOrder(style.draw_order.0.saturating_add(10_000));
878            let shadow_order = DrawOrder(style.draw_order.0.saturating_add(9_900));
879
880            if let Some(axis_pointer) = axis_pointer {
881                if let Some(rect) = axis_pointer.shadow_rect_px {
882                    let color = Color {
883                        a: 0.08,
884                        ..style.selection_fill
885                    };
886                    painter.scene().push(fret_core::SceneOp::Quad {
887                        order: shadow_order,
888                        rect,
889                        background: fret_core::Paint::Solid(color).into(),
890
891                        border: Edges::all(Px(0.0)),
892                        border_paint: fret_core::Paint::TRANSPARENT.into(),
893
894                        corner_radii: Corners::all(Px(0.0)),
895                    });
896                } else {
897                    let plot = bounds;
898                    let crosshair_w = style.crosshair_width.0.max(1.0);
899                    let x = axis_pointer
900                        .crosshair_px
901                        .x
902                        .0
903                        .clamp(plot.origin.x.0, plot.origin.x.0 + plot.size.width.0);
904                    let y = axis_pointer
905                        .crosshair_px
906                        .y
907                        .0
908                        .clamp(plot.origin.y.0, plot.origin.y.0 + plot.size.height.0);
909
910                    if axis_pointer.draw_x {
911                        painter.scene().push(fret_core::SceneOp::Quad {
912                            order: overlay_order,
913                            rect: Rect::new(
914                                Point::new(Px(x - 0.5 * crosshair_w), plot.origin.y),
915                                Size::new(Px(crosshair_w), plot.size.height),
916                            ),
917                            background: fret_core::Paint::Solid(style.crosshair_color).into(),
918
919                            border: Edges::all(Px(0.0)),
920                            border_paint: fret_core::Paint::TRANSPARENT.into(),
921
922                            corner_radii: Corners::all(Px(0.0)),
923                        });
924                    }
925                    if axis_pointer.draw_y {
926                        painter.scene().push(fret_core::SceneOp::Quad {
927                            order: overlay_order,
928                            rect: Rect::new(
929                                Point::new(plot.origin.x, Px(y - 0.5 * crosshair_w)),
930                                Size::new(plot.size.width, Px(crosshair_w)),
931                            ),
932                            background: fret_core::Paint::Solid(style.crosshair_color).into(),
933
934                            border: Edges::all(Px(0.0)),
935                            border_paint: fret_core::Paint::TRANSPARENT.into(),
936
937                            corner_radii: Corners::all(Px(0.0)),
938                        });
939                    }
940                }
941            }
942
943            if let Some(point) = hover_point_px {
944                let size = style.hover_point_size.0.max(1.0);
945                let r = 0.5 * size;
946                painter.scene().push(fret_core::SceneOp::Quad {
947                    order: overlay_order,
948                    rect: Rect::new(
949                        Point::new(Px(point.x.0 - r), Px(point.y.0 - r)),
950                        Size::new(Px(size), Px(size)),
951                    ),
952                    background: fret_core::Paint::Solid(style.hover_point_color).into(),
953
954                    border: Edges::all(Px(0.0)),
955                    border_paint: fret_core::Paint::TRANSPARENT.into(),
956
957                    corner_radii: Corners::all(Px(r)),
958                });
959            }
960        });
961    };
962
963    let engine_k = engine.clone();
964    let legend_state_k = legend_state.clone();
965    let on_key_down: OnKeyDown = Arc::new(move |host, action_cx, down| {
966        let modifiers = down.modifiers;
967        let legend_mods_ok =
968            modifiers.ctrl && !modifiers.alt && !modifiers.alt_gr && !modifiers.meta;
969        if !legend_mods_ok {
970            return false;
971        }
972
973        let in_legend = legend_state_k
974            .lock()
975            .ok()
976            .is_some_and(|st| st.is_pointer_in_panel());
977        if !in_legend {
978            return false;
979        }
980
981        let changed = host
982            .models_mut()
983            .update(&engine_k, |engine| {
984                let model = engine.model();
985                let updates = match down.key {
986                    KeyCode::KeyA if modifiers.shift => {
987                        crate::legend_logic::legend_select_none_updates(model)
988                    }
989                    KeyCode::KeyA => crate::legend_logic::legend_select_all_updates(model),
990                    KeyCode::KeyI if !modifiers.shift => {
991                        crate::legend_logic::legend_invert_updates(model)
992                    }
993                    _ => return false,
994                };
995                if updates.is_empty() {
996                    return false;
997                }
998                engine.apply_action(Action::SetSeriesVisibility { updates });
999                true
1000            })
1001            .ok()
1002            .unwrap_or(false);
1003
1004        if !changed {
1005            return false;
1006        }
1007        if let Ok(mut st) = legend_state_k.lock() {
1008            st.anchor = None;
1009        }
1010        host.request_redraw(action_cx.window);
1011        true
1012    });
1013
1014    let focus_props = FocusScopeProps::default();
1015    cx.focus_scope_with_id(focus_props, move |cx, focus_id| {
1016        cx.key_add_on_key_down_for(focus_id, on_key_down);
1017        vec![canvas_tool_router_panel(cx, router_props, tools, paint)]
1018    })
1019}