Skip to main content

fret_chart/
linking.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet};
3
4use delinea::engine::model::ChartModel;
5use delinea::engine::window::DataWindow;
6use delinea::ids::{AxisId, DatasetId, FieldId};
7use delinea::spec::AxisKind;
8use delinea::{ChartSpec, LinkEvent};
9use fret_runtime::Model;
10use fret_ui::UiHost;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct LinkAxisKey {
14    pub kind: AxisKind,
15    pub dataset: DatasetId,
16    pub field: FieldId,
17}
18
19impl Ord for LinkAxisKey {
20    fn cmp(&self, other: &Self) -> Ordering {
21        let rank = |kind: AxisKind| match kind {
22            AxisKind::X => 0u8,
23            AxisKind::Y => 1u8,
24        };
25
26        (rank(self.kind), self.dataset, self.field).cmp(&(
27            rank(other.kind),
28            other.dataset,
29            other.field,
30        ))
31    }
32}
33
34impl PartialOrd for LinkAxisKey {
35    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
36        Some(self.cmp(other))
37    }
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub struct AxisPointerLinkAnchor {
42    pub axis: LinkAxisKey,
43    pub value: f64,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct BrushSelectionLink2D {
48    pub x_axis: LinkAxisKey,
49    pub y_axis: LinkAxisKey,
50    pub x: DataWindow,
51    pub y: DataWindow,
52}
53
54#[derive(Debug, Default, Clone, Copy)]
55pub struct ChartLinkPolicy {
56    pub brush: bool,
57    pub axis_pointer: bool,
58    pub domain_windows: bool,
59}
60
61#[derive(Debug, Clone)]
62pub struct ChartLinkRouter {
63    axis_to_key: BTreeMap<AxisId, LinkAxisKey>,
64    key_to_axis: BTreeMap<LinkAxisKey, AxisId>,
65}
66
67impl ChartLinkRouter {
68    pub fn with_explicit_axis_map(mut self, map: BTreeMap<AxisId, LinkAxisKey>) -> Self {
69        self.apply_explicit_axis_map(map);
70        self
71    }
72
73    fn apply_explicit_axis_map(&mut self, map: BTreeMap<AxisId, LinkAxisKey>) {
74        let mut explicit_key_to_axis: BTreeMap<LinkAxisKey, Option<AxisId>> = BTreeMap::new();
75
76        for (axis, key) in map {
77            self.axis_to_key.insert(axis, key);
78
79            match explicit_key_to_axis.get(&key).copied().flatten() {
80                None => {
81                    explicit_key_to_axis.insert(key, Some(axis));
82                }
83                Some(existing) if existing == axis => {}
84                Some(_) => {
85                    explicit_key_to_axis.insert(key, None);
86                }
87            }
88        }
89
90        for (key, axis) in explicit_key_to_axis {
91            match axis {
92                Some(axis) => {
93                    self.key_to_axis.insert(key, axis);
94                }
95                None => {
96                    self.key_to_axis.remove(&key);
97                }
98            }
99        }
100    }
101
102    pub fn from_spec(spec: &ChartSpec) -> Self {
103        let mut axis_kind_by_id: BTreeMap<AxisId, AxisKind> = BTreeMap::new();
104        for axis in &spec.axes {
105            axis_kind_by_id.insert(axis.id, axis.kind);
106        }
107
108        let mut axis_to_pairs: BTreeMap<AxisId, BTreeSet<(DatasetId, FieldId)>> = BTreeMap::new();
109        for s in &spec.series {
110            axis_to_pairs
111                .entry(s.x_axis)
112                .or_default()
113                .insert((s.dataset, s.encode.x));
114            axis_to_pairs
115                .entry(s.y_axis)
116                .or_default()
117                .insert((s.dataset, s.encode.y));
118        }
119
120        let mut axis_to_key: BTreeMap<AxisId, LinkAxisKey> = BTreeMap::new();
121        let mut key_to_axis: BTreeMap<LinkAxisKey, AxisId> = BTreeMap::new();
122        for (axis, pairs) in axis_to_pairs {
123            let Some(kind) = axis_kind_by_id.get(&axis).copied() else {
124                continue;
125            };
126            if pairs.len() != 1 {
127                continue;
128            }
129            let (dataset, field) = pairs.into_iter().next().unwrap();
130            let key = LinkAxisKey {
131                kind,
132                dataset,
133                field,
134            };
135            axis_to_key.insert(axis, key);
136
137            match key_to_axis.get(&key).copied() {
138                None => {
139                    key_to_axis.insert(key, axis);
140                }
141                Some(_) => {
142                    // Not unique: drop the reverse mapping to avoid ambiguity.
143                    key_to_axis.remove(&key);
144                }
145            }
146        }
147
148        Self {
149            axis_to_key,
150            key_to_axis,
151        }
152    }
153
154    pub fn from_model(model: &ChartModel) -> Self {
155        let mut axis_to_pairs: BTreeMap<AxisId, BTreeSet<(DatasetId, FieldId)>> = BTreeMap::new();
156        for s in model.series.values() {
157            axis_to_pairs
158                .entry(s.x_axis)
159                .or_default()
160                .insert((s.dataset, s.encode.x));
161            axis_to_pairs
162                .entry(s.y_axis)
163                .or_default()
164                .insert((s.dataset, s.encode.y));
165        }
166
167        let mut axis_to_key: BTreeMap<AxisId, LinkAxisKey> = BTreeMap::new();
168        let mut key_to_axis: BTreeMap<LinkAxisKey, AxisId> = BTreeMap::new();
169        for (axis, pairs) in axis_to_pairs {
170            let Some(kind) = model.axes.get(&axis).map(|a| a.kind) else {
171                continue;
172            };
173            if pairs.len() != 1 {
174                continue;
175            }
176            let (dataset, field) = pairs.into_iter().next().unwrap();
177            let key = LinkAxisKey {
178                kind,
179                dataset,
180                field,
181            };
182            axis_to_key.insert(axis, key);
183
184            match key_to_axis.get(&key).copied() {
185                None => {
186                    key_to_axis.insert(key, axis);
187                }
188                Some(_) => {
189                    key_to_axis.remove(&key);
190                }
191            }
192        }
193
194        Self {
195            axis_to_key,
196            key_to_axis,
197        }
198    }
199
200    pub fn axis_key(&self, axis: AxisId) -> Option<LinkAxisKey> {
201        self.axis_to_key.get(&axis).copied()
202    }
203
204    pub fn axis_for_key(&self, key: LinkAxisKey) -> Option<AxisId> {
205        self.key_to_axis.get(&key).copied()
206    }
207}
208
209#[derive(Debug, Clone)]
210pub struct LinkedChartMember {
211    pub router: ChartLinkRouter,
212    pub output: Model<crate::retained::ChartCanvasOutput>,
213}
214
215#[derive(Debug, Clone)]
216struct LinkedChartMemberMemory {
217    last_link_events_revision: u64,
218    ignore_next_link_events_revision: bool,
219    last_domain_windows_by_key: BTreeMap<LinkAxisKey, Option<DataWindow>>,
220    ignore_next_domain_windows: bool,
221}
222
223#[derive(Debug)]
224pub struct LinkedChartGroup {
225    policy: ChartLinkPolicy,
226    members: Vec<LinkedChartMember>,
227    memory: Vec<LinkedChartMemberMemory>,
228    brush: Model<Option<BrushSelectionLink2D>>,
229    axis_pointer: Model<Option<AxisPointerLinkAnchor>>,
230    domain_windows: Model<BTreeMap<LinkAxisKey, Option<DataWindow>>>,
231}
232
233impl LinkedChartGroup {
234    pub fn new(
235        policy: ChartLinkPolicy,
236        brush: Model<Option<BrushSelectionLink2D>>,
237        axis_pointer: Model<Option<AxisPointerLinkAnchor>>,
238        domain_windows: Model<BTreeMap<LinkAxisKey, Option<DataWindow>>>,
239    ) -> Self {
240        Self {
241            policy,
242            members: Vec::new(),
243            memory: Vec::new(),
244            brush,
245            axis_pointer,
246            domain_windows,
247        }
248    }
249
250    pub fn push(&mut self, member: LinkedChartMember) -> &mut Self {
251        self.members.push(member);
252        self.memory.push(LinkedChartMemberMemory {
253            last_link_events_revision: 0,
254            ignore_next_link_events_revision: false,
255            last_domain_windows_by_key: BTreeMap::new(),
256            ignore_next_domain_windows: false,
257        });
258        self
259    }
260
261    pub fn tick<H: UiHost>(&mut self, app: &mut H) -> bool {
262        if self.members.len() <= 1 {
263            return false;
264        }
265
266        let Ok(shared_domain_windows) = self.domain_windows.read(app, |_app, w| w.clone()) else {
267            return false;
268        };
269
270        let mut outputs: Vec<Option<crate::retained::ChartCanvasOutput>> =
271            Vec::with_capacity(self.members.len());
272        for m in &self.members {
273            let out = m.output.read(app, |_app, o| o.clone()).ok();
274            outputs.push(out);
275        }
276
277        // First pass: clear ignore markers when the expected output change happened.
278        for i in 0..self.members.len() {
279            let Some(out) = outputs.get(i).and_then(|o| o.clone()) else {
280                continue;
281            };
282            let mem = &mut self.memory[i];
283            if mem.ignore_next_link_events_revision
284                && out.link_events_revision != mem.last_link_events_revision
285            {
286                mem.last_link_events_revision = out.link_events_revision;
287                mem.ignore_next_link_events_revision = false;
288            }
289            if mem.ignore_next_domain_windows
290                && out.snapshot.domain_windows_by_key != mem.last_domain_windows_by_key
291            {
292                mem.last_domain_windows_by_key = out.snapshot.domain_windows_by_key.clone();
293                mem.ignore_next_domain_windows = false;
294            }
295        }
296
297        // Second pass: pick a source member that changed (and is not suppressed).
298        //
299        // Heuristic: prefer a member that currently has an active axisPointer anchor, useful when
300        // the pointer moves between charts.
301        let mut source_index: Option<usize> = None;
302        let mut source_events: Option<Vec<LinkEvent>> = None;
303        let mut source_domain_window_updates: Vec<(LinkAxisKey, Option<DataWindow>)> = Vec::new();
304        let mut source_score: i32 = -1;
305
306        for i in 0..self.members.len() {
307            let Some(out) = outputs.get(i).and_then(|o| o.clone()) else {
308                continue;
309            };
310            let mem = &self.memory[i];
311
312            let link_events_changed = !mem.ignore_next_link_events_revision
313                && out.link_events_revision != mem.last_link_events_revision;
314
315            let domain_windows_changed = self.policy.domain_windows
316                && !mem.ignore_next_domain_windows
317                && out.snapshot.domain_windows_by_key != mem.last_domain_windows_by_key;
318
319            if !link_events_changed && !domain_windows_changed {
320                continue;
321            }
322
323            let events = if link_events_changed {
324                out.snapshot.link_events.clone()
325            } else {
326                Vec::new()
327            };
328
329            let mut domain_window_updates: Vec<(LinkAxisKey, Option<DataWindow>)> = Vec::new();
330            if domain_windows_changed {
331                let mut keys: BTreeSet<LinkAxisKey> = BTreeSet::new();
332                keys.extend(mem.last_domain_windows_by_key.keys().copied());
333                keys.extend(out.snapshot.domain_windows_by_key.keys().copied());
334                for key in keys {
335                    let prev = mem
336                        .last_domain_windows_by_key
337                        .get(&key)
338                        .copied()
339                        .unwrap_or(None);
340                    let next = out
341                        .snapshot
342                        .domain_windows_by_key
343                        .get(&key)
344                        .copied()
345                        .unwrap_or(None);
346                    if prev != next {
347                        domain_window_updates.push((key, next));
348                    }
349                }
350            }
351
352            let mut score = 0i32;
353            if events
354                .iter()
355                .any(|e| matches!(e, LinkEvent::AxisPointerChanged { anchor: Some(_) }))
356            {
357                score += 10;
358            }
359            if events
360                .iter()
361                .any(|e| matches!(e, LinkEvent::DomainWindowChanged { .. }))
362            {
363                score += 5;
364            }
365            if !domain_window_updates.is_empty() {
366                score += 4;
367                score += (domain_window_updates.len().min(8)) as i32;
368            }
369            if events
370                .iter()
371                .any(|e| matches!(e, LinkEvent::BrushSelectionChanged { .. }))
372            {
373                score += 3;
374            }
375
376            if score > source_score {
377                source_score = score;
378                source_index = Some(i);
379                source_events = Some(events.clone());
380                source_domain_window_updates = domain_window_updates;
381            }
382        }
383
384        let Some(source_index) = source_index else {
385            return false;
386        };
387        let source_events = source_events.unwrap_or_default();
388
389        // Update last seen revision for the source immediately.
390        if let Some(out) = outputs.get(source_index).and_then(|o| o.clone()) {
391            self.memory[source_index].last_link_events_revision = out.link_events_revision;
392            self.memory[source_index].last_domain_windows_by_key =
393                out.snapshot.domain_windows_by_key.clone();
394        }
395
396        let source_router = &self.members[source_index].router;
397
398        let mut changed = false;
399
400        if self.policy.axis_pointer {
401            if let Some(anchor) = source_events.iter().rev().find_map(|e| match e {
402                LinkEvent::AxisPointerChanged { anchor } => anchor.as_ref(),
403                _ => None,
404            }) {
405                let next = source_router
406                    .axis_key(anchor.axis)
407                    .map(|axis| AxisPointerLinkAnchor {
408                        axis,
409                        value: anchor.value,
410                    });
411
412                let Ok(current) = self.axis_pointer.read(app, |_app, a| a.clone()) else {
413                    return false;
414                };
415                if current != next {
416                    let _ = self.axis_pointer.update(app, |a, _cx| {
417                        *a = next;
418                    });
419                    changed = true;
420                }
421            } else if source_events
422                .iter()
423                .any(|e| matches!(e, LinkEvent::AxisPointerChanged { anchor: None }))
424            {
425                let Ok(current) = self.axis_pointer.read(app, |_app, a| a.clone()) else {
426                    return false;
427                };
428                if current.is_some() {
429                    let _ = self.axis_pointer.update(app, |a, _cx| {
430                        *a = None;
431                    });
432                    changed = true;
433                }
434            }
435        }
436
437        if self.policy.domain_windows {
438            let mut updates = source_domain_window_updates;
439
440            if updates.is_empty() {
441                updates = source_events
442                    .iter()
443                    .filter_map(|e| match e {
444                        LinkEvent::DomainWindowChanged { axis, window } => {
445                            source_router.axis_key(*axis).map(|key| (key, *window))
446                        }
447                        _ => None,
448                    })
449                    .collect();
450            }
451
452            if !updates.is_empty() {
453                let current = &shared_domain_windows;
454                let mut next = current.clone();
455                for (key, window) in updates {
456                    next.insert(key, window);
457                }
458                if &next != current {
459                    let _ = self.domain_windows.update(app, |w, _cx| {
460                        *w = next;
461                    });
462                    changed = true;
463                }
464            }
465        }
466
467        if self.policy.brush
468            && let Some(selection) = source_events.iter().rev().find_map(|e| match e {
469                LinkEvent::BrushSelectionChanged { selection } => Some(*selection),
470                _ => None,
471            })
472        {
473            let next = selection.and_then(|sel| {
474                let x_key = source_router.axis_key(sel.x_axis)?;
475                let y_key = source_router.axis_key(sel.y_axis)?;
476                Some(BrushSelectionLink2D {
477                    x_axis: x_key,
478                    y_axis: y_key,
479                    x: sel.x,
480                    y: sel.y,
481                })
482            });
483
484            let Ok(current) = self.brush.read(app, |_app, s| *s) else {
485                return false;
486            };
487            if current != next {
488                let _ = self.brush.update(app, |s, _cx| {
489                    *s = next;
490                });
491                changed = true;
492            }
493        }
494
495        if !changed {
496            return false;
497        }
498
499        // Suppress the next output revision for all non-source members to avoid ping-pong.
500        for i in 0..self.members.len() {
501            if i == source_index {
502                continue;
503            }
504            self.memory[i].ignore_next_link_events_revision = true;
505            self.memory[i].ignore_next_domain_windows = true;
506        }
507
508        true
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use delinea::spec::{
516        AxisSpec, ChartSpec, DatasetSpec, FieldSpec, GridSpec, SeriesEncode, SeriesKind, SeriesSpec,
517    };
518
519    fn spec_with_ambiguous_x_key() -> (ChartSpec, LinkAxisKey, AxisId, AxisId) {
520        let chart_id = delinea::ids::ChartId::new(1);
521        let dataset = DatasetId::new(1);
522        let grid = delinea::ids::GridId::new(1);
523        let x1 = AxisId::new(1);
524        let x2 = AxisId::new(2);
525        let y = AxisId::new(3);
526        let x_field = FieldId::new(1);
527        let y_field = FieldId::new(2);
528
529        let key = LinkAxisKey {
530            kind: AxisKind::X,
531            dataset,
532            field: x_field,
533        };
534
535        let spec = ChartSpec {
536            id: chart_id,
537            viewport: None,
538            datasets: vec![DatasetSpec {
539                id: dataset,
540                fields: vec![
541                    FieldSpec {
542                        id: x_field,
543                        column: 0,
544                    },
545                    FieldSpec {
546                        id: y_field,
547                        column: 1,
548                    },
549                ],
550
551                from: None,
552                transforms: Vec::new(),
553            }],
554            grids: vec![GridSpec { id: grid }],
555            axes: vec![
556                AxisSpec {
557                    id: x1,
558                    name: None,
559                    kind: AxisKind::X,
560                    grid,
561                    position: None,
562                    scale: Default::default(),
563                    range: None,
564                },
565                AxisSpec {
566                    id: x2,
567                    name: None,
568                    kind: AxisKind::X,
569                    grid,
570                    position: None,
571                    scale: Default::default(),
572                    range: None,
573                },
574                AxisSpec {
575                    id: y,
576                    name: None,
577                    kind: AxisKind::Y,
578                    grid,
579                    position: None,
580                    scale: Default::default(),
581                    range: None,
582                },
583            ],
584            data_zoom_x: vec![],
585            data_zoom_y: vec![],
586            tooltip: None,
587            axis_pointer: None,
588            visual_maps: vec![],
589            series: vec![
590                SeriesSpec {
591                    id: delinea::ids::SeriesId::new(1),
592                    name: None,
593                    kind: SeriesKind::Line,
594                    dataset,
595                    encode: SeriesEncode {
596                        x: x_field,
597                        y: y_field,
598                        y2: None,
599                    },
600                    x_axis: x1,
601                    y_axis: y,
602                    stack: None,
603                    stack_strategy: Default::default(),
604                    bar_layout: Default::default(),
605                    area_baseline: None,
606                    lod: None,
607                },
608                SeriesSpec {
609                    id: delinea::ids::SeriesId::new(2),
610                    name: None,
611                    kind: SeriesKind::Line,
612                    dataset,
613                    encode: SeriesEncode {
614                        x: x_field,
615                        y: y_field,
616                        y2: None,
617                    },
618                    x_axis: x2,
619                    y_axis: y,
620                    stack: None,
621                    stack_strategy: Default::default(),
622                    bar_layout: Default::default(),
623                    area_baseline: None,
624                    lod: None,
625                },
626            ],
627        };
628
629        (spec, key, x1, x2)
630    }
631
632    #[test]
633    fn explicit_axis_map_can_restore_unique_reverse_mapping() {
634        let (spec, key, x1, _x2) = spec_with_ambiguous_x_key();
635        let router = ChartLinkRouter::from_spec(&spec);
636        assert_eq!(router.axis_for_key(key), None);
637
638        let mut explicit = BTreeMap::new();
639        explicit.insert(x1, key);
640        let router = ChartLinkRouter::from_spec(&spec).with_explicit_axis_map(explicit);
641        assert_eq!(router.axis_for_key(key), Some(x1));
642    }
643
644    #[test]
645    fn duplicate_explicit_key_assignment_disables_reverse_mapping() {
646        let (spec, key, x1, x2) = spec_with_ambiguous_x_key();
647        let mut explicit = BTreeMap::new();
648        explicit.insert(x1, key);
649        explicit.insert(x2, key);
650
651        let router = ChartLinkRouter::from_spec(&spec).with_explicit_axis_map(explicit);
652        assert_eq!(router.axis_for_key(key), None);
653    }
654}