@deepnoid/canvas 0.1.78 → 0.1.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/engine/annotation/annotationTypes.d.ts +12 -2
- package/dist/engine/annotation/annotationTypes.js +1 -2
- package/dist/engine/annotation/hitTest.d.ts +5 -0
- package/dist/engine/annotation/hitTest.js +63 -0
- package/dist/engine/annotation/polygon/polygonController.d.ts +34 -0
- package/dist/engine/annotation/polygon/polygonController.js +252 -0
- package/dist/engine/annotation/polygon/polygonHitTest.d.ts +6 -0
- package/dist/engine/annotation/polygon/polygonHitTest.js +84 -0
- package/dist/engine/annotation/polygon/polygonInteraction.d.ts +19 -0
- package/dist/engine/annotation/polygon/polygonInteraction.js +36 -0
- package/dist/engine/annotation/polygon/polygonMath.d.ts +7 -0
- package/dist/engine/annotation/polygon/polygonMath.js +47 -0
- package/dist/engine/annotation/polygon/polygonRenderer.d.ts +2 -0
- package/dist/engine/annotation/polygon/polygonRenderer.js +79 -0
- package/dist/engine/annotation/polygon/polygonTransform.d.ts +14 -0
- package/dist/engine/annotation/polygon/polygonTransform.js +68 -0
- package/dist/engine/annotation/polygon/polygonTypes.d.ts +9 -0
- package/dist/engine/annotation/polygon/polygonTypes.js +1 -0
- package/dist/engine/annotation/rectangle/rectangleController.js +8 -15
- package/dist/engine/annotation/rectangle/rectangleHitTest.js +5 -4
- package/dist/engine/annotation/rectangle/rectangleRenderer.js +8 -0
- package/dist/engine/annotation/rectangle/rectangleTransform.js +31 -28
- package/dist/engine/interaction/drawModeRouter.js +47 -27
- package/dist/engine/interaction/interactionController.d.ts +1 -0
- package/dist/engine/interaction/interactionController.js +17 -0
- package/dist/engine/interaction/interface.d.ts +1 -0
- package/dist/engine/interaction/pointerInteraction.js +7 -2
- package/dist/engine/public/annotationEngine.d.ts +3 -1
- package/dist/engine/public/annotationEngine.js +23 -2
- package/dist/react/AnnotationCanvas.js +6 -14
- package/dist/react/hooks/useHotkeys.d.ts +2 -1
- package/dist/react/hooks/useHotkeys.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,20 @@ Canvas 기반 이미지 annotation 라이브러리로, **TypeScript Engine + Rea
|
|
|
11
11
|
|
|
12
12
|
## 주요 기능
|
|
13
13
|
|
|
14
|
-
- ✏️
|
|
14
|
+
- ✏️ **Annotation 지원 (그리기 및 편집)**
|
|
15
|
+
- **Rectangle (BBox)**:
|
|
16
|
+
- 모서리/상하좌우 테두리를 통한 직관적 크기 조절
|
|
17
|
+
- 영역 내부 드래그(`move` 커서)를 통한 도형 전체 이동
|
|
18
|
+
- **Polygon**:
|
|
19
|
+
- 다각형 점찍기 및 시작점(또는 더블클릭) 클릭으로 도형 완성
|
|
20
|
+
- **선 분할(Edge Point Insertion)**: 기존 그려진 폴리곤 선(Edge) 위에 마우스를 1초간 올려둘 시, 새로운 꼭짓점(Vertex)을 간편하게 추가 가능 (클릭/이동 오작동 방지)
|
|
21
|
+
- 내부 클릭 후 드래그하여 전체 다각형 이동 지원
|
|
22
|
+
- **스마트 UI 동기화**: 캔버스 내의 도형 클릭 시 선택된 객체 타입(RECTANGLE/POLYGON)에 맞춰 외부 도구(React 상태)가 자동으로 전환되도록 이벤트(`annotationSelected`) 연동
|
|
15
23
|
- 👁️ Viewer 모드 지원 (읽기 전용)
|
|
16
|
-
- 🔍 Pan & Zoom 지원
|
|
17
|
-
- ↩️ Undo/Redo 기능
|
|
18
|
-
- ⌨️ 단축키 지원
|
|
19
|
-
- 🎨 커스터마이징 가능한 스타일링
|
|
24
|
+
- 🔍 Pan & Zoom 지원 (Image Canvas / Annotation Canvas 레이어 완전 분리)
|
|
25
|
+
- ↩️ Undo/Redo 기능 (History 상태망 자체 관리)
|
|
26
|
+
- ⌨️ 단축키 지원 (선택 객체 삭제, 되돌리기, 한 객체만 보기 등)
|
|
27
|
+
- 🎨 커스터마이징 가능한 스타일링 (커스텀 렌더링 함수(`applyStyle`) 주입 가능)
|
|
20
28
|
|
|
21
29
|
## 빠른 시작
|
|
22
30
|
|
|
@@ -200,6 +208,9 @@ src/
|
|
|
200
208
|
|
|
201
209
|
## 설계 원칙
|
|
202
210
|
|
|
203
|
-
1. **관심사 분리**: Engine(
|
|
204
|
-
2. **확장성**: 새로운
|
|
205
|
-
3.
|
|
211
|
+
1. **관심사 분리**: Engine(로직, Canvas 렌더링) ↔ React(UI DOM, 상태 연동) 완전 분리
|
|
212
|
+
2. **확장성**: `Renderer`, `HitTest`, `Controller` 구조로 분리되어 있어, Polygon과 같은 새로운 Annotation 타입을 손쉽게 플러그인처럼 추가 가능합니다.
|
|
213
|
+
3. **사용자 경험(UX) 중심**:
|
|
214
|
+
- 툴 오버 액션과 클릭/드래그 충돌을 방지하는 스마트 HitTest 우선순위
|
|
215
|
+
- 1초 Hover Timer 등 인간 중심적인 편집 인터랙션 제공
|
|
216
|
+
4. **성능 최적화**: 레이어별(base image / annotation) 독립된 Canvas를 사용하여 불필요한 이미지 렌더링(Redraw) 비용 원천 차단
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Label } from '../types';
|
|
2
2
|
export declare enum DrawMode {
|
|
3
|
-
RECTANGLE = "RECTANGLE"
|
|
3
|
+
RECTANGLE = "RECTANGLE",
|
|
4
|
+
POLYGON = "POLYGON"
|
|
4
5
|
}
|
|
5
6
|
export type AnnotationBase = {
|
|
6
7
|
label?: Label;
|
|
@@ -18,4 +19,13 @@ export type Rectangle = {
|
|
|
18
19
|
export type RectangleAnnotation = AnnotationBase & {
|
|
19
20
|
type: DrawMode.RECTANGLE;
|
|
20
21
|
} & Rectangle;
|
|
21
|
-
export type
|
|
22
|
+
export type Polygon = {
|
|
23
|
+
points: {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}[];
|
|
27
|
+
};
|
|
28
|
+
export type PolygonAnnotation = AnnotationBase & {
|
|
29
|
+
type: DrawMode.POLYGON;
|
|
30
|
+
} & Polygon;
|
|
31
|
+
export type Annotation = RectangleAnnotation | PolygonAnnotation;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Point } from '../types';
|
|
2
|
+
import { Annotation, PolygonAnnotation, RectangleAnnotation } from './annotationTypes';
|
|
3
|
+
export declare function isPointInRectangle(zoom: number, mousePoint: Point, a: RectangleAnnotation): boolean;
|
|
4
|
+
export declare function isPointInOrOnPolygon(zoom: number, mousePoint: Point, a: PolygonAnnotation): boolean;
|
|
5
|
+
export declare function selectAnnotationAtPoint(zoom: number, mousePoint: Point, annotations: Annotation[]): Annotation | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DrawMode } from './annotationTypes';
|
|
2
|
+
import { ACTIVE_POINT_SIZE, isInsidePolygon, distToSegment } from './polygon/polygonMath';
|
|
3
|
+
export function isPointInRectangle(zoom, mousePoint, a) {
|
|
4
|
+
const padding = ACTIVE_POINT_SIZE / zoom;
|
|
5
|
+
return (mousePoint.x >= a.x - padding &&
|
|
6
|
+
mousePoint.x <= a.x + a.width + padding &&
|
|
7
|
+
mousePoint.y >= a.y - padding &&
|
|
8
|
+
mousePoint.y <= a.y + a.height + padding);
|
|
9
|
+
}
|
|
10
|
+
export function isPointInOrOnPolygon(zoom, mousePoint, a) {
|
|
11
|
+
if (a.points.length < 3)
|
|
12
|
+
return false;
|
|
13
|
+
if (isInsidePolygon(mousePoint, a.points))
|
|
14
|
+
return true;
|
|
15
|
+
const padding = ACTIVE_POINT_SIZE / zoom;
|
|
16
|
+
for (let i = 0; i < a.points.length; i++) {
|
|
17
|
+
const p1 = a.points[i];
|
|
18
|
+
const p2 = a.points[(i + 1) % a.points.length];
|
|
19
|
+
if (distToSegment(mousePoint, p1, p2) <= padding) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
export function selectAnnotationAtPoint(zoom, mousePoint, annotations) {
|
|
26
|
+
let selectedIndex = -1;
|
|
27
|
+
let minArea = Number.MAX_SAFE_INTEGER;
|
|
28
|
+
const currentIndex = annotations.findIndex((a) => a.selected);
|
|
29
|
+
if (currentIndex >= 0) {
|
|
30
|
+
const c = annotations[currentIndex];
|
|
31
|
+
if (c.type === DrawMode.RECTANGLE && isPointInRectangle(zoom, mousePoint, c)) {
|
|
32
|
+
selectedIndex = currentIndex;
|
|
33
|
+
minArea = c.width * c.height;
|
|
34
|
+
}
|
|
35
|
+
else if (c.type === DrawMode.POLYGON && isPointInOrOnPolygon(zoom, mousePoint, c)) {
|
|
36
|
+
selectedIndex = currentIndex;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (selectedIndex === -1) {
|
|
40
|
+
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
41
|
+
const a = annotations[i];
|
|
42
|
+
if (a.type === DrawMode.RECTANGLE && isPointInRectangle(zoom, mousePoint, a)) {
|
|
43
|
+
const area = a.width * a.height;
|
|
44
|
+
if (area < minArea) {
|
|
45
|
+
minArea = area;
|
|
46
|
+
selectedIndex = i;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (a.type === DrawMode.POLYGON && isPointInOrOnPolygon(zoom, mousePoint, a)) {
|
|
50
|
+
selectedIndex = i;
|
|
51
|
+
break; // polygon has precedence
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
annotations.forEach((a) => (a.selected = false));
|
|
56
|
+
if (selectedIndex >= 0) {
|
|
57
|
+
annotations[selectedIndex].selected = true;
|
|
58
|
+
return annotations[selectedIndex];
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AnnotationEngine } from '../../public/annotationEngine';
|
|
2
|
+
import { MouseAction } from '../../utils/mouseActions';
|
|
3
|
+
import { PointState, Point } from '../../types';
|
|
4
|
+
import { PolygonAnchor } from './polygonTypes';
|
|
5
|
+
export declare class PolygonController {
|
|
6
|
+
private readonly engine;
|
|
7
|
+
private mouseAction;
|
|
8
|
+
private mousePoint;
|
|
9
|
+
private prevMousePoint;
|
|
10
|
+
private drawingPoints;
|
|
11
|
+
private polygonAnchor;
|
|
12
|
+
private needsRedraw;
|
|
13
|
+
private isDrawingNewPolygon;
|
|
14
|
+
private isCompletingPolygon;
|
|
15
|
+
private hasMoved;
|
|
16
|
+
private hoverEdgeTimer;
|
|
17
|
+
private isEdgeInsertReady;
|
|
18
|
+
constructor(engine: AnnotationEngine);
|
|
19
|
+
getRenderState(): {
|
|
20
|
+
mousePoint: PointState;
|
|
21
|
+
drawingPoints: Point[];
|
|
22
|
+
mouseAction: MouseAction | null;
|
|
23
|
+
anchor: PolygonAnchor | null;
|
|
24
|
+
isEdgeInsertReady: boolean;
|
|
25
|
+
};
|
|
26
|
+
shouldRedraw(): boolean;
|
|
27
|
+
begin(event: MouseEvent): true | undefined;
|
|
28
|
+
move(event: MouseEvent): boolean;
|
|
29
|
+
end(event: MouseEvent): true | undefined;
|
|
30
|
+
leave(event: MouseEvent): boolean;
|
|
31
|
+
cancel(): boolean;
|
|
32
|
+
private commitPolygon;
|
|
33
|
+
private reset;
|
|
34
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { cloneDeep } from '../../utils/cloneDeep';
|
|
2
|
+
import { isMouseClickAction, isMouseDragAction, MouseAction } from '../../utils/mouseActions';
|
|
3
|
+
import { getCanvasMousePoint } from '../../utils/mousePoint';
|
|
4
|
+
import { updateActivePolygonAnchor } from './polygonHitTest';
|
|
5
|
+
import { selectAnnotationAtPoint } from '../hitTest';
|
|
6
|
+
import { applyPolygonMove, applyPolygonResize } from './polygonTransform';
|
|
7
|
+
import { DrawMode } from '../annotationTypes';
|
|
8
|
+
import { clampPointToImage, clipLineToImage, ACTIVE_POINT_SIZE } from './polygonMath';
|
|
9
|
+
export class PolygonController {
|
|
10
|
+
constructor(engine) {
|
|
11
|
+
this.engine = engine;
|
|
12
|
+
this.mouseAction = null;
|
|
13
|
+
this.mousePoint = { x: 0, y: 0, selected: false };
|
|
14
|
+
this.prevMousePoint = null;
|
|
15
|
+
this.drawingPoints = [];
|
|
16
|
+
this.polygonAnchor = null;
|
|
17
|
+
this.needsRedraw = false;
|
|
18
|
+
this.isDrawingNewPolygon = false;
|
|
19
|
+
this.isCompletingPolygon = false;
|
|
20
|
+
this.hasMoved = false;
|
|
21
|
+
this.hoverEdgeTimer = null;
|
|
22
|
+
this.isEdgeInsertReady = false;
|
|
23
|
+
}
|
|
24
|
+
getRenderState() {
|
|
25
|
+
return {
|
|
26
|
+
mousePoint: this.mousePoint,
|
|
27
|
+
drawingPoints: this.drawingPoints,
|
|
28
|
+
mouseAction: this.mouseAction,
|
|
29
|
+
anchor: this.isEdgeInsertReady
|
|
30
|
+
? this.polygonAnchor
|
|
31
|
+
: this.polygonAnchor?.type === 'edge'
|
|
32
|
+
? null
|
|
33
|
+
: this.polygonAnchor,
|
|
34
|
+
isEdgeInsertReady: this.isEdgeInsertReady,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
shouldRedraw() {
|
|
38
|
+
return this.needsRedraw;
|
|
39
|
+
}
|
|
40
|
+
begin(event) {
|
|
41
|
+
if (!isMouseClickAction(event.button, MouseAction.LEFT))
|
|
42
|
+
return;
|
|
43
|
+
const imageCanvasState = this.engine.getImageCanvasState();
|
|
44
|
+
const { zoom, dw, dh } = imageCanvasState;
|
|
45
|
+
const mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), imageCanvasState);
|
|
46
|
+
this.mouseAction = MouseAction.LEFT;
|
|
47
|
+
this.mousePoint = mousePoint;
|
|
48
|
+
this.hasMoved = false;
|
|
49
|
+
// Drawing new polygon logic
|
|
50
|
+
if (this.isDrawingNewPolygon) {
|
|
51
|
+
let nextPoint = mousePoint;
|
|
52
|
+
if (this.drawingPoints.length > 0) {
|
|
53
|
+
nextPoint = clipLineToImage(this.drawingPoints[this.drawingPoints.length - 1], mousePoint, dw, dh);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
nextPoint = clampPointToImage(mousePoint.x, mousePoint.y, dw, dh);
|
|
57
|
+
}
|
|
58
|
+
if (this.drawingPoints.length >= 3) {
|
|
59
|
+
// Double click or click on start point
|
|
60
|
+
const distToStart = Math.sqrt((nextPoint.x - this.drawingPoints[0].x) ** 2 + (nextPoint.y - this.drawingPoints[0].y) ** 2);
|
|
61
|
+
if (event.detail >= 2 || distToStart <= (ACTIVE_POINT_SIZE * 2) / zoom) {
|
|
62
|
+
this.commitPolygon();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.drawingPoints.push(nextPoint);
|
|
67
|
+
this.needsRedraw = true;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
// Hit test existing polygon
|
|
71
|
+
const annotations = this.engine.getAnnotations();
|
|
72
|
+
const currentSelected = this.engine.getSelectedAnnotation();
|
|
73
|
+
const targetAnnotation = selectAnnotationAtPoint(zoom, mousePoint, annotations);
|
|
74
|
+
if (targetAnnotation !== currentSelected) {
|
|
75
|
+
this.engine.setSelectedAnnotation(targetAnnotation);
|
|
76
|
+
this.engine.setAnnotations(annotations);
|
|
77
|
+
}
|
|
78
|
+
this.polygonAnchor = updateActivePolygonAnchor(zoom, mousePoint, targetAnnotation);
|
|
79
|
+
if (this.polygonAnchor && this.polygonAnchor.type === 'edge') {
|
|
80
|
+
if (this.hoverEdgeTimer) {
|
|
81
|
+
window.clearTimeout(this.hoverEdgeTimer);
|
|
82
|
+
this.hoverEdgeTimer = null;
|
|
83
|
+
}
|
|
84
|
+
if (this.isEdgeInsertReady) {
|
|
85
|
+
const selected = this.engine.getSelectedAnnotation();
|
|
86
|
+
if (selected && selected.type === DrawMode.POLYGON) {
|
|
87
|
+
const poly = selected;
|
|
88
|
+
const insertIdx = this.polygonAnchor.edgeIndex + 1;
|
|
89
|
+
poly.points.splice(insertIdx, 0, this.polygonAnchor.insertPoint);
|
|
90
|
+
this.polygonAnchor = { type: 'point', pointIndex: insertIdx };
|
|
91
|
+
this.engine.commitHistory(true);
|
|
92
|
+
this.isEdgeInsertReady = false;
|
|
93
|
+
this.needsRedraw = true;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!this.polygonAnchor && !targetAnnotation) {
|
|
99
|
+
if (mousePoint.x < 0 || mousePoint.y < 0 || mousePoint.x > dw || mousePoint.y > dh) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
// Begin new polygon
|
|
103
|
+
this.isDrawingNewPolygon = true;
|
|
104
|
+
this.drawingPoints = [clampPointToImage(mousePoint.x, mousePoint.y, dw, dh)];
|
|
105
|
+
this.needsRedraw = true;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
move(event) {
|
|
110
|
+
this.needsRedraw = false;
|
|
111
|
+
const imageCanvasState = this.engine.getImageCanvasState();
|
|
112
|
+
const { dw, dh, zoom } = imageCanvasState;
|
|
113
|
+
this.prevMousePoint = cloneDeep(this.mousePoint);
|
|
114
|
+
this.mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), imageCanvasState);
|
|
115
|
+
if (this.isDrawingNewPolygon) {
|
|
116
|
+
this.needsRedraw = true;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (!isMouseDragAction(event.buttons, MouseAction.LEFT)) {
|
|
120
|
+
const currentSelected = this.engine.getSelectedAnnotation();
|
|
121
|
+
if (currentSelected && currentSelected.type === DrawMode.POLYGON) {
|
|
122
|
+
const newAnchor = updateActivePolygonAnchor(zoom, this.mousePoint, currentSelected);
|
|
123
|
+
const isSameAnchorType = this.polygonAnchor?.type === newAnchor?.type;
|
|
124
|
+
const isSameEdge = isSameAnchorType &&
|
|
125
|
+
newAnchor?.type === 'edge' &&
|
|
126
|
+
this.polygonAnchor?.edgeIndex === newAnchor.edgeIndex;
|
|
127
|
+
if (isSameEdge) {
|
|
128
|
+
this.polygonAnchor = newAnchor;
|
|
129
|
+
this.needsRedraw = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.polygonAnchor = newAnchor;
|
|
133
|
+
this.isEdgeInsertReady = false;
|
|
134
|
+
if (this.hoverEdgeTimer) {
|
|
135
|
+
window.clearTimeout(this.hoverEdgeTimer);
|
|
136
|
+
this.hoverEdgeTimer = null;
|
|
137
|
+
}
|
|
138
|
+
if (newAnchor && newAnchor.type === 'edge') {
|
|
139
|
+
this.hoverEdgeTimer = window.setTimeout(() => {
|
|
140
|
+
this.isEdgeInsertReady = true;
|
|
141
|
+
this.needsRedraw = true;
|
|
142
|
+
this.engine.redrawAnnotations?.();
|
|
143
|
+
this.engine.getAnnotationsCanvas().style.cursor = 'pointer';
|
|
144
|
+
}, 1000);
|
|
145
|
+
}
|
|
146
|
+
this.needsRedraw = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
this.hasMoved = true;
|
|
152
|
+
const selectedAnnotation = this.engine.getSelectedAnnotation();
|
|
153
|
+
if (selectedAnnotation && this.prevMousePoint) {
|
|
154
|
+
const params = {
|
|
155
|
+
annotations: this.engine.getAnnotations(),
|
|
156
|
+
dw,
|
|
157
|
+
dh,
|
|
158
|
+
mousePoint: this.mousePoint,
|
|
159
|
+
prevMousePoint: this.prevMousePoint,
|
|
160
|
+
polygonAnchor: this.polygonAnchor,
|
|
161
|
+
};
|
|
162
|
+
if (this.polygonAnchor) {
|
|
163
|
+
applyPolygonResize(params);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
applyPolygonMove(params);
|
|
167
|
+
}
|
|
168
|
+
this.needsRedraw = true;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
end(event) {
|
|
173
|
+
if (!isMouseClickAction(event.button, MouseAction.LEFT))
|
|
174
|
+
return;
|
|
175
|
+
if (this.isDrawingNewPolygon) {
|
|
176
|
+
// Do nothing, wait for double click or close to finish
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (this.isCompletingPolygon) {
|
|
180
|
+
this.isCompletingPolygon = false;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (this.hasMoved) {
|
|
184
|
+
if (this.engine.getSelectedAnnotation()) {
|
|
185
|
+
this.engine.commitHistory(true);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const imageCanvasState = this.engine.getImageCanvasState();
|
|
190
|
+
const mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), imageCanvasState);
|
|
191
|
+
const annotations = this.engine.getAnnotations();
|
|
192
|
+
const selected = selectAnnotationAtPoint(imageCanvasState.zoom, mousePoint, annotations);
|
|
193
|
+
if (selected !== this.engine.getSelectedAnnotation()) {
|
|
194
|
+
this.engine.setSelectedAnnotation(selected);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.mouseAction = null;
|
|
198
|
+
this.hasMoved = false;
|
|
199
|
+
if (this.hoverEdgeTimer) {
|
|
200
|
+
window.clearTimeout(this.hoverEdgeTimer);
|
|
201
|
+
this.hoverEdgeTimer = null;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
leave(event) {
|
|
206
|
+
this.mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), this.engine.getImageCanvasState());
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
cancel() {
|
|
210
|
+
if (this.hoverEdgeTimer) {
|
|
211
|
+
window.clearTimeout(this.hoverEdgeTimer);
|
|
212
|
+
this.hoverEdgeTimer = null;
|
|
213
|
+
}
|
|
214
|
+
if (this.isDrawingNewPolygon) {
|
|
215
|
+
this.reset();
|
|
216
|
+
this.needsRedraw = true;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
commitPolygon() {
|
|
222
|
+
if (this.drawingPoints.length < 3) {
|
|
223
|
+
this.reset();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const label = this.engine.getDrawing().label;
|
|
227
|
+
const points = Array.from(this.drawingPoints);
|
|
228
|
+
this.engine.appendAnnotation({
|
|
229
|
+
label,
|
|
230
|
+
type: DrawMode.POLYGON,
|
|
231
|
+
points,
|
|
232
|
+
selected: false,
|
|
233
|
+
});
|
|
234
|
+
this.engine.commitHistory(true);
|
|
235
|
+
this.reset();
|
|
236
|
+
this.isCompletingPolygon = true;
|
|
237
|
+
}
|
|
238
|
+
reset() {
|
|
239
|
+
if (this.hoverEdgeTimer) {
|
|
240
|
+
window.clearTimeout(this.hoverEdgeTimer);
|
|
241
|
+
this.hoverEdgeTimer = null;
|
|
242
|
+
}
|
|
243
|
+
this.mouseAction = null;
|
|
244
|
+
this.polygonAnchor = null;
|
|
245
|
+
this.isDrawingNewPolygon = false;
|
|
246
|
+
this.drawingPoints = [];
|
|
247
|
+
this.hasMoved = false;
|
|
248
|
+
this.needsRedraw = true;
|
|
249
|
+
this.isCompletingPolygon = false;
|
|
250
|
+
this.isEdgeInsertReady = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Point } from '../../types';
|
|
2
|
+
import { Annotation, PolygonAnnotation } from '../annotationTypes';
|
|
3
|
+
import { PolygonAnchor } from './polygonTypes';
|
|
4
|
+
export declare function selectPolygonAtPoint(zoom: number, mousePoint: Point, annotations: Annotation[]): Annotation | null;
|
|
5
|
+
export declare function updateActivePolygonAnchor(zoom: number, mousePoint: Point, selectedAnnotation: Annotation | null): PolygonAnchor | null;
|
|
6
|
+
export declare function getPolygonCursor(mousePoint: Point, annotation: PolygonAnnotation, zoom: number, isEdgeInsertReady?: boolean): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DrawMode } from '../annotationTypes';
|
|
2
|
+
import { ACTIVE_POINT_SIZE, distToSegment, isInsidePolygon, projectOnSegment } from './polygonMath';
|
|
3
|
+
export function selectPolygonAtPoint(zoom, mousePoint, annotations) {
|
|
4
|
+
const padding = ACTIVE_POINT_SIZE / zoom;
|
|
5
|
+
const isPointInOrOnPolygon = (a) => {
|
|
6
|
+
if (a.points.length < 3)
|
|
7
|
+
return false;
|
|
8
|
+
if (isInsidePolygon(mousePoint, a.points))
|
|
9
|
+
return true;
|
|
10
|
+
// Check edges
|
|
11
|
+
for (let i = 0; i < a.points.length; i++) {
|
|
12
|
+
const p1 = a.points[i];
|
|
13
|
+
const p2 = a.points[(i + 1) % a.points.length];
|
|
14
|
+
if (distToSegment(mousePoint, p1, p2) <= padding) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
};
|
|
20
|
+
let selectedIndex = -1;
|
|
21
|
+
const currentIndex = annotations.findIndex((a) => a.selected);
|
|
22
|
+
if (currentIndex >= 0) {
|
|
23
|
+
const c = annotations[currentIndex];
|
|
24
|
+
if (c.type === DrawMode.POLYGON && isPointInOrOnPolygon(c)) {
|
|
25
|
+
selectedIndex = currentIndex;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (selectedIndex === -1) {
|
|
29
|
+
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
30
|
+
const a = annotations[i];
|
|
31
|
+
if (a.type === DrawMode.POLYGON && isPointInOrOnPolygon(a)) {
|
|
32
|
+
selectedIndex = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
annotations.forEach((a) => (a.selected = false));
|
|
38
|
+
if (selectedIndex >= 0) {
|
|
39
|
+
annotations[selectedIndex].selected = true;
|
|
40
|
+
return annotations[selectedIndex];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function updateActivePolygonAnchor(zoom, mousePoint, selectedAnnotation) {
|
|
47
|
+
if (!selectedAnnotation || selectedAnnotation.type !== DrawMode.POLYGON)
|
|
48
|
+
return null;
|
|
49
|
+
const pointSize = ACTIVE_POINT_SIZE / zoom;
|
|
50
|
+
const { points } = selectedAnnotation;
|
|
51
|
+
// Check points first
|
|
52
|
+
for (let i = 0; i < points.length; i++) {
|
|
53
|
+
const p = points[i];
|
|
54
|
+
if (mousePoint.x >= p.x - pointSize / 2 &&
|
|
55
|
+
mousePoint.x <= p.x + pointSize / 2 &&
|
|
56
|
+
mousePoint.y >= p.y - pointSize / 2 &&
|
|
57
|
+
mousePoint.y <= p.y + pointSize / 2) {
|
|
58
|
+
return { type: 'point', pointIndex: i };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Check edges
|
|
62
|
+
for (let i = 0; i < points.length; i++) {
|
|
63
|
+
const p1 = points[i];
|
|
64
|
+
const p2 = points[(i + 1) % points.length];
|
|
65
|
+
if (distToSegment(mousePoint, p1, p2) <= pointSize / 2) {
|
|
66
|
+
const projection = projectOnSegment(mousePoint, p1, p2);
|
|
67
|
+
return { type: 'edge', edgeIndex: i, insertPoint: { x: projection.x, y: projection.y, selected: false } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
export function getPolygonCursor(mousePoint, annotation, zoom, isEdgeInsertReady) {
|
|
73
|
+
const anchor = updateActivePolygonAnchor(zoom, mousePoint, annotation);
|
|
74
|
+
if (anchor) {
|
|
75
|
+
if (anchor.type === 'point')
|
|
76
|
+
return 'pointer';
|
|
77
|
+
if (anchor.type === 'edge')
|
|
78
|
+
return isEdgeInsertReady ? 'pointer' : 'move';
|
|
79
|
+
}
|
|
80
|
+
if (annotation.points.length >= 3 && isInsidePolygon(mousePoint, annotation.points)) {
|
|
81
|
+
return 'move';
|
|
82
|
+
}
|
|
83
|
+
return 'default';
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RedrawLayers } from '../../interaction/interface';
|
|
2
|
+
import { PolygonController } from './polygonController';
|
|
3
|
+
export declare class PolygonInteraction {
|
|
4
|
+
private readonly controller;
|
|
5
|
+
constructor(controller: PolygonController);
|
|
6
|
+
getRenderState(): {
|
|
7
|
+
mousePoint: import("../../types").PointState;
|
|
8
|
+
drawingPoints: import("../../types").Point[];
|
|
9
|
+
mouseAction: import("../../utils/mouseActions").MouseAction | null;
|
|
10
|
+
anchor: import("./polygonTypes").PolygonAnchor | null;
|
|
11
|
+
isEdgeInsertReady: boolean;
|
|
12
|
+
};
|
|
13
|
+
getRedrawLayers(): RedrawLayers;
|
|
14
|
+
handleMouseDown(e: MouseEvent): boolean;
|
|
15
|
+
handleMouseMove(e: MouseEvent): boolean;
|
|
16
|
+
handleMouseUp(e: MouseEvent): boolean;
|
|
17
|
+
handleMouseLeave(e: MouseEvent): boolean;
|
|
18
|
+
handleKeyDown(e: KeyboardEvent): boolean;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class PolygonInteraction {
|
|
2
|
+
constructor(controller) {
|
|
3
|
+
this.controller = controller;
|
|
4
|
+
}
|
|
5
|
+
getRenderState() {
|
|
6
|
+
return this.controller.getRenderState();
|
|
7
|
+
}
|
|
8
|
+
getRedrawLayers() {
|
|
9
|
+
return {
|
|
10
|
+
imageCanvas: false,
|
|
11
|
+
annotationsCanvas: this.controller.shouldRedraw(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
handleMouseDown(e) {
|
|
15
|
+
this.controller.begin(e);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
handleMouseMove(e) {
|
|
19
|
+
this.controller.move(e);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
handleMouseUp(e) {
|
|
23
|
+
this.controller.end(e);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
handleMouseLeave(e) {
|
|
27
|
+
this.controller.leave(e);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
handleKeyDown(e) {
|
|
31
|
+
if (e.key === 'Escape') {
|
|
32
|
+
return this.controller.cancel();
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Point } from '../../types';
|
|
2
|
+
export declare const ACTIVE_POINT_SIZE = 8;
|
|
3
|
+
export declare function clampPointToImage(x: number, y: number, dw: number, dh: number): Point;
|
|
4
|
+
export declare function clipLineToImage(start: Point, end: Point, dw: number, dh: number): Point;
|
|
5
|
+
export declare function isInsidePolygon(point: Point, vs: Point[]): boolean;
|
|
6
|
+
export declare function projectOnSegment(p: Point, v: Point, w: Point): Point;
|
|
7
|
+
export declare function distToSegment(p: Point, v: Point, w: Point): number;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const ACTIVE_POINT_SIZE = 8;
|
|
2
|
+
export function clampPointToImage(x, y, dw, dh) {
|
|
3
|
+
return {
|
|
4
|
+
x: Math.max(0, Math.min(x, dw)),
|
|
5
|
+
y: Math.max(0, Math.min(y, dh)),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function clipLineToImage(start, end, dw, dh) {
|
|
9
|
+
let t = 1.0;
|
|
10
|
+
const dx = end.x - start.x;
|
|
11
|
+
const dy = end.y - start.y;
|
|
12
|
+
if (end.x < 0)
|
|
13
|
+
t = Math.min(t, (0 - start.x) / dx);
|
|
14
|
+
if (end.x > dw)
|
|
15
|
+
t = Math.min(t, (dw - start.x) / dx);
|
|
16
|
+
if (end.y < 0)
|
|
17
|
+
t = Math.min(t, (0 - start.y) / dy);
|
|
18
|
+
if (end.y > dh)
|
|
19
|
+
t = Math.min(t, (dh - start.y) / dy);
|
|
20
|
+
return {
|
|
21
|
+
x: start.x + t * dx,
|
|
22
|
+
y: start.y + t * dy,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function isInsidePolygon(point, vs) {
|
|
26
|
+
let isInside = false;
|
|
27
|
+
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
|
|
28
|
+
const xi = vs[i].x, yi = vs[i].y;
|
|
29
|
+
const xj = vs[j].x, yj = vs[j].y;
|
|
30
|
+
const intersect = yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
|
|
31
|
+
if (intersect)
|
|
32
|
+
isInside = !isInside;
|
|
33
|
+
}
|
|
34
|
+
return isInside;
|
|
35
|
+
}
|
|
36
|
+
export function projectOnSegment(p, v, w) {
|
|
37
|
+
const l2 = (w.x - v.x) ** 2 + (w.y - v.y) ** 2;
|
|
38
|
+
if (l2 === 0)
|
|
39
|
+
return v;
|
|
40
|
+
let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2;
|
|
41
|
+
t = Math.max(0, Math.min(1, t));
|
|
42
|
+
return { x: v.x + t * (w.x - v.x), y: v.y + t * (w.y - v.y) };
|
|
43
|
+
}
|
|
44
|
+
export function distToSegment(p, v, w) {
|
|
45
|
+
const projection = projectOnSegment(p, v, w);
|
|
46
|
+
return Math.sqrt((p.x - projection.x) ** 2 + (p.y - projection.y) ** 2);
|
|
47
|
+
}
|