235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
|
|
/**
|
|||
|
|
* Zoom geometry for the lightbox (#467). Pure, so it can be tested without a
|
|||
|
|
* DOM; the gesture wiring lives in hooks/useZoom.ts.
|
|||
|
|
*
|
|||
|
|
* The model: the photo is laid out at its "fit" size, as the lightbox always
|
|||
|
|
* shows it, and zooming applies `translate(x, y) scale(scale)` to that box
|
|||
|
|
* with the transform origin at its centre. `scale: 1` is fit. Every
|
|||
|
|
* coordinate is in CSS pixels relative to the viewport.
|
|||
|
|
*
|
|||
|
|
* The top of the range is 1:1 — one image pixel per *device* pixel, so the
|
|||
|
|
* CSS size at 1:1 is the image width divided by devicePixelRatio. That is the
|
|||
|
|
* view a sharpness check needs. Going further only interpolates pixels: it
|
|||
|
|
* shows nothing the file does not hold and makes good focus look soft, so the
|
|||
|
|
* range is clamped to [fit, 1:1] rather than [fit, 2 × 1:1].
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/** The current transform of the fit box. */
|
|||
|
|
export interface ZoomView {
|
|||
|
|
scale: number;
|
|||
|
|
/** Translation of the box centre, CSS px. */
|
|||
|
|
x: number;
|
|||
|
|
y: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Where the photo sits at fit: the centre of its box and its size, CSS px. */
|
|||
|
|
export interface FitBox {
|
|||
|
|
cx: number;
|
|||
|
|
cy: number;
|
|||
|
|
width: number;
|
|||
|
|
height: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface Viewport {
|
|||
|
|
width: number;
|
|||
|
|
height: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface Point {
|
|||
|
|
x: number;
|
|||
|
|
y: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The largest image offered for zoom on a touch device (coarse pointer), in
|
|||
|
|
* pixels. Showing a photo at 1:1 means decoding all of it: 50 MP is 200 MB of
|
|||
|
|
* RGBA, about what phones and tablets cope with alongside the page. Larger
|
|||
|
|
* files (a 60 MP Leica Q3 frame, 240 MB decoded) get no zoom control there,
|
|||
|
|
* rather than one that crashes the tab. Mouse and trackpad devices are not
|
|||
|
|
* limited.
|
|||
|
|
*/
|
|||
|
|
export const TOUCH_ZOOM_MAX_PIXELS = 50_000_000;
|
|||
|
|
|
|||
|
|
/** Whether a photo of this pixel size may be zoomed on this kind of device. */
|
|||
|
|
export function zoomFitsDevice(
|
|||
|
|
width: number | undefined,
|
|||
|
|
height: number | undefined,
|
|||
|
|
coarsePointer: boolean,
|
|||
|
|
): boolean {
|
|||
|
|
if (!width || !height) return false;
|
|||
|
|
return !coarsePointer || width * height <= TOUCH_ZOOM_MAX_PIXELS;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The untouched view. */
|
|||
|
|
export const FIT_VIEW: ZoomView = Object.freeze({ scale: 1, x: 0, y: 0 });
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Below this the view counts as fit. Pinching back almost to fit should land
|
|||
|
|
* on fit, not leave the photo a pixel off and swipe navigation switched off.
|
|||
|
|
*/
|
|||
|
|
const FIT_EPSILON = 0.02;
|
|||
|
|
|
|||
|
|
export function isZoomed(view: ZoomView): boolean {
|
|||
|
|
return view.scale > 1 + FIT_EPSILON;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The scale (relative to fit) at which one image pixel covers one device
|
|||
|
|
* pixel. Below 1 when the photo is already shown at more than 1:1 — a small
|
|||
|
|
* file on a large screen — which leaves nothing to zoom into.
|
|||
|
|
*/
|
|||
|
|
export function oneToOneScale(naturalWidth: number, fitWidth: number, dpr: number): number {
|
|||
|
|
if (!(naturalWidth > 0) || !(fitWidth > 0)) return 1;
|
|||
|
|
const ratio = dpr > 0 && Number.isFinite(dpr) ? dpr : 1;
|
|||
|
|
return naturalWidth / ratio / fitWidth;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The top of the zoom range: 1:1, and never below fit. */
|
|||
|
|
export function maxZoomScale(naturalWidth: number, fitWidth: number, dpr: number): number {
|
|||
|
|
return Math.max(1, oneToOneScale(naturalWidth, fitWidth, dpr));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Whether zooming would show anything the fit view does not. */
|
|||
|
|
export function canZoom(maxScale: number): boolean {
|
|||
|
|
return maxScale > 1 + FIT_EPSILON;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function clampScale(scale: number, maxScale: number): number {
|
|||
|
|
if (!Number.isFinite(scale)) return 1;
|
|||
|
|
return Math.min(Math.max(scale, 1), Math.max(1, maxScale));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The allowed range for one axis of the translation.
|
|||
|
|
*
|
|||
|
|
* A box larger than the viewport may move until its edge meets the viewport
|
|||
|
|
* edge, never further — no empty band appears beside the photo. A box smaller
|
|||
|
|
* than the viewport may move anywhere that keeps it fully on screen; that
|
|||
|
|
* range always contains 0, so a photo that is barely zoomed stays where it was
|
|||
|
|
* instead of jumping to the centre.
|
|||
|
|
*/
|
|||
|
|
function axisRange(centre: number, half: number, extent: number): [number, number] {
|
|||
|
|
const a = half - centre;
|
|||
|
|
const b = extent - centre - half;
|
|||
|
|
return [Math.min(a, b), Math.max(a, b)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Keep the photo covering the viewport (or fully on it), at a clamped scale. */
|
|||
|
|
export function clampView(
|
|||
|
|
view: ZoomView,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
): ZoomView {
|
|||
|
|
const scale = clampScale(view.scale, maxScale);
|
|||
|
|
if (scale <= 1) return { ...FIT_VIEW };
|
|||
|
|
const [minX, maxX] = axisRange(fit.cx, (fit.width * scale) / 2, viewport.width);
|
|||
|
|
const [minY, maxY] = axisRange(fit.cy, (fit.height * scale) / 2, viewport.height);
|
|||
|
|
return {
|
|||
|
|
scale,
|
|||
|
|
x: Math.min(Math.max(view.x, minX), maxX),
|
|||
|
|
y: Math.min(Math.max(view.y, minY), maxY),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Change the scale while the photo point under `point` stays under it — the
|
|||
|
|
* cursor for a wheel or a double-click, the midpoint of two fingers for a
|
|||
|
|
* pinch. Clamping can still move it at the edges, which is the price of never
|
|||
|
|
* showing an empty band.
|
|||
|
|
*/
|
|||
|
|
export function zoomAround(
|
|||
|
|
view: ZoomView,
|
|||
|
|
nextScale: number,
|
|||
|
|
point: Point,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
): ZoomView {
|
|||
|
|
const scale = clampScale(nextScale, maxScale);
|
|||
|
|
// The photo point under `point`, as an offset from the box centre at scale 1.
|
|||
|
|
const u = (point.x - fit.cx - view.x) / view.scale;
|
|||
|
|
const v = (point.y - fit.cy - view.y) / view.scale;
|
|||
|
|
return clampView(
|
|||
|
|
{ scale, x: point.x - fit.cx - u * scale, y: point.y - fit.cy - v * scale },
|
|||
|
|
fit,
|
|||
|
|
viewport,
|
|||
|
|
maxScale,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The pinch step: the photo point that was under the fingers' midpoint when
|
|||
|
|
* the pinch began follows the midpoint, at the start scale times how far the
|
|||
|
|
* fingers spread. Computed from the start of the gesture rather than
|
|||
|
|
* incrementally, so rounding cannot drift the photo away under the fingers.
|
|||
|
|
*/
|
|||
|
|
export function pinchView(
|
|||
|
|
start: ZoomView,
|
|||
|
|
startMid: Point,
|
|||
|
|
startDistance: number,
|
|||
|
|
mid: Point,
|
|||
|
|
distance: number,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
): ZoomView {
|
|||
|
|
const factor = startDistance > 0 ? distance / startDistance : 1;
|
|||
|
|
const scale = clampScale(start.scale * factor, maxScale);
|
|||
|
|
const u = (startMid.x - fit.cx - start.x) / start.scale;
|
|||
|
|
const v = (startMid.y - fit.cy - start.y) / start.scale;
|
|||
|
|
return clampView(
|
|||
|
|
{ scale, x: mid.x - fit.cx - u * scale, y: mid.y - fit.cy - v * scale },
|
|||
|
|
fit,
|
|||
|
|
viewport,
|
|||
|
|
maxScale,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Move the photo by a pointer delta, clamped. */
|
|||
|
|
export function panBy(
|
|||
|
|
view: ZoomView,
|
|||
|
|
dx: number,
|
|||
|
|
dy: number,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
): ZoomView {
|
|||
|
|
return clampView({ ...view, x: view.x + dx, y: view.y + dy }, fit, viewport, maxScale);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Double-click / double-tap: fit ↔ 1:1, around the point. */
|
|||
|
|
export function toggleView(
|
|||
|
|
view: ZoomView,
|
|||
|
|
point: Point,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
): ZoomView {
|
|||
|
|
if (isZoomed(view)) return { ...FIT_VIEW };
|
|||
|
|
return zoomAround(view, maxScale, point, fit, viewport, maxScale);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The `+` / `-` keys and the wheel: multiply the scale, around the centre of
|
|||
|
|
* the viewport unless a point is given. A step that lands within reach of fit
|
|||
|
|
* snaps to it.
|
|||
|
|
*/
|
|||
|
|
export function stepView(
|
|||
|
|
view: ZoomView,
|
|||
|
|
factor: number,
|
|||
|
|
fit: FitBox,
|
|||
|
|
viewport: Viewport,
|
|||
|
|
maxScale: number,
|
|||
|
|
point?: Point,
|
|||
|
|
): ZoomView {
|
|||
|
|
const at = point ?? { x: viewport.width / 2, y: viewport.height / 2 };
|
|||
|
|
const next = zoomAround(view, view.scale * factor, at, fit, viewport, maxScale);
|
|||
|
|
return isZoomed(next) ? next : { ...FIT_VIEW };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The CSS transform for a view. */
|
|||
|
|
export function viewTransform(view: ZoomView): string {
|
|||
|
|
return `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`;
|
|||
|
|
}
|