From 319e2b261e4261fb85f62b574ba6f8f381f4090b Mon Sep 17 00:00:00 2001 From: Daniel Kostro Date: Wed, 15 May 2024 16:30:01 +0200 Subject: [PATCH] feat: add zoomIntoROI action (#151) Closes: https://github.com/zakodium-oss/react-roi/issues/150 --- src/context/roiReducer.tsx | 29 ++++++- src/context/updaters/zoom.ts | 41 ++++++++- src/hooks/useActions.ts | 17 +++- src/types/CommittedRoi.ts | 11 +-- src/utilities/box.ts | 11 +++ src/utilities/point.ts | 37 ++++++-- .../useActions/zoom-external.stories.tsx | 85 +++++++++++++++++++ 7 files changed, 208 insertions(+), 23 deletions(-) diff --git a/src/context/roiReducer.tsx b/src/context/roiReducer.tsx index c9859a2..ba7cba2 100644 --- a/src/context/roiReducer.tsx +++ b/src/context/roiReducer.tsx @@ -1,7 +1,14 @@ import { produce } from 'immer'; import type { PointerEvent as ReactPointerEvent } from 'react'; -import { PanZoom, ReactRoiAction, ResizeStrategy, RoiMode, Size } from '..'; +import { + CommittedRoi, + PanZoom, + ReactRoiAction, + ResizeStrategy, + RoiMode, + Size, +} from '..'; import { CommittedRoiProperties } from '../types/CommittedRoi'; import { Roi, XCornerPosition, YCornerPosition } from '../types/Roi'; import { assert, assertUnreachable } from '../utilities/assert'; @@ -10,6 +17,7 @@ import { xAxisCornerToCenter, yAxisCornerToCenter, } from '../utilities/box'; +import { Point } from '../utilities/point'; import { createCommittedRoi, createRoi, @@ -24,7 +32,7 @@ import { sanitizeRois } from './updaters/sanitizeRois'; import { selectBoxAndStartAction } from './updaters/selectBoxAndStartAction'; import { startDraw } from './updaters/startDraw'; import { startPan } from './updaters/startPan'; -import { resetZoomAction, zoomAction } from './updaters/zoom'; +import { resetZoomAction, zoomAction, zoomIntoROI } from './updaters/zoom'; interface ZoomDomain { min: number; @@ -106,6 +114,19 @@ export interface ZoomPayload { clientY: number; } +export interface ZoomIntoROIOptions { + /** + * A margin relative to the viewport to leave around the zoomed in ROI + * 0 means no margin. A value of 0.5 means 50% of the viewport is for the margin and the remaining 50% for the ROI. + */ + margin: number; +} + +export interface ZoomIntoROIPayload { + roiOrPoints: CommittedRoi | Point[]; + options: ZoomIntoROIOptions; +} + export interface StartDrawPayload { event: PointerEvent | ReactPointerEvent; containerBoundingRect: DOMRect; @@ -192,6 +213,7 @@ export type RoiReducerAction = type: 'ZOOM'; payload: ZoomPayload; } + | { type: 'ZOOM_INTO_ROI'; payload: ZoomIntoROIPayload } | { type: 'RESET_ZOOM'; } @@ -349,6 +371,9 @@ export function roiReducer( case 'ZOOM': zoomAction(draft, action.payload); break; + case 'ZOOM_INTO_ROI': + zoomIntoROI(draft, action.payload); + break; case 'RESET_ZOOM': resetZoomAction(draft); break; diff --git a/src/context/updaters/zoom.ts b/src/context/updaters/zoom.ts index f7fd695..f18c0d9 100644 --- a/src/context/updaters/zoom.ts +++ b/src/context/updaters/zoom.ts @@ -1,5 +1,7 @@ +import { getRectanglePoints } from '../../utilities/box'; import { applyInverseX, applyInverseY } from '../../utilities/panZoom'; -import { ReactRoiState, ZoomPayload } from '../roiReducer'; +import { getBoundaries } from '../../utilities/point'; +import { ReactRoiState, ZoomIntoROIPayload, ZoomPayload } from '../roiReducer'; import { rectifyPanZoom } from './rectifyPanZoom'; @@ -34,3 +36,40 @@ export function resetZoomAction(draft: ReactRoiState) { draft.panZoom.scale = 1; draft.panZoom.translation = [0, 0]; } + +export function zoomIntoROI(draft: ReactRoiState, zoom: ZoomIntoROIPayload) { + const { + roiOrPoints, + options: { margin }, + } = zoom; + // Avoid dividing by 0 + const sanitizedMargin = Math.min(margin, 1 - Number.EPSILON); + const { containerSize } = draft; + + const boundaries = Array.isArray(roiOrPoints) + ? getBoundaries(roiOrPoints) + : getBoundaries(getRectanglePoints(roiOrPoints)); + const width = boundaries.maxX - boundaries.minX; + const height = boundaries.maxY - boundaries.minY; + const center = { + x: boundaries.minX + width / 2, + y: boundaries.minY + height / 2, + }; + + const totalScale = + Math.min(containerSize.width / width, containerSize.height / height) * + (1 - sanitizedMargin); + + draft.panZoom.scale = totalScale / draft.initialPanZoom.scale; + + // Target translation for initial and transform panzoom combined + const translationX = containerSize.width / 2 - center.x * totalScale; + const translationY = containerSize.height / 2 - center.y * totalScale; + + draft.panZoom.translation = [ + translationX - draft.panZoom.scale * draft.initialPanZoom.translation[0], + translationY - draft.panZoom.scale * draft.initialPanZoom.translation[1], + ]; + + rectifyPanZoom(draft); +} diff --git a/src/hooks/useActions.ts b/src/hooks/useActions.ts index c4f8663..7f65c72 100644 --- a/src/hooks/useActions.ts +++ b/src/hooks/useActions.ts @@ -1,10 +1,11 @@ import { produce } from 'immer'; import { KeyboardEvent as ReactKeyboardEvent, useMemo } from 'react'; -import { CommittedRoiProperties } from '..'; -import { CancelActionPayload } from '../context/roiReducer'; +import { CommittedRoi, CommittedRoiProperties } from '..'; +import { CancelActionPayload, ZoomIntoROIOptions } from '../context/roiReducer'; import { zoomAction } from '../context/updaters/zoom'; import { RoiMode } from '../types/utils'; +import { Point } from '../utilities/point'; import useCallbacksRef from './useCallbacksRef'; import { useCurrentState } from './useCurrentState'; @@ -35,6 +36,18 @@ export function useActions() { }); } }, + zoomIntoROI: ( + roiOrPoints: CommittedRoi | Point[], + options: ZoomIntoROIOptions = { margin: 0.2 }, + ) => { + roiDispatch({ + type: 'ZOOM_INTO_ROI', + payload: { + roiOrPoints, + options, + }, + }); + }, zoom: (factor: number) => { if (!containerRef?.current) return; const refBound = containerRef.current.getBoundingClientRect(); diff --git a/src/types/CommittedRoi.ts b/src/types/CommittedRoi.ts index 4a03cdc..9c7e028 100644 --- a/src/types/CommittedRoi.ts +++ b/src/types/CommittedRoi.ts @@ -1,5 +1,5 @@ +import { getRectanglePoints } from '../utilities/box'; import { Point } from '../utilities/point'; -import { rotatePoint } from '../utilities/rotate'; import { Roi } from './Roi'; import { CommittedBox } from './box'; @@ -30,13 +30,6 @@ export class CommittedRoi this.data = properties.data; } public getRectanglePoints(): Point[] { - const { x, y, width, height, angle } = this; - const center: Point = { x, y }; - return [ - center, - rotatePoint({ x: x + width, y }, center, angle), - rotatePoint({ x: x + width, y: y + height }, center, angle), - rotatePoint({ x, y: y + height }, center, angle), - ]; + return getRectanglePoints(this); } } diff --git a/src/utilities/box.ts b/src/utilities/box.ts index ffe8318..46df305 100644 --- a/src/utilities/box.ts +++ b/src/utilities/box.ts @@ -375,3 +375,14 @@ export const yAxisCornerToCenter: Record = { bottom: 'top', center: 'center', }; + +export function getRectanglePoints(box: CommittedBox): Point[] { + const { x, y, width, height, angle } = box; + const center: Point = { x, y }; + return [ + center, + rotatePoint({ x: x + width, y }, center, angle), + rotatePoint({ x: x + width, y: y + height }, center, angle), + rotatePoint({ x, y: y + height }, center, angle), + ]; +} diff --git a/src/utilities/point.ts b/src/utilities/point.ts index d6f223b..deac81c 100644 --- a/src/utilities/point.ts +++ b/src/utilities/point.ts @@ -1,3 +1,5 @@ +import { assert } from './assert'; + export interface Point { x: number; y: number; @@ -14,13 +16,6 @@ export function add(pointA: Point, pointB: Point): Point { }; } -export function subtract(pointA: Point, pointB: Point): Point { - return { - x: pointA.x - pointB.x, - y: pointA.y - pointB.y, - }; -} - export function mulScalar(point: Point, scalar: number): Point { return { x: point.x * scalar, @@ -28,6 +23,30 @@ export function mulScalar(point: Point, scalar: number): Point { }; } -export function scalarMultiply(pointA: Point, pointB: Point) { - return pointA.x * pointB.x + pointA.y * pointB.y; +export function getBoundaries(points: Point[]) { + assert(points.length > 1, 'must pass at least 2 points'); + let maxX = 0; + let minX = Number.MAX_VALUE; + let maxY = 0; + let minY = Number.MAX_VALUE; + for (const { x, y } of points) { + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + } + return { + minX, + maxX, + minY, + maxY, + }; } diff --git a/stories/hooks/useActions/zoom-external.stories.tsx b/stories/hooks/useActions/zoom-external.stories.tsx index 22275a1..b7ed7d2 100644 --- a/stories/hooks/useActions/zoom-external.stories.tsx +++ b/stories/hooks/useActions/zoom-external.stories.tsx @@ -2,11 +2,13 @@ import { Meta } from '@storybook/react'; import { PanZoom, + ResizeStrategy, RoiContainer, RoiList, RoiProvider, TargetImage, useActions, + useCommittedRois, } from '../../../src'; import { CommittedRoisButton } from '../../utils/CommittedRoisButton'; import { Layout } from '../../utils/Layout'; @@ -47,6 +49,10 @@ export default { containerHeight: { control: 'number', }, + resizeStrategy: { + control: 'select', + options: ['contain', 'cover', 'center', 'none'], + }, }, args: { minZoom: 0.1, @@ -54,6 +60,7 @@ export default { spaceAroundTarget: 0.5, containerWidth: 500, containerHeight: 500, + resizeStrategy: 'cover', }, } as Meta; @@ -63,6 +70,7 @@ interface ZoomStoryProps { spaceAroundTarget: number; containerWidth: number; containerHeight: number; + resizeStrategy: ResizeStrategy; onZoomChange: (zoom: PanZoom) => void; } @@ -73,14 +81,17 @@ export function UpdateZoom({ containerWidth, containerHeight, onZoomChange, + resizeStrategy, }: ZoomStoryProps) { const keyId = useResetOnChange([ minZoom, maxZoom, + resizeStrategy, spaceAroundTarget, containerWidth, containerHeight, ]); + function ZoomButton() { const { zoom } = useActions(); @@ -142,6 +153,7 @@ export function UpdateZoom({ max: maxZoom, spaceAroundTarget, }, + resizeStrategy, }} onAfterZoomChange={onZoomChange} > @@ -163,3 +175,76 @@ export function UpdateZoom({ ); } + +export function ZoomIntoROI({ + minZoom, + maxZoom, + resizeStrategy, + spaceAroundTarget, + containerWidth, + containerHeight, + onZoomChange, +}: ZoomStoryProps) { + const keyId = useResetOnChange([ + minZoom, + maxZoom, + resizeStrategy, + spaceAroundTarget, + containerWidth, + containerHeight, + ]); + + return ( + + +
+ } + > + + + + +
+
+ +
+ ); +} + +function ZoomTargetButtons() { + const rois = useCommittedRois(); + const { zoomIntoROI } = useActions(); + return ( +
+ {rois.map((roi, idx) => ( + + ))} +
+ ); +}