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 ( +
+

+ +

+
+ +
+
+ + {": "} + + {encounterId} + +
+
+ placeholder + +
+ +
+ ); +} 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 ? ( + + ) : ( + <> +

+ +

+
+ + + * + + ({ + ...provided, + width: "200px", + }), + }} + onChange={(selected) => { + setViewpoint(selected); + }} + /> + +
+
+
+
+ +
+
{ + if (drawStatus === "DELETE") { + setRect({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + } else if (drawStatus === "DRAW") { + setDrawStatus("DRAWING"); + } + }} + > + + {drawStatus === "DELETE" && ( + + )} +
+
+
+ annotationimages + + + +
+
+ { + 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); } ]);