import { Logger } from './Logger.js';
import { PositionInPixels } from './PositionInPixels.js';
import { PositionInRelativeCoord } from './PositionInRelativeCoord.js';
import { SizeInPixels } from './SizeInPixels.js';
import { SizeInRelativeCoord } from './SizeInRelativeCoord.js';
import { Transformation } from './Transformation.js';
export interface ImageRegionFromHtmlAttr {
id?: string;
shape?: string; // only 'rectangle' is supported
unit?: string; // 'relative' or 'pixel'
imageWidth?: number | string; // in pixels, required when unit is 'relative'
imageHeight?: number | string; // in pixels, required when unit is 'relative'
x?: number | string;
y?: number | string;
width?: number | string;
height?: number | string;
}
export class RectangleImageRegion {
// Returns the current size of the image on screen, given the currently
// applied transformation, in order to zoom in on a region.
static getTransformedImageSize(
originalImageRegionSize: SizeInPixels,
transformation: Transformation, // current image transformation being applied
): SizeInPixels {
return originalImageRegionSize.getScaled(transformation.factor);
}
constructor(
id?: string,
position?: PositionInRelativeCoord,
size?: SizeInRelativeCoord,
) {
this.id = id ?? '';
this.position = position ?? new PositionInRelativeCoord();
this.size = size ?? new SizeInRelativeCoord();
this._unknown = !position || !size;
}
// Sets the fields according to the values coming from HTML attributes.
setFields(values: ImageRegionFromHtmlAttr, logger: Logger) {
const shape = values.shape ? values.shape.toLowerCase() : 'rectangle';
if (shape !== 'rectangle') {
logger.debug(`Region ${values.id} has unknown shape ${shape}, skipping.`);
this._unknown = true;
return;
}
let unit = values.unit?.toLowerCase();
if (!unit) {
if (values.imageWidth && values.imageHeight) {
unit = 'pixel';
} else if (!values.imageWidth && !values.imageHeight) {
unit = 'relative';
}
}
if (unit !== 'relative' && unit !== 'pixel') {
logger.debug(`Region ${values.id} has unknown unit ${unit}, skipping.`);
this._unknown = true;
return;
}
let baseSize = new SizeInPixels();
if (unit === 'pixel') {
if (values.imageWidth == null || values.imageHeight == null) {
logger.warn(
`Region ${values.id} has missing imageWidth or imageHeight, skipping.`,
);
this._unknown = true;
return;
}
baseSize = new SizeInPixels(
parseFloat(`${values.imageWidth}`),
parseFloat(`${values.imageHeight}`),
);
}
if (
values.x == null ||
values.y == null ||
values.width == null ||
values.height == null
) {
logger.warn(
`Region ${values.id} has missing x, y, width or height, skipping.`,
);
this._unknown = true;
return;
}
const x: number = parseFloat(`${values.x}`);
const y: number = parseFloat(`${values.y}`);
const width: number = parseFloat(`${values.width}`);
const height: number = parseFloat(`${values.height}`);
if (
Number.isNaN(x) ||
Number.isNaN(y) ||
Number.isNaN(width) ||
Number.isNaN(height)
) {
logger.warn(
`Region ${values.id} has non-numeric x, y, width or height, skipping.`,
);
this._unknown = true;
return;
}
if (x < 0 || y < 0 || width <= 0 || height <= 0) {
logger.warn(
`Region ${values.id} has negative/zero x, y, width or height, skipping.`,
);
this._unknown = true;
return;
}
if (unit === 'relative') {
this.position = new PositionInRelativeCoord(x, y);
this.size = new SizeInRelativeCoord(width, height);
} else {
this.position = new PositionInPixels(x, y).getRelativeCoord(baseSize);
this.size = new SizeInPixels(width, height).getRelativeCoord(baseSize);
}
this.id = values.id ?? window.crypto.randomUUID();
this._unknown = false;
}
isUnknown() {
return this._unknown;
}
// Returns the necessary scaling factor and origin, and clipping to apply via
// CSS in order to pan and zoom on this region.
getTransformation(
currentComponentSize: SizeInPixels, // component =
element
originalImageRegionSize: SizeInPixels,
bottomRightClipMargins: SizeInPixels,
): Transformation {
const regionPos = this.position.getPositionInPixels(
originalImageRegionSize,
);
const regionSize = this.size.getSizeInPixels(originalImageRegionSize);
const regionWidth = regionSize.getSafeWidth();
const regionHeight = regionSize.getSafeHeight();
const componentWidth = currentComponentSize.getSafeWidth();
const componentHeight = currentComponentSize.getSafeHeight();
const originalImageWidth = originalImageRegionSize.getWidth();
const originalImageHeight = originalImageRegionSize.getHeight();
const regionXFromRight = originalImageWidth - regionWidth - regionPos.x;
const regionYFromBottom = originalImageHeight - regionHeight - regionPos.y;
let xOffset = 0;
let yOffset = 0;
let scaleFactor = 1;
if (currentComponentSize.getSafeRatio() < regionSize.getSafeRatio()) {
// Here the region to focus on has a higher width/height ratio than the
// component, i.e. the region is "flatter". This means that we need to
// zoom the region in order to render both boxes with exactly the same
// width:
//
// +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+
// | invisible image outside the component | |
// |
// | +----------------------------------------+ <--- | regionPos |
// | component / image around region | | yOffset | .y
// | | | | | |
// +----------------------------------------+ <---------------
// | | region | |
// | |
// | | | |
// +----------------------------------------+
// | | | |
// | component / image around region |
// | +----------------------------------------+ |
// ^
// | | xOffset = 0 |
// |
// | | |
// +- - - - - -|- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -+
// ^ |
// |-----------|
// | regionPos |
// .x
//
// As we can see, the image will be shifted to the left, compared to the
// compoment's origin (its top left corner), by regionPos.x, so that the
// left side of the region and the left side of the component coincide.
// If we then shifted the image to the top by regionPos.y, the top side
// of the region and the top side of the component would coincide, but
// instead we want the region's center and the component's center to
// coincide. So the image will be shifted up by (regionPos.y - yOffset)
// instead.
scaleFactor = componentWidth / regionWidth;
yOffset = (componentHeight / scaleFactor - regionHeight) / 2;
// On extreme ratios, the calculations above lead to an extreme zooming
// out, leading to blank margins appearing, either
//
// * at the top:
//
// +---------------------------------+ <---
// | component / no image | | yOffset > regionPos.y
// | | |
// +---------------------------------+ <-|-----
// | component / image around region | | | regionPos.y
// | | | |
// +---------------------------------+ <-------
// | region |
// +---------------------------------+ <---
// | | | yOffset
// | | |
// | | |
// | | |
// | component / image around region | |
// +---------------------------------+ <---
//
// * or at the bottom:
//
// +---------------------------------+ <---
// | component / image around region | | yOffset
// | | |
// | | |
// | | |
// | | |
// +---------------------------------+ <---
// | region |
// +---------------------------------+ <-------
// | | | | regionYFromBottom
// | component / image around region | | |
// +---------------------------------+ <-|-----
// | | |
// | component / no image | | yOffset > regionYFromBottom
// +---------------------------------+ <---
//
// Depending which one of regionPos.y and regionYFromBottom is greater
// than yOffset, the problem will appear first at the top or at the bottom
// of the image.
const blankAtTop = yOffset - regionPos.y;
const blankAtBottom = yOffset - regionYFromBottom;
if (blankAtTop > 0 || blankAtBottom > 0) {
// We are about to zoom out too much. If we don't fix this here, some
// blank margin will appear either at the top or at the bottom.
// Let's zoom less and perform a middle-cropping.
// Note that this is similar to the `object-fit: cover;` behavior, which
// can only be applied to the entire image unfortunately.
// Take back yOffset to its maximum allowed:
yOffset = Math.min(regionPos.y, regionYFromBottom);
// Now we know from the original yOffset calculation the following
// equation:
//
// yOffset === (componentHeight / scaleFactor - regionHeight) / 2
//
// which now becomes:
//
// yOffset * 2 === componentHeight / scaleFactor - regionHeight
// yOffset * 2 + regionHeight === componentHeight / scaleFactor
// (yOffset * 2 + regionHeight) / componentHeight === 1 / scaleFactor
scaleFactor = componentHeight / (yOffset * 2 + regionHeight);
// Now center the region on the X axis, in order to middle-crop. Note
// the similarity with the original yOffset formula.
xOffset = (componentWidth / scaleFactor - regionWidth) / 2;
}
} else {
// Same calculations as in the other code branch, but simply having
// swapped the X and Y axes.
scaleFactor = componentHeight / regionHeight;
xOffset = (componentWidth / scaleFactor - regionWidth) / 2;
const blankAtLeft = xOffset - regionPos.x;
const blankAtRight = xOffset - regionXFromRight;
if (blankAtLeft > 0 || blankAtRight > 0) {
xOffset = Math.min(regionPos.x, regionXFromRight);
scaleFactor = componentWidth / (xOffset * 2 + regionWidth);
yOffset = (componentHeight / scaleFactor - regionHeight) / 2;
}
}
const origin = new PositionInPixels(
regionPos.x - xOffset,
regionPos.y - yOffset,
);
return {
origin,
factor: scaleFactor,
// See https://developer.mozilla.org/en-US/docs/Web/CSS/basic-shape/inset
insetClipFromTopLeft: new SizeInPixels(origin.x, origin.y),
insetClipFromBottomRight: new SizeInPixels(
regionXFromRight - xOffset + bottomRightClipMargins.getWidth(),
regionYFromBottom - yOffset + bottomRightClipMargins.getHeight(),
),
};
}
// Returns the position and size of the region in the component's coordinate
// system.
getBoundingBox(
currentComponentSize: SizeInPixels, // component =
element
originalImageRegionSize: SizeInPixels,
transformation: Transformation, // current image transformation being applied
) {
const transformedImagePositionInComponent = new PositionInPixels(
-transformation.origin.x * transformation.factor,
-transformation.origin.y * transformation.factor,
);
const transformedImageSize = RectangleImageRegion.getTransformedImageSize(
originalImageRegionSize,
transformation,
);
const regionPositionInTransformedImage =
this.position.getPositionInPixels(transformedImageSize);
return {
position: new PositionInPixels(
regionPositionInTransformedImage.x +
transformedImagePositionInComponent.x,
regionPositionInTransformedImage.y +
transformedImagePositionInComponent.y,
),
size: this.size.getSizeInPixels(transformedImageSize),
};
}
id: string;
position: PositionInRelativeCoord;
size: SizeInRelativeCoord;
_unknown: boolean;
}