Files
immich/web/src/lib/utils/container-utils.ts
midzelis ed04d87273 feat(web): OCR overlay interactivity during zoom
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
2026-03-23 13:16:08 +00:00

112 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Coordinate spaces used throughout the viewer:
//
// "Normalized": 01 range, (0,0) = top-left, (1,1) = bottom-right. Resolution-independent.
// Example: OCR coordinates, or face coords after dividing by metadata dimensions.
//
// "Content": pixel position within the container after scaling (scaleToFit/scaleToCover)
// and centering. Used for DOM overlay positioning (face boxes, OCR text).
//
// "Natural": pixel position in the original full-resolution image file (e.g. 4000×3000).
// Used when cropping or drawing on the source image.
//
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
export type Point = {
x: number;
y: number;
};
export type Size = {
width: number;
height: number;
};
export type ContentMetrics = {
contentWidth: number;
contentHeight: number;
offsetX: number;
offsetY: number;
};
export const scaleToCover = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.max(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};
export const scaleToFit = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
}
return { width: element.naturalWidth, height: element.naturalHeight };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
return {
contentWidth,
contentHeight,
offsetX: (client.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2,
};
};
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
return {
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
};
}
return {
x: point.x * sizeOrMetrics.width,
y: point.y * sizeOrMetrics.height,
};
}
export type Rect = {
top: number;
left: number;
width: number;
height: number;
};
export function mapNormalizedRectToContent(
topLeft: Point,
bottomRight: Point,
sizeOrMetrics: Size | ContentMetrics,
): Rect {
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
return {
top: tl.y,
left: tl.x,
width: br.x - tl.x,
height: br.y - tl.y,
};
}