Skip to main content

adam_web_ui/graph/
constraints.rs

1//! Pure owner of the embedded Adam graph-view policy.
2//!
3//! Construct one [`ViewConstraints`] per graph, submit absolute proposals, and
4//! use [`ViewAction::Refresh`] for geometry-only updates. Only an explicit
5//! [`ViewAction::Fit`] replaces the user's pan/zoom aspiration with the fit.
6//!
7//! ```
8//! use adam_web_ui::graph::{ViewAction, ViewConstraints, ViewInput};
9//! use cel_euclid::{Point2D, Rect, Size2D};
10//! let mut model = ViewConstraints::new()?;
11//! let output = model.update(ViewInput {
12//!     bounds: Rect::new(Point2D::zero(), Size2D::new(200.0, 100.0)),
13//!     viewport: Size2D::new(400.0, 300.0),
14//!     action: ViewAction::Fit,
15//! })?;
16//! assert_eq!((output.view.x, output.view.y, output.view.k), (0.0, 50.0, 2.0));
17//! # Ok::<(), anyhow::Error>(())
18//! ```
19
20use adam_lang::{AdamParser, ParsedSheet};
21use adam_rs::CellId;
22use anyhow::{Result, anyhow, ensure};
23use cel_euclid::{Rect, Size2D, Transform2D, Vector2D};
24use serde::{Deserialize, Serialize};
25
26use crate::{build, labels::Renderer, labels::format_adam_error};
27
28/// The embedded policy evaluated by every model instance.
29const SOURCE: &str = include_str!("view_constraints.adm2");
30/// The diagnostic source name for the embedded policy.
31const FILE_NAME: &str = "view_constraints.adm2";
32
33/// A uniform scale followed by a viewport-space translation.
34///
35/// Approved values are finite and `k` is positive.
36///
37/// # Examples
38/// ```
39/// use adam_web_ui::graph::ViewTransform;
40/// let view = ViewTransform { x: 0.0, y: 50.0, k: 2.0 };
41/// assert_eq!(view.k, 2.0);
42/// ```
43#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
44pub struct ViewTransform {
45    /// The horizontal viewport-space translation.
46    pub x: f64,
47    /// The vertical viewport-space translation.
48    pub y: f64,
49    /// The positive uniform zoom factor.
50    pub k: f64,
51}
52
53/// An explicit source edit, geometry refresh, or replacement with the sheet's fit.
54///
55/// # Examples
56/// ```
57/// use adam_web_ui::graph::ViewAction;
58/// assert_eq!(serde_json::to_string(&ViewAction::Fit).unwrap(), r#"{"kind":"fit"}"#);
59/// ```
60#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum ViewAction {
63    /// Replaces the original source aspiration with an absolute proposal.
64    Propose {
65        /// The finite proposed translation and positive finite zoom.
66        view: ViewTransform,
67    },
68    /// Updates only bounds and viewport, preserving source aspirations.
69    Refresh,
70    /// Replaces pan and zoom sources with the sheet's current derived fit.
71    Fit,
72}
73
74/// Geometry and an action for a single model update.
75///
76/// Bounds have finite ordered endpoints and representable nonnegative extents.
77/// Viewport extents are positive and finite.
78///
79/// # Examples
80/// ```
81/// use adam_web_ui::graph::{ViewAction, ViewInput};
82/// use cel_euclid::{Rect, Size2D};
83/// let input = ViewInput {
84///     bounds: Rect::default(),
85///     viewport: Size2D::new(400.0, 300.0),
86///     action: ViewAction::Refresh,
87/// };
88/// assert!(input.viewport.width > 0.0);
89/// ```
90#[derive(Clone, Copy, Debug)]
91pub struct ViewInput {
92    /// The graph's content bounds, including any renderer-provided margin.
93    pub bounds: Rect,
94    /// The measured viewport size.
95    pub viewport: Size2D,
96    /// The requested source operation.
97    pub action: ViewAction,
98}
99
100/// Complete finite approved values and gesture hints derived by Adam.
101///
102/// `min_zoom <= view.k <= max_zoom`; the maximum can exceed eight when fit does.
103///
104/// # Examples
105/// ```
106/// # use adam_web_ui::graph::{ViewOutput, ViewTransform};
107/// let view = ViewTransform { x: 0.0, y: 50.0, k: 2.0 };
108/// let output = ViewOutput { view, fit: view, min_zoom: 2.0, max_zoom: 8.0 };
109/// assert!(output.min_zoom <= output.view.k && output.view.k <= output.max_zoom);
110/// ```
111#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
112pub struct ViewOutput {
113    /// The conformed view to render.
114    pub view: ViewTransform,
115    /// The fit target for the current geometry.
116    pub fit: ViewTransform,
117    /// The fit-derived minimum scale.
118    pub min_zoom: f64,
119    /// The sheet's effective maximum scale.
120    pub max_zoom: f64,
121}
122
123/// Cached identities for the fixed embedded sheet.
124struct Cells {
125    /// The bounds source.
126    bounds: CellId,
127    /// The viewport source.
128    viewport: CellId,
129    /// The ordinary pan cell retaining its source aspiration.
130    pan: CellId,
131    /// The ordinary zoom cell retaining its source aspiration.
132    zoom: CellId,
133    /// The derived fit scale.
134    fit_zoom: CellId,
135    /// The derived fit translation.
136    fit_pan: CellId,
137    /// The derived minimum scale.
138    min_zoom: CellId,
139    /// The derived maximum scale.
140    max_zoom: CellId,
141    /// The derived affine view.
142    view: CellId,
143}
144
145/// Owns one parsed sheet and cached identities without per-request parsing.
146///
147/// All fit, centering, clamping, and zoom-limit arithmetic lives in the sheet.
148/// Errors leave the model available for subsequent updates, but do not promise
149/// rollback of writes already propagated.
150///
151/// # Examples
152/// ```
153/// let model = adam_web_ui::graph::ViewConstraints::new();
154/// assert!(model.is_ok());
155/// ```
156pub struct ViewConstraints {
157    /// The persistent source and derived values.
158    parsed: ParsedSheet,
159    /// The identities resolved once during construction.
160    cells: Cells,
161}
162
163impl ViewConstraints {
164    /// Parses and propagates the embedded sheet using the configured library host.
165    ///
166    /// # Errors
167    /// Returns a formatted diagnostic for invalid sheet syntax, missing cells,
168    /// evaluation failure, or incomplete/non-finite initial outputs.
169    ///
170    /// - Complexity: O(s² + n + p) time and O(s + n + p) space for installed
171    ///   signatures `s`, source length `n`, and the sheet's propagation work `p`.
172    ///
173    /// # Examples
174    /// ```
175    /// let mut model = adam_web_ui::graph::ViewConstraints::new()?;
176    /// # Ok::<(), anyhow::Error>(())
177    /// ```
178    pub fn new() -> Result<Self> {
179        let mut parser = AdamParser::new(build::type_registry(), build::op_lookup());
180        let parsed = parser.parse_str(SOURCE).map_err(|error| {
181            anyhow!(error.format_rustc_style(SOURCE, FILE_NAME, 1, &Renderer::plain()))
182        })?;
183        let id = |name: &str| {
184            parsed
185                .cell_names
186                .get(name)
187                .map(|&(id, _)| id)
188                .ok_or_else(|| anyhow!("{FILE_NAME}: missing cell `{name}`"))
189        };
190        let cells = Cells {
191            bounds: id("bounds")?,
192            viewport: id("viewport")?,
193            pan: id("pan")?,
194            zoom: id("zoom")?,
195            fit_zoom: id("fit_zoom")?,
196            fit_pan: id("fit_pan")?,
197            min_zoom: id("min_zoom")?,
198            max_zoom: id("effective_max_zoom")?,
199            view: id("view")?,
200        };
201        let mut model = Self { parsed, cells };
202        model.propagate()?;
203        model.output()?;
204        Ok(model)
205    }
206
207    /// Validates inputs, writes geometry and requested sources, and propagates.
208    ///
209    /// Refresh preserves the original pan/zoom aspiration; Fit copies only the
210    /// sheet's derived fit into those sources and propagates a second time.
211    ///
212    /// # Errors
213    /// Returns an error for non-finite/unordered/unrepresentable bounds,
214    /// nonpositive/non-finite viewport or proposed zoom, non-finite proposed pan,
215    /// sheet evaluation failure, or incomplete/non-finite/nonpositive outputs.
216    /// A failed update never returns a fallback view.
217    ///
218    /// - Complexity: O(p) time and space in sheet propagation work; no parsing.
219    ///
220    /// # Examples
221    /// ```
222    /// # use adam_web_ui::graph::{ViewAction, ViewConstraints, ViewInput};
223    /// # use cel_euclid::{Rect, Size2D};
224    /// let mut model = ViewConstraints::new()?;
225    /// let output = model.update(ViewInput {
226    ///     bounds: Rect::default(),
227    ///     viewport: Size2D::new(400.0, 300.0),
228    ///     action: ViewAction::Fit,
229    /// })?;
230    /// assert_eq!(output.view.k, 300.0);
231    /// # Ok::<(), anyhow::Error>(())
232    /// ```
233    pub fn update(&mut self, input: ViewInput) -> Result<ViewOutput> {
234        validate_input(&input)?;
235        self.write(self.cells.bounds, input.bounds)?;
236        self.write(self.cells.viewport, input.viewport)?;
237        if let ViewAction::Propose { view } = input.action {
238            self.write(self.cells.pan, Vector2D::new(view.x, view.y))?;
239            self.write(self.cells.zoom, view.k)?;
240        }
241        self.propagate()?;
242        let output = self.output()?;
243        if input.action == ViewAction::Fit {
244            let pan: Vector2D = self.read(self.cells.fit_pan)?;
245            let zoom: f64 = self.read(self.cells.fit_zoom)?;
246            self.write(self.cells.pan, pan)?;
247            self.write(self.cells.zoom, zoom)?;
248            self.propagate()?;
249            return self.output();
250        }
251        Ok(output)
252    }
253
254    /// Formats a sheet error against the embedded source.
255    ///
256    /// - Complexity: O(n + e) in source length and error sites.
257    fn diagnostic(&self, error: adam_rs::Error) -> anyhow::Error {
258        anyhow!(format_adam_error(
259            &error,
260            &self.parsed,
261            SOURCE,
262            FILE_NAME,
263            &Renderer::plain(),
264        ))
265    }
266
267    /// Writes a typed source, returning a formatted sheet error on failure.
268    fn write<T: 'static>(&mut self, id: CellId, value: T) -> Result<()> {
269        self.parsed
270            .write(id, value)
271            .map_err(|error| self.diagnostic(error))
272    }
273
274    /// Reads a complete typed value, returning a formatted sheet error on failure.
275    fn read<T: Copy + 'static>(&self, id: CellId) -> Result<T> {
276        self.parsed
277            .read::<T>(id)
278            .copied()
279            .map_err(|error| self.diagnostic(error))
280    }
281
282    /// Propagates the cached sheet and clears its presentation change flags.
283    ///
284    /// # Errors
285    /// Returns the formatted evaluation diagnostic on failure.
286    ///
287    /// - Complexity: O(p) time and space in sheet propagation work.
288    fn propagate(&mut self) -> Result<()> {
289        self.parsed
290            .propagate()
291            .map_err(|error| self.diagnostic(error))?;
292        self.parsed.clear_changed();
293        Ok(())
294    }
295
296    /// Reads complete outputs and rejects non-finite or invalid scale results.
297    ///
298    /// # Errors
299    /// Returns an explicit diagnostic for unavailable values or invalid outputs.
300    fn output(&self) -> Result<ViewOutput> {
301        let view: Transform2D = self.read(self.cells.view)?;
302        let fit_pan: Vector2D = self.read(self.cells.fit_pan)?;
303        let fit_zoom: f64 = self.read(self.cells.fit_zoom)?;
304        let min_zoom: f64 = self.read(self.cells.min_zoom)?;
305        let max_zoom: f64 = self.read(self.cells.max_zoom)?;
306        ensure!(
307            [
308                view.m11, view.m12, view.m21, view.m22, view.m31, view.m32, fit_pan.x, fit_pan.y,
309                fit_zoom, min_zoom, max_zoom,
310            ]
311            .into_iter()
312            .all(f64::is_finite)
313                && fit_zoom > 0.0
314                && min_zoom > 0.0
315                && min_zoom <= view.m11
316                && view.m11 <= max_zoom,
317            "{FILE_NAME}: invalid or unrepresentable view/fit outputs"
318        );
319        Ok(ViewOutput {
320            view: ViewTransform {
321                x: view.m31,
322                y: view.m32,
323                k: view.m11,
324            },
325            fit: ViewTransform {
326                x: fit_pan.x,
327                y: fit_pan.y,
328                k: fit_zoom,
329            },
330            min_zoom,
331            max_zoom,
332        })
333    }
334}
335
336/// Rejects invalid geometry and proposals before any sheet source is written.
337///
338/// Positive extents must advance the represented endpoint.
339///
340/// # Errors
341/// Returns a named input error for invalid bounds, viewport, or proposal.
342fn validate_input(input: &ViewInput) -> Result<()> {
343    let bounds = input.bounds;
344    for (origin, extent, end) in [
345        (bounds.origin.x, bounds.size.width, bounds.max_x()),
346        (bounds.origin.y, bounds.size.height, bounds.max_y()),
347    ] {
348        ensure!(
349            origin.is_finite()
350                && extent.is_finite()
351                && extent >= 0.0
352                && end.is_finite()
353                && end >= origin
354                && (end - origin).is_finite()
355                && (extent == 0.0 || end > origin),
356            "invalid bounds: expected finite ordered endpoints and representable nonnegative extents"
357        );
358    }
359    ensure!(
360        input.viewport.width.is_finite()
361            && input.viewport.height.is_finite()
362            && input.viewport.width > 0.0
363            && input.viewport.height > 0.0,
364        "invalid viewport: expected positive finite extents"
365    );
366    if let ViewAction::Propose { view } = input.action {
367        ensure!(
368            view.x.is_finite() && view.y.is_finite(),
369            "invalid proposed pan: expected finite coordinates"
370        );
371        ensure!(
372            view.k.is_finite() && view.k > 0.0,
373            "invalid proposed zoom: expected a positive finite scale"
374        );
375    }
376    Ok(())
377}