Skip to content

Commit

Permalink
feat: add zoomIntoROI action (#151)
Browse files Browse the repository at this point in the history
Closes: #150
  • Loading branch information
stropitek authored May 15, 2024
1 parent 4367a4d commit 319e2b2
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 23 deletions.
29 changes: 27 additions & 2 deletions src/context/roiReducer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +17,7 @@ import {
xAxisCornerToCenter,
yAxisCornerToCenter,
} from '../utilities/box';
import { Point } from '../utilities/point';
import {
createCommittedRoi,
createRoi,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -192,6 +213,7 @@ export type RoiReducerAction =
type: 'ZOOM';
payload: ZoomPayload;
}
| { type: 'ZOOM_INTO_ROI'; payload: ZoomIntoROIPayload }
| {
type: 'RESET_ZOOM';
}
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 40 additions & 1 deletion src/context/updaters/zoom.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
}
17 changes: 15 additions & 2 deletions src/hooks/useActions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -35,6 +36,18 @@ export function useActions<TData = unknown>() {
});
}
},
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();
Expand Down
11 changes: 2 additions & 9 deletions src/types/CommittedRoi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,13 +30,6 @@ export class CommittedRoi<TData = unknown>
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);
}
}
11 changes: 11 additions & 0 deletions src/utilities/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,14 @@ export const yAxisCornerToCenter: Record<YCornerPosition, YRotationCenter> = {
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),
];
}
37 changes: 28 additions & 9 deletions src/utilities/point.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { assert } from './assert';

export interface Point {
x: number;
y: number;
Expand All @@ -14,20 +16,37 @@ 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,
y: point.y * scalar,
};
}

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,
};
}
85 changes: 85 additions & 0 deletions stories/hooks/useActions/zoom-external.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,13 +49,18 @@ export default {
containerHeight: {
control: 'number',
},
resizeStrategy: {
control: 'select',
options: ['contain', 'cover', 'center', 'none'],
},
},
args: {
minZoom: 0.1,
maxZoom: 20,
spaceAroundTarget: 0.5,
containerWidth: 500,
containerHeight: 500,
resizeStrategy: 'cover',
},
} as Meta;

Expand All @@ -63,6 +70,7 @@ interface ZoomStoryProps {
spaceAroundTarget: number;
containerWidth: number;
containerHeight: number;
resizeStrategy: ResizeStrategy;
onZoomChange: (zoom: PanZoom) => void;
}

Expand All @@ -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();

Expand Down Expand Up @@ -142,6 +153,7 @@ export function UpdateZoom({
max: maxZoom,
spaceAroundTarget,
},
resizeStrategy,
}}
onAfterZoomChange={onZoomChange}
>
Expand All @@ -163,3 +175,76 @@ export function UpdateZoom({
</RoiProvider>
);
}

export function ZoomIntoROI({
minZoom,
maxZoom,
resizeStrategy,
spaceAroundTarget,
containerWidth,
containerHeight,
onZoomChange,
}: ZoomStoryProps) {
const keyId = useResetOnChange([
minZoom,
maxZoom,
resizeStrategy,
spaceAroundTarget,
containerWidth,
containerHeight,
]);

return (
<RoiProvider
key={keyId}
initialConfig={{
rois: getInitialRois(320, 320),
zoom: {
min: minZoom,
max: maxZoom,
spaceAroundTarget,
},
resizeStrategy,
}}
onAfterZoomChange={onZoomChange}
>
<Layout fit>
<div style={{ display: 'flex', gap: '3px' }}>
<RoiContainer
style={{
width: containerWidth,
height: containerHeight,
border: '2px red solid',
}}
target={<TargetImage id="story-image" src="/barbara.jpg" />}
>
<RoiList />
</RoiContainer>

<ZoomTargetButtons />
</div>
</Layout>
<CommittedRoisButton />
</RoiProvider>
);
}

function ZoomTargetButtons() {
const rois = useCommittedRois();
const { zoomIntoROI } = useActions();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{rois.map((roi, idx) => (
<button
type="button"
key={roi.id}
onClick={() => {
zoomIntoROI(roi);
}}
>
Zoom into ROI {idx}
</button>
))}
</div>
);
}

0 comments on commit 319e2b2

Please sign in to comment.