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