blob: 896dc11fe25a876c28d2ab6831a2e88ae7251a21 [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface Point {
x: number;
y: number;
}
export interface Dimensions {
width: number;
height: number;
}
export interface Rect {
origin: Point;
dimensions: Dimensions;
}
export interface FittedContent {
top: number;
left: number;
width: number;
height: number;
scale: number;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max));
}
/**
* Fits content of the given dimensions into the given frame, maintaining the
* aspect ratio of the content and applying letterboxing / pillarboxing as
* needed.
*/
export function fitToFrame(
content: Dimensions,
frame: Dimensions
): FittedContent {
const contentAspectRatio = content.width / content.height;
const frameAspectRatio = frame.width / frame.height;
// If the content is wider than the frame, it will be letterboxed, otherwise
// it will be pillarboxed. When letterboxed, content and frame width will
// match exactly, when pillarboxed, content and frame height will match
// exactly.
const isLetterboxed = contentAspectRatio > frameAspectRatio;
let width: number;
let height: number;
if (isLetterboxed) {
width = Math.min(frame.width, content.width);
height = content.height * (width / content.width);
} else {
height = Math.min(frame.height, content.height);
width = content.width * (height / content.height);
}
const top = (frame.height - height) / 2;
const left = (frame.width - width) / 2;
const scale = width / content.width;
return {top, left, width, height, scale};
}
function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
const x =
part.dimensions.width <= bounds.width
? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
: (bounds.width - part.dimensions.width) / 2;
const y =
part.dimensions.height <= bounds.height
? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
: (bounds.height - part.dimensions.height) / 2;
return {origin: {x, y}, dimensions: part.dimensions};
}
/**
* Maintains a given frame inside given bounds, adjusting requested positions
* for the frame as needed. This supports the non-destructive application of a
* scaling factor, so that e.g. the magnification of an image can be changed
* easily while keeping the frame centered over the same spot. Changing bounds
* or frame size also keeps the frame position when possible.
*/
export class FrameConstrainer {
private center: Point = {x: 0, y: 0};
private frameSize: Dimensions = {width: 0, height: 0};
private bounds: Dimensions = {width: 0, height: 0};
private scale = 1;
private unscaledFrame: Rect = {
origin: {x: 0, y: 0},
dimensions: {width: 0, height: 0},
};
private scaledFrame: Rect = {
origin: {x: 0, y: 0},
dimensions: {width: 0, height: 0},
};
getCenter(): Point {
return {...this.center};
}
/**
* Returns the frame at its original size, positioned within the given bounds
* at the given scale; its origin will be in scaled bounds coordinates.
*
* Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
* all at 1x scale, when setting scale to 2, this will return a frame of size
* 30x20, centered over (100, 50), within bounds 200x100.
*
* Useful for positioning a viewport of fixed size over a magnified image.
*/
getUnscaledFrame(): Rect {
return {
origin: {...this.unscaledFrame.origin},
dimensions: {...this.unscaledFrame.dimensions},
};
}
/**
* Returns the scaled down frame–a scale of 2 will result in frame dimensions
* being halved—position within the given bounds at 1x scale; its origin will
* be in unscaled bounds coordinates.
*
* Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
* all at 1x scale, when setting scale to 2, this will return a frame of size
* 15x10, centered over (50, 25), within bounds 100x50.
*
* Useful for highlighting the magnified portion of an image as determined by
* getUnscaledFrame() in an overview image of fixed size.
*/
getScaledFrame(): Rect {
return {
origin: {...this.scaledFrame.origin},
dimensions: {...this.scaledFrame.dimensions},
};
}
/**
* Requests the frame to be centered over the given point, in unscaled bounds
* coordinates. This will keep the frame within the given bounds, also when
* requesting a center point fully outside the given bounds.
*/
requestCenter(center: Point) {
this.center = {...center};
this.ensureFrameInBounds();
}
/**
* Sets the frame size, while keeping the frame within the given bounds, and
* maintaining the current center if possible.
*/
setFrameSize(frameSize: Dimensions) {
if (frameSize.width <= 0 || frameSize.height <= 0) return;
this.frameSize = {...frameSize};
this.ensureFrameInBounds();
}
/**
* Sets the bounds, while keeping the frame within them, and maintaining the
* current center if possible.
*/
setBounds(bounds: Dimensions) {
if (bounds.width <= 0 || bounds.height <= 0) return;
this.bounds = {...bounds};
this.ensureFrameInBounds();
}
/**
* Sets the applied scale, while keeping the frame within the given bounds,
* and maintaining the current center if possible (both relevant moving from
* a larger scale to a smaller scale).
*/
setScale(scale: number) {
if (!scale || scale <= 0) return;
this.scale = scale;
this.ensureFrameInBounds();
}
private ensureFrameInBounds() {
const scaledCenter = {
x: this.center.x * this.scale,
y: this.center.y * this.scale,
};
const scaledBounds = {
width: this.bounds.width * this.scale,
height: this.bounds.height * this.scale,
};
const scaledFrameSize = {
width: this.frameSize.width / this.scale,
height: this.frameSize.height / this.scale,
};
const requestedUnscaledFrame = {
origin: {
x: scaledCenter.x - this.frameSize.width / 2,
y: scaledCenter.y - this.frameSize.height / 2,
},
dimensions: this.frameSize,
};
const requestedScaledFrame = {
origin: {
x: this.center.x - scaledFrameSize.width / 2,
y: this.center.y - scaledFrameSize.height / 2,
},
dimensions: scaledFrameSize,
};
this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
this.center = {
x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
};
}
}