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}