Skip to content

Commit 1253b0b

Browse files
author
Conrad Chan
authored
fix(scrollto): Fix scroll to on rotated images (#538)
1 parent 4308da7 commit 1253b0b

File tree

8 files changed

+330
-27
lines changed

8 files changed

+330
-27
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
rules: {
1717
'@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
1818
'@typescript-eslint/no-explicit-any': ['error', { ignoreRestArgs: true }],
19+
'@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
1920
},
2021
},
2122
{

src/image/ImageAnnotator.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Unsubscribe } from 'redux';
22
import BaseAnnotator, { Options } from '../common/BaseAnnotator';
33
import BaseManager from '../common/BaseManager';
44
import { getAnnotation, getRotation } from '../store';
5-
import { centerRegion, isRegion, RegionManager } from '../region';
5+
import { centerRegion, getTransformedShape, isRegion, RegionManager } from '../region';
66
import { CreatorStatus, getCreatorStatus } from '../store/creator';
77
import { scrollToLocation } from '../utils/scroll';
88
import './ImageAnnotator.scss';
@@ -99,15 +99,17 @@ export default class ImageAnnotator extends BaseAnnotator {
9999
const annotation = getAnnotation(this.store.getState(), annotationId);
100100
const annotationLocation = annotation?.target.location.value ?? 1;
101101
const referenceEl = this.getReference();
102+
const rotation = getRotation(this.store.getState()) || 0;
102103

103104
if (!annotation || !annotationLocation || !referenceEl || !this.annotatedEl) {
104105
return;
105106
}
106107

107108
if (isRegion(annotation)) {
108-
// TODO: Add support for scroll when image is rotated
109+
const transformedShape = getTransformedShape(annotation.target.shape, rotation);
110+
109111
scrollToLocation(this.annotatedEl, referenceEl, {
110-
offsets: centerRegion(annotation.target.shape),
112+
offsets: centerRegion(transformedShape),
111113
});
112114
}
113115
}

src/region/__tests__/regionUtil-test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { Rect } from '../../@types';
22
import { annotation } from '../__mocks__/data';
3-
import { centerRegion, centerShape, isRegion, styleShape } from '../regionUtil';
3+
import {
4+
centerRegion,
5+
centerShape,
6+
getPoints,
7+
getTransformedShape,
8+
isRegion,
9+
styleShape,
10+
selectTransformationPoint,
11+
} from '../regionUtil';
412

513
describe('regionUtil', () => {
614
const getRect = (): Rect => ({
@@ -10,6 +18,12 @@ describe('regionUtil', () => {
1018
x: 10,
1119
y: 10,
1220
});
21+
const parseValue = (value: number): number => parseFloat(value.toFixed(3));
22+
23+
const regionShape = { height: 2, width: 3, x: 1, y: 1 };
24+
const regionShapeRotated90 = { height: 3, width: 2, x: 1, y: 96 };
25+
const regionShapeRotated180 = { height: 2, width: 3, x: 96, y: 97 };
26+
const regionShapeRotated270 = { height: 3, width: 2, x: 97, y: 1 };
1327

1428
describe('centerShape()', () => {
1529
test('should return the position of the center of the shape', () => {
@@ -27,6 +41,61 @@ describe('regionUtil', () => {
2741
});
2842
});
2943

44+
describe('getPoints()', () => {
45+
test('should return the points based on a region shape', () => {
46+
const [p1, p2, p3, p4] = getPoints(regionShape);
47+
expect(p1).toEqual({ x: 1, y: 1 });
48+
expect(p2).toEqual({ x: 4, y: 1 });
49+
expect(p3).toEqual({ x: 4, y: 3 });
50+
expect(p4).toEqual({ x: 1, y: 3 });
51+
});
52+
});
53+
54+
describe('selectTransformationPoint()', () => {
55+
test.each`
56+
rotation | expectedPoint
57+
${0} | ${{ x: 1, y: 1 }}
58+
${-90} | ${{ x: 4, y: 1 }}
59+
${-180} | ${{ x: 4, y: 3 }}
60+
${-270} | ${{ x: 1, y: 3 }}
61+
${-360} | ${{ x: 1, y: 1 }}
62+
`(
63+
'should return the appropriate point if shape=$shape and rotation=$rotation',
64+
({ rotation, expectedPoint }) => {
65+
expect(selectTransformationPoint(regionShape, rotation)).toEqual(expectedPoint);
66+
},
67+
);
68+
});
69+
70+
describe('getTransformedShape()', () => {
71+
test.each`
72+
rotation | expectedShape
73+
${0} | ${regionShape}
74+
${-90} | ${regionShapeRotated90}
75+
${-180} | ${regionShapeRotated180}
76+
${-270} | ${regionShapeRotated270}
77+
${-360} | ${regionShape}
78+
`(
79+
'should return the transformed shape based on rotation=$rotation and reference element',
80+
({ rotation, expectedShape }) => {
81+
const { height: expHeight, width: expWidth, x: expX, y: expY } = expectedShape;
82+
const { height, width, x = NaN, y = NaN } = getTransformedShape(regionShape, rotation) || {};
83+
84+
expect({ height, width }).toEqual({ height: expHeight, width: expWidth });
85+
expect(parseValue(x)).toEqual(parseValue(expX));
86+
expect(parseValue(y)).toEqual(parseValue(expY));
87+
},
88+
);
89+
90+
test('should transform -90deg shape back to an unrotated state', () => {
91+
const { height, width, x, y } = getTransformedShape(regionShapeRotated90, -270);
92+
const { height: expHeight, width: expWidth, x: expX, y: expY } = regionShape;
93+
expect({ height, width }).toEqual({ height: expHeight, width: expWidth });
94+
expect(parseValue(x)).toEqual(parseValue(expX));
95+
expect(parseValue(y)).toEqual(parseValue(expY));
96+
});
97+
});
98+
3099
describe('isRegion()', () => {
31100
test.each`
32101
type | result
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { invertYCoordinate, rotatePoint, translatePoint } from '../transformUtil';
2+
3+
describe('src/region/transformUtil', () => {
4+
const parseValue = (value: number): number => parseFloat(value.toFixed(3));
5+
6+
describe('invertYCoordinate()', () => {
7+
test.each`
8+
point | height | expectedPoint
9+
${{ x: 0, y: 0 }} | ${10} | ${{ x: 0, y: 10 }}
10+
${{ x: 1, y: 5 }} | ${10} | ${{ x: 1, y: 5 }}
11+
${{ x: 2, y: 7 }} | ${10} | ${{ x: 2, y: 3 }}
12+
${{ x: 2, y: 7 }} | ${0} | ${{ x: 2, y: 7 }}
13+
`(
14+
'should return inverted y-coordinate given point=$point and height=$height',
15+
({ point, height, expectedPoint }) => {
16+
expect(invertYCoordinate(point, height)).toEqual(expectedPoint);
17+
},
18+
);
19+
});
20+
21+
describe('rotatePoint()', () => {
22+
test.each`
23+
point | angle | expectedPoint
24+
${{ x: 1, y: 9 }} | ${0} | ${{ x: 1, y: 9 }}
25+
${{ x: 1, y: 9 }} | ${90} | ${{ x: -9, y: 1 }}
26+
${{ x: 1, y: 9 }} | ${180} | ${{ x: -1, y: -9 }}
27+
${{ x: 1, y: 9 }} | ${270} | ${{ x: 9, y: -1 }}
28+
${{ x: 1, y: 9 }} | ${-90} | ${{ x: 9, y: -1 }}
29+
`('should return rotated point given point=$point and angle=$angle', ({ point, angle, expectedPoint }) => {
30+
const { x, y } = rotatePoint(point, angle);
31+
const { x: expX, y: expY } = expectedPoint;
32+
33+
expect(parseValue(x)).toEqual(parseValue(expX));
34+
expect(parseValue(y)).toEqual(parseValue(expY));
35+
});
36+
});
37+
38+
describe('translatePoint()', () => {
39+
test.each`
40+
point | translation | expectedPoint
41+
${{ x: 1, y: 1 }} | ${{ dx: 3 }} | ${{ x: 4, y: 1 }}
42+
${{ x: 1, y: 1 }} | ${{ dy: 3 }} | ${{ x: 1, y: 4 }}
43+
${{ x: 1, y: 1 }} | ${{ dx: 3, dy: 5 }} | ${{ x: 4, y: 6 }}
44+
${{ x: 1, y: 1 }} | ${{ dx: -3, dy: 5 }} | ${{ x: -2, y: 6 }}
45+
`(
46+
'should return translated point given point=$point and translation=$translation',
47+
({ point, translation, expectedPoint }) => {
48+
expect(translatePoint(point, translation)).toEqual(expectedPoint);
49+
},
50+
);
51+
});
52+
});

src/region/regionUtil.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import * as React from 'react';
22
import { Annotation, AnnotationRegion, Position } from '../@types';
3+
import { invertYCoordinate, Point, rotatePoint, translatePoint } from './transformUtil';
4+
5+
// Possible rotation values as supplied by box-content-preview
6+
const ROTATION_ONCE_DEG = -90;
7+
const ROTATION_TWICE_DEG = -180;
8+
const ROTATION_THRICE_DEG = -270;
9+
10+
// Region annotation shapes are, by default, percentages, so a 100x100 space
11+
const DEFAULT_DIMENSIONS = { height: 100, width: 100 };
12+
13+
export type Dimensions = {
14+
height: number;
15+
width: number;
16+
};
317

418
export type Shape = {
519
height: number;
@@ -8,6 +22,11 @@ export type Shape = {
822
y: number;
923
};
1024

25+
export type Translation = {
26+
dx?: number;
27+
dy?: number;
28+
};
29+
1130
export const EMPTY_STYLE = { display: 'none' };
1231

1332
export const centerShape = (shape: Shape): Position => {
@@ -29,6 +48,94 @@ export const centerRegion = (shape: Shape): Position => {
2948
};
3049
};
3150

51+
export const getPoints = (shape: Shape): [Point, Point, Point, Point] => {
52+
const { height, width, x, y } = shape;
53+
54+
// Define the points from x,y and then in a clockwise fashion
55+
// p1 p2
56+
// +-------+
57+
// | |
58+
// +-------+
59+
// p4 p3
60+
const p1 = { x, y };
61+
const p2 = { x: x + width, y };
62+
const p3 = { x: x + width, y: y + height };
63+
const p4 = { x, y: y + height };
64+
65+
return [p1, p2, p3, p4];
66+
};
67+
68+
export function selectTransformationPoint(shape: Shape, rotation: number): Point {
69+
const [p1, p2, p3, p4] = getPoints(shape);
70+
71+
// Determine which point will be the new x,y (as defined as the top left point) after rotation
72+
// If -90deg: use p2
73+
// If -180deg: use p3
74+
// If -270deg: use p4
75+
// Otherwise: use p1
76+
switch (rotation) {
77+
case ROTATION_ONCE_DEG:
78+
return p2;
79+
case ROTATION_TWICE_DEG:
80+
return p3;
81+
case ROTATION_THRICE_DEG:
82+
return p4;
83+
default:
84+
return p1;
85+
}
86+
}
87+
88+
// Determines the translation needed to anchor the bottom left corner of the
89+
// coordinate space at the (0, 0) origin after rotation.
90+
export function selectTranslation(dimensions: Dimensions, rotation: number): Translation {
91+
const { height, width } = dimensions;
92+
93+
switch (rotation) {
94+
case ROTATION_ONCE_DEG:
95+
return { dx: height };
96+
case ROTATION_TWICE_DEG:
97+
return { dx: width, dy: height };
98+
case ROTATION_THRICE_DEG:
99+
return { dy: width };
100+
default:
101+
}
102+
103+
return { dx: 0, dy: 0 };
104+
}
105+
106+
export function getTransformedShape(shape: Shape, rotation: number): Shape {
107+
const { height: shapeHeight, width: shapeWidth } = shape;
108+
const { height: spaceHeight, width: spaceWidth } = DEFAULT_DIMENSIONS;
109+
const isInverted = rotation % 180 === 0;
110+
const isNoRotation = rotation % 360 === 0;
111+
const translation = selectTranslation(DEFAULT_DIMENSIONS, rotation);
112+
const point = selectTransformationPoint(shape, rotation);
113+
114+
if (isNoRotation) {
115+
return shape;
116+
}
117+
118+
// To transform from shape with 0 rotation to provided rotation:
119+
// 1. Invert y-axis to convert from web to mathematical coordinate system
120+
// 2. Apply rotation transformation (with inverted rotation -- again mathematical coordinate system)
121+
// 3. Translate to align coordinate space with mathematical origin
122+
// 4. Invert y-axis to convert back to web coordinate system
123+
const invertedPoint = invertYCoordinate(point, spaceHeight);
124+
const rotatedPoint = rotatePoint(invertedPoint, -rotation);
125+
const translatedPoint = translatePoint(rotatedPoint, translation);
126+
const { x: transformedX, y: transformedY } = invertYCoordinate(
127+
translatedPoint,
128+
isInverted ? spaceHeight : spaceWidth,
129+
);
130+
131+
return {
132+
height: isInverted ? shapeHeight : shapeWidth,
133+
width: isInverted ? shapeWidth : shapeHeight,
134+
x: transformedX,
135+
y: transformedY,
136+
};
137+
}
138+
32139
export function isRegion(annotation: Annotation): annotation is AnnotationRegion {
33140
return annotation?.target?.type === 'region';
34141
}

src/region/transformUtil.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type Point = {
2+
x: number;
3+
y: number;
4+
};
5+
6+
export const invertYCoordinate = ({ x, y }: Point, height: number): Point => ({
7+
x,
8+
y: height > 0 ? height - y : y,
9+
});
10+
11+
export const rotatePoint = ({ x, y }: Point, rotationInDegrees: number): Point => {
12+
const radians = (rotationInDegrees * Math.PI) / 180;
13+
const cosine = Math.cos(radians);
14+
const sine = Math.sin(radians);
15+
16+
// Formula to apply a rotation to a point is:
17+
// x' = x * cos(θ) - y * sin(θ)
18+
// y' = x * sin(θ) + y * cos(θ)
19+
return {
20+
x: x * cosine - y * sine,
21+
y: x * sine + y * cosine,
22+
};
23+
};
24+
25+
export const translatePoint = ({ x, y }: Point, { dx = 0, dy = 0 }: { dx?: number; dy?: number }): Point => ({
26+
x: x + dx,
27+
y: y + dy,
28+
});

0 commit comments

Comments
 (0)