Skip to main content

ridge_core/
geometry.rs

1//! Turn a processed elevation grid into drawable ridge lines.
2//!
3//! Upstream `plot_map` draws row `i` at `y = row - 6*i` with a
4//! background-colored fill from the baseline up to the line (the occlusion
5//! trick that makes front ridges hide back ridges). `RidgeScene` captures
6//! that geometry, plus the matplotlib figure layout (figure size, axes rect,
7//! data limits) so the SVG exporter and the web canvas render identically.
8
9use ndarray::Array2;
10
11use crate::colormap::LineColor;
12use crate::Error;
13
14/// Upstream: `y_base = -6 * idx * np.ones_like(row)`.
15pub const LINE_SPACING: f64 = 6.0;
16/// matplotlib default dpi used for px math (figsize is in inches).
17pub const FIG_DPI: f64 = 100.0;
18/// matplotlib default `figure.figsize` used by upstream (`size_scale=20`).
19pub const DEFAULT_SIZE_SCALE: f64 = 20.0;
20/// matplotlib default `subplot_params` (fractions of the figure).
21pub const SUBPLOT_LEFT: f64 = 0.125;
22pub const SUBPLOT_RIGHT: f64 = 0.9;
23pub const SUBPLOT_BOTTOM: f64 = 0.11;
24pub const SUBPLOT_TOP: f64 = 0.88;
25/// matplotlib default axes margins (5% padding each side of the data).
26pub const AXES_MARGIN: f64 = 0.05;
27
28/// Where the axes rect sits and how data maps into it.
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
30pub struct FigureLayout {
31    /// Figure size in px (`size_scale` inches at `FIG_DPI`).
32    pub width_px: f64,
33    pub height_px: f64,
34    /// Axes rect in px from the top-left: `[x0, y0, x1, y1]`.
35    pub axes: [f64; 4],
36    /// Data window shown in the axes (includes matplotlib's 5% margins).
37    pub xlim: [f64; 2],
38    pub ylim: [f64; 2],
39}
40
41impl FigureLayout {
42    /// Map scene data coords to figure px (y flipped, SVG convention).
43    pub fn to_px(&self, x: f64, y: f64) -> (f64, f64) {
44        let fx = (x - self.xlim[0]) / (self.xlim[1] - self.xlim[0]);
45        let fy = (y - self.ylim[0]) / (self.ylim[1] - self.ylim[0]);
46        let ax_x0 = self.axes[0];
47        let ax_x1 = self.axes[2];
48        let ax_y0 = self.axes[1]; // top
49        let ax_y1 = self.axes[3]; // bottom
50        (
51            ax_x0 + fx * (ax_x1 - ax_x0),
52            ax_y0 + (1.0 - fy) * (ax_y1 - ax_y0),
53        )
54    }
55
56    /// Axes-fraction coords (upstream `transform=ax.transAxes`) to figure px.
57    pub fn frac_to_px(&self, fx: f64, fy: f64) -> (f64, f64) {
58        let x = self.axes[0] + fx * (self.axes[2] - self.axes[0]);
59        let y = self.axes[1] + (1.0 - fy) * (self.axes[3] - self.axes[1]);
60        (x, y)
61    }
62}
63
64/// One ridge line: its baseline and y-values (`NaN` = gap, no line drawn).
65#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
66pub struct RidgeRow {
67    pub baseline: f64,
68    /// y = elevation * vertical_ratio + baseline; `NaN` marks water/gaps.
69    pub y: Vec<f64>,
70}
71
72impl RidgeRow {
73    /// Contiguous (start, end) runs of finite values, for gap-aware drawing.
74    pub fn runs(&self) -> Vec<(usize, usize)> {
75        let mut runs = Vec::new();
76        let mut start: Option<usize> = None;
77        for (i, v) in self.y.iter().enumerate() {
78            if v.is_finite() {
79                if start.is_none() {
80                    start = Some(i);
81                }
82            } else if let Some(s) = start.take() {
83                if i > s {
84                    runs.push((s, i)); // exclusive end
85                }
86            }
87        }
88        if let Some(s) = start {
89            if self.y.len() > s {
90                runs.push((s, self.y.len()));
91            }
92        }
93        runs
94    }
95}
96
97/// What drives per-line color.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum ColorKind {
101    /// Color by line index (upstream `kind="gradient"`).
102    Gradient,
103    /// Color by actual elevation along the line (upstream `kind="elevation"`).
104    Elevation,
105}
106
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
108pub struct RidgeScene {
109    pub rows: Vec<RidgeRow>,
110    pub n_points: usize,
111    /// Range of the (scaled) processed elevations, for `Elevation` coloring.
112    pub vmin: f64,
113    pub vmax: f64,
114    pub layout: FigureLayout,
115}
116
117impl RidgeScene {
118    /// Build a scene from a preprocessed grid (already masked, flipped and
119    /// vertically scaled by `preprocess::preprocess`).
120    pub fn from_grid(
121        processed: &Array2<f64>,
122        bbox_ratio: f64,
123        size_scale: f64,
124        frame: Frame,
125    ) -> RidgeScene {
126        let (nrows, ncols) = processed.dim();
127        let rows: Vec<RidgeRow> = processed
128            .outer_iter()
129            .enumerate()
130            .map(|(i, src)| {
131                let baseline = -LINE_SPACING * i as f64;
132                RidgeRow {
133                    baseline,
134                    y: src.iter().map(|&v| v + baseline).collect(),
135                }
136            })
137            .collect();
138
139        let (mut vmin, mut vmax) = processed
140            .iter()
141            .copied()
142            .filter(|v| v.is_finite())
143            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
144                (lo.min(v), hi.max(v))
145            });
146        if !vmin.is_finite() {
147            vmin = 0.0;
148            vmax = 1.0;
149        }
150
151        // Where the axes start and stop. `Frame::Window` frames the whole
152        // requested window, so water and voids keep their share of the picture
153        // and no masking-related setting can move it. `Frame::Land` reproduces
154        // the legacy plot instead: matplotlib autoscales over the points it
155        // draws and drops the non-finite ones, so all-water columns and rows
156        // fall out of the frame and `water_ntile` rescales the picture.
157        // `fixtures/legacy/frame.json` records the legacy run for the same
158        // input, so the difference is measured rather than assumed.
159        let (xmin, xmax_data, ymin) = match frame {
160            Frame::Window => (0.0, (ncols - 1) as f64, -LINE_SPACING * (nrows - 1) as f64),
161            Frame::Land => {
162                let (first, last) = rows
163                    .iter()
164                    .flat_map(|row| row.y.iter().enumerate())
165                    .filter(|&(_, y)| y.is_finite())
166                    .fold((usize::MAX, 0usize), |(first, last), (c, _)| {
167                        (first.min(c), last.max(c))
168                    });
169                let lowest = rows
170                    .iter()
171                    .filter(|row| row.y.iter().any(|y| y.is_finite()))
172                    .map(|row| row.baseline)
173                    .fold(f64::INFINITY, f64::min);
174                if first == usize::MAX {
175                    // Nothing drawable even by matplotlib's reckoning: fall
176                    // back to the theoretical frame.
177                    (0.0, (ncols - 1) as f64, -LINE_SPACING * (nrows - 1) as f64)
178                } else {
179                    (first as f64, last as f64, lowest)
180                }
181            }
182        };
183        let ymax_data = rows
184            .iter()
185            .flat_map(|row| row.y.iter())
186            .copied()
187            .filter(|y| y.is_finite())
188            .fold(f64::NEG_INFINITY, f64::max);
189        // No drawable content at all: fall back to the theoretical frame.
190        let ymax_data = if ymax_data.is_finite() {
191            ymax_data
192        } else {
193            vmax
194        };
195        let dx = (xmax_data - xmin) * AXES_MARGIN;
196        let dy = (ymax_data - ymin) * AXES_MARGIN;
197
198        let width_px = size_scale * FIG_DPI;
199        let height_px = size_scale * bbox_ratio * FIG_DPI;
200        let layout = FigureLayout {
201            width_px,
202            height_px,
203            axes: [
204                SUBPLOT_LEFT * width_px,
205                (1.0 - SUBPLOT_TOP) * height_px,
206                SUBPLOT_RIGHT * width_px,
207                (1.0 - SUBPLOT_BOTTOM) * height_px,
208            ],
209            xlim: [xmin - dx, xmax_data + dx],
210            ylim: [ymin - dy, ymax_data + dy],
211        };
212
213        RidgeScene {
214            rows,
215            n_points: ncols,
216            vmin,
217            vmax,
218            layout,
219        }
220    }
221
222    /// Color for line `idx` under `Gradient` mode (upstream `line_color(i/n)`).
223    pub fn gradient_color(&self, line: &LineColor, idx: usize) -> crate::colormap::Rgb {
224        match line {
225            LineColor::Solid(rgb) => *rgb,
226            LineColor::Map(cm) => {
227                let denom = self.rows.len().saturating_sub(1).max(1) as f64;
228                cm.at(idx as f64 / denom)
229            }
230        }
231    }
232
233    /// Color for a point value under `Elevation` mode (upstream norm).
234    pub fn elevation_color(&self, line: &LineColor, value: f64) -> crate::colormap::Rgb {
235        let LineColor::Map(cm) = line else {
236            return [0, 0, 0];
237        };
238        let t = if self.vmax > self.vmin {
239            (value - self.vmin) / (self.vmax - self.vmin)
240        } else {
241            0.0
242        };
243        cm.at(t)
244    }
245
246    /// The upstream default label color: `line_color(0.0)` for colormaps.
247    pub fn label_color(&self, line: &LineColor) -> crate::colormap::Rgb {
248        match line {
249            LineColor::Solid(rgb) => *rgb,
250            LineColor::Map(cm) => cm.at(0.0),
251        }
252    }
253}
254
255/// How the figure frame is fitted around the processed grid.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
257pub enum Frame {
258    /// The whole requested window: water, lakes and voids keep their place, so
259    /// no mask can move the frame. This is the default.
260    #[default]
261    Window,
262    /// matplotlib's tight autoscale around the cells it actually draws, which
263    /// is what the legacy plot does: any all-water column or row drops out of
264    /// the frame, so `water_ntile` rescales the picture as well as the ridges.
265    Land,
266}
267
268/// How the rotated grid fits the canvas.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
270#[serde(rename_all = "lowercase")]
271pub enum Fit {
272    /// Upstream behavior: scipy `reshape=True/False`, zero-filled borders.
273    Reshape,
274    /// Fixed canvas rotating about the center; out-of-plane cells become
275    /// gaps. Used for interactive client-side rotation and WYSIWYG export.
276    Plane,
277}
278
279/// Run the full pipeline: sample -> (optional rotate) -> preprocess -> scene.
280#[allow(clippy::too_many_arguments)]
281pub fn build_scene(
282    source: &dyn crate::srtm::TileSource,
283    bbox: &crate::Bbox,
284    num_lines: usize,
285    elevation_pts: usize,
286    viewpoint_angle: f64,
287    crop: bool,
288    interpolation: u32,
289    lock_resolution: bool,
290    water_ntile: f64,
291    lake_flatness: i32,
292    vertical_ratio: f64,
293    size_scale: f64,
294    frame: Frame,
295) -> Result<RidgeScene, Error> {
296    build_scene_fit(
297        source,
298        bbox,
299        num_lines,
300        elevation_pts,
301        viewpoint_angle,
302        crop,
303        interpolation,
304        lock_resolution,
305        Fit::Reshape,
306        water_ntile,
307        lake_flatness,
308        vertical_ratio,
309        size_scale,
310        frame,
311    )
312}
313
314/// As `build_scene`, with an explicit [`Fit`]. `Fit::Plane` also skips the
315/// upstream axis swap (the client rotates a fixed-resolution plane), so the
316/// scene the browser recomputes locally matches the exported SVG exactly.
317#[allow(clippy::too_many_arguments)]
318pub fn build_scene_fit(
319    source: &dyn crate::srtm::TileSource,
320    bbox: &crate::Bbox,
321    num_lines: usize,
322    elevation_pts: usize,
323    viewpoint_angle: f64,
324    crop: bool,
325    interpolation: u32,
326    lock_resolution: bool,
327    fit: Fit,
328    water_ntile: f64,
329    lake_flatness: i32,
330    vertical_ratio: f64,
331    size_scale: f64,
332    frame: Frame,
333) -> Result<RidgeScene, Error> {
334    if !bbox.is_valid() {
335        return Err(Error::InvalidBbox(*bbox));
336    }
337    let rotating = viewpoint_angle.rem_euclid(360.0) != 0.0;
338    let mut values = match fit {
339        Fit::Plane => {
340            let _ = lock_resolution;
341            crate::grid::sample(source, bbox, num_lines, elevation_pts)
342        }
343        Fit::Reshape => {
344            let (mut lines, mut pts) = (num_lines, elevation_pts);
345            if !lock_resolution && crate::grid::swap_for_angle(viewpoint_angle) {
346                std::mem::swap(&mut lines, &mut pts);
347            }
348            crate::grid::sample(source, bbox, lines, pts)
349        }
350    };
351    if rotating {
352        values = match fit {
353            Fit::Plane => {
354                crate::rotate::rotate_fixed_plane(&values, viewpoint_angle, interpolation)
355            }
356            Fit::Reshape => crate::rotate::rotate(&values, viewpoint_angle, !crop, interpolation),
357        };
358    }
359    let processed = crate::preprocess::preprocess(
360        &values,
361        water_ntile,
362        lake_flatness,
363        vertical_ratio,
364        1.0,  // reshape is upstream-faithful: naive threshold at grid sampling
365        None, // stats over the whole (rotated) grid
366    )?;
367    Ok(RidgeScene::from_grid(
368        &processed,
369        bbox.ratio(),
370        size_scale,
371        frame,
372    ))
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::srtm::SyntheticSource;
379
380    #[test]
381    fn runs_split_at_gaps() {
382        let row = RidgeRow {
383            baseline: -6.0,
384            y: vec![1.0, f64::NAN, f64::NAN, 2.0, 3.0, f64::NAN, 4.0],
385        };
386        assert_eq!(row.runs(), vec![(0, 1), (3, 5), (6, 7)]);
387    }
388
389    #[test]
390    fn scene_geometry() {
391        let src = SyntheticSource { side: 1201 };
392        let scene = build_scene(
393            &src,
394            &crate::DEFAULT_BBOX,
395            20,
396            30,
397            0.0,
398            false,
399            0,
400            false,
401            10.0,
402            3,
403            40.0,
404            DEFAULT_SIZE_SCALE,
405            Frame::Window,
406        )
407        .unwrap();
408        assert_eq!(scene.rows.len(), 20);
409        assert_eq!(scene.n_points, 30);
410        // Row baselines step by -6.
411        assert_eq!(scene.rows[0].baseline, 0.0);
412        assert_eq!(scene.rows[7].baseline, -42.0);
413        // Layout: 20 in x 100 dpi = 2000 px wide.
414        assert_eq!(scene.layout.width_px, 2000.0);
415        assert_eq!(scene.layout.height_px, 2000.0 * crate::DEFAULT_BBOX.ratio());
416    }
417
418    /// A grid whose top two rows, bottom row and left three columns are all
419    /// water (NaN): the shapes that exposed the frame clipping.
420    fn water_edged_grid() -> Array2<f64> {
421        Array2::from_shape_fn((6, 10), |(r, c)| {
422            if r < 2 || r == 5 || c < 3 {
423                f64::NAN
424            } else {
425                20.0
426            }
427        })
428    }
429
430    #[test]
431    fn window_frame_is_the_whole_requested_window() {
432        // The picture follows the requested window, so masking cannot rescale
433        // it: every column keeps its place...
434        let scene =
435            RidgeScene::from_grid(&water_edged_grid(), 1.0, DEFAULT_SIZE_SCALE, Frame::Window);
436        assert_eq!(
437            scene.layout.xlim,
438            [-9.0 * AXES_MARGIN, 9.0 + 9.0 * AXES_MARGIN]
439        );
440        // ...and so does every row, down to the last baseline (-30), even the
441        // all-water one. The top is still the highest drawn point (row 2,
442        // baseline -12, value 20).
443        assert_eq!(
444            scene.layout.ylim,
445            [-30.0 - 38.0 * AXES_MARGIN, 8.0 + 38.0 * AXES_MARGIN]
446        );
447    }
448
449    #[test]
450    fn land_frame_reproduces_the_legacy_crop() {
451        // The legacy plot lets matplotlib autoscale around the points it
452        // draws, so the water columns and the all-water bottom row leave the
453        // frame entirely.
454        let scene =
455            RidgeScene::from_grid(&water_edged_grid(), 1.0, DEFAULT_SIZE_SCALE, Frame::Land);
456        // x: only columns 3..9 carry land.
457        assert_eq!(
458            scene.layout.xlim,
459            [3.0 - 6.0 * AXES_MARGIN, 9.0 + 6.0 * AXES_MARGIN]
460        );
461        // y: up to the last baseline that still carries land (row 4, -24).
462        assert_eq!(
463            scene.layout.ylim,
464            [-24.0 - 32.0 * AXES_MARGIN, 8.0 + 32.0 * AXES_MARGIN]
465        );
466    }
467
468    #[test]
469    fn water_masking_never_moves_the_frame() {
470        // The regression behind "water_ntile clips the frame back": masking a
471        // band to water used to shrink the frame onto the remaining land, so
472        // raising water_ntile rescaled the picture, and a bbox covering a tile
473        // with no data collapsed onto its coastline.
474        let land = Array2::from_elem((5, 7), 40.0);
475        let half_water =
476            Array2::from_shape_fn((5, 7), |(_, c)| if c < 3 { f64::NAN } else { 40.0 });
477        let a = RidgeScene::from_grid(&land, 1.0, DEFAULT_SIZE_SCALE, Frame::Window);
478        let b = RidgeScene::from_grid(&half_water, 1.0, DEFAULT_SIZE_SCALE, Frame::Window);
479        assert_eq!(
480            a.layout, b.layout,
481            "the frame is the window, whether the water is there or not"
482        );
483    }
484}