Skip to main content

teksilo_data/
chart_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ChartModel<T>` — concrete reactive multi-series chart data model.
5//!
6//! `ChartModel<T>` owns an ordered collection of named series, each holding a
7//! `Vec<ChartDatum<T>>` (a `category: T` paired with a numeric `value: f32`),
8//! in a flat SlotMap arena — the same shape as [`crate::TreeModel`]. Every
9//! mutation (series add/remove/move/rename/recolor/repattern/show-hide,
10//! point push/insert/remove/update/replace) emits a [`ChartChange`] to all
11//! registered observers *and* bumps one of two reactive version signals:
12//! [`ChartModel::style_version`] (color and pattern changes only — a
13//! paint-only signal a chart can bind at `BindingLevel::RepaintOnly`) or
14//! [`ChartModel::structure_version`] (everything else — series/point shape,
15//! bound at `BindingLevel::Relayout`/`Rebuild`). Series identity is a stable,
16//! versioned [`SeriesId`] (a SlotMap key) that is never reused after removal.
17//!
18//! Cloning produces a second handle to the **same** data — all handles share
19//! series/points and receive the same change notifications. Register
20//! observers via [`observe_changes`](ChartModel::observe_changes); the
21//! returned `ObserverHandle` is RAII — dropping it unregisters the callback.
22//!
23//! For a bounded "last N points" streaming view use
24//! [`ChartWindow`](crate::ChartWindow). For bucketed/rolled-up display use
25//! [`ChartAggregate`](crate::ChartAggregate). For point-level selection use
26//! [`ChartSelection`](crate::ChartSelection).
27//!
28//! ```rust
29//! # use teksilo_data::{ChartModel, ChartSeries, ChartDatum};
30//! let model = ChartModel::from_series_vec(vec![
31//!     ChartSeries::new("Revenue").data(vec![
32//!         ChartDatum::new("Q1".to_string(), 10.0),
33//!         ChartDatum::new("Q2".to_string(), 20.0),
34//!     ]),
35//! ]);
36//! assert_eq!(model.series_count(), 1);
37//! let s = model.series_id_at(0).unwrap();
38//! assert_eq!(model.point_count(s), 2);
39//!
40//! model.push_point(s, "Q3".to_string(), 30.0);
41//! assert_eq!(model.point_count(s), 3);
42//! ```
43
44use std::cell::RefCell;
45use std::rc::Rc;
46
47use slotmap::SlotMap;
48
49use teksilo_core::ObserverHandle;
50use teksilo_core::color_prop::ColorProp;
51use teksilo_core::signal::Signal;
52
53use crate::chart_change::{ChartChange, SeriesId};
54use crate::series_pattern::SeriesPattern;
55
56/// One numeric data point at a category/x-axis position, with an optional
57/// per-point color that overrides the series color (bar charts only).
58#[derive(Debug, Clone)]
59pub struct ChartDatum<T> {
60    pub category: T,
61    pub value: f32,
62    pub color: Option<ColorProp>,
63}
64
65impl<T> ChartDatum<T> {
66    pub fn new(category: T, value: f32) -> Self {
67        Self {
68            category,
69            value,
70            color: None,
71        }
72    }
73
74    /// Override this point's color (a bar's fill). Ignored by line/pie charts,
75    /// which color by series.
76    pub fn with_color(mut self, color: impl Into<ColorProp>) -> Self {
77        self.color = Some(color.into());
78        self
79    }
80}
81
82/// A named series of data points with an optional explicit color and a
83/// visibility flag, used to construct a [`ChartModel`] (via
84/// [`ChartModel::from_series_vec`]) or to describe one series' desired
85/// shape. Unlike the model, `visible` here is a plain `bool` — reactivity
86/// lives in the model's [`ChartModel::structure_version`] /
87/// [`ChartModel::style_version`] signals, not in this construction DTO.
88pub struct ChartSeries<T> {
89    pub name: String,
90    pub color: Option<ColorProp>,
91    /// The non-colour channel identifying this series — a dash pattern on a
92    /// line, a marker shape on a point, a hatch on a filled region. `None`
93    /// takes the pattern for the series' position
94    /// ([`SeriesPattern::for_index`]), which is why the channel exists even
95    /// with no application code. Set it when a series' identity must survive a
96    /// reorder. See [`SeriesPattern`].
97    pub pattern: Option<SeriesPattern>,
98    pub visible: bool,
99    pub points: Vec<ChartDatum<T>>,
100}
101
102impl<T> std::fmt::Debug for ChartSeries<T>
103where
104    T: std::fmt::Debug,
105{
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("ChartSeries")
108            .field("name", &self.name)
109            .field("pattern", &self.pattern)
110            .field("visible", &self.visible)
111            .field("len", &self.points.len())
112            .finish()
113    }
114}
115
116impl<T> Clone for ChartSeries<T>
117where
118    T: Clone,
119{
120    fn clone(&self) -> Self {
121        Self {
122            name: self.name.clone(),
123            color: self.color.clone(),
124            pattern: self.pattern,
125            visible: self.visible,
126            points: self.points.clone(),
127        }
128    }
129}
130
131impl<T> ChartSeries<T> {
132    pub fn new(name: impl Into<String>) -> Self {
133        Self {
134            name: name.into(),
135            color: None,
136            pattern: None,
137            visible: true,
138            points: Vec::new(),
139        }
140    }
141
142    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
143        self.color = Some(color.into());
144        self
145    }
146
147    /// Pin this series' non-colour channel instead of taking the one its
148    /// position implies. See [`SeriesPattern`].
149    pub fn pattern(mut self, pattern: SeriesPattern) -> Self {
150        self.pattern = Some(pattern);
151        self
152    }
153
154    pub fn visibility(mut self, visible: bool) -> Self {
155        self.visible = visible;
156        self
157    }
158
159    pub fn push(&mut self, category: T, value: f32) {
160        self.points.push(ChartDatum::new(category, value));
161    }
162
163    pub fn data(mut self, points: Vec<ChartDatum<T>>) -> Self {
164        self.points = points;
165        self
166    }
167}
168
169/// A read-only, borrowed view over one series — returned by
170/// [`ChartModel::with_series_view`] / [`ChartModel::with_all_series`].
171pub struct SeriesView<'a, T> {
172    pub id: SeriesId,
173    pub name: &'a str,
174    pub color: Option<&'a ColorProp>,
175    /// The series' explicit non-colour channel, or `None` to take the one its
176    /// position implies. See [`SeriesPattern`].
177    pub pattern: Option<SeriesPattern>,
178    pub visible: bool,
179    pub points: &'a [ChartDatum<T>],
180}
181
182struct SeriesEntry<T> {
183    name: String,
184    color: Option<ColorProp>,
185    pattern: Option<SeriesPattern>,
186    visible: bool,
187    points: Vec<ChartDatum<T>>,
188}
189
190struct ObserverEntry {
191    id: u64,
192    callback: Rc<dyn Fn(&ChartChange)>,
193}
194
195/// Structural equality for `ColorProp`, which has no general `PartialEq`
196/// (a `Signal` carries no value-equality of its own). `Static`/role variants
197/// compare by value; `Bound`/`Dynamic*` variants compare by `Signal`
198/// identity ([`Signal::same`]). Anything else (different variants, or
199/// either side `Undimmed`) is conservatively "different" so a genuine value
200/// swap is never mistaken for a no-op — used only to gate the idempotency
201/// short-circuit in [`ChartModel::set_series_color`].
202fn color_prop_eq(a: &ColorProp, b: &ColorProp) -> bool {
203    match (a, b) {
204        (ColorProp::Static(x), ColorProp::Static(y)) => x == y,
205        (ColorProp::Bound(x), ColorProp::Bound(y)) => Signal::same(x, y),
206        (ColorProp::TextRole(x), ColorProp::TextRole(y)) => x == y,
207        (ColorProp::SurfaceRole(x), ColorProp::SurfaceRole(y)) => x == y,
208        (ColorProp::BorderRole(x), ColorProp::BorderRole(y)) => x == y,
209        (ColorProp::DynamicTextRole(x), ColorProp::DynamicTextRole(y)) => Signal::same(x, y),
210        (ColorProp::DynamicSurfaceRole(x), ColorProp::DynamicSurfaceRole(y)) => Signal::same(x, y),
211        (ColorProp::DynamicBorderRole(x), ColorProp::DynamicBorderRole(y)) => Signal::same(x, y),
212        _ => false,
213    }
214}
215
216struct ChartModelInner<T> {
217    arena: SlotMap<slotmap::DefaultKey, SeriesEntry<T>>,
218    order: Vec<SeriesId>,
219    observers: Vec<ObserverEntry>,
220    next_observer_id: u64,
221    /// Structural version — bumped by every mutation except a color or
222    /// pattern change.
223    structure_version: Signal<u64>,
224    /// Style version — bumped only by `SeriesColorChanged` /
225    /// `SeriesPatternChanged`.
226    style_version: Signal<u64>,
227    /// Strong handle to the debug-registry adapter for this model. Owned
228    /// here so that the registration drops automatically when the inner is
229    /// freed (the adapter holds only a `Weak` to inner, breaking the
230    /// cycle). `None` until `.debug_named()` is called. Compiled out in
231    /// release.
232    #[cfg(debug_assertions)]
233    debug_adapter: Option<Rc<dyn crate::debug_registry::ModelDebug>>,
234}
235
236/// A concrete reactive multi-series chart data model.
237///
238/// `ChartModel<T>` is `Clone` — cloning produces a second handle to the same
239/// data. Multiple charts can hold clones and all see the same series and
240/// points, and receive the same [`ChartChange`] notifications.
241pub struct ChartModel<T: 'static> {
242    inner: Rc<RefCell<ChartModelInner<T>>>,
243}
244
245impl<T: 'static> ChartModel<T> {
246    /// Create an empty chart model with no series.
247    pub fn new() -> Self {
248        Self {
249            inner: Rc::new(RefCell::new(ChartModelInner {
250                arena: SlotMap::new(),
251                order: Vec::new(),
252                observers: Vec::new(),
253                next_observer_id: 1,
254                structure_version: Signal::new(0),
255                style_version: Signal::new(0),
256                #[cfg(debug_assertions)]
257                debug_adapter: None,
258            })),
259        }
260    }
261
262    /// Build a model directly from a vector of [`ChartSeries`] DTOs — the
263    /// primary constructor. Populates the arena in one pass with no
264    /// per-item notification (mirrors [`crate::ListModel::from_vec`]).
265    pub fn from_series_vec(series: Vec<ChartSeries<T>>) -> Self {
266        let model = Self::new();
267        {
268            let mut guard = model.inner.borrow_mut();
269            for s in series {
270                let key = guard.arena.insert(SeriesEntry {
271                    name: s.name,
272                    color: s.color,
273                    pattern: s.pattern,
274                    visible: s.visible,
275                    points: s.points,
276                });
277                let id = SeriesId::from_key(key);
278                guard.order.push(id);
279            }
280        }
281        model
282    }
283
284    /// Build a model with a single anonymous, visible series holding
285    /// `points` — the flat/pie-chart path where series structure doesn't
286    /// matter.
287    pub fn from_points(points: Vec<ChartDatum<T>>) -> Self {
288        Self::from_series_vec(vec![ChartSeries::new(String::new()).data(points)])
289    }
290
291    /// The model's sole series id, iff it has exactly one series.
292    pub fn only_series(&self) -> Option<SeriesId> {
293        let guard = self.inner.borrow();
294        if guard.order.len() == 1 {
295            Some(guard.order[0])
296        } else {
297            None
298        }
299    }
300
301    // --- Series mutations ---
302
303    /// Append a new, empty, visible series named `name`.
304    pub fn add_series(&self, name: impl Into<String>) -> SeriesId {
305        let (id, index) = {
306            let mut guard = self.inner.borrow_mut();
307            let key = guard.arena.insert(SeriesEntry {
308                name: name.into(),
309                color: None,
310                pattern: None,
311                visible: true,
312                points: Vec::new(),
313            });
314            let id = SeriesId::from_key(key);
315            let index = guard.order.len();
316            guard.order.push(id);
317            (id, index)
318        };
319        self.notify(ChartChange::SeriesInserted { index, series: id });
320        self.bump_structure();
321        id
322    }
323
324    /// Insert a new, empty, visible series named `name` at `index`.
325    ///
326    /// # Panics
327    /// Panics if `index > series_count()`.
328    pub fn insert_series(&self, index: usize, name: impl Into<String>) -> SeriesId {
329        let id = {
330            let mut guard = self.inner.borrow_mut();
331            let key = guard.arena.insert(SeriesEntry {
332                name: name.into(),
333                color: None,
334                pattern: None,
335                visible: true,
336                points: Vec::new(),
337            });
338            let id = SeriesId::from_key(key);
339            guard.order.insert(index, id);
340            id
341        };
342        self.notify(ChartChange::SeriesInserted { index, series: id });
343        self.bump_structure();
344        id
345    }
346
347    /// Remove a series and all of its points.
348    ///
349    /// # Panics
350    /// Panics if `series` is unknown.
351    pub fn remove_series(&self, series: SeriesId) {
352        {
353            let mut guard = self.inner.borrow_mut();
354            guard.arena.remove(series.key()).expect("unknown SeriesId");
355            guard.order.retain(|&id| id != series);
356        }
357        self.notify(ChartChange::SeriesRemoved { series });
358        self.bump_structure();
359    }
360
361    /// Rename a series. A no-op (no notify, no version bump) if `name`
362    /// already matches the current value.
363    ///
364    /// # Panics
365    /// Panics if `series` is unknown.
366    pub fn rename_series(&self, series: SeriesId, name: impl Into<String>) {
367        let name = name.into();
368        let changed = {
369            let mut guard = self.inner.borrow_mut();
370            let entry = &mut guard.arena[series.key()];
371            if entry.name == name {
372                false
373            } else {
374                entry.name = name;
375                true
376            }
377        };
378        if !changed {
379            return;
380        }
381        self.notify(ChartChange::SeriesRenamed { series });
382        self.bump_structure();
383    }
384
385    /// Set a series' explicit color. Bumps [`Self::style_version`] (not
386    /// [`Self::structure_version`]) — this is a paint-only change. A no-op
387    /// (no notify, no version bump) if `color` already matches the current
388    /// value.
389    ///
390    /// # Panics
391    /// Panics if `series` is unknown.
392    pub fn set_series_color(&self, series: SeriesId, color: impl Into<ColorProp>) {
393        let color = color.into();
394        let changed = {
395            let mut guard = self.inner.borrow_mut();
396            let entry = &mut guard.arena[series.key()];
397            if entry
398                .color
399                .as_ref()
400                .is_some_and(|c| color_prop_eq(c, &color))
401            {
402                false
403            } else {
404                entry.color = Some(color);
405                true
406            }
407        };
408        if !changed {
409            return;
410        }
411        self.notify(ChartChange::SeriesColorChanged { series });
412        self.bump_style();
413    }
414
415    /// Clear a series' explicit color (falls back to the chart's palette).
416    /// Bumps [`Self::style_version`]. A no-op (no notify, no version bump)
417    /// if the series already has no explicit color.
418    ///
419    /// # Panics
420    /// Panics if `series` is unknown.
421    pub fn clear_series_color(&self, series: SeriesId) {
422        let changed = {
423            let mut guard = self.inner.borrow_mut();
424            let entry = &mut guard.arena[series.key()];
425            if entry.color.is_none() {
426                false
427            } else {
428                entry.color = None;
429                true
430            }
431        };
432        if !changed {
433            return;
434        }
435        self.notify(ChartChange::SeriesColorChanged { series });
436        self.bump_style();
437    }
438
439    /// Set a series' explicit [`SeriesPattern`] — the non-colour channel that
440    /// identifies it. Bumps [`Self::style_version`] (paint-only), like
441    /// [`set_series_color`](Self::set_series_color). A no-op if unchanged.
442    ///
443    /// # Panics
444    /// Panics if `series` is unknown.
445    pub fn set_series_pattern(&self, series: SeriesId, pattern: SeriesPattern) {
446        let changed = {
447            let mut guard = self.inner.borrow_mut();
448            let entry = &mut guard.arena[series.key()];
449            if entry.pattern == Some(pattern) {
450                false
451            } else {
452                entry.pattern = Some(pattern);
453                true
454            }
455        };
456        if !changed {
457            return;
458        }
459        self.notify(ChartChange::SeriesPatternChanged { series });
460        self.bump_style();
461    }
462
463    /// Clear a series' explicit pattern, falling back to the one its position
464    /// implies. Bumps [`Self::style_version`]. A no-op if already unset.
465    ///
466    /// # Panics
467    /// Panics if `series` is unknown.
468    pub fn clear_series_pattern(&self, series: SeriesId) {
469        let changed = {
470            let mut guard = self.inner.borrow_mut();
471            let entry = &mut guard.arena[series.key()];
472            if entry.pattern.is_none() {
473                false
474            } else {
475                entry.pattern = None;
476                true
477            }
478        };
479        if !changed {
480            return;
481        }
482        self.notify(ChartChange::SeriesPatternChanged { series });
483        self.bump_style();
484    }
485
486    /// Show or hide a series. A no-op (no notify, no version bump) if
487    /// `visible` already matches the current value.
488    ///
489    /// # Panics
490    /// Panics if `series` is unknown.
491    pub fn set_series_visible(&self, series: SeriesId, visible: bool) {
492        let changed = {
493            let mut guard = self.inner.borrow_mut();
494            let entry = &mut guard.arena[series.key()];
495            if entry.visible == visible {
496                false
497            } else {
498                entry.visible = visible;
499                true
500            }
501        };
502        if !changed {
503            return;
504        }
505        self.notify(ChartChange::SeriesVisibilityChanged { series });
506        self.bump_structure();
507    }
508
509    /// Move a series to a new position among its siblings. A no-op (no
510    /// notify, no version bump) if `to` is already the series' position.
511    ///
512    /// # Panics
513    /// Panics if `series` is unknown or `to` is out of bounds.
514    pub fn move_series(&self, series: SeriesId, to: usize) {
515        let from = {
516            let guard = self.inner.borrow();
517            guard
518                .order
519                .iter()
520                .position(|&id| id == series)
521                .expect("unknown SeriesId")
522        };
523        if from == to {
524            return;
525        }
526        {
527            let mut guard = self.inner.borrow_mut();
528            let id = guard.order.remove(from);
529            guard.order.insert(to, id);
530        }
531        self.notify(ChartChange::SeriesMoved { series, from, to });
532        self.bump_structure();
533    }
534
535    /// Remove every series.
536    pub fn clear(&self) {
537        {
538            let mut guard = self.inner.borrow_mut();
539            guard.arena.clear();
540            guard.order.clear();
541        }
542        self.notify(ChartChange::Reset);
543        self.bump_structure();
544    }
545
546    // --- Point mutations ---
547
548    /// Append a point to the end of `series`.
549    ///
550    /// # Panics
551    /// Panics if `series` is unknown.
552    pub fn push_point(&self, series: SeriesId, category: T, value: f32) {
553        let index = {
554            let mut guard = self.inner.borrow_mut();
555            let entry = &mut guard.arena[series.key()];
556            let index = entry.points.len();
557            entry.points.push(ChartDatum::new(category, value));
558            index
559        };
560        self.notify(ChartChange::PointsInserted {
561            series,
562            range: index..index + 1,
563        });
564        self.bump_structure();
565    }
566
567    /// Insert a point at `index` within `series`.
568    ///
569    /// # Panics
570    /// Panics if `series` is unknown or `index > point_count(series)`.
571    pub fn insert_point(&self, series: SeriesId, index: usize, category: T, value: f32) {
572        {
573            let mut guard = self.inner.borrow_mut();
574            guard.arena[series.key()]
575                .points
576                .insert(index, ChartDatum::new(category, value));
577        }
578        self.notify(ChartChange::PointsInserted {
579            series,
580            range: index..index + 1,
581        });
582        self.bump_structure();
583    }
584
585    /// Remove and return the point at `index` within `series`.
586    ///
587    /// # Panics
588    /// Panics if `series` is unknown or `index >= point_count(series)`.
589    pub fn remove_point(&self, series: SeriesId, index: usize) -> ChartDatum<T> {
590        let datum = {
591            let mut guard = self.inner.borrow_mut();
592            guard.arena[series.key()].points.remove(index)
593        };
594        self.notify(ChartChange::PointsRemoved {
595            series,
596            range: index..index + 1,
597        });
598        self.bump_structure();
599        datum
600    }
601
602    /// Replace the point at `index` within `series`.
603    ///
604    /// # Panics
605    /// Panics if `series` is unknown or `index >= point_count(series)`.
606    pub fn update_point(&self, series: SeriesId, index: usize, category: T, value: f32) {
607        {
608            let mut guard = self.inner.borrow_mut();
609            guard.arena[series.key()].points[index] = ChartDatum::new(category, value);
610        }
611        self.notify(ChartChange::PointUpdated { series, index });
612        self.bump_structure();
613    }
614
615    /// Replace `series`' entire point list.
616    ///
617    /// # Panics
618    /// Panics if `series` is unknown.
619    pub fn replace_series_data(&self, series: SeriesId, points: Vec<ChartDatum<T>>) {
620        {
621            let mut guard = self.inner.borrow_mut();
622            guard.arena[series.key()].points = points;
623        }
624        self.notify(ChartChange::SeriesDataReplaced { series });
625        self.bump_structure();
626    }
627
628    // --- Queries ---
629
630    /// Number of series.
631    pub fn series_count(&self) -> usize {
632        self.inner.borrow().order.len()
633    }
634
635    /// The series ids, in display order.
636    pub fn series_ids(&self) -> Vec<SeriesId> {
637        self.inner.borrow().order.clone()
638    }
639
640    /// The series id at `index`, if any.
641    pub fn series_id_at(&self, index: usize) -> Option<SeriesId> {
642        self.inner.borrow().order.get(index).copied()
643    }
644
645    /// The display index of `series`, if it exists.
646    pub fn series_index_of(&self, series: SeriesId) -> Option<usize> {
647        self.inner
648            .borrow()
649            .order
650            .iter()
651            .position(|&id| id == series)
652    }
653
654    /// Number of points in `series` (0 if unknown).
655    pub fn point_count(&self, series: SeriesId) -> usize {
656        self.inner
657            .borrow()
658            .arena
659            .get(series.key())
660            .map(|e| e.points.len())
661            .unwrap_or(0)
662    }
663
664    /// Access a series' metadata (name, color, visibility) via a callback.
665    /// Returns `None` if `series` is unknown.
666    pub fn with_series<R>(
667        &self,
668        series: SeriesId,
669        f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R,
670    ) -> Option<R> {
671        let guard = self.inner.borrow();
672        guard
673            .arena
674            .get(series.key())
675            .map(|e| f(&e.name, e.color.as_ref(), e.visible))
676    }
677
678    /// Access a point within `series` via a callback. Returns `None` if the
679    /// series or index is unknown.
680    pub fn with_point<R>(
681        &self,
682        series: SeriesId,
683        index: usize,
684        f: impl FnOnce(&ChartDatum<T>) -> R,
685    ) -> Option<R> {
686        let guard = self.inner.borrow();
687        guard
688            .arena
689            .get(series.key())
690            .and_then(|e| e.points.get(index))
691            .map(f)
692    }
693
694    /// Access a whole-series view (metadata + points slice) via a callback.
695    /// Returns `None` if `series` is unknown.
696    pub fn with_series_view<R>(
697        &self,
698        series: SeriesId,
699        f: impl FnOnce(SeriesView<'_, T>) -> R,
700    ) -> Option<R> {
701        let guard = self.inner.borrow();
702        guard.arena.get(series.key()).map(|e| {
703            f(SeriesView {
704                id: series,
705                name: &e.name,
706                color: e.color.as_ref(),
707                pattern: e.pattern,
708                visible: e.visible,
709                points: &e.points,
710            })
711        })
712    }
713
714    /// Access every series as an ordered slice of views via a callback.
715    pub fn with_all_series<R>(&self, f: impl FnOnce(&[SeriesView<'_, T>]) -> R) -> R {
716        let guard = self.inner.borrow();
717        let views: Vec<SeriesView<'_, T>> = guard
718            .order
719            .iter()
720            .filter_map(|&id| {
721                guard.arena.get(id.key()).map(|e| SeriesView {
722                    id,
723                    name: &e.name,
724                    color: e.color.as_ref(),
725                    pattern: e.pattern,
726                    visible: e.visible,
727                    points: &e.points,
728                })
729            })
730            .collect();
731        f(&views)
732    }
733
734    // --- Reactivity ---
735
736    /// Structural version signal — bumped by every mutation except a color
737    /// or pattern change (series add/remove/move/rename/show-hide, all point
738    /// ops). Bind at `BindingLevel::Relayout` or `Rebuild`.
739    ///
740    /// Ordering: every mutator notifies the `ChartChange` observers
741    /// registered via [`Self::observe_changes`] *before* bumping this
742    /// signal — see the note on `observe_changes` for what that means for a
743    /// callback that reads the signal back synchronously.
744    pub fn structure_version(&self) -> Signal<u64> {
745        self.inner.borrow().structure_version.clone()
746    }
747
748    /// Style version signal — bumped only by a series color or pattern
749    /// change. Bind at `BindingLevel::RepaintOnly`. Same notify-before-bump
750    /// ordering as [`Self::structure_version`] — see
751    /// [`Self::observe_changes`].
752    pub fn style_version(&self) -> Signal<u64> {
753        self.inner.borrow().style_version.clone()
754    }
755
756    /// Register an observer that is called on every mutation.
757    /// Returns an `ObserverHandle` — dropping it removes the callback.
758    ///
759    /// **Ordering contract:** every mutator calls this observer *before*
760    /// bumping [`Self::structure_version`] / [`Self::style_version`] (see
761    /// e.g. `rename_series`, `push_point`) — notify, then bump. This is
762    /// intentional, not an implementation accident: it lets a `ChartChange`
763    /// callback distinguish "did I get here via the change I'm reacting to"
764    /// from "did something else bump the version already", by comparing the
765    /// version signal's value inside the callback against a value captured
766    /// before the mutation. The flip side: a callback that reads
767    /// `structure_version()`/`style_version()` synchronously **inside**
768    /// itself always observes the **pre-bump** value for the mutation
769    /// currently being notified — the bump hasn't happened yet. Don't use
770    /// the version signal from inside a `ChartChange` observer as a proxy
771    /// for "has this specific mutation been applied" — the `ChartChange`
772    /// argument already tells you that; use the signal for *external*
773    /// bind-and-rerun consumers (widgets), not from within the notify path
774    /// itself.
775    pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle {
776        let mut guard = self.inner.borrow_mut();
777        let id = guard.next_observer_id;
778        guard.next_observer_id += 1;
779        guard.observers.push(ObserverEntry {
780            id,
781            callback: Rc::new(f),
782        });
783        let inner = self.inner.clone();
784        ObserverHandle::new(
785            self.inner.clone(),
786            id,
787            Rc::new(move |observer_id| {
788                inner.borrow_mut().observers.retain(|e| e.id != observer_id);
789            }),
790        )
791    }
792
793    fn notify(&self, change: ChartChange) {
794        let callbacks: Vec<Rc<dyn Fn(&ChartChange)>> = self
795            .inner
796            .borrow()
797            .observers
798            .iter()
799            .map(|e| e.callback.clone())
800            .collect();
801        for cb in &callbacks {
802            cb(&change);
803        }
804    }
805
806    /// Bump `structure_version`. Clones the signal handle under a short
807    /// borrow and drops it before calling `.set(...)` — an observer bound
808    /// to the signal may read back into this model, and holding the
809    /// `RefCell` borrow across that call would panic (re-entrant borrow).
810    fn bump_structure(&self) {
811        let sig = self.inner.borrow().structure_version.clone();
812        sig.set(sig.get().wrapping_add(1));
813    }
814
815    /// Bump `style_version`. Same drop-borrow-before-set discipline as
816    /// [`Self::bump_structure`].
817    fn bump_style(&self) {
818        let sig = self.inner.borrow().style_version.clone();
819        sig.set(sig.get().wrapping_add(1));
820    }
821}
822
823impl<T: std::fmt::Debug + 'static> ChartModel<T> {
824    /// Register this model with the debug inspector under `name`. In
825    /// release builds (`!cfg(debug_assertions)`) this is a no-op
826    /// pass-through so call sites stay free of `#[cfg]` lines.
827    ///
828    /// Idempotent on repeated calls — the latest registration wins.
829    /// The registration drops automatically when the last `ChartModel`
830    /// handle is freed (the adapter the registry holds is `Weak`).
831    pub fn debug_named(self, _name: impl Into<String>) -> Self {
832        #[cfg(debug_assertions)]
833        {
834            let weak = Rc::downgrade(&self.inner);
835            let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
836                Rc::new(ChartModelDebug::<T> { weak });
837            let name = _name.into();
838            crate::debug_registry::register(name, Rc::downgrade(&adapter));
839            self.inner.borrow_mut().debug_adapter = Some(adapter);
840        }
841        self
842    }
843}
844
845#[cfg(debug_assertions)]
846struct ChartModelDebug<T> {
847    weak: std::rc::Weak<RefCell<ChartModelInner<T>>>,
848}
849
850#[cfg(debug_assertions)]
851impl<T: std::fmt::Debug + 'static> crate::debug_registry::ModelDebug for ChartModelDebug<T> {
852    fn kind(&self) -> &'static str {
853        "ChartModel"
854    }
855    fn len(&self) -> usize {
856        self.weak
857            .upgrade()
858            .map(|inner| inner.borrow().arena.values().map(|e| e.points.len()).sum())
859            .unwrap_or(0)
860    }
861    fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
862        let Some(inner) = self.weak.upgrade() else {
863            return;
864        };
865        let guard = inner.borrow();
866        for (i, &id) in guard.order.iter().enumerate() {
867            if let Some(e) = guard.arena.get(id.key()) {
868                let _ = writeln!(
869                    out,
870                    "[{}] {:?} ({} pts, visible={})",
871                    i,
872                    e.name,
873                    e.points.len(),
874                    e.visible
875                );
876            }
877        }
878    }
879}
880
881impl<T: 'static> Default for ChartModel<T> {
882    fn default() -> Self {
883        Self::new()
884    }
885}
886
887impl<T: 'static> Clone for ChartModel<T> {
888    fn clone(&self) -> Self {
889        Self {
890            inner: self.inner.clone(),
891        }
892    }
893}
894
895impl<T: std::fmt::Debug + 'static> std::fmt::Debug for ChartModel<T> {
896    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897        let guard = self.inner.borrow();
898        f.debug_struct("ChartModel")
899            .field("series_count", &guard.order.len())
900            .finish()
901    }
902}
903
904#[cfg(test)]
905impl<T: 'static> ChartModel<T> {
906    /// Test-only introspection: number of live observers. Not part of the
907    /// public API surface.
908    pub(crate) fn observer_count(&self) -> usize {
909        self.inner.borrow().observers.len()
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use std::cell::Cell;
916
917    use super::*;
918
919    #[test]
920    fn datum_with_color_sets_a_per_point_override() {
921        use teksilo_core::color_prop::ColorProp;
922        use teksilo_tokens::SurfaceRole;
923        let plain = ChartDatum::new("Q1".to_string(), 5.0);
924        assert!(plain.color.is_none(), "a plain datum has no color override");
925        let colored = ChartDatum::new("Q1".to_string(), 5.0).with_color(SurfaceRole::StatusError);
926        assert!(matches!(
927            colored.color,
928            Some(ColorProp::SurfaceRole(SurfaceRole::StatusError))
929        ));
930        // The 2-arg constructor is unchanged, so existing call sites still compile.
931        let _ = ChartDatum::new("Q2".to_string(), 1.0);
932    }
933
934    fn sample() -> (ChartModel<String>, SeriesId, SeriesId) {
935        let model = ChartModel::from_series_vec(vec![
936            ChartSeries::new("Revenue").data(vec![
937                ChartDatum::new("Q1".to_string(), 10.0),
938                ChartDatum::new("Q2".to_string(), 20.0),
939            ]),
940            ChartSeries::new("Costs").data(vec![ChartDatum::new("Q1".to_string(), 5.0)]),
941        ]);
942        let a = model.series_id_at(0).unwrap();
943        let b = model.series_id_at(1).unwrap();
944        (model, a, b)
945    }
946
947    #[test]
948    fn from_series_vec_builds_correctly() {
949        let (model, a, b) = sample();
950        assert_eq!(model.series_count(), 2);
951        assert_eq!(model.point_count(a), 2);
952        assert_eq!(model.point_count(b), 1);
953        assert_eq!(
954            model.with_series(a, |name, _, visible| (name.to_string(), visible)),
955            Some(("Revenue".to_string(), true))
956        );
957    }
958
959    #[test]
960    fn new_is_empty() {
961        let model: ChartModel<String> = ChartModel::new();
962        assert_eq!(model.series_count(), 0);
963        assert_eq!(model.only_series(), None);
964    }
965
966    #[test]
967    fn only_series_some_iff_exactly_one() {
968        let model: ChartModel<String> = ChartModel::new();
969        assert_eq!(model.only_series(), None);
970        let a = model.add_series("A");
971        assert_eq!(model.only_series(), Some(a));
972        model.add_series("B");
973        assert_eq!(model.only_series(), None);
974    }
975
976    #[test]
977    fn from_points_builds_single_anonymous_series() {
978        let model = ChartModel::from_points(vec![
979            ChartDatum::new("a".to_string(), 1.0),
980            ChartDatum::new("b".to_string(), 2.0),
981        ]);
982        assert_eq!(model.series_count(), 1);
983        let s = model.only_series().unwrap();
984        assert_eq!(model.point_count(s), 2);
985        assert_eq!(model.with_series(s, |_, _, visible| visible), Some(true));
986    }
987
988    fn track_changes(
989        model: &ChartModel<String>,
990    ) -> (Rc<RefCell<Vec<ChartChange>>>, ObserverHandle) {
991        let log: Rc<RefCell<Vec<ChartChange>>> = Rc::new(RefCell::new(Vec::new()));
992        let l = log.clone();
993        let handle = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
994        (log, handle)
995    }
996
997    #[test]
998    fn add_series_emits_inserted_and_bumps_structure() {
999        let model: ChartModel<String> = ChartModel::new();
1000        let structure = model.structure_version();
1001        let style = model.style_version();
1002        let (log, _handle) = track_changes(&model);
1003
1004        let s = model.add_series("A");
1005        assert_eq!(log.borrow().len(), 1);
1006        assert_eq!(
1007            log.borrow()[0],
1008            ChartChange::SeriesInserted {
1009                index: 0,
1010                series: s
1011            }
1012        );
1013        assert_eq!(structure.get(), 1);
1014        assert_eq!(style.get(), 0);
1015    }
1016
1017    #[test]
1018    fn observer_sees_pre_bump_structure_version_during_notify() {
1019        let model: ChartModel<String> = ChartModel::new();
1020        let structure = model.structure_version();
1021        let seen_during_callback: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
1022        let seen = seen_during_callback.clone();
1023        let sig = structure.clone();
1024        let _handle = model.observe_changes(move |_| seen.set(Some(sig.get())));
1025
1026        let before = structure.get();
1027        model.add_series("A");
1028        let after = structure.get();
1029
1030        assert_eq!(after, before + 1, "the mutation did bump the signal");
1031        assert_eq!(
1032            seen_during_callback.get(),
1033            Some(before),
1034            "notify runs before the version bump, so a ChartChange observer \
1035             reading structure_version() synchronously sees the pre-bump value"
1036        );
1037    }
1038
1039    #[test]
1040    fn observer_sees_pre_bump_style_version_during_notify() {
1041        let (model, a, _b) = sample();
1042        let style = model.style_version();
1043        let seen_during_callback: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
1044        let seen = seen_during_callback.clone();
1045        let sig = style.clone();
1046        let _handle = model.observe_changes(move |_| seen.set(Some(sig.get())));
1047
1048        let before = style.get();
1049        model.set_series_color(a, test_color());
1050        let after = style.get();
1051
1052        assert_eq!(after, before + 1);
1053        assert_eq!(seen_during_callback.get(), Some(before));
1054    }
1055
1056    #[test]
1057    fn insert_series_at_index() {
1058        let (model, a, b) = sample();
1059        let c = model.insert_series(1, "Middle");
1060        assert_eq!(model.series_ids(), vec![a, c, b]);
1061    }
1062
1063    #[test]
1064    fn remove_series_emits_removed_and_bumps_structure() {
1065        let (model, a, _b) = sample();
1066        let structure_before = model.structure_version().get();
1067        let (log, _handle) = track_changes(&model);
1068
1069        model.remove_series(a);
1070        assert_eq!(log.borrow().len(), 1);
1071        assert_eq!(log.borrow()[0], ChartChange::SeriesRemoved { series: a });
1072        assert_eq!(model.series_count(), 1);
1073        assert!(model.structure_version().get() > structure_before);
1074    }
1075
1076    #[test]
1077    fn rename_series_emits_renamed_and_bumps_structure() {
1078        let (model, a, _b) = sample();
1079        let style_before = model.style_version().get();
1080        let (log, _handle) = track_changes(&model);
1081
1082        model.rename_series(a, "New Name");
1083        assert_eq!(log.borrow().len(), 1);
1084        assert_eq!(log.borrow()[0], ChartChange::SeriesRenamed { series: a });
1085        assert_eq!(
1086            model.with_series(a, |name, _, _| name.to_string()),
1087            Some("New Name".to_string())
1088        );
1089        assert_eq!(
1090            model.style_version().get(),
1091            style_before,
1092            "renaming is not a style change"
1093        );
1094    }
1095
1096    #[test]
1097    fn rename_series_noop_does_not_notify() {
1098        let (model, a, _b) = sample();
1099        let structure_before = model.structure_version().get();
1100        let (log, _handle) = track_changes(&model);
1101
1102        model.rename_series(a, "Revenue"); // already the name
1103        assert_eq!(log.borrow().len(), 0);
1104        assert_eq!(model.structure_version().get(), structure_before);
1105    }
1106
1107    #[test]
1108    fn set_series_color_bumps_style_not_structure() {
1109        let (model, a, _b) = sample();
1110        let structure_before = model.structure_version().get();
1111        let style_before = model.style_version().get();
1112        let (log, _handle) = track_changes(&model);
1113
1114        model.set_series_color(a, test_color());
1115        assert_eq!(log.borrow().len(), 1);
1116        assert_eq!(
1117            log.borrow()[0],
1118            ChartChange::SeriesColorChanged { series: a }
1119        );
1120        assert_eq!(
1121            model.structure_version().get(),
1122            structure_before,
1123            "color change must not bump structure_version"
1124        );
1125        assert!(model.style_version().get() > style_before);
1126        assert!(model.with_series(a, |_, color, _| color.is_some()).unwrap());
1127    }
1128
1129    #[test]
1130    fn set_series_color_noop_does_not_notify() {
1131        let (model, a, _b) = sample();
1132        model.set_series_color(a, test_color());
1133        let structure_before = model.structure_version().get();
1134        let style_before = model.style_version().get();
1135        let (log, _handle) = track_changes(&model);
1136
1137        model.set_series_color(a, test_color()); // same static color again
1138        assert_eq!(log.borrow().len(), 0);
1139        assert_eq!(model.structure_version().get(), structure_before);
1140        assert_eq!(model.style_version().get(), style_before);
1141    }
1142
1143    #[test]
1144    fn clear_series_color_bumps_style_and_clears() {
1145        let (model, a, _b) = sample();
1146        model.set_series_color(a, test_color());
1147        let style_before = model.style_version().get();
1148        let (log, _handle) = track_changes(&model);
1149
1150        model.clear_series_color(a);
1151        assert_eq!(log.borrow().len(), 1);
1152        assert_eq!(
1153            log.borrow()[0],
1154            ChartChange::SeriesColorChanged { series: a }
1155        );
1156        assert!(model.style_version().get() > style_before);
1157        assert!(!model.with_series(a, |_, color, _| color.is_some()).unwrap());
1158    }
1159
1160    #[test]
1161    fn clear_series_color_noop_does_not_notify_when_already_none() {
1162        let (model, a, _b) = sample();
1163        // Freshly-built series has no explicit color.
1164        let structure_before = model.structure_version().get();
1165        let style_before = model.style_version().get();
1166        let (log, _handle) = track_changes(&model);
1167
1168        model.clear_series_color(a);
1169        assert_eq!(log.borrow().len(), 0);
1170        assert_eq!(model.structure_version().get(), structure_before);
1171        assert_eq!(model.style_version().get(), style_before);
1172    }
1173
1174    #[test]
1175    fn set_series_visible_bumps_structure() {
1176        let (model, a, _b) = sample();
1177        let structure_before = model.structure_version().get();
1178        let (log, _handle) = track_changes(&model);
1179
1180        model.set_series_visible(a, false);
1181        assert_eq!(log.borrow().len(), 1);
1182        assert_eq!(
1183            log.borrow()[0],
1184            ChartChange::SeriesVisibilityChanged { series: a }
1185        );
1186        assert!(model.structure_version().get() > structure_before);
1187        assert_eq!(model.with_series(a, |_, _, visible| visible), Some(false));
1188    }
1189
1190    #[test]
1191    fn set_series_visible_noop_does_not_notify() {
1192        let (model, a, _b) = sample();
1193        let structure_before = model.structure_version().get();
1194        let (log, _handle) = track_changes(&model);
1195
1196        model.set_series_visible(a, true); // already true
1197        assert_eq!(log.borrow().len(), 0);
1198        assert_eq!(model.structure_version().get(), structure_before);
1199    }
1200
1201    #[test]
1202    fn move_series_emits_moved_and_bumps_structure() {
1203        let (model, a, b) = sample();
1204        let structure_before = model.structure_version().get();
1205        let (log, _handle) = track_changes(&model);
1206
1207        model.move_series(a, 1);
1208        assert_eq!(model.series_ids(), vec![b, a]);
1209        assert_eq!(log.borrow().len(), 1);
1210        assert_eq!(
1211            log.borrow()[0],
1212            ChartChange::SeriesMoved {
1213                series: a,
1214                from: 0,
1215                to: 1
1216            }
1217        );
1218        assert!(model.structure_version().get() > structure_before);
1219    }
1220
1221    #[test]
1222    fn move_series_noop_does_not_notify() {
1223        let (model, a, _b) = sample();
1224        let structure_before = model.structure_version().get();
1225        let (log, _handle) = track_changes(&model);
1226
1227        model.move_series(a, 0); // already at 0
1228        assert_eq!(log.borrow().len(), 0);
1229        assert_eq!(model.structure_version().get(), structure_before);
1230    }
1231
1232    #[test]
1233    fn push_point_emits_inserted_and_bumps_structure() {
1234        let (model, a, _b) = sample();
1235        let structure_before = model.structure_version().get();
1236        let (log, _handle) = track_changes(&model);
1237
1238        model.push_point(a, "Q3".to_string(), 30.0);
1239        assert_eq!(model.point_count(a), 3);
1240        assert_eq!(log.borrow().len(), 1);
1241        assert_eq!(
1242            log.borrow()[0],
1243            ChartChange::PointsInserted {
1244                series: a,
1245                range: 2..3
1246            }
1247        );
1248        assert!(model.structure_version().get() > structure_before);
1249    }
1250
1251    #[test]
1252    fn insert_point_at_index() {
1253        let (model, a, _b) = sample();
1254        model.insert_point(a, 1, "Q1.5".to_string(), 15.0);
1255        assert_eq!(model.point_count(a), 3);
1256        assert_eq!(
1257            model.with_point(a, 1, |d| d.category.clone()),
1258            Some("Q1.5".to_string())
1259        );
1260    }
1261
1262    #[test]
1263    fn remove_point_emits_removed_and_returns_datum() {
1264        let (model, a, _b) = sample();
1265        let (log, _handle) = track_changes(&model);
1266
1267        let removed = model.remove_point(a, 0);
1268        assert_eq!(removed.category, "Q1");
1269        assert_eq!(model.point_count(a), 1);
1270        assert_eq!(log.borrow().len(), 1);
1271        assert_eq!(
1272            log.borrow()[0],
1273            ChartChange::PointsRemoved {
1274                series: a,
1275                range: 0..1
1276            }
1277        );
1278    }
1279
1280    #[test]
1281    fn update_point_bumps_structure_not_style() {
1282        let (model, a, _b) = sample();
1283        let structure_before = model.structure_version().get();
1284        let style_before = model.style_version().get();
1285        let (log, _handle) = track_changes(&model);
1286
1287        model.update_point(a, 0, "Q1-revised".to_string(), 99.0);
1288        assert_eq!(log.borrow().len(), 1);
1289        assert_eq!(
1290            log.borrow()[0],
1291            ChartChange::PointUpdated {
1292                series: a,
1293                index: 0
1294            }
1295        );
1296        assert!(model.structure_version().get() > structure_before);
1297        assert_eq!(model.style_version().get(), style_before);
1298        assert_eq!(model.with_point(a, 0, |d| d.value), Some(99.0));
1299    }
1300
1301    #[test]
1302    fn replace_series_data_emits_replaced() {
1303        let (model, a, _b) = sample();
1304        let (log, _handle) = track_changes(&model);
1305
1306        model.replace_series_data(a, vec![ChartDatum::new("X".to_string(), 1.0)]);
1307        assert_eq!(model.point_count(a), 1);
1308        assert_eq!(log.borrow().len(), 1);
1309        assert_eq!(
1310            log.borrow()[0],
1311            ChartChange::SeriesDataReplaced { series: a }
1312        );
1313    }
1314
1315    #[test]
1316    fn clear_emits_reset() {
1317        let (model, _a, _b) = sample();
1318        let (log, _handle) = track_changes(&model);
1319
1320        model.clear();
1321        assert_eq!(model.series_count(), 0);
1322        assert_eq!(log.borrow().len(), 1);
1323        assert_eq!(log.borrow()[0], ChartChange::Reset);
1324    }
1325
1326    #[test]
1327    fn observer_removed_on_handle_drop() {
1328        let model: ChartModel<String> = ChartModel::new();
1329        let count = Rc::new(Cell::new(0));
1330        let c = count.clone();
1331        let handle = model.observe_changes(move |_| c.set(c.get() + 1));
1332
1333        model.add_series("A");
1334        assert_eq!(count.get(), 1);
1335
1336        drop(handle);
1337        model.add_series("B");
1338        assert_eq!(count.get(), 1);
1339    }
1340
1341    #[test]
1342    fn multiple_observers() {
1343        let model: ChartModel<String> = ChartModel::new();
1344        let count = Rc::new(Cell::new(0));
1345        let c1 = count.clone();
1346        let c2 = count.clone();
1347        let _h1 = model.observe_changes(move |_| c1.set(c1.get() + 1));
1348        let _h2 = model.observe_changes(move |_| c2.set(c2.get() + 1));
1349
1350        model.add_series("A");
1351        assert_eq!(count.get(), 2);
1352    }
1353
1354    #[test]
1355    fn clone_shares_data_and_observers() {
1356        let (model, _a, _b) = sample();
1357        let clone = model.clone();
1358        let count = Rc::new(Cell::new(0));
1359        let c = count.clone();
1360        let _handle = model.observe_changes(move |_| c.set(c.get() + 1));
1361
1362        clone.add_series("New");
1363        assert_eq!(model.series_count(), 3);
1364        assert_eq!(count.get(), 1);
1365    }
1366
1367    #[test]
1368    fn with_all_series_returns_views_in_order() {
1369        let (model, a, b) = sample();
1370        let ids: Vec<SeriesId> =
1371            model.with_all_series(|views| views.iter().map(|v| v.id).collect());
1372        assert_eq!(ids, vec![a, b]);
1373        let names: Vec<String> =
1374            model.with_all_series(|views| views.iter().map(|v| v.name.to_string()).collect());
1375        assert_eq!(names, vec!["Revenue".to_string(), "Costs".to_string()]);
1376    }
1377
1378    #[test]
1379    fn with_point_out_of_bounds_returns_none() {
1380        let (model, a, _b) = sample();
1381        assert_eq!(model.with_point(a, 99, |d| d.value), None);
1382    }
1383
1384    /// A `SeriesId` that is guaranteed stale **within `model`'s own arena**.
1385    /// A `SeriesId` minted by a *different* `ChartModel` is not a safe
1386    /// stand-in — `SlotMap` keys are per-arena, so two freshly-created
1387    /// arenas hand out colliding keys (both start at index 0, generation
1388    /// 1). Removing a series from `model` itself bumps that slot's
1389    /// generation, so its old id is reliably rejected by this same arena.
1390    fn stale_id(model: &ChartModel<String>) -> SeriesId {
1391        let ghost = model.add_series("Ghost");
1392        model.remove_series(ghost);
1393        ghost
1394    }
1395
1396    #[test]
1397    fn with_series_unknown_id_returns_none() {
1398        let (model, _a, _b) = sample();
1399        let ghost = stale_id(&model);
1400        assert_eq!(model.with_series(ghost, |_, _, _| ()), None);
1401        assert_eq!(model.with_point(ghost, 0, |d| d.value), None);
1402    }
1403
1404    #[test]
1405    #[should_panic(expected = "unknown SeriesId")]
1406    fn remove_series_unknown_id_panics() {
1407        let (model, _a, _b) = sample();
1408        let ghost = stale_id(&model);
1409        model.remove_series(ghost);
1410    }
1411
1412    #[test]
1413    #[should_panic]
1414    fn rename_series_unknown_id_panics() {
1415        let (model, _a, _b) = sample();
1416        let ghost = stale_id(&model);
1417        model.rename_series(ghost, "X");
1418    }
1419
1420    #[test]
1421    #[should_panic]
1422    fn push_point_unknown_series_panics() {
1423        let (model, _a, _b) = sample();
1424        let ghost = stale_id(&model);
1425        model.push_point(ghost, "X".to_string(), 1.0);
1426    }
1427
1428    #[test]
1429    #[should_panic(expected = "unknown SeriesId")]
1430    fn move_series_unknown_id_panics() {
1431        let (model, _a, _b) = sample();
1432        let ghost = stale_id(&model);
1433        model.move_series(ghost, 0);
1434    }
1435
1436    // A minimal ColorProp value, built without a direct teksilo-tokens
1437    // *production* dependency — `teksilo-tokens` is pulled in as a
1438    // dev-dependency solely so tests can construct a concrete `Color`.
1439    fn test_color() -> teksilo_tokens::Color {
1440        teksilo_tokens::Color::from_hex("#FF0000")
1441    }
1442}