2026-10-02 18:14:23 +02:00
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
|
import {
|
2026-10-04 09:49:35 +02:00
|
|
|
|
CLICK_ZOOM_SCALE,
|
2026-10-02 18:14:23 +02:00
|
|
|
|
FIT_VIEW,
|
|
|
|
|
|
canZoom,
|
|
|
|
|
|
clampView,
|
|
|
|
|
|
isZoomed,
|
|
|
|
|
|
maxZoomScale,
|
|
|
|
|
|
oneToOneScale,
|
2026-10-04 09:49:35 +02:00
|
|
|
|
onFitBox,
|
2026-10-02 18:14:23 +02:00
|
|
|
|
panBy,
|
|
|
|
|
|
pinchView,
|
|
|
|
|
|
stepView,
|
|
|
|
|
|
toggleView,
|
|
|
|
|
|
TOUCH_ZOOM_MAX_PIXELS,
|
|
|
|
|
|
zoomFitsDevice,
|
|
|
|
|
|
zoomAround,
|
|
|
|
|
|
type FitBox,
|
|
|
|
|
|
type ZoomView,
|
|
|
|
|
|
} from '../zoom';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The lightbox zoom geometry (#467). A 1000×600 fit box centred in a
|
|
|
|
|
|
* 1200×800 viewport, showing a 6000×3600 photo: 1:1 is six times the fit size
|
|
|
|
|
|
* on a 1× screen, three times on a 2× one.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const viewport = { width: 1200, height: 800 };
|
|
|
|
|
|
const fit: FitBox = { cx: 600, cy: 400, width: 1000, height: 600 };
|
|
|
|
|
|
const NATURAL = 6000;
|
|
|
|
|
|
|
|
|
|
|
|
/** Where a photo point (offset from the box centre at fit) lands on screen. */
|
|
|
|
|
|
const screenOf = (view: ZoomView, u: number, v: number) => ({
|
|
|
|
|
|
x: fit.cx + view.x + u * view.scale,
|
|
|
|
|
|
y: fit.cy + view.y + v * view.scale,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('1:1 scale', () => {
|
|
|
|
|
|
it('is one image pixel per device pixel', () => {
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, 1)).toBe(6);
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, 2)).toBe(3);
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, 3)).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('accounts for a fractional ratio', () => {
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, 1.5)).toBeCloseTo(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('treats a missing or nonsense ratio as 1', () => {
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, 0)).toBe(6);
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 1000, NaN)).toBe(6);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('never goes below fit', () => {
|
|
|
|
|
|
// A 1500px photo on a 3× screen is already shown beyond 1:1 at fit.
|
|
|
|
|
|
expect(oneToOneScale(1500, 1000, 3)).toBe(0.5);
|
|
|
|
|
|
expect(maxZoomScale(1500, 1000, 3)).toBe(1);
|
|
|
|
|
|
expect(canZoom(maxZoomScale(1500, 1000, 3))).toBe(false);
|
|
|
|
|
|
expect(canZoom(maxZoomScale(NATURAL, 1000, 2))).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is 1 when a size is unknown', () => {
|
|
|
|
|
|
expect(oneToOneScale(0, 1000, 1)).toBe(1);
|
|
|
|
|
|
expect(oneToOneScale(NATURAL, 0, 1)).toBe(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('clamping', () => {
|
|
|
|
|
|
const max = 6;
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps the scale between fit and 1:1', () => {
|
|
|
|
|
|
expect(clampView({ scale: 10, x: 0, y: 0 }, fit, viewport, max).scale).toBe(6);
|
|
|
|
|
|
expect(clampView({ scale: 0.5, x: 30, y: 30 }, fit, viewport, max)).toEqual(FIT_VIEW);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('never shows an empty band beside a photo larger than the viewport', () => {
|
|
|
|
|
|
// At 6× the box is 6000×3600: its left edge may go no further right than
|
|
|
|
|
|
// the viewport's left edge, and so on.
|
|
|
|
|
|
const v = clampView({ scale: 6, x: 99999, y: -99999 }, fit, viewport, max);
|
|
|
|
|
|
expect(fit.cx + v.x - (fit.width * 6) / 2).toBeCloseTo(0);
|
|
|
|
|
|
expect(fit.cy + v.y + (fit.height * 6) / 2).toBeCloseTo(viewport.height);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps a photo smaller than the viewport on screen, where it was', () => {
|
|
|
|
|
|
// 1.1× is 1100 wide in a 1200 viewport: it may move 50px either way.
|
|
|
|
|
|
const centred = clampView({ scale: 1.1, x: 0, y: 0 }, fit, viewport, max);
|
|
|
|
|
|
expect(centred.x).toBe(0);
|
|
|
|
|
|
const pushed = clampView({ scale: 1.1, x: 500, y: 0 }, fit, viewport, max);
|
|
|
|
|
|
expect(pushed.x).toBeCloseTo(50);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pans by a delta, within the same bounds', () => {
|
|
|
|
|
|
const start = { scale: 3, x: 0, y: 0 };
|
|
|
|
|
|
expect(panBy(start, 100, -50, fit, viewport, max)).toEqual({ scale: 3, x: 100, y: -50 });
|
|
|
|
|
|
// 3000 wide in 1200: at most 900 either side of centre.
|
|
|
|
|
|
expect(panBy(start, 5000, 0, fit, viewport, max).x).toBeCloseTo(900);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('zooming around a point', () => {
|
|
|
|
|
|
const max = 6;
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps the photo point under the cursor where it was', () => {
|
|
|
|
|
|
const point = { x: 700, y: 450 };
|
|
|
|
|
|
// Photo point under the cursor at fit:
|
|
|
|
|
|
const u = point.x - fit.cx;
|
|
|
|
|
|
const v = point.y - fit.cy;
|
|
|
|
|
|
const next = zoomAround(FIT_VIEW, 3, point, fit, viewport, max);
|
|
|
|
|
|
expect(next.scale).toBe(3);
|
|
|
|
|
|
const after = screenOf(next, u, v);
|
|
|
|
|
|
expect(after.x).toBeCloseTo(point.x);
|
|
|
|
|
|
expect(after.y).toBeCloseTo(point.y);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps it fixed from an already zoomed, panned view too', () => {
|
|
|
|
|
|
const start = { scale: 2, x: -150, y: 80 };
|
|
|
|
|
|
const point = { x: 400, y: 300 };
|
|
|
|
|
|
const u = (point.x - fit.cx - start.x) / start.scale;
|
|
|
|
|
|
const v = (point.y - fit.cy - start.y) / start.scale;
|
|
|
|
|
|
const next = zoomAround(start, 4, point, fit, viewport, max);
|
|
|
|
|
|
const after = screenOf(next, u, v);
|
|
|
|
|
|
expect(after.x).toBeCloseTo(point.x);
|
|
|
|
|
|
expect(after.y).toBeCloseTo(point.y);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives way to the clamp at the photo edge', () => {
|
|
|
|
|
|
// A point at the very corner cannot stay put without exposing the
|
|
|
|
|
|
// background, so the photo edge wins.
|
|
|
|
|
|
const next = zoomAround(FIT_VIEW, 6, { x: 100, y: 100 }, fit, viewport, max);
|
|
|
|
|
|
expect(fit.cx + next.x - (fit.width * 6) / 2).toBeCloseTo(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-04 09:49:35 +02:00
|
|
|
|
it('toggles to a given scale at the point, clamped to 1:1', () => {
|
|
|
|
|
|
const point = { x: 650, y: 420 };
|
|
|
|
|
|
const closer = toggleView(FIT_VIEW, point, fit, viewport, max, CLICK_ZOOM_SCALE);
|
|
|
|
|
|
expect(closer.scale).toBe(1.5);
|
|
|
|
|
|
const after = screenOf(closer, point.x - fit.cx, point.y - fit.cy);
|
|
|
|
|
|
expect(after.x).toBeCloseTo(point.x);
|
|
|
|
|
|
expect(after.y).toBeCloseTo(point.y);
|
|
|
|
|
|
expect(toggleView(closer, point, fit, viewport, max, CLICK_ZOOM_SCALE)).toEqual(FIT_VIEW);
|
|
|
|
|
|
// A photo with less to give than the click asks for stops at 1:1.
|
|
|
|
|
|
expect(toggleView(FIT_VIEW, point, fit, viewport, 1.2, CLICK_ZOOM_SCALE).scale).toBe(1.2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('knows the photo from the band beside it', () => {
|
|
|
|
|
|
expect(onFitBox({ x: fit.cx, y: fit.cy }, fit)).toBe(true);
|
|
|
|
|
|
expect(onFitBox({ x: fit.cx + fit.width / 2, y: fit.cy }, fit)).toBe(true);
|
|
|
|
|
|
expect(onFitBox({ x: fit.cx + fit.width / 2 + 1, y: fit.cy }, fit)).toBe(false);
|
|
|
|
|
|
expect(onFitBox({ x: fit.cx, y: fit.cy - fit.height / 2 - 1 }, fit)).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('double-tap and the button go to 1:1 at the point, and back to fit', () => {
|
2026-10-02 18:14:23 +02:00
|
|
|
|
const point = { x: 650, y: 420 };
|
|
|
|
|
|
const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max);
|
|
|
|
|
|
expect(zoomed.scale).toBe(max);
|
|
|
|
|
|
expect(isZoomed(zoomed)).toBe(true);
|
|
|
|
|
|
const after = screenOf(zoomed, point.x - fit.cx, point.y - fit.cy);
|
|
|
|
|
|
expect(after.x).toBeCloseTo(point.x);
|
|
|
|
|
|
expect(toggleView(zoomed, point, fit, viewport, max)).toEqual(FIT_VIEW);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('steps by a factor around the viewport centre, and snaps back to fit', () => {
|
|
|
|
|
|
const once = stepView(FIT_VIEW, 2, fit, viewport, max);
|
|
|
|
|
|
expect(once.scale).toBe(2);
|
|
|
|
|
|
expect(stepView(stepView(once, 2, fit, viewport, max), 2, fit, viewport, max).scale).toBe(6);
|
|
|
|
|
|
expect(stepView(once, 0.5, fit, viewport, max)).toEqual(FIT_VIEW);
|
|
|
|
|
|
expect(stepView(once, 0.51, fit, viewport, max)).toEqual(FIT_VIEW);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('pinch', () => {
|
|
|
|
|
|
const max = 6;
|
|
|
|
|
|
|
|
|
|
|
|
it('scales by how far the fingers spread, around their midpoint', () => {
|
|
|
|
|
|
const mid = { x: 640, y: 380 };
|
|
|
|
|
|
const next = pinchView(FIT_VIEW, mid, 100, mid, 250, fit, viewport, max);
|
|
|
|
|
|
expect(next.scale).toBeCloseTo(2.5);
|
|
|
|
|
|
const after = screenOf(next, mid.x - fit.cx, mid.y - fit.cy);
|
|
|
|
|
|
expect(after.x).toBeCloseTo(mid.x);
|
|
|
|
|
|
expect(after.y).toBeCloseTo(mid.y);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('follows the midpoint as the fingers move', () => {
|
|
|
|
|
|
const startMid = { x: 600, y: 400 };
|
|
|
|
|
|
const mid = { x: 660, y: 430 };
|
|
|
|
|
|
const next = pinchView({ scale: 3, x: 0, y: 0 }, startMid, 100, mid, 100, fit, viewport, max);
|
|
|
|
|
|
expect(next).toEqual({ scale: 3, x: 60, y: 30 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('pinching back past fit lands on fit', () => {
|
|
|
|
|
|
const mid = { x: 600, y: 400 };
|
|
|
|
|
|
expect(pinchView({ scale: 2, x: 40, y: 0 }, mid, 200, mid, 50, fit, viewport, max)).toEqual(
|
|
|
|
|
|
FIT_VIEW,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('cannot pinch beyond 1:1', () => {
|
|
|
|
|
|
const mid = { x: 600, y: 400 };
|
|
|
|
|
|
expect(pinchView(FIT_VIEW, mid, 10, mid, 1000, fit, viewport, max).scale).toBe(max);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('zoomFitsDevice (review of #830)', () => {
|
|
|
|
|
|
it('limits touch devices to 50 MP', () => {
|
|
|
|
|
|
expect(TOUCH_ZOOM_MAX_PIXELS).toBe(50_000_000);
|
|
|
|
|
|
// Leica Q3, 60 MP: not on a phone, fine with a mouse.
|
|
|
|
|
|
expect(zoomFitsDevice(9520, 6336, true)).toBe(false);
|
|
|
|
|
|
expect(zoomFitsDevice(9520, 6336, false)).toBe(true);
|
|
|
|
|
|
// iPhone, 12 MP; and exactly at the limit.
|
|
|
|
|
|
expect(zoomFitsDevice(4032, 3024, true)).toBe(true);
|
|
|
|
|
|
expect(zoomFitsDevice(10000, 5000, true)).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('needs a size', () => {
|
|
|
|
|
|
expect(zoomFitsDevice(undefined, 100, false)).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('isZoomed', () => {
|
|
|
|
|
|
it('counts a hair above fit as fit', () => {
|
|
|
|
|
|
expect(isZoomed(FIT_VIEW)).toBe(false);
|
|
|
|
|
|
expect(isZoomed({ scale: 1.01, x: 0, y: 0 })).toBe(false);
|
|
|
|
|
|
expect(isZoomed({ scale: 1.5, x: 0, y: 0 })).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|