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