diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b40eabe9f2..fc9496007f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -33,6 +33,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.1",
"react-intl": "^6.6.2",
+ "react-konva": "^18.2.10",
"react-paginate": "^8.2.0",
"react-router-bootstrap": "^0.26.2",
"react-router-dom": "^6.22.0",
@@ -4921,6 +4922,14 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "peerDependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-transition-group": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz",
@@ -11369,6 +11378,17 @@
"set-function-name": "^2.0.1"
}
},
+ "node_modules/its-fine": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
+ "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": ">=18.0"
+ }
+ },
"node_modules/jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
@@ -13606,6 +13626,26 @@
"node": ">= 8"
}
},
+ "node_modules/konva": {
+ "version": "9.3.18",
+ "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.18.tgz",
+ "integrity": "sha512-ad5h0Y9phUrinBrKXyIISbURRHQO7Rx5cz7mAEEfdVCs45gDqRD8Y0I0nJRk8S6iqEbiRE87CEZu5GVSnU8oow==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ],
+ "peer": true
+ },
"node_modules/language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -16865,6 +16905,7 @@
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.6.2.tgz",
"integrity": "sha512-FjkoFjyvUQWcBo1F3RgSglky3ar0+qHLM41PlFVYB4Bj3RD8E/Mv7kqMouLFBU+3aFglMzzctAIWRwajEuueSw==",
+ "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
@@ -17401,6 +17442,37 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
+ "node_modules/react-konva": {
+ "version": "18.2.10",
+ "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
+ "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.2",
+ "its-fine": "^1.1.1",
+ "react-reconciler": "~0.29.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
@@ -17443,6 +17515,21 @@
}
}
},
+ "node_modules/react-reconciler": {
+ "version": "0.29.2",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+ "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -24722,6 +24809,12 @@
"@types/react": "*"
}
},
+ "@types/react-reconciler": {
+ "version": "0.28.9",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
+ "requires": {}
+ },
"@types/react-transition-group": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz",
@@ -29336,6 +29429,14 @@
"set-function-name": "^2.0.1"
}
},
+ "its-fine": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
+ "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
+ "requires": {
+ "@types/react-reconciler": "^0.28.0"
+ }
+ },
"jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
@@ -30973,6 +31074,12 @@
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="
},
+ "konva": {
+ "version": "9.3.18",
+ "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.18.tgz",
+ "integrity": "sha512-ad5h0Y9phUrinBrKXyIISbURRHQO7Rx5cz7mAEEfdVCs45gDqRD8Y0I0nJRk8S6iqEbiRE87CEZu5GVSnU8oow==",
+ "peer": true
+ },
"language-subtag-registry": {
"version": "0.3.23",
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
@@ -33437,6 +33544,17 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
+ "react-konva": {
+ "version": "18.2.10",
+ "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
+ "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
+ "requires": {
+ "@types/react-reconciler": "^0.28.2",
+ "its-fine": "^1.1.1",
+ "react-reconciler": "~0.29.0",
+ "scheduler": "^0.23.0"
+ }
+ },
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
@@ -33461,6 +33579,15 @@
"match-sorter": "^6.0.2"
}
},
+ "react-reconciler": {
+ "version": "0.29.2",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
+ "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ }
+ },
"react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 3befd3ea34..348432e882 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,6 +28,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.1",
"react-intl": "^6.6.2",
+ "react-konva": "^18.2.10",
"react-paginate": "^8.2.0",
"react-router-bootstrap": "^0.26.2",
"react-router-dom": "^6.22.0",
@@ -100,10 +101,13 @@
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
- },
- "jest":{
+ },
+ "jest": {
"collectCoverage": true,
- "coverageReporters": ["lcov", "text"],
+ "coverageReporters": [
+ "lcov",
+ "text"
+ ],
"coverageDirectory": "./coverage"
}
}
diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx
index bcdff0f5fc..d62107b5cb 100644
--- a/frontend/src/AuthenticatedSwitch.jsx
+++ b/frontend/src/AuthenticatedSwitch.jsx
@@ -14,6 +14,7 @@ import AdminLogs from "./pages/AdminLogs";
import ReportEncounter from "./pages/ReportsAndManagamentPages/ReportEncounter";
import ReportConfirm from "./pages/ReportsAndManagamentPages/ReportConfirm";
import ProjectList from "./pages/ProjectList";
+import ManualAnnotation from "./pages/ManualAnnotation";
export default function AuthenticatedSwitch({
showAlert,
@@ -67,6 +68,10 @@ export default function AuthenticatedSwitch({
} />
} />
} />
+ } />
+
} />
} />
} />
diff --git a/frontend/src/components/AddAnnotationModal.jsx b/frontend/src/components/AddAnnotationModal.jsx
new file mode 100644
index 0000000000..a349c8fca7
--- /dev/null
+++ b/frontend/src/components/AddAnnotationModal.jsx
@@ -0,0 +1,58 @@
+import React from "react";
+import { Button, Modal } from "react-bootstrap";
+import { FormattedMessage } from "react-intl";
+import { useIntl } from "react-intl";
+
+export default function AddAnnotationModal({
+ showModal,
+ setShowModal,
+ incomplete,
+ error,
+}) {
+ const intl = useIntl();
+
+ console.log("error",error)
+
+ return (
+ setShowModal(false)}>
+
+
+
+
+
+
+ {incomplete && intl.formatMessage({ id: "MISSING_REQUIRED_FIELDS" })}
+ {error &&
+ (Array.isArray(error) ? error : [error]).map((error, index) => {
+ return (
+
+ {error.code === "INVALID" && (
+
+
+ {error.fieldName}{" "}
+
+ )}
+ {error.code === "REQUIRED" && (
+
+
+ {error.fieldName}{" "}
+
+ )}
+ {!error.code && (
+
+
+ {error.fieldName}{" "}
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/AnnotationSuccessful.jsx b/frontend/src/components/AnnotationSuccessful.jsx
new file mode 100644
index 0000000000..b26aab1cef
--- /dev/null
+++ b/frontend/src/components/AnnotationSuccessful.jsx
@@ -0,0 +1,145 @@
+import React, { useEffect, useRef } from "react";
+import { FormattedMessage } from "react-intl";
+
+export default function AnnotationSuccessful({ encounterId, rect, imageData }) {
+ const canvasRef = useRef(null);
+ const imgRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const context = canvas.getContext("2d");
+ const handleImageLoad = () => {
+ if (imgRef.current) {
+ const naturalWidth = imageData.width;
+ const naturalHeight = imageData.height;
+
+ const displayWidth = imgRef.current.clientWidth;
+ const displayHeight = imgRef.current.clientHeight;
+
+ const scaleX = naturalWidth / displayWidth;
+ const scaleY = naturalHeight / displayHeight;
+
+ canvas.width = imgRef.current.clientWidth;
+ canvas.height = imgRef.current.clientHeight;
+
+ const imageContainer = imgElement?.parentElement;
+
+ if (imgRef && imageContainer) {
+ imageContainer.style.height = `${imgElement.clientHeight}px`;
+ }
+
+ const scaledRect = {
+ x: rect.x / scaleX,
+ y: rect.y / scaleY,
+ width: rect.width / scaleX,
+ height: rect.height / scaleY,
+ };
+
+ context.clearRect(0, 0, canvas.width, canvas.height);
+
+ context.strokeStyle = "red";
+ context.lineWidth = 2;
+
+ const rectCenterX = scaledRect.x + scaledRect.width / 2;
+ const rectCenterY = scaledRect.y + scaledRect.height / 2;
+
+ context.translate(rectCenterX, rectCenterY);
+ context.rotate(rect.rotation);
+ context.strokeRect(
+ -scaledRect.width / 2,
+ -scaledRect.height / 2,
+ scaledRect.width,
+ scaledRect.height,
+ );
+
+ // save for future use: draw bounding box line by line to indicate top border
+ // context.strokeStyle = "blue";
+ // context.lineWidth = 2;
+ // context.beginPath();
+ // context.moveTo(-scaledRect.width / 2, -scaledRect.height / 2); // Top-left corner
+ // context.lineTo(scaledRect.width / 2, -scaledRect.height / 2); // Top-right corner
+ // context.stroke();
+
+ // context.strokeStyle = "yellow";
+ // context.lineWidth = 1;
+ // context.beginPath();
+ // context.moveTo(-scaledRect.width / 2, -scaledRect.height / 2); // Top-left corner
+ // context.lineTo(-scaledRect.width / 2, scaledRect.height / 2); // Bottom-left corner
+ // context.lineTo(scaledRect.width / 2, scaledRect.height / 2); // Bottom-right corner
+ // context.lineTo(scaledRect.width / 2, -scaledRect.height / 2); // Top-right corner
+ // context.closePath();
+ // context.stroke();
+
+ context.restore();
+ }
+ };
+
+ const imgElement = imgRef.current;
+ if (imgElement && imgElement.complete) {
+ handleImageLoad();
+ } else if (imgElement) {
+ imgElement.addEventListener("load", handleImageLoad);
+ }
+
+ return () => {
+ if (imgElement) {
+ imgElement.removeEventListener("load", handleImageLoad);
+ }
+ };
+ }, [rect]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
![placeholder]({imageData.url)
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ResizableRotatableRect.jsx b/frontend/src/components/ResizableRotatableRect.jsx
new file mode 100644
index 0000000000..edea10e3a8
--- /dev/null
+++ b/frontend/src/components/ResizableRotatableRect.jsx
@@ -0,0 +1,108 @@
+import React, { useEffect, useRef, useState } from "react";
+import { Stage, Layer, Rect, Transformer } from "react-konva";
+
+const ResizableRotatableRect = ({
+ rect,
+ imgHeight,
+ imgWidth,
+ setRect,
+ setValue,
+ drawStatus,
+}) => {
+ const [rectProps, setRectProps] = useState({});
+
+ useEffect(() => {
+ if (drawStatus !== "DELETE") {
+ setRectProps({
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ fill: null,
+ stroke: "red",
+ strokeWidth: 2,
+ draggable: true,
+ });
+ }
+ }, [rect.x, rect.y, rect.width, rect.height, drawStatus]);
+
+ const rectRef = useRef(null);
+ const transformerRef = useRef(null);
+
+ const handleTransform = () => {
+ const node = rectRef.current;
+ const scaleX = node.scaleX();
+ const scaleY = node.scaleY();
+
+ const newWidth = Math.max(5, node.width() * scaleX);
+ const newHeight = Math.max(5, node.height() * scaleY);
+
+ const updatedRect = {
+ x: node.x(),
+ y: node.y(),
+ width: newWidth,
+ height: newHeight,
+ rotation: node.rotation(),
+ };
+ setRectProps({
+ ...rectProps,
+ ...updatedRect,
+ });
+
+ setRect({
+ ...rect,
+ ...updatedRect,
+ });
+
+ node.scaleX(1);
+ node.scaleY(1);
+ setValue(node.rotation());
+ };
+
+ const handleDragEnd = () => {
+ const node = rectRef.current;
+ const updatedRect = {
+ x: node.x(),
+ y: node.y(),
+ };
+
+ setRectProps({
+ ...rectProps,
+ ...updatedRect,
+ });
+
+ setRect({
+ ...rectProps,
+ ...updatedRect,
+ });
+ };
+
+ const handleSelect = () => {
+ transformerRef.current.nodes([rectRef.current]);
+ transformerRef.current.getLayer().batchDraw();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default ResizableRotatableRect;
diff --git a/frontend/src/components/Slider.jsx b/frontend/src/components/Slider.jsx
new file mode 100644
index 0000000000..86b7e8dd5e
--- /dev/null
+++ b/frontend/src/components/Slider.jsx
@@ -0,0 +1,92 @@
+import React, { useState, useContext } from "react";
+import Slider from "rc-slider";
+import "rc-slider/assets/index.css";
+import ThemeColorContext from "../ThemeColorProvider";
+
+function RotationSlider({ setValue }) {
+ const [angle, setAngle] = useState(0);
+ const theme = useContext(ThemeColorContext);
+
+ return (
+
+ {
+ setAngle(value);
+ setValue(value);
+ }}
+ marks={{
+ "-360": "-360°",
+ "-270": "-270°",
+ "-180": "-180°",
+ "-90": "-90°",
+ 0: "0°",
+ 90: "90°",
+ 180: "180°",
+ 270: "270°",
+ 360: "360°",
+ }}
+ railStyle={{ backgroundColor: "transparent", height: 8 }}
+ trackStyle={{ backgroundColor: "transparent", height: 8 }}
+ handleStyle={{
+ backgroundColor: theme.primaryColors.primary500,
+ borderColor: theme.primaryColors.primary500,
+ width: "8px",
+ height: "20px",
+ borderRadius: "4px",
+ marginTop: -8,
+ // backgroundColor: "#fff",
+ }}
+ dots={true}
+ dotStyle={(dotValue) => {
+ if (angle > 0 && angle <= 180 && dotValue <= angle && dotValue > 0) {
+ return {
+ backgroundColor: theme.primaryColors.primary500,
+ borderColor: theme.primaryColors.primary500,
+ width: "8px",
+ height: "8px",
+ };
+ }
+
+ if (angle >= -180 && angle < 0 && dotValue >= angle && dotValue < 0) {
+ return {
+ backgroundColor: theme.primaryColors.primary500,
+ borderColor: theme.primaryColors.primary500,
+ width: "8px",
+ height: "8px",
+ };
+ }
+
+ if (dotValue % 90 === 0) {
+ return {
+ backgroundColor: theme.grayColors.gray200,
+ borderColor: theme.grayColors.gray200,
+ width: "6px",
+ height: "12px",
+ borderRadius: "4px",
+ };
+ }
+ if (dotValue === 0) {
+ return {
+ backgroundColor: theme.primaryColors.primary500,
+ width: "14px",
+ height: "14px",
+ };
+ }
+
+ return {
+ backgroundColor: theme.grayColors.gray200,
+ borderColor: theme.grayColors.gray200,
+ width: "8px",
+ height: "8px",
+ };
+ }}
+ />
+
+ );
+}
+
+export default RotationSlider;
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 1be96402cd..fbea8f1b80 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -328,5 +328,14 @@
"ITEMS" : "Artikel",
"INPUT_PAGE_ALERT" : "Bitte geben Sie eine gültige Seitenzahl zwischen 1 und {totalPages}.",
"NO_PROJECTS" : "Keine Projekte",
- "PROJECT_LIST_TITLE": "Wildbook – Meine Projekte"
+ "PROJECT_LIST_TITLE": "Wildbook – Meine Projekte",
+ "ADD_ANNOTATIONS": "Annotationen hinzufügen",
+ "SAVE_ANNOTATION" : "Annotation speichern",
+ "DRAW_ANNOTATION" : "Annotation zeichnen",
+ "DRAWING" : "Zeichnen",
+ "DELETE" : "Löschen",
+ "ANNOTATION_SAVED" : "Annotation gespeichert",
+ "ANNOTATION_SAVED_DESC" : "Ihre Annotation wurde erfolgreich gespeichert.",
+ "ANNOTATION_ID": "Annotation ID",
+ "ENCOUNTER_ID": "Begegnungs ID"
}
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index af7f243530..f185861569 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -328,5 +328,14 @@
"ITEMS" : "items",
"INPUT_PAGE_ALERT" : "Please enter a valid page number between 1 and {totalPages}.",
"NO_PROJECTS" : "No Projects",
- "PROJECT_LIST_TITLE": "Wildbook - My Projects"
+ "PROJECT_LIST_TITLE": "Wildbook - My Projects",
+ "ADD_ANNOTATIONS" : "Add Annotations",
+ "SAVE_ANNOTATION" : "Save Annotation",
+ "DRAW_ANNOTATION" : "Draw Annotation",
+ "DRAWING" : "Drawing",
+ "DELETE" : "Delete",
+ "ANNOTATION_SAVED" : "Annotation Saved",
+ "ANNOTATION_SAVED_DESC" : "Your annotation was saved successfully.",
+ "ANNOTATION_ID": "Annotation ID",
+ "ENCOUNTER_ID": "Encounter ID"
}
\ No newline at end of file
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 0ed7ddc6c0..9531e25517 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -327,5 +327,14 @@
"ITEMS" : "elementos",
"INPUT_PAGE_ALERT" : "Por favor, introduzca un número de página válido entre 1 y {totalPages}.",
"NO_PROJECTS" : "Sin proyectos",
- "PROJECT_LIST_TITLE": "Wildbook - Mis Proyectos"
+ "PROJECT_LIST_TITLE": "Wildbook - Mis Proyectos",
+ "ADD_ANNOTATIONS": "Añadir anotaciones",
+ "SAVE_ANNOTATION" : "Guardar anotación",
+ "DRAW_ANNOTATION" : "Dibujar anotación",
+ "DRAWING" : "Dibujando",
+ "DELETE" : "Eliminar",
+ "ANNOTATION_SAVED" : "Anotación guardada",
+ "ANNOTATION_SAVED_DESC" : "La anotación se ha guardado correctamente.",
+ "ANNOTATION_ID" : "ID de anotación",
+ "ENCOUNTER_ID" : "ID de encuentro"
}
\ No newline at end of file
diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json
index b72d20e9b8..eb343e5669 100644
--- a/frontend/src/locale/fr.json
+++ b/frontend/src/locale/fr.json
@@ -327,5 +327,14 @@
"ITEMS" : "articles",
"INPUT_PAGE_ALERT" : "Veuillez saisir un numéro de page valide entre 1 et {totalPages}.",
"NO_PROJECTS" : "Aucun projet",
- "PROJECT_LIST_TITLE": "Wildbook - Mes projets"
+ "PROJECT_LIST_TITLE": "Wildbook - Mes projets",
+ "ADD_ANNOTATIONS": "Ajouter des annotations",
+ "SAVE_ANNOTATION" : "Enregistrer l'annotation",
+ "DRAW_ANNOTATION" : "Dessiner une annotation",
+ "DRAWING" : "Dessin",
+ "DELETE" : "Supprimer",
+ "ANNOTATION_SAVED" : "Annotation enregistrée",
+ "ANNOTATION_SAVED_DESC" : "L'annotation a été enregistrée avec succès.",
+ "ANNOTATION_ID": "ID d'annotation",
+ "ENCOUNTER_ID": "ID de rencontre"
}
\ No newline at end of file
diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json
index c8b9c75cf4..81db21dfb5 100644
--- a/frontend/src/locale/it.json
+++ b/frontend/src/locale/it.json
@@ -327,5 +327,14 @@
"ITEMS" : "elementi",
"INPUT_PAGE_ALERT" : "Inserisci un numero di pagina valido compreso tra 1 e {totalPages}.",
"NO_PROJECTS" : "Nessun progetto",
- "PROJECT_LIST_TITLE": "Wildbook - I miei progetti"
+ "PROJECT_LIST_TITLE": "Wildbook - I miei progetti",
+ "ADD_ANNOTATIONS": "Aggiungi annotazioni",
+ "SAVE_ANNOTATION" : "Salva annotazione",
+ "DRAW_ANNOTATION" : "Disegna annotazione",
+ "DRAWING": "Disegno",
+ "DELETE" : "Elimina",
+ "ANNOTATION_SAVED" : "Annotazione salvata",
+ "ANNOTATION_SAVED_DESC" : "L'annotazione è stata salvata con successo.",
+ "ANNOTATION_ID": "ID Annotazione",
+ "ENCOUNTER_ID": "ID Incontro"
}
\ No newline at end of file
diff --git a/frontend/src/models/encounters/useCreateAnnotation.js b/frontend/src/models/encounters/useCreateAnnotation.js
new file mode 100644
index 0000000000..e2293be466
--- /dev/null
+++ b/frontend/src/models/encounters/useCreateAnnotation.js
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import axios from "axios";
+
+// Custom Hook
+export default function useCreateAnnotation() {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [submissionDone, setSubmissionDone] = useState(false);
+ const [responseData, setResponseData] = useState(null);
+
+ // Function to perform the Axios request
+ const createAnnotation = async ({
+ encounterId,
+ assetId,
+ ia,
+ viewpoint,
+ x,
+ y,
+ width,
+ height,
+ rotation,
+ }) => {
+ setLoading(true);
+ setError(null);
+
+ console.log("rotation", rotation);
+
+ try {
+ const response = await axios.request({
+ method: "post",
+ url: "/api/v3/annotations",
+ data: {
+ encounterId: encounterId,
+ height: height,
+ iaClass: ia.value,
+ mediaAssetId: assetId,
+ theta: rotation,
+ viewpoint: viewpoint.value,
+ width: width,
+ x: x,
+ y: y,
+ },
+ });
+
+ if (response.status === 200) {
+ setSubmissionDone(true);
+ setResponseData(response.data);
+ }
+ } catch (error) {
+ setError(error);
+
+ if (error.response && error.response.status === 400) {
+ setError(error.response.data.errors);
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return { createAnnotation, loading, error, submissionDone, responseData };
+}
diff --git a/frontend/src/models/js/calculateFinalRect.js b/frontend/src/models/js/calculateFinalRect.js
new file mode 100644
index 0000000000..597b60fd7b
--- /dev/null
+++ b/frontend/src/models/js/calculateFinalRect.js
@@ -0,0 +1,25 @@
+export default function calculateFinalRect(rect, scaleFactor, value) {
+ const radians = (value * Math.PI) / 180;
+ const halfW = rect.width / 2;
+ const halfH = rect.height / 2;
+
+ const theta0 = Math.atan(halfH / halfW);
+ const radius = Math.sqrt(halfW * halfW + halfH * halfH);
+
+ const a = Math.cos(radians + theta0) * radius;
+ const b = Math.sin(radians + theta0) * radius;
+
+ const cx = rect.x + a;
+ const cy = rect.y + b;
+
+ const x = cx - halfW;
+ const y = cy - halfH;
+
+ return {
+ x: x * scaleFactor.x,
+ y: y * scaleFactor.y,
+ width: rect.width * scaleFactor.x,
+ height: rect.height * scaleFactor.y,
+ rotation: radians,
+ };
+}
diff --git a/frontend/src/models/js/calculateScaleFactor.js b/frontend/src/models/js/calculateScaleFactor.js
new file mode 100644
index 0000000000..b4427a53c9
--- /dev/null
+++ b/frontend/src/models/js/calculateScaleFactor.js
@@ -0,0 +1,11 @@
+export default function calculateScaleFactor(
+ naturalWidth,
+ naturalHeight,
+ displayWidth,
+ displayHeight,
+) {
+ const scaleX = naturalWidth / displayWidth;
+ const scaleY = naturalHeight / displayHeight;
+
+ return { x: scaleX, y: scaleY };
+}
diff --git a/frontend/src/pages/ManualAnnotation.jsx b/frontend/src/pages/ManualAnnotation.jsx
new file mode 100644
index 0000000000..f51c121990
--- /dev/null
+++ b/frontend/src/pages/ManualAnnotation.jsx
@@ -0,0 +1,440 @@
+import React, { useState, useContext, useRef, useEffect } from "react";
+import Select from "react-select";
+import Form from "react-bootstrap/Form";
+import { FormattedMessage } from "react-intl";
+import Container from "react-bootstrap/Container";
+import MainButton from "../components/MainButton";
+import ThemeColorContext from "../ThemeColorProvider";
+import ResizableRotatableRect from "../components/ResizableRotatableRect";
+import useGetSiteSettings from "../models/useGetSiteSettings";
+import { useSearchParams } from "react-router-dom";
+import AnnotationSuccessful from "../components/AnnotationSuccessful";
+import useCreateAnnotation from "../models/encounters/useCreateAnnotation";
+import calculateFinalRect from "../models/js/calculateFinalRect";
+import calculateScaleFactor from "../models/js/calculateScaleFactor";
+import AddAnnotationModal from "../components/AddAnnotationModal";
+
+export default function ManualAnnotation() {
+ const [searchParams] = useSearchParams();
+ const assetId = searchParams.get("assetId");
+ const encounterId = searchParams.get("encounterId");
+ const theme = useContext(ThemeColorContext);
+ const imgRef = useRef(null);
+ const canvasRef = useRef(null);
+ const [value, setValue] = useState(0);
+ const [incomplete, setIncomplete] = useState(false);
+ const [data, setData] = useState({
+ width: 100,
+ height: 100,
+ url: "",
+ annotations: [],
+ });
+
+ const { createAnnotation, loading, error, submissionDone, responseData } =
+ useCreateAnnotation();
+
+ console.log("error", error);
+
+ const [showModal, setShowModal] = useState(false);
+ const [scaleFactor, setScaleFactor] = useState({ x: 1, y: 1 });
+ const [ia, setIa] = useState(null);
+ const [viewpoint, setViewpoint] = useState(null);
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [drawStatus, setDrawStatus] = useState("DRAW");
+ const [rect, setRect] = useState({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ rotation: 0,
+ });
+
+ const { data: siteData } = useGetSiteSettings();
+ const iaOptions = siteData?.iaClass?.map((iaClass) => ({
+ value: iaClass,
+ label: iaClass,
+ }));
+ const viewpointOptions = siteData?.annotationViewpoint?.map((viewpoint) => ({
+ value: viewpoint,
+ label: viewpoint,
+ }));
+
+ const getMediaAssets = async () => {
+ try {
+ const response = await fetch(`/api/v3/media-assets/${assetId}`);
+ const data = await response.json();
+ setData(data);
+ } catch (error) {
+ alert("Error fetching media assets", error);
+ }
+ };
+
+ useEffect(() => {
+ if (isDrawing) {
+ setDrawStatus("DRAWING");
+ } else if (rect.width > 0 && rect.height > 0) {
+ setDrawStatus("DELETE");
+ } else {
+ setDrawStatus("DRAW");
+ }
+ }, [isDrawing, rect]);
+
+ useEffect(() => {
+ if (error) {
+ setShowModal(true);
+ }
+ }, [error]);
+
+ useEffect(() => {
+ const handleImageLoad = () => {
+ if (imgRef.current) {
+ const factor = calculateScaleFactor(
+ data.width,
+ data.height,
+ imgRef.current.clientWidth,
+ imgRef.current.clientHeight,
+ );
+ setScaleFactor(factor);
+
+ const canvas = canvasRef.current;
+ const context = canvas.getContext("2d");
+ canvas.width = imgRef.current.clientWidth;
+ canvas.height = imgRef.current.clientHeight;
+
+ // draw existing annotations
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ const validAnnotations = data.annotations.filter(
+ (annotation) => !annotation.trivial,
+ );
+ for (const annotation of validAnnotations) {
+ const { x, y, width, height, theta } = annotation;
+ const scaledRect = {
+ x: x / factor.x,
+ y: y / factor.y,
+ width: width / factor.x,
+ height: height / factor.y,
+ };
+
+ const rectCenterX = scaledRect.x + scaledRect.width / 2;
+ const rectCenterY = scaledRect.y + scaledRect.height / 2;
+ context.save();
+ context.translate(rectCenterX, rectCenterY);
+ context.rotate(theta);
+
+ context.strokeStyle = "yellow";
+ context.lineWidth = 1;
+
+ context.strokeRect(
+ -scaledRect.width / 2,
+ -scaledRect.height / 2,
+ scaledRect.width,
+ scaledRect.height,
+ );
+
+ // context.strokeStyle = "blue";
+ // context.lineWidth = 1;
+ // context.beginPath();
+ // context.moveTo(-scaledRect.width / 2, -scaledRect.height / 2); // Top-left corner
+ // context.lineTo(scaledRect.width / 2, -scaledRect.height / 2); // Top-right corner
+ // context.stroke();
+
+ // // Draw the other borders in yellow
+ // context.strokeStyle = "yellow";
+ // context.lineWidth = 1;
+ // context.beginPath();
+ // context.moveTo(-scaledRect.width / 2, -scaledRect.height / 2);
+ // context.lineTo(-scaledRect.width / 2, scaledRect.height / 2);
+ // context.lineTo(scaledRect.width / 2, scaledRect.height / 2);
+ // context.lineTo(scaledRect.width / 2, -scaledRect.height / 2);
+ context.restore();
+ }
+ }
+ };
+ const imgElement = imgRef.current;
+ if (imgElement && imgElement.complete) {
+ handleImageLoad();
+ } else if (imgElement) {
+ imgElement.addEventListener("load", handleImageLoad);
+ }
+
+ return () => {
+ if (imgElement) {
+ imgElement.removeEventListener("load", handleImageLoad);
+ }
+ };
+ }, [data]);
+
+ useEffect(() => {
+ if (assetId && encounterId) {
+ const fetchData = async () => {
+ await getMediaAssets();
+ };
+ fetchData();
+ }
+ }, [assetId, encounterId]);
+
+ useEffect(() => {
+ const handleMouseUp = () => setIsDrawing(false);
+ window.addEventListener("mouseup", handleMouseUp);
+ const handleKeyDown = (event) => {
+ if (event.key === "Delete") {
+ setRect({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ rotation: 0,
+ });
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("mouseup", handleMouseUp);
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, []);
+
+ const handleMouseDown = (e) => {
+ if (!imgRef.current || drawStatus === "DELETE") return;
+
+ const { left, top } = imgRef.current.getBoundingClientRect();
+ setRect({
+ x: e.clientX - left,
+ y: e.clientY - top,
+ width: 0,
+ height: 0,
+ rotation: value,
+ });
+ setIsDrawing(true);
+ };
+
+ const handleMouseMove = (e) => {
+ if (!imgRef.current || drawStatus === "DELETE") return;
+
+ const { left, top } = imgRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - left;
+ const mouseY = e.clientY - top;
+
+ if (isDrawing) {
+ setRect((prevRect) => ({
+ ...prevRect,
+ width: mouseX - prevRect.x,
+ height: mouseY - prevRect.y,
+ rotation: value,
+ }));
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (!imgRef.current || drawStatus === "DELETE") return;
+ setIsDrawing(false);
+ };
+
+ return (
+
+ {submissionDone ? (
+
+ ) : (
+ <>
+
+
+
+
+
+ *
+
+
+
+
+ *
+
+
+
+
+
+
+
+
+
{
+ if (drawStatus === "DELETE") {
+ setRect({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
+ } else if (drawStatus === "DRAW") {
+ setDrawStatus("DRAWING");
+ }
+ }}
+ >
+
+ {drawStatus === "DELETE" && (
+
+ )}
+
+
+
+
![annotationimages]({data.url})
+
+
+
+
+
+ {
+ try {
+ if (!ia || !viewpoint || !rect.width || !rect.height) {
+ setShowModal(true);
+ setIncomplete(true);
+ return;
+ } else {
+ setIncomplete(false);
+ const { x, y, width, height, rotation } = calculateFinalRect(
+ rect,
+ scaleFactor,
+ value,
+ );
+ await createAnnotation({
+ encounterId,
+ assetId,
+ ia,
+ viewpoint,
+ x,
+ y,
+ width,
+ height,
+ rotation,
+ });
+ }
+ } catch (error) {
+ alert("Error creating annotation", error);
+ setShowModal(true);
+ }
+ }}
+ >
+
+ {loading && (
+
+
+
+
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/main/java/org/ecocean/api/GenericObject.java b/src/main/java/org/ecocean/api/GenericObject.java
index 35024e6a05..c478e9e048 100644
--- a/src/main/java/org/ecocean/api/GenericObject.java
+++ b/src/main/java/org/ecocean/api/GenericObject.java
@@ -9,6 +9,8 @@
import org.json.JSONArray;
import org.json.JSONObject;
+import org.ecocean.Annotation;
+import org.ecocean.media.Feature;
import org.ecocean.media.MediaAsset;
import org.ecocean.media.MediaAssetFactory;
import org.ecocean.servlet.ServletUtilities;
@@ -56,6 +58,27 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
rtn.put("url", url.toString());
rtn.put("width", ma.getWidth());
rtn.put("height", ma.getHeight());
+ JSONArray janns = new JSONArray();
+ for (Annotation ann : ma.getAnnotations()) {
+ JSONObject jann = new JSONObject();
+ if (ann.getFeatures() != null) {
+ for (Feature ft : ann.getFeatures()) {
+ if (ft.isUnity()) {
+ jann.put("trivial", true);
+ jann.put("x", 0);
+ jann.put("y", 0);
+ jann.put("width", (int)ma.getWidth());
+ jann.put("height", (int)ma.getHeight());
+ } else {
+ // basically if we have more than one feature, only one wins
+ if (ft.getParameters() != null) jann = ft.getParameters();
+ }
+ }
+ }
+ jann.put("id", ann.getId());
+ janns.put(jann);
+ }
+ rtn.put("annotations", janns);
}
}
break;
diff --git a/src/main/webapp/javascript/ia.IBEIS.js b/src/main/webapp/javascript/ia.IBEIS.js
index d5e92f4457..5da34479bf 100644
--- a/src/main/webapp/javascript/ia.IBEIS.js
+++ b/src/main/webapp/javascript/ia.IBEIS.js
@@ -71,7 +71,7 @@ wildbook.IA.plugins.push({
function(enh) { //the menu action
var mid = imageEnhancer.mediaAssetIdFromElement(enh.imgEl);
var ma = assetById(mid);
- wildbook.openInTab('manualAnnotation.jsp?encounterId=' + encounterNumberFromElement(enh.imgEl) + '&assetId=' + mid);
+ wildbook.openInTab('/react/manual-annotation?encounterId=' + encounterNumberFromElement(enh.imgEl) + '&assetId=' + mid);
}
]);