Skip to main content

r/interpreter/grid/
viewport.rs

1//! Grid viewport system — hierarchical coordinate contexts.
2//!
3//! Viewports define rectangular regions with their own coordinate systems,
4//! scales, and graphical parameters. They form a stack (push/pop) that
5//! determines how child grobs are positioned and sized.
6
7use super::gpar::Gpar;
8use super::units::{Unit, UnitContext};
9
10// region: Justification
11
12/// Justification for positioning within a viewport.
13///
14/// Maps to numeric values: Left=0.0, Centre=0.5, Right=1.0 (horizontal),
15/// Bottom=0.0, Centre=0.5, Top=1.0 (vertical).
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub enum Justification {
18    /// Left (horizontal) or Bottom (vertical) — 0.0.
19    Left,
20    /// Center — 0.5.
21    Centre,
22    /// Right (horizontal) or Top (vertical) — 1.0.
23    Right,
24    /// Top — 1.0 (vertical only, alias for Right in vertical context).
25    Top,
26    /// Bottom — 0.0 (vertical only, alias for Left in vertical context).
27    Bottom,
28}
29
30impl Justification {
31    /// Convert justification to a numeric offset fraction (0.0 to 1.0).
32    pub fn as_fraction(self) -> f64 {
33        match self {
34            Justification::Left | Justification::Bottom => 0.0,
35            Justification::Centre => 0.5,
36            Justification::Right | Justification::Top => 1.0,
37        }
38    }
39
40    /// Parse a justification from a string, as R's grid accepts.
41    pub fn parse(s: &str) -> Option<Self> {
42        match s {
43            "left" => Some(Justification::Left),
44            "centre" | "center" => Some(Justification::Centre),
45            "right" => Some(Justification::Right),
46            "top" => Some(Justification::Top),
47            "bottom" => Some(Justification::Bottom),
48            _ => None,
49        }
50    }
51}
52
53// endregion
54
55// region: GridLayout
56
57/// A grid layout divides a viewport into rows and columns.
58///
59/// Row heights and column widths are expressed as units. Grobs can be
60/// placed into specific cells (row, col) or span multiple cells.
61#[derive(Clone, Debug)]
62pub struct GridLayout {
63    /// Number of rows.
64    pub nrow: usize,
65    /// Number of columns.
66    pub ncol: usize,
67    /// Row heights — one unit per row.
68    pub heights: Vec<Unit>,
69    /// Column widths — one unit per column.
70    pub widths: Vec<Unit>,
71    /// Whether to fill layout cells left-to-right (true) or top-to-bottom (false).
72    pub respect: bool,
73}
74
75impl GridLayout {
76    /// Create a layout with uniform NPC-sized rows and columns.
77    pub fn uniform(nrow: usize, ncol: usize) -> Self {
78        let row_height = 1.0 / nrow as f64;
79        let col_width = 1.0 / ncol as f64;
80        GridLayout {
81            nrow,
82            ncol,
83            heights: (0..nrow).map(|_| Unit::npc(row_height)).collect(),
84            widths: (0..ncol).map(|_| Unit::npc(col_width)).collect(),
85            respect: false,
86        }
87    }
88}
89
90// endregion
91
92// region: Viewport
93
94/// A grid viewport — a rectangular region with its own coordinate system.
95///
96/// Viewports have position, size, justification, native scale, rotation,
97/// clipping behavior, and graphical parameters. They can optionally contain
98/// a layout for subdividing into cells.
99#[derive(Clone, Debug)]
100pub struct Viewport {
101    /// Optional name for this viewport (for viewport navigation).
102    pub name: Option<String>,
103    /// X position within parent viewport.
104    pub x: Unit,
105    /// Y position within parent viewport.
106    pub y: Unit,
107    /// Width of this viewport.
108    pub width: Unit,
109    /// Height of this viewport.
110    pub height: Unit,
111    /// Justification: (horizontal, vertical).
112    pub just: (Justification, Justification),
113    /// Native x-coordinate scale (min, max).
114    pub xscale: (f64, f64),
115    /// Native y-coordinate scale (min, max).
116    pub yscale: (f64, f64),
117    /// Rotation angle in degrees.
118    pub angle: f64,
119    /// Whether to clip drawing to this viewport's bounds.
120    pub clip: bool,
121    /// Graphical parameters for this viewport.
122    pub gp: Gpar,
123    /// Optional layout for subdividing this viewport.
124    pub layout: Option<GridLayout>,
125}
126
127impl Viewport {
128    /// Create a root viewport that covers the entire device.
129    pub fn root(width_cm: f64, height_cm: f64) -> Self {
130        Viewport {
131            name: Some("ROOT".to_string()),
132            x: Unit::cm(0.0),
133            y: Unit::cm(0.0),
134            width: Unit::cm(width_cm),
135            height: Unit::cm(height_cm),
136            just: (Justification::Left, Justification::Bottom),
137            xscale: (0.0, 1.0),
138            yscale: (0.0, 1.0),
139            angle: 0.0,
140            clip: true,
141            gp: Gpar::new(),
142            layout: None,
143        }
144    }
145
146    /// Create a new viewport with default settings (centered, full NPC extent).
147    pub fn new() -> Self {
148        Viewport {
149            name: None,
150            x: Unit::npc(0.5),
151            y: Unit::npc(0.5),
152            width: Unit::npc(1.0),
153            height: Unit::npc(1.0),
154            just: (Justification::Centre, Justification::Centre),
155            xscale: (0.0, 1.0),
156            yscale: (0.0, 1.0),
157            angle: 0.0,
158            clip: false,
159            gp: Gpar::new(),
160            layout: None,
161        }
162    }
163}
164
165impl Default for Viewport {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171// endregion
172
173// region: ViewportStack
174
175/// A stack of viewports, starting from the root (device) viewport.
176///
177/// The stack grows as viewports are pushed and shrinks as they are popped.
178/// The current viewport (top of stack) determines the coordinate context
179/// for drawing operations.
180pub struct ViewportStack {
181    stack: Vec<Viewport>,
182}
183
184impl ViewportStack {
185    /// Create a new viewport stack with a root viewport for the given device size.
186    pub fn new(viewport_width_cm: f64, viewport_height_cm: f64) -> Self {
187        ViewportStack {
188            stack: vec![Viewport::root(viewport_width_cm, viewport_height_cm)],
189        }
190    }
191
192    /// Push a child viewport onto the stack.
193    pub fn push(&mut self, vp: Viewport) {
194        self.stack.push(vp);
195    }
196
197    /// Pop the top viewport. Returns `None` if only the root remains
198    /// (the root viewport cannot be popped).
199    pub fn pop(&mut self) -> Option<Viewport> {
200        if self.stack.len() > 1 {
201            self.stack.pop()
202        } else {
203            None
204        }
205    }
206
207    /// Return a reference to the current (topmost) viewport.
208    pub fn current(&self) -> &Viewport {
209        self.stack
210            .last()
211            .expect("viewport stack always has at least the root viewport")
212    }
213
214    /// Return the depth of the viewport stack (1 = root only).
215    pub fn depth(&self) -> usize {
216        self.stack.len()
217    }
218}
219
220// endregion
221
222// region: ViewportTransform
223
224/// The computed absolute transform for a viewport, in device cm coordinates.
225///
226/// This is produced by walking the viewport stack from root to current,
227/// accumulating position, size, and scale transforms at each level.
228#[derive(Clone, Debug)]
229pub struct ViewportTransform {
230    /// X offset of the viewport's bottom-left corner from device origin, in cm.
231    pub x_offset_cm: f64,
232    /// Y offset of the viewport's bottom-left corner from device origin, in cm.
233    pub y_offset_cm: f64,
234    /// Viewport width in cm.
235    pub width_cm: f64,
236    /// Viewport height in cm.
237    pub height_cm: f64,
238    /// Accumulated rotation angle in degrees.
239    pub angle: f64,
240    /// Native x-scale.
241    pub xscale: (f64, f64),
242    /// Native y-scale.
243    pub yscale: (f64, f64),
244}
245
246impl ViewportTransform {
247    /// Create the root transform for the device.
248    pub fn root(width_cm: f64, height_cm: f64) -> Self {
249        ViewportTransform {
250            x_offset_cm: 0.0,
251            y_offset_cm: 0.0,
252            width_cm,
253            height_cm,
254            angle: 0.0,
255            xscale: (0.0, 1.0),
256            yscale: (0.0, 1.0),
257        }
258    }
259
260    /// Compute the transform for a child viewport given its parent's transform.
261    pub fn from_viewport(vp: &Viewport, parent: &ViewportTransform) -> Self {
262        let ctx = UnitContext {
263            viewport_width_cm: parent.width_cm,
264            viewport_height_cm: parent.height_cm,
265            xscale: parent.xscale,
266            yscale: parent.yscale,
267            fontsize_pt: 12.0,
268            lineheight: 1.2,
269        };
270
271        // Resolve the viewport's position and size in parent cm
272        let vp_x = ctx.resolve_x(&vp.x, 0);
273        let vp_y = ctx.resolve_y(&vp.y, 0);
274
275        let vp_width = ctx.resolve_x(&vp.width, 0);
276        let vp_height = ctx.resolve_y(&vp.height, 0);
277
278        // Apply justification: the (x, y) is the justification point,
279        // so we offset to get the bottom-left corner.
280        let hjust = vp.just.0.as_fraction();
281        let vjust = vp.just.1.as_fraction();
282        let x_offset = parent.x_offset_cm + vp_x - hjust * vp_width;
283        let y_offset = parent.y_offset_cm + vp_y - vjust * vp_height;
284
285        ViewportTransform {
286            x_offset_cm: x_offset,
287            y_offset_cm: y_offset,
288            width_cm: vp_width,
289            height_cm: vp_height,
290            angle: parent.angle + vp.angle,
291            xscale: vp.xscale,
292            yscale: vp.yscale,
293        }
294    }
295
296    /// Convert a native (data) x coordinate to cm from device origin.
297    pub fn native_to_cm_x(&self, x: f64) -> f64 {
298        let range = self.xscale.1 - self.xscale.0;
299        if range == 0.0 {
300            self.x_offset_cm
301        } else {
302            self.x_offset_cm + ((x - self.xscale.0) / range) * self.width_cm
303        }
304    }
305
306    /// Convert a native (data) y coordinate to cm from device origin.
307    pub fn native_to_cm_y(&self, y: f64) -> f64 {
308        let range = self.yscale.1 - self.yscale.0;
309        if range == 0.0 {
310            self.y_offset_cm
311        } else {
312            self.y_offset_cm + ((y - self.yscale.0) / range) * self.height_cm
313        }
314    }
315
316    /// Convert an NPC x coordinate (0..1) to cm from device origin.
317    pub fn npc_to_cm_x(&self, x: f64) -> f64 {
318        self.x_offset_cm + x * self.width_cm
319    }
320
321    /// Convert an NPC y coordinate (0..1) to cm from device origin.
322    pub fn npc_to_cm_y(&self, y: f64) -> f64 {
323        self.y_offset_cm + y * self.height_cm
324    }
325
326    /// Build a UnitContext for resolving units within this viewport transform.
327    pub fn unit_context(&self) -> UnitContext {
328        UnitContext {
329            viewport_width_cm: self.width_cm,
330            viewport_height_cm: self.height_cm,
331            xscale: self.xscale,
332            yscale: self.yscale,
333            fontsize_pt: 12.0,
334            lineheight: 1.2,
335        }
336    }
337}
338
339/// Compute the full transform stack for a ViewportStack, returning the
340/// transform for the current (topmost) viewport.
341pub fn compute_transform(stack: &ViewportStack) -> ViewportTransform {
342    let root = &stack.stack[0];
343    let mut transform = ViewportTransform::root(root.width.value(), root.height.value());
344    for vp in stack.stack.iter().skip(1) {
345        transform = ViewportTransform::from_viewport(vp, &transform);
346    }
347    transform
348}
349
350// endregion