Skip to main content

dioxus_flow/
paper.rs

1//! The graph paper behind the map: dots on the snap grid, with a heavier mark
2//! every fifth one.
3//!
4//! Two things make that harder than it looks, and both are settled here rather
5//! than left to a CSS background.
6//!
7//! A single world pitch cannot serve a fifteen-fold zoom range. Held at the snap
8//! grid the dots crowd into a smear on the way out; held at the heavy mark
9//! instead, the paper coarsens abruptly and then crowds again. So the lattice is
10//! promoted five cells at a time until it is legible, and the levels cross-fade:
11//! as the fine lattice dissolves, the heavy mark takes over its weight and a new
12//! heavy mark rises five times coarser again. Promotion happens exactly where the
13//! dissolving level has reached nothing, so what is drawn either side of it is the
14//! same paper — see `promotion_changes_nothing_that_is_drawn`.
15//!
16//! What draws them is a tiled SVG pattern per level, one dot to a tile — the same
17//! shape React Flow's background has, and the reason panning and zooming cost a
18//! handful of attribute changes rather than any drawing work. A CSS background
19//! would be cheaper still, but it lays its tiles out on whole pixels: the dots
20//! land up to a pixel from where the map puts them, the error grows across the
21//! screen, and it shifts as the zoom changes, which is what makes a tiled
22//! background snap and flicker while zooming. An SVG pattern is placed by a
23//! matrix, so it stays exactly where this says it does — and
24//! `every_level_lands_on_the_maps_own_grid` is what says it.
25
26use crate::types::{Grid, Point, Viewport};
27
28/// Dots closer together than this, in screen pixels, have dissolved: the gap is
29/// down to the width of a dot and the lattice reads as haze. This is also where
30/// a level is promoted, so the promotion cannot be seen.
31const DISSOLVED: f64 = 6.0;
32/// How many cells to a heavier mark, and so how far one promotion travels.
33const RUN: f64 = 5.0;
34/// A dot's radius in screen pixels, from a fine dot to a heavy mark. A painted
35/// circle carries a little less ink than the hard-edged one a CSS gradient drew
36/// at the same radius, so these are the radii that keep the paper's weight.
37const FINE_RADIUS: f64 = 1.1;
38const HEAVY_RADIUS: f64 = 1.45;
39/// Below this a level contributes nothing a reader could see, so it is left out
40/// of the drawing altogether.
41pub const INVISIBLE: f64 = 0.02;
42
43/// One lattice of dots, ready to paint.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct Level {
46    /// The gap between neighbouring dots, in screen pixels.
47    pub pitch: f64,
48    /// The first dot at or after the canvas's top-left corner, in canvas pixels.
49    /// Every other dot of this level is a whole number of pitches from it.
50    pub first: Point,
51    /// How present this level is: 0 draws nothing, 1 draws it in full.
52    pub alpha: f64,
53    /// 0 draws a fine dot, 1 a heavy mark, in between while this level is taking
54    /// over the role of the one below it.
55    pub weight: f64,
56}
57
58impl Level {
59    pub fn radius(self) -> f64 {
60        FINE_RADIUS + (HEAVY_RADIUS - FINE_RADIUS) * self.weight.clamp(0.0, 1.0)
61    }
62
63    /// Whether this level is worth asking the painter for.
64    pub fn visible(self) -> bool {
65        self.alpha > INVISIBLE && self.pitch.is_finite() && self.pitch > 0.0
66    }
67
68    /// How much of the heavy ink this level's dots are mixed from, as a
69    /// percentage for `color-mix`. The rest is the fine ink.
70    pub fn heaviness(self) -> f64 {
71        self.weight.clamp(0.0, 1.0) * 100.0
72    }
73}
74
75/// The levels that draw the paper, finest first. Three is what a cross-fade
76/// needs: the level dissolving, the one taking its place, and the one rising
77/// behind it.
78pub type Paper = [Level; 3];
79
80/// The paper for one viewport of a grid.
81pub fn paper(viewport: Viewport, grid: Grid) -> Paper {
82    // A lattice this far apart is drawn in full: the grid itself at 100% zoom, so
83    // that zoom draws plain graph paper.
84    let drawn = grid.size();
85    let zoom = if viewport.zoom.is_finite() && viewport.zoom > 0.0 {
86        viewport.zoom
87    } else {
88        1.0
89    };
90    let mut pitch = grid.size() * zoom;
91    // Promote until the finest level is legible. One promotion covers the whole
92    // zoom range; the bound is a guard against a nonsensical zoom, not a limit.
93    for _ in 0..8 {
94        if pitch >= DISSOLVED {
95            break;
96        }
97        pitch *= RUN;
98    }
99    let blend = ((pitch - DISSOLVED) / (drawn - DISSOLVED)).clamp(0.0, 1.0);
100    [
101        // The snap grid itself, for as long as it can be read.
102        level(viewport, pitch, blend, 0.0),
103        // Every fifth cell: the heavy mark, which becomes the fine lattice of the
104        // next level as the blend runs out.
105        level(viewport, pitch * RUN, 1.0, blend),
106        // The mark that will be the heavy one after the next promotion.
107        level(viewport, pitch * RUN * RUN, 1.0 - blend, 1.0),
108    ]
109}
110
111/// A level's dots sit on the grid-line intersections. Node frames snap to those
112/// same boundaries, so their resting edges pass through dots rather than through
113/// the middle of the spaces between them.
114fn level(viewport: Viewport, pitch: f64, alpha: f64, weight: f64) -> Level {
115    Level {
116        pitch,
117        first: Point::new(viewport.x.rem_euclid(pitch), viewport.y.rem_euclid(pitch)),
118        alpha,
119        weight,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    /// The zoom range the sweep covers — a superset of what any host editor
128    /// allows, including the promotion boundaries.
129    const MIN_ZOOM: f64 = 0.2;
130    const MAX_ZOOM: f64 = 3.0;
131
132    const GRID: Grid = Grid::new(12.0);
133
134    fn paper(zoom: f64) -> Paper {
135        super::paper(view(zoom), GRID)
136    }
137
138    fn view(zoom: f64) -> Viewport {
139        Viewport {
140            x: 544.3,
141            y: -217.8,
142            zoom,
143        }
144    }
145
146    /// Every zoom a viewer can reach, plus the promotion boundaries either side.
147    fn zooms() -> Vec<f64> {
148        let mut zooms = vec![MIN_ZOOM, MAX_ZOOM, 1.0];
149        let mut zoom = MIN_ZOOM;
150        while zoom < MAX_ZOOM {
151            zooms.push(zoom);
152            zoom *= 1.01;
153        }
154        for boundary in [DISSOLVED / GRID.size(), DISSOLVED * RUN / GRID.size()] {
155            zooms.extend([boundary - 1e-9, boundary, boundary + 1e-9]);
156        }
157        zooms.retain(|zoom| (MIN_ZOOM..=MAX_ZOOM).contains(zoom));
158        zooms
159    }
160
161    /// The property that makes the paper belong to the map: every dot is drawn
162    /// on a boundary of the map's own grid, at every level and zoom.
163    #[test]
164    fn every_level_lands_on_the_maps_own_grid() {
165        for zoom in zooms() {
166            let viewport = view(zoom);
167            for level in paper(zoom) {
168                for (axis, first, pan) in [
169                    ("x", level.first.x, viewport.x),
170                    ("y", level.first.y, viewport.y),
171                ] {
172                    let world = (first - pan) / zoom;
173                    let cell = world.rem_euclid(GRID.size());
174                    assert!(
175                        cell.abs() < 1e-6 || (cell - GRID.size()).abs() < 1e-6,
176                        "zoom {zoom}: pitch {} sits {cell} into an {axis} cell, not on its boundary",
177                        level.pitch,
178                    );
179                }
180                let step = level.pitch / zoom;
181                assert!(
182                    (step / GRID.size() - (step / GRID.size()).round()).abs() < 1e-6,
183                    "zoom {zoom}: pitch {} is not a whole number of cells",
184                    level.pitch,
185                );
186            }
187        }
188    }
189
190    /// Crossing a promotion must not change the paper: the level that dissolved
191    /// has reached nothing, and every level still drawn is drawn the same way.
192    #[test]
193    fn promotion_changes_nothing_that_is_drawn() {
194        for boundary in [DISSOLVED / GRID.size(), DISSOLVED * RUN / GRID.size()] {
195            let drawn = |zoom: f64| {
196                paper(zoom)
197                    .into_iter()
198                    .filter(|level| level.visible())
199                    .collect::<Vec<_>>()
200            };
201            // A hair either side of the promotion: any difference left is the
202            // hair itself, not the promotion.
203            let before = drawn(boundary + 1e-6);
204            let after = drawn(boundary - 1e-6);
205            assert_eq!(
206                before.len(),
207                after.len(),
208                "a level appears or vanishes across the promotion at zoom {boundary}"
209            );
210            for (before, after) in before.iter().zip(&after) {
211                let same = |name: &str, before: f64, after: f64, tolerance: f64| {
212                    assert!(
213                        (before - after).abs() <= tolerance,
214                        "{name} jumps from {before} to {after} \
215                         across the promotion at zoom {boundary}"
216                    );
217                };
218                same("pitch", before.pitch, after.pitch, 0.01);
219                same("alpha", before.alpha, after.alpha, 0.001);
220                same("weight", before.weight, after.weight, 0.001);
221                same("phase", before.first.x, after.first.x, 0.01);
222                same("phase", before.first.y, after.first.y, 0.01);
223            }
224        }
225    }
226
227    /// However far out the view goes, the dots a reader can see never crowd into
228    /// a smear — the reason the lattice is promoted at all.
229    #[test]
230    fn visible_dots_never_crowd_together() {
231        for zoom in zooms() {
232            for level in paper(zoom) {
233                assert!(
234                    !level.visible() || level.pitch >= DISSOLVED,
235                    "zoom {zoom}: a visible level has dots {}px apart",
236                    level.pitch,
237                );
238            }
239        }
240    }
241
242    /// The paper reads the same at any zoom: something legible is always drawn,
243    /// and never so coarse that the surface looks blank.
244    #[test]
245    fn some_legible_lattice_is_always_drawn() {
246        for zoom in zooms() {
247            let paper = paper(zoom);
248            let solid = paper
249                .into_iter()
250                .filter(|level| level.alpha >= 0.5)
251                .map(|level| level.pitch)
252                .fold(f64::INFINITY, f64::min);
253            assert!(
254                (DISSOLVED..=GRID.size() * RUN * RUN).contains(&solid),
255                "zoom {zoom}: the finest solid lattice is {solid}px",
256            );
257        }
258    }
259
260    #[test]
261    fn levels_stay_within_their_ranges() {
262        for zoom in zooms() {
263            for level in paper(zoom) {
264                assert!((0.0..=1.0).contains(&level.alpha), "alpha {}", level.alpha);
265                assert!(
266                    (0.0..=1.0).contains(&level.weight),
267                    "weight {}",
268                    level.weight
269                );
270                assert!(level.pitch.is_finite() && level.pitch > 0.0);
271                assert!(level.first.x >= 0.0 && level.first.x < level.pitch);
272                assert!(level.first.y >= 0.0 && level.first.y < level.pitch);
273                assert!((FINE_RADIUS..=HEAVY_RADIUS).contains(&level.radius()));
274            }
275        }
276    }
277
278    /// A viewport that should not exist must still not hang the painter.
279    #[test]
280    fn a_nonsense_viewport_still_describes_a_drawable_paper() {
281        for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e9] {
282            for level in super::paper(
283                Viewport {
284                    x: 0.0,
285                    y: 0.0,
286                    zoom,
287                },
288                GRID,
289            ) {
290                assert!(level.pitch.is_finite() && level.pitch > 0.0, "zoom {zoom}");
291                assert!(level.first.x.is_finite() && level.first.y.is_finite());
292            }
293        }
294    }
295}