1use std::sync::LazyLock;
8
9use serde::{Deserialize, Serialize};
10
11use crate::generated::catalog as g;
12
13#[derive(Debug, Clone, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct ZoomPillSpec {
17 pub height: f64,
18 pub inset: f64,
19 pub padding: f64,
20 pub gap: f64,
21 pub percent_min_width: f64,
22 pub divider_height: f64,
23}
24
25#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ZoomSpec {
29 pub tall_aspect: f64,
30 pub max_scale: f64,
31 pub presets: Vec<f64>,
32 pub menu_presets: Vec<f64>,
33 pub double_tap_factor: f64,
34 pub wheel_rate: f64,
35 pub wheel_line_px: f64,
36 pub rubber: f64,
37 pub momentum_tau_ms: f64,
38 pub auto_hide_ms: f64,
39 pub pill: ZoomPillSpec,
40}
41
42pub static ZOOM: LazyLock<ZoomSpec> = LazyLock::new(|| {
43 let style: serde_json::Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
44 serde_json::from_value(style["zoomableImage"].clone()).expect("style.json zoomableImage")
45});
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase", from = "String")]
50pub enum ZoomInitial {
51 #[default]
52 Auto,
53 Fit,
54 FitWidth,
55 Actual,
56}
57
58impl From<String> for ZoomInitial {
59 fn from(s: String) -> Self {
60 Self::parse(&s)
61 }
62}
63
64impl ZoomInitial {
65 pub fn parse(s: &str) -> Self {
66 match s {
67 "fit" => Self::Fit,
68 "fitWidth" => Self::FitWidth,
69 "actual" => Self::Actual,
70 _ => Self::Auto,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
76pub struct ZoomSize {
77 pub width: f64,
78 pub height: f64,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
82pub struct ZoomPoint {
83 pub x: f64,
84 pub y: f64,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
90pub struct ZoomView {
91 pub scale: f64,
92 pub x: f64,
93 pub y: f64,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct ZoomFrame {
100 pub natural: ZoomSize,
101 pub viewport: ZoomSize,
102 #[serde(default)]
103 pub initial: ZoomInitial,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub max_scale: Option<f64>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
109pub struct ZoomLimits {
110 pub min: f64,
111 pub max: f64,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ZoomPanRoom {
116 pub x: bool,
117 pub y: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct ZoomBounds {
123 pub at_min: bool,
124 pub at_max: bool,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ZoomMenuItem {
129 pub scale: Option<f64>,
131 pub label: String,
132 pub checked: bool,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub enum WheelDeltaMode {
138 Pixel,
139 Line,
140}
141
142const EPS: f64 = 1e-6;
143
144fn tidy(v: f64) -> f64 {
146 let r = (v * 1e6).round() / 1e6;
147 if r == 0.0 {
148 0.0
149 } else {
150 r
151 }
152}
153
154fn tidy_view(v: ZoomView) -> ZoomView {
155 ZoomView { scale: tidy(v.scale), x: tidy(v.x), y: tidy(v.y) }
156}
157
158fn clamp_n(v: f64, lo: f64, hi: f64) -> f64 {
159 lo.max(hi.min(v))
160}
161
162fn positive(v: f64) -> f64 {
163 if v.is_finite() && v > 0.0 {
164 v
165 } else {
166 0.0
167 }
168}
169
170struct Sizes {
171 iw: f64,
172 ih: f64,
173 vw: f64,
174 vh: f64,
175}
176
177fn sizes(f: &ZoomFrame) -> Sizes {
178 Sizes {
179 iw: positive(f.natural.width),
180 ih: positive(f.natural.height),
181 vw: positive(f.viewport.width),
182 vh: positive(f.viewport.height),
183 }
184}
185
186pub fn zoom_is_tall(natural: ZoomSize) -> bool {
188 let w = positive(natural.width);
189 let h = positive(natural.height);
190 w > 0.0 && h > 0.0 && w / h < ZOOM.tall_aspect
191}
192
193fn fit_raw(f: &ZoomFrame) -> f64 {
194 let s = sizes(f);
195 if s.iw == 0.0 || s.ih == 0.0 || s.vw == 0.0 || s.vh == 0.0 {
196 return 1.0;
197 }
198 1f64.min(s.vw / s.iw).min(s.vh / s.ih)
199}
200
201fn limits_raw(f: &ZoomFrame) -> (f64, f64) {
202 let min = fit_raw(f);
203 let wanted = match f.max_scale.map(positive) {
204 Some(v) if v > 0.0 => v,
205 _ => ZOOM.max_scale,
206 };
207 (min, min.max(wanted))
208}
209
210fn rest_raw(f: &ZoomFrame) -> f64 {
211 let s = sizes(f);
212 let (min, max) = limits_raw(f);
213 let mode = match f.initial {
214 ZoomInitial::Auto if zoom_is_tall(f.natural) => ZoomInitial::FitWidth,
215 ZoomInitial::Auto => ZoomInitial::Fit,
216 other => other,
217 };
218 let scale = match mode {
219 ZoomInitial::FitWidth => {
220 if s.iw > 0.0 && s.vw > 0.0 {
221 1f64.min(s.vw / s.iw)
222 } else {
223 1.0
224 }
225 }
226 ZoomInitial::Actual => 1.0,
227 _ => min,
228 };
229 clamp_n(scale, min, max)
230}
231
232pub fn zoom_fit_scale(f: &ZoomFrame) -> f64 {
234 tidy(fit_raw(f))
235}
236
237pub fn zoom_limits(f: &ZoomFrame) -> ZoomLimits {
239 let (min, max) = limits_raw(f);
240 ZoomLimits { min: tidy(min), max: tidy(max) }
241}
242
243pub fn zoom_rest_scale(f: &ZoomFrame) -> f64 {
246 tidy(rest_raw(f))
247}
248
249fn clamp_axis(offset: f64, scaled: f64, extent: f64) -> f64 {
251 if scaled <= extent + EPS {
252 (extent - scaled) / 2.0
253 } else {
254 clamp_n(offset, extent - scaled, 0.0)
255 }
256}
257
258pub fn zoom_clamp(view: ZoomView, f: &ZoomFrame) -> ZoomView {
260 let s = sizes(f);
261 let (min, max) = limits_raw(f);
262 let scale = clamp_n(view.scale, min, max);
263 tidy_view(ZoomView {
264 scale,
265 x: clamp_axis(view.x, s.iw * scale, s.vw),
266 y: clamp_axis(view.y, s.ih * scale, s.vh),
267 })
268}
269
270pub fn zoom_rest_view(f: &ZoomFrame) -> ZoomView {
273 let s = sizes(f);
274 let scale = rest_raw(f);
275 zoom_clamp(ZoomView { scale, x: (s.vw - s.iw * scale) / 2.0, y: 0.0 }, f)
276}
277
278pub fn zoom_at_rest(view: ZoomView, f: &ZoomFrame) -> bool {
280 (view.scale - rest_raw(f)).abs() < 1e-4
281}
282
283pub fn zoom_to(view: ZoomView, scale: f64, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
285 let (min, max) = limits_raw(f);
286 let next = clamp_n(scale, min, max);
287 let ratio = if view.scale > 0.0 { next / view.scale } else { 1.0 };
288 zoom_clamp(
289 ZoomView {
290 scale: next,
291 x: anchor.x - (anchor.x - view.x) * ratio,
292 y: anchor.y - (anchor.y - view.y) * ratio,
293 },
294 f,
295 )
296}
297
298pub fn zoom_centre(f: &ZoomFrame) -> ZoomPoint {
300 let s = sizes(f);
301 ZoomPoint { x: s.vw / 2.0, y: s.vh / 2.0 }
302}
303
304pub fn zoom_wheel_factor(delta_y: f64, mode: WheelDeltaMode) -> f64 {
307 let px = match mode {
308 WheelDeltaMode::Line => delta_y * ZOOM.wheel_line_px,
309 WheelDeltaMode::Pixel => delta_y,
310 };
311 (-px * ZOOM.wheel_rate).exp()
312}
313
314pub fn zoom_wheel(view: ZoomView, delta_y: f64, mode: WheelDeltaMode, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
316 zoom_to(view, view.scale * zoom_wheel_factor(delta_y, mode), anchor, f)
317}
318
319pub fn zoom_scroll(view: ZoomView, delta_x: f64, delta_y: f64, f: &ZoomFrame) -> ZoomView {
321 zoom_clamp(ZoomView { scale: view.scale, x: view.x - delta_x, y: view.y - delta_y }, f)
322}
323
324pub fn zoom_ladder(f: &ZoomFrame) -> Vec<f64> {
327 let (min, max) = limits_raw(f);
328 let rest = rest_raw(f);
329 let mut all: Vec<f64> = ZOOM.presets.iter().copied().chain([min, rest, max]).filter(|s| *s >= min - EPS && *s <= max + EPS).collect();
330 all.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
331 let mut out: Vec<f64> = Vec::new();
332 for s in all {
333 if out.last().is_none_or(|last| s - last > 1e-4) {
334 out.push(tidy(s));
335 }
336 }
337 out
338}
339
340pub fn zoom_step_scale(scale: f64, direction: i32, f: &ZoomFrame) -> f64 {
343 let ladder = zoom_ladder(f);
344 if direction > 0 {
345 return ladder.iter().copied().find(|s| *s > scale + 1e-4).unwrap_or(*ladder.last().unwrap_or(&scale));
346 }
347 ladder.iter().rev().copied().find(|s| *s < scale - 1e-4).unwrap_or(*ladder.first().unwrap_or(&scale))
348}
349
350pub fn zoom_step(view: ZoomView, direction: i32, f: &ZoomFrame) -> ZoomView {
352 zoom_to(view, zoom_step_scale(view.scale, direction, f), zoom_centre(f), f)
353}
354
355pub fn zoom_double_tap_scale(f: &ZoomFrame) -> f64 {
357 let (_, max) = limits_raw(f);
358 tidy(max.min(1f64.max(ZOOM.double_tap_factor * rest_raw(f))))
359}
360
361pub fn zoom_double_tap(view: ZoomView, point: ZoomPoint, f: &ZoomFrame) -> ZoomView {
363 if zoom_at_rest(view, f) {
364 zoom_to(view, zoom_double_tap_scale(f), point, f)
365 } else {
366 zoom_rest_view(f)
367 }
368}
369
370pub fn zoom_key(view: ZoomView, key: &str, f: &ZoomFrame) -> Option<ZoomView> {
372 match key {
373 "+" | "=" => Some(zoom_step(view, 1, f)),
374 "-" | "−" | "_" => Some(zoom_step(view, -1, f)),
375 "0" => Some(zoom_rest_view(f)),
376 "1" => Some(zoom_to(view, 1.0, zoom_centre(f), f)),
377 _ => None,
378 }
379}
380
381pub fn zoom_rubber_band(overshoot: f64, extent: f64) -> f64 {
383 if extent <= 0.0 || overshoot == 0.0 {
384 return 0.0;
385 }
386 let sign = if overshoot < 0.0 { -1.0 } else { 1.0 };
387 let o = overshoot.abs();
388 tidy(sign * (1.0 - 1.0 / ((o * ZOOM.rubber) / extent + 1.0)) * extent)
389}
390
391fn rubber_axis(offset: f64, scaled: f64, extent: f64) -> f64 {
392 let fits = scaled <= extent + EPS;
393 let lo = if fits { (extent - scaled) / 2.0 } else { extent - scaled };
394 let hi = if fits { (extent - scaled) / 2.0 } else { 0.0 };
395 if offset > hi {
396 hi + zoom_rubber_band(offset - hi, extent)
397 } else if offset < lo {
398 lo + zoom_rubber_band(offset - lo, extent)
399 } else {
400 offset
401 }
402}
403
404pub fn zoom_drag(start: ZoomView, dx: f64, dy: f64, f: &ZoomFrame) -> ZoomView {
407 let s = sizes(f);
408 tidy_view(ZoomView {
409 scale: start.scale,
410 x: rubber_axis(start.x + dx, s.iw * start.scale, s.vw),
411 y: rubber_axis(start.y + dy, s.ih * start.scale, s.vh),
412 })
413}
414
415pub fn zoom_pinch(start: ZoomView, factor: f64, anchor: ZoomPoint, f: &ZoomFrame) -> ZoomView {
419 let s = sizes(f);
420 let (min, max) = limits_raw(f);
421 let raw = start.scale * factor;
422 let scale = if raw > max {
423 max + zoom_rubber_band(raw - max, max)
424 } else if raw < min {
425 min + zoom_rubber_band(raw - min, min)
426 } else {
427 raw
428 };
429 let ratio = if start.scale > 0.0 { scale / start.scale } else { 1.0 };
430 tidy_view(ZoomView {
431 scale,
432 x: rubber_axis(anchor.x - (anchor.x - start.x) * ratio, s.iw * scale, s.vw),
433 y: rubber_axis(anchor.y - (anchor.y - start.y) * ratio, s.ih * scale, s.vh),
434 })
435}
436
437pub fn zoom_momentum(view: ZoomView, vx: f64, vy: f64, t: f64, f: &ZoomFrame) -> ZoomView {
440 let tau = ZOOM.momentum_tau_ms;
441 let travel = tau * (1.0 - (-t.max(0.0) / tau).exp());
442 zoom_clamp(ZoomView { scale: view.scale, x: view.x + vx * travel, y: view.y + vy * travel }, f)
443}
444
445pub fn zoom_momentum_active(vx: f64, vy: f64, t: f64) -> bool {
447 let decay = (-t.max(0.0) / ZOOM.momentum_tau_ms).exp();
448 vx.hypot(vy) * decay > 0.01
449}
450
451pub fn zoom_pan_room(view: ZoomView, f: &ZoomFrame) -> ZoomPanRoom {
454 let s = sizes(f);
455 ZoomPanRoom { x: s.iw * view.scale > s.vw + EPS, y: s.ih * view.scale > s.vh + EPS }
456}
457
458pub fn zoom_resize(view: ZoomView, from: ZoomSize, f: &ZoomFrame) -> ZoomView {
461 let before = ZoomFrame { viewport: from, ..*f };
462 if zoom_at_rest(view, &before) {
463 return zoom_rest_view(f);
464 }
465 let s = sizes(f);
466 let cx = positive(from.width) / 2.0;
467 let cy = positive(from.height) / 2.0;
468 zoom_clamp(ZoomView { scale: view.scale, x: s.vw / 2.0 - (cx - view.x), y: s.vh / 2.0 - (cy - view.y) }, f)
469}
470
471pub fn zoom_percent(scale: f64) -> String {
473 format!("{}%", (scale * 100.0 + 0.5).floor() as i64)
474}
475
476pub fn zoom_menu(view: ZoomView, f: &ZoomFrame, fit_label: &str) -> Vec<ZoomMenuItem> {
478 let (min, max) = limits_raw(f);
479 let at_rest = zoom_at_rest(view, f);
480 let mut items = Vec::new();
481 for &s in &ZOOM.menu_presets {
482 if s == 0.0 {
483 items.push(ZoomMenuItem { scale: None, label: fit_label.to_string(), checked: at_rest });
484 } else if s >= min - EPS && s <= max + EPS {
485 items.push(ZoomMenuItem { scale: Some(s), label: zoom_percent(s), checked: !at_rest && (view.scale - s).abs() < 1e-4 });
486 }
487 }
488 items
489}
490
491pub fn zoom_bounds(view: ZoomView, f: &ZoomFrame) -> ZoomBounds {
493 let (min, max) = limits_raw(f);
494 ZoomBounds { at_min: view.scale <= min + 1e-4, at_max: view.scale >= max - 1e-4 }
495}
496
497pub fn zoom_request(req: &serde_json::Value) -> Result<serde_json::Value, String> {
508 use serde_json::{json, Value};
509 let op = req.get("op").and_then(Value::as_str).ok_or("op is required")?;
510 let num = |k: &str| req.get(k).and_then(Value::as_f64).unwrap_or(0.0);
511 let point = |k: &str| -> ZoomPoint { req.get(k).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default() };
512 let frame: ZoomFrame = match req.get("frame") {
513 Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("frame: {e}"))?,
514 None => ZoomFrame::default(),
515 };
516 let from = |f: &ZoomFrame| -> Result<ZoomView, String> {
517 match req.get("from") {
518 None => Ok(zoom_rest_view(f)),
519 Some(Value::String(s)) if s == "rest" => Ok(zoom_rest_view(f)),
520 Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("from: {e}")),
521 }
522 };
523 let view = |v: ZoomView| json!(v);
524 Ok(match op {
525 "rest" => json!({
526 "fit": zoom_fit_scale(&frame),
527 "limits": zoom_limits(&frame),
528 "restScale": zoom_rest_scale(&frame),
529 "view": zoom_rest_view(&frame),
530 "tall": zoom_is_tall(frame.natural),
531 }),
532 "clamp" => {
533 let v: ZoomView = serde_json::from_value(req.get("view").cloned().unwrap_or(Value::Null)).map_err(|e| format!("view: {e}"))?;
534 view(zoom_clamp(v, &frame))
535 }
536 "zoomTo" => view(zoom_to(from(&frame)?, num("scale"), point("anchor"), &frame)),
537 "wheel" => {
538 let mode = if req.get("mode").and_then(Value::as_str) == Some("line") { WheelDeltaMode::Line } else { WheelDeltaMode::Pixel };
539 view(zoom_wheel(from(&frame)?, num("deltaY"), mode, point("anchor"), &frame))
540 }
541 "scroll" => view(zoom_scroll(from(&frame)?, num("deltaX"), num("deltaY"), &frame)),
542 "ladder" => json!(zoom_ladder(&frame)),
543 "steps" => json!(zoom_step_scale(num("scale"), if num("direction") < 0.0 { -1 } else { 1 }, &frame)),
544 "step" => view(zoom_step(from(&frame)?, if num("direction") < 0.0 { -1 } else { 1 }, &frame)),
545 "doubleTap" => view(zoom_double_tap(from(&frame)?, point("point"), &frame)),
546 "keys" => json!(zoom_key(from(&frame)?, req.get("key").and_then(Value::as_str).unwrap_or(""), &frame)),
547 "rubberBand" => json!(zoom_rubber_band(num("overshoot"), num("extent"))),
548 "drag" => view(zoom_drag(from(&frame)?, num("dx"), num("dy"), &frame)),
549 "pinch" => view(zoom_pinch(from(&frame)?, num("factor"), point("anchor"), &frame)),
550 "momentum" => view(zoom_momentum(from(&frame)?, num("vx"), num("vy"), num("t"), &frame)),
551 "momentumActive" => json!(zoom_momentum_active(num("vx"), num("vy"), num("t"))),
552 "panRoom" => json!(zoom_pan_room(from(&frame)?, &frame)),
553 "resize" => {
554 let old: ZoomSize = serde_json::from_value(req.get("fromViewport").cloned().unwrap_or(Value::Null)).map_err(|e| format!("fromViewport: {e}"))?;
555 let start = match req.get("from") {
556 Some(Value::String(s)) if s == "rest" => zoom_rest_view(&ZoomFrame { viewport: old, ..frame }),
557 _ => from(&frame)?,
558 };
559 view(zoom_resize(start, old, &frame))
560 }
561 "percent" => json!(zoom_percent(num("scale"))),
562 "menu" => json!(zoom_menu(from(&frame)?, &frame, req.get("fitLabel").and_then(Value::as_str).unwrap_or("Fit"))),
563 "bounds" => json!(zoom_bounds(from(&frame)?, &frame)),
564 other => return Err(format!("unknown zoom op {other:?}")),
565 })
566}