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}