From 68ebbc3046f8204ffbcfb2358e7bb640414e0251 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 26 Nov 2025 01:13:01 +0000 Subject: [PATCH 001/192] page structure --- frontend/src/AuthenticatedSwitch.jsx | 4 +- .../pages/MatchResultsPage/MatchResults.jsx | 381 ++++++++++++++++++ .../pages/MatchResultsPage/icons/Icon3.jsx | 38 ++ .../pages/MatchResultsPage/icons/Icon4.jsx | 31 ++ .../pages/MatchResultsPage/icons/Icon5.jsx | 31 ++ .../pages/MatchResultsPage/icons/Icon6.jsx | 31 ++ .../pages/MatchResultsPage/icons/Icon7.jsx | 31 ++ .../MatchResultsPage/icons/ZoomInIcon.jsx | 31 ++ .../MatchResultsPage/icons/ZoomOutIcon.jsx | 31 ++ .../store/matchResultsStore.js | 113 ++++++ 10 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/MatchResultsPage/MatchResults.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon3.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon4.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon5.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon6.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon7.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx create mode 100644 frontend/src/pages/MatchResultsPage/store/matchResultsStore.js diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx index 45347df41f..17a3a22861 100644 --- a/frontend/src/AuthenticatedSwitch.jsx +++ b/frontend/src/AuthenticatedSwitch.jsx @@ -26,6 +26,7 @@ const ManualAnnotation = lazy(() => import("./pages/ManualAnnotation")); const BulkImport = lazy(() => import("./pages/BulkImport/BulkImport")); const BulkImportTask = lazy(() => import("./pages/BulkImport/BulkImportTask")); +const MatchResults = lazy(() => import("./pages/MatchResultsPage/MatchResults")); export default function AuthenticatedSwitch({ showclassicsubmit, @@ -69,7 +70,8 @@ export default function AuthenticatedSwitch({ > Loading...}> - } /> + + } /> } /> } /> } /> diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx new file mode 100644 index 0000000000..287dad00fa --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -0,0 +1,381 @@ +import React, { useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import { FormattedMessage } from "react-intl"; +import { Container, Row, Col, Form, Button } from "react-bootstrap"; +import ThemeColorContext from "../../ThemeColorProvider"; +import MatchResultsStore from "./store/matchResultsStore"; +import ZoomInIcon from "./icons/ZoomInIcon"; +import ZoomOutIcon from "./icons/ZoomOutIcon"; +import Icon3 from "./icons/Icon3"; +import Icon4 from "./icons/Icon4"; +import Icon5 from "./icons/Icon5"; +import Icon6 from "./icons/Icon6"; +import Icon7 from "./icons/Icon7"; + +const styles = { + matchRow: (selected, themeColor) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 10px", + fontSize: "0.9rem", + marginTop: "4px", + borderRadius: "5px", + backgroundColor: selected ? themeColor.primaryColors.primary50 : "transparent", + }), + matchRank: { + width: "24px", + textAlign: "right", + }, + matchScore: { + width: "64px", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + }), + idPillOutline: { + background: "transparent", + border: "1px solid #ccc", + }, + matchImageCard: { + position: "relative", + borderRadius: "8px", + // overflow: "hidden", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + }, + matchImage: { + width: "100%", + height: "auto", + display: "block", + }, + cornerLabel: (themeColor) => ({ + position: "absolute", + top: "8px", + left: "-8px", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + padding: "2px 8px", + borderRadius: "2px", + fontSize: "0.75rem", + }), + toolsBarLeft: { + position: "absolute", + top: "50%", + left: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + toolsBarRight: { + position: "absolute", + top: "50%", + right: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + toolBtn: { + width: "32px", + height: "32px", + borderRadius: "16px", + border: "none", + background: "#ffffff", + boxShadow: "0 1px 4px rgba(0, 0, 0, 0.2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "16px", + }, + bottomBar: { + position: "fixed", + left: 0, + right: 0, + bottom: 0, + background: "#f8f9fa", + borderTop: "1px solid #dee2e6", + padding: "10px 24px", + display: "flex", + justifyContent: "center", + gap: "24px", + alignItems: "center", + zIndex: 1000, + }, + bottomText: { + fontSize: "0.9rem", + }, +}; + +const MatchResults = observer(() => { + const themeColor = React.useContext(ThemeColorContext); + const store = useMemo(() => new MatchResultsStore(), []); + + return ( + +

+ +

+ +
+
+ + + +
+ +
+ + + + + store.setNumResults(Number(e.target.value))} + style={{ width: "80px" }} + > + {[4, 8, 12, 16].map((n) => ( + + ))} + + + + + + + + store.setProjectName(e.target.value)} + style={{ minWidth: "220px" }} + > + + + + +
+ {store.evaluatedAt} + +
+
+
+ + {store.algorithms.map((algo) => { + const half = Math.ceil(algo.matches.length / 2); + const leftMatches = algo.matches.slice(0, half); + const rightMatches = algo.matches.slice(half); + + return ( +
+
+
{algo.label}
+
+ + against {store.numCandidates} candidates + + {store.evaluatedAt} +
+
+ + + +
+ {leftMatches.map((m, idx) => ( +
+ {m.rank}. + + {m.score.toFixed(4)} + + +
+ + store.setSelectedMatch(e.target.checked, m.encounterId, m.individualId) + } + /> +
+
+ ))} +
+ + + +
+ {rightMatches.map((m, idx) => ( +
+ {m.rank}. + + {m.score.toFixed(4)} + + +
+ + store.setSelectedMatch(e.target.checked, m.encounterId, m.individualId) + } + /> +
+
+ ))} +
+ +
+ + + +
+
This encounter
+ This encounter +
+ +
+ + + +
+ + + +
+
+ Possible Match +
+ Possible match +
+ +
+ + + + + + + +
+ +
+
+ ); + })} + + {store.selectedIndividualId && ( +
+
+ Encounter{" "} + + {store.encounterId} + + {" is a match to Individual "} + + {store.selectedIndividualId} + +
+
+ + +
+
+ )} +
+ ); +}); + +export default MatchResults; diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx new file mode 100644 index 0000000000..89bf38a591 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx @@ -0,0 +1,38 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + + + + + + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx new file mode 100644 index 0000000000..f571171657 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx new file mode 100644 index 0000000000..a609c95918 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx new file mode 100644 index 0000000000..a51b6d5fa6 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx new file mode 100644 index 0000000000..59e6615247 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx new file mode 100644 index 0000000000..4a0c31ee6f --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx new file mode 100644 index 0000000000..0784d05b5c --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js new file mode 100644 index 0000000000..bda61d2e10 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -0,0 +1,113 @@ +import { makeAutoObservable } from "mobx"; + +const MOCK_DATA = { + viewMode: "individual", // "individual" | "image" + encounterId: "sdf9-sdaw-f624-4d3", + projectName: "Giraffe Conservation Project", + evaluatedAt: "2024/02/29 7:34 PM", + numResults: 12, + numCandidates: 2343, + thisEncounterImageUrl: + "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", + possibleMatchImageUrl: + "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", + + algorithms: [ + { + id: "miewId", + label: "Matches Based on MIEW ID Algorithm", + matches: [ + { rank: 1, score: 0.8315, encounterId: "123", individualId: "TC_00124" }, + { rank: 2, score: 0.8315, encounterId: "456",individualId: "TC_00126" }, + { rank: 3, score: 0.8315, encounterId:"test",individualId: "TC_00125" }, + { rank: 4, score: 0.8315, encounterId:"test",individualId: "TC_00127" }, + { rank: 5, score: 0.8315, encounterId:"test",individualId: "TC_00130" }, + { rank: 6, score: 0.8315, encounterId:"test",individualId: "TC_00129" }, + { rank: 7, score: 0.8315, encounterId:"test",individualId: "TC_00131" }, + { rank: 8, score: 0.8315, encounterId:"test",individualId: "TC_00128" }, + { rank: 9, score: 0.8315, encounterId:"test",individualId: "TC_00135" }, + { rank: 10, score: 0.8315, encounterId:"test",individualId: "TC_00133" }, + { rank: 11, score: 0.8315, encounterId:"test",individualId: "TC_00134" }, + { rank: 12, score: 0.8315, encounterId:"test",individualId: "TC_00132" }, + ], + }, + { + id: "hotspotter", + label: "Matches Based on Hotspotter", + matches: [ + { rank: 1, score: 0.8315, encounterId: "789",individualId: "TC_00124" }, + { rank: 2, score: 0.8315, encounterId: "000",individualId: "TC_00126" }, + { rank: 3, score: 0.8315, encounterId:"test",individualId: "TC_00125" }, + { rank: 4, score: 0.8315, encounterId:"test",individualId: "TC_00127" }, + { rank: 5, score: 0.8315, encounterId:"test",individualId: "TC_00130" }, + { rank: 6, score: 0.8315, encounterId:"test",individualId: "TC_00129" }, + { rank: 7, score: 0.8315, encounterId:"test",individualId: "TC_00131" }, + { rank: 8, score: 0.8315, encounterId:"test",individualId: "TC_00128" }, + { rank: 9, score: 0.8315, encounterId:"test",individualId: "TC_00135" }, + { rank: 10, score: 0.8315, encounterId:"test",individualId: "TC_00133" }, + { rank: 11, score: 0.8315, encounterId:"test",individualId: "TC_00134" }, + { rank: 12, score: 0.8315, encounterId:"test",individualId: "TC_00132" }, + ], + }, + ], + +}; + +export default class MatchResultsStore { + viewMode = "individual"; + encounterId = ""; + projectName = ""; + evaluatedAt = ""; + numResults = 12; + numCandidates = 0; + thisEncounterImageUrl = ""; + possibleMatchImageUrl = ""; + algorithms = []; + _selectedMatch = null; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + this.loadMockData(); + } + + loadMockData() { + this.viewMode = MOCK_DATA.viewMode; + this.encounterId = MOCK_DATA.encounterId; + this.projectName = MOCK_DATA.projectName; + this.evaluatedAt = MOCK_DATA.evaluatedAt; + this.numResults = MOCK_DATA.numResults; + this.numCandidates = MOCK_DATA.numCandidates; + this.thisEncounterImageUrl = MOCK_DATA.thisEncounterImageUrl; + this.possibleMatchImageUrl = MOCK_DATA.possibleMatchImageUrl; + this.algorithms = MOCK_DATA.algorithms.map((algo) => ({ + ...algo, + matches: algo.matches.map((m) => ({ ...m })), + })); + } + + setViewMode(mode) { + this.viewMode = mode; + } + + setNumResults(n) { + this.numResults = n; + } + + setProjectName(name) { + this.projectName = name; + } + + get selectedMatch() { + return this._selectedMatch; + } + setSelectedMatch(selected, encounterId, individualId) { + if (selected) { + this._selectedMatch = { + encounterId, + individualId, + }; + } else { + this._selectedMatch = null; + } + } +} From c6ccd6a066bb386778b954130fdcc2567ef58642 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 26 Nov 2025 15:48:54 +0000 Subject: [PATCH 002/192] selected match is a list --- .../pages/MatchResultsPage/MatchResults.jsx | 85 ++++++--- .../store/matchResultsStore.js | 179 +++++++++++++++--- 2 files changed, 212 insertions(+), 52 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 287dad00fa..d6953a713a 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -21,7 +21,9 @@ const styles = { fontSize: "0.9rem", marginTop: "4px", borderRadius: "5px", - backgroundColor: selected ? themeColor.primaryColors.primary50 : "transparent", + backgroundColor: selected + ? themeColor.primaryColors.primary50 + : "transparent", }), matchRank: { width: "24px", @@ -129,10 +131,16 @@ const MatchResults = observer(() => { type="button" style={{ borderRadius: "35px", - backgroundColor: store.viewMode === "individual" ? themeColor.primaryColors.primary500 : "white", + backgroundColor: + store.viewMode === "individual" + ? themeColor.primaryColors.primary500 + : "white", border: "none", padding: "5px 10px", - color: store.viewMode === "individual" ? "white" : themeColor.primaryColors.primary500, + color: + store.viewMode === "individual" + ? "white" + : themeColor.primaryColors.primary500, }} onClick={() => store.setViewMode("individual")} > @@ -143,10 +151,16 @@ const MatchResults = observer(() => { type="button" style={{ borderRadius: "35px", - backgroundColor: store.viewMode === "image" ? themeColor.primaryColors.primary500 : themeColor.primaryColors.primary100, + backgroundColor: + store.viewMode === "image" + ? themeColor.primaryColors.primary500 + : themeColor.primaryColors.primary100, padding: "5px 10px", border: "none", - color: store.viewMode === "image" ? "white" : themeColor.primaryColors.primary500, + color: + store.viewMode === "image" + ? "white" + : themeColor.primaryColors.primary500, }} onClick={() => store.setViewMode("image")} > @@ -192,7 +206,9 @@ const MatchResults = observer(() => {
{store.evaluatedAt} - + + +
@@ -221,10 +237,15 @@ const MatchResults = observer(() => {
- {leftMatches.map((m, idx) => ( + {leftMatches.map((m) => (
data.encounterId === m.encounterId, + ), + themeColor, + )} > {m.rank}. @@ -240,9 +261,15 @@ const MatchResults = observer(() => {
data.encounterId === m.encounterId, + )} onChange={(e) => - store.setSelectedMatch(e.target.checked, m.encounterId, m.individualId) + store.setSelectedMatch( + e.target.checked, + m.encounterId, + m.individualId, + ) } />
@@ -253,10 +280,15 @@ const MatchResults = observer(() => {
- {rightMatches.map((m, idx) => ( + {rightMatches.map((m) => (
data.encounterId === m.encounterId, + ), + themeColor, + )} > {m.rank}. @@ -272,9 +304,15 @@ const MatchResults = observer(() => {
data.encounterId === m.encounterId, + )} onChange={(e) => - store.setSelectedMatch(e.target.checked, m.encounterId, m.individualId) + store.setSelectedMatch( + e.target.checked, + m.encounterId, + m.individualId, + ) } />
@@ -285,9 +323,15 @@ const MatchResults = observer(() => { - +
-
This encounter
+
+ This encounter +
This encounter {
Possible match { defaultMessage="Confirm Match" /> -
- - -
- {leftMatches.map((m) => ( -
data.encounterId === m.encounterId, - ), - themeColor, - )} - > - {m.rank}. - - {m.score.toFixed(4)} - - -
- +
+ {matchColumns.map((column, columnIndex) => ( +
+ {column.map((m) => ( +
data.encounterId === m.encounterId, - )} - onChange={(e) => - store.setSelectedMatch( - e.target.checked, - m.encounterId, - m.individualId, - ) - } - /> -
-
- ))} -
- - - -
- {rightMatches.map((m) => ( -
data.encounterId === m.encounterId, - ), - themeColor, - )} - > - {m.rank}. - - {m.score.toFixed(4)} - - -
- data.encounterId === m.encounterId, - )} - onChange={(e) => - store.setSelectedMatch( - e.target.checked, - m.encounterId, - m.individualId, - ) - } - /> + {m.id}. + + {m.score.toFixed(4)} + + +
+ data.encounterId === m.encounterId, + )} + onChange={(e) => + store.setSelectedMatch( + e.target.checked, + m.encounterId, + m.individualId, + ) + } + /> +
-
- ))} -
- - + ))} +
+ ))} +
+
{ ); })} - {store.selectedIndividualId && ( + {store.selectedMatch.length > 0 && (
Encounter{" "} diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index ecf5ea8bf8..37931ae953 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -89,6 +89,78 @@ const MOCK_DATA = { encounterId: "test", individualId: "TC_00132", }, + { + rank: 1, + score: 0.8315, + encounterId: "123", + individualId: "TC_00124", + }, + { + rank: 2, + score: 0.8315, + encounterId: "456", + individualId: "TC_00126", + }, + { + rank: 3, + score: 0.8315, + encounterId: "test", + individualId: "TC_00125", + }, + { + rank: 4, + score: 0.8315, + encounterId: "test", + individualId: "TC_00127", + }, + { + rank: 5, + score: 0.8315, + encounterId: "test", + individualId: "TC_00130", + }, + { + rank: 6, + score: 0.8315, + encounterId: "test", + individualId: "TC_00129", + }, + { + rank: 7, + score: 0.8315, + encounterId: "test", + individualId: "TC_00131", + }, + { + rank: 8, + score: 0.8315, + encounterId: "test", + individualId: "TC_00128", + }, + { + rank: 9, + score: 0.8315, + encounterId: "test", + individualId: "TC_00135", + }, + { + rank: 10, + score: 0.8315, + encounterId: "test", + individualId: "TC_00133", + }, + { + rank: 11, + score: 0.8315, + encounterId: "test", + individualId: "TC_00134", + }, + { + rank: 12, + score: 0.8315, + encounterId: "test", + individualId: "TC_00132", + }, ], }, { From 1e01e75e6801fdcdcdba1cadaaf56ec184e77b08 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Tue, 2 Dec 2025 15:53:10 -0700 Subject: [PATCH 004/192] very basic MatchResult WIP --- src/main/java/org/ecocean/ia/MatchResult.java | 55 +++++++++++++++++++ src/main/resources/org/ecocean/ia/package.jdo | 14 +++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/java/org/ecocean/ia/MatchResult.java diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java new file mode 100644 index 0000000000..0fdbd49dea --- /dev/null +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -0,0 +1,55 @@ +package org.ecocean.ia; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.ServletException; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; + +/* + import org.ecocean.Annotation; + import org.ecocean.Base; + import org.ecocean.Encounter; + import org.ecocean.media.AssetStore; + import org.ecocean.media.MediaAsset; + import org.ecocean.media.MediaAssetFactory; + import org.ecocean.MarkedIndividual; + import org.ecocean.Occurrence; + import org.ecocean.OpenSearch; + import org.ecocean.resumableupload.UploadServlet; + import org.ecocean.servlet.ReCAPTCHA; + import org.ecocean.servlet.ServletUtilities; + import org.ecocean.shepherd.core.Shepherd; + import org.ecocean.shepherd.core.ShepherdPMF; + import org.ecocean.User; + */ + +import org.ecocean.ia.Task; +import org.ecocean.Util; + +public class MatchResult implements java.io.Serializable { + private String id; + private long created; + private Task task; + + public MatchResult() { + id = Util.generateUUID(); + created = System.currentTimeMillis(); + } + + public MatchResult(Task task) { + this(); + this.task = task; + } +} diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo index cca74a32f5..b4b20b6ff3 100755 --- a/src/main/resources/org/ecocean/ia/package.jdo +++ b/src/main/resources/org/ecocean/ia/package.jdo @@ -68,5 +68,19 @@ alter table "TASK" alter column "PARAMETERS" type text; + + + + + + + + + + + + + + From d2ec3e423dc0eca37f52e406476f8dfcb8a2c9f0 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Tue, 2 Dec 2025 16:19:42 -0700 Subject: [PATCH 005/192] MatchResultProspect wip --- src/main/java/org/ecocean/ia/MatchResult.java | 1 + .../org/ecocean/ia/MatchResultProspect.java | 25 +++++++++++++++++++ src/main/resources/org/ecocean/ia/package.jdo | 14 +++++++++++ 3 files changed, 40 insertions(+) create mode 100644 src/main/java/org/ecocean/ia/MatchResultProspect.java diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index 0fdbd49dea..ab70ac0585 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -42,6 +42,7 @@ public class MatchResult implements java.io.Serializable { private String id; private long created; private Task task; + private Set prospects; public MatchResult() { id = Util.generateUUID(); diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java new file mode 100644 index 0000000000..334b9e78af --- /dev/null +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -0,0 +1,25 @@ +package org.ecocean.ia; + +import java.util.HashSet; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; + +import org.ecocean.Annotation; +import org.ecocean.media.MediaAsset; +import org.ecocean.Util; + +public class MatchResultProspect implements java.io.Serializable { + private Annotation annotation; + private double score; + private MediaAsset asset; + private MatchResult matchResult; + + public MatchResultProspect() {} + + public MatchResultProspect(Annotation ann) { + this(); + this.annotation = ann; + } +} diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo index b4b20b6ff3..67e305692f 100755 --- a/src/main/resources/org/ecocean/ia/package.jdo +++ b/src/main/resources/org/ecocean/ia/package.jdo @@ -80,6 +80,20 @@ alter table "TASK" alter column "PARAMETERS" type text; + + + + + + + + + + + + + + From a62f41485eb471890b394e724421be48d08275c0 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 3 Dec 2025 16:04:05 +0000 Subject: [PATCH 006/192] break down the main page --- .../MatchResultsPage/MatchProspectTable.jsx | 209 +++++++++ .../pages/MatchResultsPage/MatchResults.jsx | 338 ++------------ .../MatchResultsBottomBar.jsx | 162 +++++++ .../src/pages/MatchResultsPage/constants.js | 22 + .../src/pages/MatchResultsPage/mockupdata.js | 244 ++++++++++ .../store/matchResultsStore.js | 417 +++++++----------- 6 files changed, 826 insertions(+), 566 deletions(-) create mode 100644 frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx create mode 100644 frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx create mode 100644 frontend/src/pages/MatchResultsPage/constants.js create mode 100644 frontend/src/pages/MatchResultsPage/mockupdata.js diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx new file mode 100644 index 0000000000..68656018f2 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -0,0 +1,209 @@ +import React from "react"; +import { Row, Col, Form } from "react-bootstrap"; +import { observer } from "mobx-react-lite"; +import ZoomInIcon from "./icons/ZoomInIcon"; +import ZoomOutIcon from "./icons/ZoomOutIcon"; +import Icon3 from "./icons/Icon3"; +import Icon5 from "./icons/Icon5"; +import Icon7 from "./icons/Icon7"; + +const styles = { + matchRow: (selected, themeColor) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 10px", + fontSize: "0.9rem", + marginTop: "4px", + borderRadius: "5px", + backgroundColor: selected + ? themeColor.primaryColors.primary50 + : "transparent", + }), + matchRank: { + width: "24px", + textAlign: "right", + }, + matchScore: { + width: "64px", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + }), + matchImageCard: { + position: "relative", + borderRadius: "8px", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + }, + matchImage: { + width: "100%", + height: "auto", + display: "block", + }, + cornerLabel: (themeColor) => ({ + position: "absolute", + top: "8px", + left: "-8px", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + padding: "2px 8px", + borderRadius: "2px", + fontSize: "0.75rem", + }), + toolsBarLeft: { + position: "absolute", + top: "50%", + left: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + toolsBarRight: { + position: "absolute", + top: "50%", + right: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + matchListScrollContainer: { + overflowX: "auto", + overflowY: "hidden", + marginBottom: "1rem", + }, + matchListGrid: { + display: "flex", + gap: "12px", + width: "100%", + }, + matchColumn: { + flex: 1, + minWidth: "30%", + display: "flex", + flexDirection: "column", + }, +}; + +const MatchProspectTable = observer( + ({ + label, + matchColumns, + numCandidates, + evaluatedAt, + selectedMatch, + onToggleSelected, + thisEncounterImageUrl, + possibleMatchImageUrl, + themeColor, + }) => { + const isSelected = (encounterId) => + selectedMatch?.some((d) => d.encounterId === encounterId); + + return ( +
+
+
{label}
+
+ against {numCandidates} candidates + {evaluatedAt} +
+
+ +
+
+ {matchColumns.map((column, columnIndex) => ( +
+ {column.map((m) => ( +
+ {m.id}. + + {m.score.toFixed(4)} + + + + +
+ + onToggleSelected( + e.target.checked, + m.encounterId, + m.individualId, + ) + } + /> +
+
+ ))} +
+ ))} +
+
+ + + +
+
This encounter
+ This encounter +
+ +
+ + + +
+ + + +
+
+ Possible Match +
+ Possible match +
+ +
+ + + + + +
+ +
+
+ ); + }, +); + +export default MatchProspectTable; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 7263457fbe..2301a4b4d0 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -1,159 +1,36 @@ import React, { useMemo } from "react"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; -import { Container, Row, Col, Form, Button } from "react-bootstrap"; +import { Container, Form, Modal } from "react-bootstrap"; import ThemeColorContext from "../../ThemeColorProvider"; import MatchResultsStore from "./store/matchResultsStore"; -import ZoomInIcon from "./icons/ZoomInIcon"; -import ZoomOutIcon from "./icons/ZoomOutIcon"; -import Icon3 from "./icons/Icon3"; -import Icon4 from "./icons/Icon4"; -import Icon5 from "./icons/Icon5"; -import Icon6 from "./icons/Icon6"; -import Icon7 from "./icons/Icon7"; - -const styles = { - matchRow: (selected, themeColor) => ({ - display: "flex", - alignItems: "center", - gap: "8px", - padding: "6px 10px", - fontSize: "0.9rem", - marginTop: "4px", - borderRadius: "5px", - backgroundColor: selected - ? themeColor.primaryColors.primary50 - : "transparent", - }), - matchRank: { - width: "24px", - textAlign: "right", - }, - matchScore: { - width: "64px", - }, - idPill: (themeColor) => ({ - borderRadius: "5px", - border: "none", - padding: "2px 10px", - fontSize: "0.8rem", - background: themeColor.wildMeColors.teal100, - color: themeColor.wildMeColors.teal800, - }), - idPillOutline: { - background: "transparent", - border: "1px solid #ccc", - }, - matchImageCard: { - position: "relative", - borderRadius: "8px", - // overflow: "hidden", - boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", - }, - matchImage: { - width: "100%", - height: "auto", - display: "block", - }, - cornerLabel: (themeColor) => ({ - position: "absolute", - top: "8px", - left: "-8px", - background: themeColor.wildMeColors.teal100, - color: themeColor.wildMeColors.teal800, - padding: "2px 8px", - borderRadius: "2px", - fontSize: "0.75rem", - }), - toolsBarLeft: { - position: "absolute", - top: "50%", - left: "-40px", - transform: "translateY(-50%)", - display: "flex", - flexDirection: "column", - gap: "6px", - }, - toolsBarRight: { - position: "absolute", - top: "50%", - right: "-40px", - transform: "translateY(-50%)", - display: "flex", - flexDirection: "column", - gap: "6px", - }, - toolBtn: { - width: "32px", - height: "32px", - borderRadius: "16px", - border: "none", - background: "#ffffff", - boxShadow: "0 1px 4px rgba(0, 0, 0, 0.2)", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "16px", - }, - bottomBar: { - position: "fixed", - left: 0, - right: 0, - bottom: 0, - background: "#f8f9fa", - borderTop: "1px solid #dee2e6", - padding: "10px 24px", - display: "flex", - justifyContent: "center", - gap: "24px", - alignItems: "center", - zIndex: 1000, - }, - bottomText: { - fontSize: "0.9rem", - }, - matchListScrollContainer: { - overflowX: "auto", - overflowY: "hidden", - marginBottom: "1rem", - }, - matchListGrid: { - display: "flex", - gap: "12px", - width: "100%", - }, - matchColumn: { - flex: 1, - minWidth: "30%", - display: "flex", - flexDirection: "column", - }, -}; +import MatchProspectTable from "./MatchProspectTable"; +import MatchResultsBottomBar from "./MatchResultsBottomBar"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); const store = useMemo(() => new MatchResultsStore(), []); - - const organizeMatchesIntoColumns = (matches) => { - const MAX_ROWS_PER_COLUMN = 4; - const totalMatches = matches.length; - if (totalMatches === 0) return []; - - const columns = []; - for (let i = 0; i < totalMatches; i += MAX_ROWS_PER_COLUMN) { - const columnData = matches - .slice(i, i + MAX_ROWS_PER_COLUMN) - .map((match, index) => ({ - ...match, - id: i + index + 1, - })); - columns.push(columnData); - } - return columns; - }; + const [instructionsVisible, setInstructionsVisible] = React.useState(false); return ( + setInstructionsVisible(false)} + > + setInstructionsVisible(false)}> + + + + + +

+ +

+
+
+

@@ -167,7 +44,7 @@ const MatchResults = observer(() => { fontSize: "1.6rem", }} title="Help" - onClick={() => alert("Help information goes here.")} + onClick={() => setInstructionsVisible(true)} >

@@ -247,170 +124,35 @@ const MatchResults = observer(() => { - -
{store.algorithms.map((algo) => { - const matchColumns = organizeMatchesIntoColumns(algo.matches); + const matchColumns = store.organizeMatchesIntoColumns(algo.matches); return ( -
-
-
{algo.label}
-
- - against {store.numCandidates} candidates - - {store.evaluatedAt} -
-
- -
-
- {matchColumns.map((column, columnIndex) => ( -
- {column.map((m) => ( -
data.encounterId === m.encounterId, - ), - themeColor, - )} - > - {m.id}. - - {m.score.toFixed(4)} - - -
- data.encounterId === m.encounterId, - )} - onChange={(e) => - store.setSelectedMatch( - e.target.checked, - m.encounterId, - m.individualId, - ) - } - /> -
-
- ))} -
- ))} -
-
- - - -
-
- This encounter -
- This encounter -
- -
- - - -
- - - -
-
- Possible Match -
- Possible match -
- -
- - - - - - - -
- -
-
+ label={algo.label} + matchColumns={matchColumns} + numCandidates={store.numCandidates} + evaluatedAt={store.evaluatedAt} + selectedMatch={store.selectedMatch} + onToggleSelected={(checked, encounterId, individualId) => + store.setSelectedMatch(checked, encounterId, individualId) + } + thisEncounterImageUrl={store.thisEncounterImageUrl} + possibleMatchImageUrl={ + store.selectedMatchImageUrl || store.thisEncounterImageUrl + } + themeColor={themeColor} + /> ); })} - {store.selectedMatch.length > 0 && ( -
-
- Encounter{" "} - - {store.encounterId} - - {" is a match to Individual "} - - {store.selectedIndividualId} - -
-
- - -
-
- )} + ); }); -export default MatchResults; +export default MatchResults; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx new file mode 100644 index 0000000000..785c18de40 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx @@ -0,0 +1,162 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { FormattedMessage } from "react-intl"; +import { Form, Button } from "react-bootstrap"; + +const styles = { + bottomBar: (themeColor) => ({ + position: "fixed", + left: 0, + right: 0, + bottom: 0, + background: themeColor.primaryColors.primary50, + borderTop: "1px solid #dee2e6", + padding: "10px 24px", + display: "flex", + gap: "24px", + zIndex: 1000, + }), + bottomText: { + fontSize: "0.9rem", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + }), + idPillOutline: { + background: "transparent", + border: "1px solid #ccc", + }, + warningText: { + color: "#dc3545", + fontSize: "0.9rem", + fontWeight: "500", + }, +}; + +const MatchResultsBottomBar = observer(({ store, themeColor }) => { + const renderActions = () => { + const matchingState = store.matchingState; + + switch (matchingState) { + case "no_selection": + return ( + <> + + + + ); + + case "no_individuals": + return ( + <> + store.setNoMatchReason(e.target.value)} + style={{ maxWidth: "300px" }} + size="sm" + /> + + + ); + + case "single_individual": + return ( + + ); + + case "two_individuals": + return ( + + ); + + case "too_many_individuals": + return ( +
+ + +
+ ); + + default: + return null; + } + }; + + return ( +
+
+ {" "} + + {store.encounterId} + +
+
+ {renderActions()} +
+
+ ); +}); + +export default MatchResultsBottomBar; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/constants.js b/frontend/src/pages/MatchResultsPage/constants.js new file mode 100644 index 0000000000..ff0738ef04 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/constants.js @@ -0,0 +1,22 @@ +export const MATCHING_STATES = { + NO_SELECTION: "no_selection", + NO_INDIVIDUALS: "no_individuals", + SINGLE_INDIVIDUAL: "single_individual", + TWO_INDIVIDUALS: "two_individuals", + TOO_MANY_INDIVIDUALS: "too_many_individuals", +}; + +export const VIEW_MODES = { + INDIVIDUAL: "individual", + IMAGE: "image", +}; + +export const MAX_ROWS_PER_COLUMN = 4; + +export const API_ENDPOINTS = { + MATCH_RESULTS: "/api/match-results", + CONFIRM_MATCH: "/api/confirm-match", + CONFIRM_NO_MATCH: "/api/confirm-no-match", + MERGE_INDIVIDUALS: "/api/merge-individuals", + MARK_NEW_INDIVIDUAL: "/api/mark-new-individual", +}; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/mockupdata.js b/frontend/src/pages/MatchResultsPage/mockupdata.js new file mode 100644 index 0000000000..8539647bc9 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/mockupdata.js @@ -0,0 +1,244 @@ +export const MOCK_DATA = { + viewMode: "individual", + encounterId: "sdf9-sdaw-f624-4d3", + individualId: null, + projectName: "Giraffe Conservation Project", + evaluatedAt: "2024/02/29 7:34 PM", + numResults: 12, + numCandidates: 2343, + thisEncounterImageUrl: + "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", + possibleMatchImageUrl: + "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", + + algorithms: [ + { + id: "miewId", + label: "Matches Based on MIEW ID Algorithm", + matches: [ + { + rank: 1, + score: 0.8315, + encounterId: "123", + individualId: "TC_00124", + }, + { + rank: 2, + score: 0.8315, + encounterId: "456", + individualId: "TC_00126", + }, + { + rank: 3, + score: 0.8315, + encounterId: "test", + individualId: "TC_00125", + }, + { + rank: 4, + score: 0.8315, + encounterId: "test", + individualId: "TC_00127", + }, + { + rank: 5, + score: 0.8315, + encounterId: "test", + individualId: "TC_00130", + }, + { + rank: 6, + score: 0.8315, + encounterId: "test", + individualId: "TC_00129", + }, + { + rank: 7, + score: 0.8315, + encounterId: "test", + individualId: "TC_00131", + }, + { + rank: 8, + score: 0.8315, + encounterId: "test", + individualId: "TC_00128", + }, + { + rank: 9, + score: 0.8315, + encounterId: "test", + individualId: "TC_00135", + }, + { + rank: 10, + score: 0.8315, + encounterId: "test", + individualId: "TC_00133", + }, + { + rank: 11, + score: 0.8315, + encounterId: "test", + individualId: "TC_00134", + }, + { + rank: 12, + score: 0.8315, + encounterId: "test", + individualId: "TC_00132", + }, + { + rank: 1, + score: 0.8315, + encounterId: "123", + individualId: "TC_00124", + }, + { + rank: 2, + score: 0.8315, + encounterId: "456", + individualId: "TC_00126", + }, + { + rank: 3, + score: 0.8315, + encounterId: "test", + individualId: "TC_00125", + }, + { + rank: 4, + score: 0.8315, + encounterId: "test", + individualId: "TC_00127", + }, + { + rank: 5, + score: 0.8315, + encounterId: "test", + individualId: "TC_00130", + }, + { + rank: 6, + score: 0.8315, + encounterId: "test", + individualId: "TC_00129", + }, + { + rank: 7, + score: 0.8315, + encounterId: "test", + individualId: "TC_00131", + }, + { + rank: 8, + score: 0.8315, + encounterId: "test", + individualId: "TC_00128", + }, + { + rank: 9, + score: 0.8315, + encounterId: "test", + individualId: "TC_00135", + }, + { + rank: 10, + score: 0.8315, + encounterId: "test", + individualId: "TC_00133", + }, + { + rank: 11, + score: 0.8315, + encounterId: "test", + individualId: "TC_00134", + }, + { + rank: 12, + score: 0.8315, + encounterId: "test", + individualId: "TC_00132", + }, + ], + }, + { + id: "hotspotter", + label: "Matches Based on Hotspotter", + matches: [ + { + rank: 1, + score: 0.8315, + encounterId: "789", + individualId: "TC_00124", + }, + { + rank: 2, + score: 0.8315, + encounterId: "000", + individualId: "TC_00126", + }, + { + rank: 3, + score: 0.8315, + encounterId: "test", + individualId: "TC_00125", + }, + { + rank: 4, + score: 0.8315, + encounterId: "test", + individualId: "TC_00127", + }, + { + rank: 5, + score: 0.8315, + encounterId: "test", + individualId: "TC_00130", + }, + { + rank: 6, + score: 0.8315, + encounterId: "test", + individualId: "TC_00129", + }, + { + rank: 7, + score: 0.8315, + encounterId: "test", + individualId: "TC_00131", + }, + { + rank: 8, + score: 0.8315, + encounterId: "test", + individualId: "TC_00128", + }, + { + rank: 9, + score: 0.8315, + encounterId: "test", + individualId: "TC_00135", + }, + { + rank: 10, + score: 0.8315, + encounterId: "test", + individualId: "TC_00133", + }, + { + rank: 11, + score: 0.8315, + encounterId: "test", + individualId: "TC_00134", + }, + { + rank: 12, + score: 0.8315, + encounterId: "test", + individualId: "TC_00132", + }, + ], + }, + ], +}; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index 37931ae953..eae0121ec6 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -1,296 +1,126 @@ import { makeAutoObservable } from "mobx"; +import axios from "axios"; +import { MAX_ROWS_PER_COLUMN } from "../constants"; +import { MOCK_DATA } from "../mockupdata"; -const MOCK_DATA = { - viewMode: "individual", // "individual" | "image" - encounterId: "sdf9-sdaw-f624-4d3", - projectName: "Giraffe Conservation Project", - evaluatedAt: "2024/02/29 7:34 PM", - numResults: 12, - numCandidates: 2343, - thisEncounterImageUrl: - "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", - possibleMatchImageUrl: - "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", - - algorithms: [ - { - id: "miewId", - label: "Matches Based on MIEW ID Algorithm", - matches: [ - { - rank: 1, - score: 0.8315, - encounterId: "123", - individualId: "TC_00124", - }, - { - rank: 2, - score: 0.8315, - encounterId: "456", - individualId: "TC_00126", - }, - { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", - }, - { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", - }, - { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", - }, - { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", - }, - { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", - }, - { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", - }, - { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", - }, - { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", - }, - { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", - }, - { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", - }, - { - rank: 1, - score: 0.8315, - encounterId: "123", - individualId: "TC_00124", - }, - { - rank: 2, - score: 0.8315, - encounterId: "456", - individualId: "TC_00126", - }, - { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", - }, - { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", - }, - { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", - }, - { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", - }, - { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", - }, - { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", - }, - { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", - }, - { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", - }, - { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", - }, - { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", - }, - ], - }, - { - id: "hotspotter", - label: "Matches Based on Hotspotter", - matches: [ - { - rank: 1, - score: 0.8315, - encounterId: "789", - individualId: "TC_00124", - }, - { - rank: 2, - score: 0.8315, - encounterId: "000", - individualId: "TC_00126", - }, - { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", - }, - { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", - }, - { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", - }, - { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", - }, - { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", - }, - { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", - }, - { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", - }, - { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", - }, - { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", - }, - { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", - }, - ], - }, - ], -}; export default class MatchResultsStore { - viewMode = "individual"; - encounterId = ""; - projectName = ""; - evaluatedAt = ""; - numResults = 12; - numCandidates = 0; - thisEncounterImageUrl = ""; - possibleMatchImageUrl = ""; - algorithms = []; + _viewMode = "individual"; + _encounterId = ""; + _individualId = null; + _projectName = ""; + _evaluatedAt = ""; + _numResults = 12; + _numCandidates = 0; + _thisEncounterImageUrl = ""; + _possibleMatchImageUrl = ""; + _algorithms = []; _selectedMatch = []; + _taskId = null; + _noMatchReason = ""; constructor() { makeAutoObservable(this, {}, { autoBind: true }); - this.loadMockData(); + this.loadData(); } - loadMockData() { - this.viewMode = MOCK_DATA.viewMode; - this.encounterId = MOCK_DATA.encounterId; - this.projectName = MOCK_DATA.projectName; - this.evaluatedAt = MOCK_DATA.evaluatedAt; - this.numResults = MOCK_DATA.numResults; - this.numCandidates = MOCK_DATA.numCandidates; - this.thisEncounterImageUrl = MOCK_DATA.thisEncounterImageUrl; - this.possibleMatchImageUrl = MOCK_DATA.possibleMatchImageUrl; - this.algorithms = MOCK_DATA.algorithms.map((algo) => ({ + loadData(result) { + this._viewMode = MOCK_DATA.viewMode; + this._encounterId = MOCK_DATA.encounterId; + this._individualId = MOCK_DATA.individualId; + this._projectName = MOCK_DATA.projectName; + this._evaluatedAt = MOCK_DATA.evaluatedAt; + this._numResults = MOCK_DATA.numResults; + this._numCandidates = MOCK_DATA.numCandidates; + this._thisEncounterImageUrl = MOCK_DATA.thisEncounterImageUrl; + this._possibleMatchImageUrl = MOCK_DATA.possibleMatchImageUrl; + this._algorithms = MOCK_DATA.algorithms.map((algo) => ({ ...algo, matches: algo.matches.map((m) => ({ ...m })), })); } + get viewMode() { + return this._viewMode; + } + + get encounterId() { + return this._encounterId; + } + + get individualId() { + return this._individualId; + } + + get projectName() { + return this._projectName; + } + + get evaluatedAt() { + return this._evaluatedAt; + } + + get numResults() { + return this._numResults; + } + + get numCandidates() { + return this._numCandidates; + } + + get thisEncounterImageUrl() { + return this._thisEncounterImageUrl; + } + + get possibleMatchImageUrl() { + return this._possibleMatchImageUrl; + } + + get algorithms() { + return this._algorithms; + } + + get noMatchReason() { + return this._noMatchReason; + } + + get taskId() { + return this._taskId; + } + + setTaskId(id) { + this._taskId = id; + } + + async getMatchResults() { + try { + const result = await axios.get(); + this.loadData(result); + } catch (e) { + console.log(); + } + } + setViewMode(mode) { - this.viewMode = mode; + this._viewMode = mode; } setNumResults(n) { - this.numResults = n; + this._numResults = n; } setProjectName(name) { - this.projectName = name; + this._projectName = name; + } + + setNoMatchReason(reason) { + this._noMatchReason = reason; } get selectedMatch() { return this._selectedMatch; } + setSelectedMatch(selected, encounterId, individualId) { if (selected) { this._selectedMatch.push({ @@ -298,9 +128,60 @@ export default class MatchResultsStore { individualId, }); } else { - this._selectedMatch = this.selectedMatch.filter( + this._selectedMatch = this._selectedMatch.filter( (data) => data.encounterId !== encounterId, ); } } -} + + get uniqueIndividualIds() { + const ids = new Set(); + + if (this._individualId) { + ids.add(this._individualId); + } + + this._selectedMatch.forEach((match) => { + if (match.individualId) { + ids.add(match.individualId); + } + }); + + return Array.from(ids); + } + + get matchingState() { + if (this._selectedMatch.length === 0) { + return "no_selection"; + } + + const uniqueIds = this.uniqueIndividualIds; + const idCount = uniqueIds.length; + + if (idCount === 0) { + return "no_individuals"; + } else if (idCount === 1) { + return "single_individual"; + } else if (idCount === 2) { + return "two_individuals"; + } else { + return "too_many_individuals"; + } + } + + organizeMatchesIntoColumns(matches) { + const totalMatches = matches.length; + if (totalMatches === 0) return []; + const columns = []; + for (let i = 0; i < totalMatches; i += MAX_ROWS_PER_COLUMN) { + const columnData = matches + .slice(i, i + MAX_ROWS_PER_COLUMN) + .map((match, index) => ({ + ...match, + id: i + index + 1, + })); + columns.push(columnData); + } + return columns; + } +} \ No newline at end of file From b830d70a43dd12017f5fb49ffa402bc81b7106fb Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 3 Dec 2025 20:55:55 +0000 Subject: [PATCH 007/192] zoom in, zoom out, pan --- .../MatchResultsPage/MatchProspectTable.jsx | 327 ++++++++++++++---- .../pages/MatchResultsPage/MatchResults.jsx | 6 +- .../MatchResultsBottomBar.jsx | 57 +-- .../src/pages/MatchResultsPage/constants.js | 20 -- .../src/pages/MatchResultsPage/mockupdata.js | 268 ++++---------- .../store/matchResultsStore.js | 54 +-- 6 files changed, 403 insertions(+), 329 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index 68656018f2..8536f36064 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -39,11 +39,25 @@ const styles = { position: "relative", borderRadius: "8px", boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + overflow: "hidden", + height: "400px", + }, + imageContainer: { + width: "100%", + height: "100%", + overflow: "hidden", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f8f9fa", }, matchImage: { width: "100%", - height: "auto", + height: "100% ", display: "block", + objectFit: "contain", + backgroundColor: "#f8f9fa", + transformOrigin: "center center", }, cornerLabel: (themeColor) => ({ position: "absolute", @@ -91,7 +105,7 @@ const styles = { }, }; -const MatchProspectTable = observer( +const MatchProspectTable = ({ label, matchColumns, @@ -99,13 +113,120 @@ const MatchProspectTable = observer( evaluatedAt, selectedMatch, onToggleSelected, + onRowClick, thisEncounterImageUrl, - possibleMatchImageUrl, + selectedMatchImageUrl, themeColor, }) => { + const [previewedEncounterId, setPreviewedEncounterId] = React.useState( + matchColumns[0]?.[0]?.encounterId || null + ); + const [leftImageZoom, setLeftImageZoom] = React.useState(1); + const [rightImageZoom, setRightImageZoom] = React.useState(1); + + const handleZoomIn = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); + } else { + setRightImageZoom((prev) => Math.min(prev + 0.25, 3)); + } + }; + + const handleZoomOut = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } else { + setRightImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } + }; + + const handleResetZoom = (side) => { + if (side === "left") { + setLeftImageZoom(1); + } else { + setRightImageZoom(1); + } + }; + + const [leftPanEnabled, setLeftPanEnabled] = React.useState(false); + const [rightPanEnabled, setRightPanEnabled] = React.useState(false); + const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); + const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = React.useState(null); + const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); + + const togglePanMode = (side) => { + if (side === "left") { + setLeftPanEnabled((prev) => !prev); + if (leftPanEnabled) { + setLeftPanPosition({ x: 0, y: 0 }); + } + } else { + setRightPanEnabled((prev) => !prev); + if (rightPanEnabled) { + setRightPanPosition({ x: 0, y: 0 }); + } + } + }; + + const handleMouseDown = (side, e) => { + const panEnabled = side === "left" ? leftPanEnabled : rightPanEnabled; + if (!panEnabled) return; + + setIsDragging(side); + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; + + const handleMouseMove = (e) => { + if (!isDragging) return; + + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + if (isDragging === "left") { + setLeftPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } else { + setRightPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } + + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + React.useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, dragStart]); + const isSelected = (encounterId) => selectedMatch?.some((d) => d.encounterId === encounterId); + const handleRowClick = (encounterId, imageUrl) => { + setPreviewedEncounterId(encounterId); + onRowClick(imageUrl); + }; + return (
@@ -120,63 +241,111 @@ const MatchProspectTable = observer(
{matchColumns.map((column, columnIndex) => (
- {column.map((m) => ( -
- {m.id}. - - {m.score.toFixed(4)} - - - - -
- - onToggleSelected( - e.target.checked, - m.encounterId, - m.individualId, - ) - } - /> + {m.id}. + + {m.score.toFixed(4)} + + + + +
e.stopPropagation()} + > + + onToggleSelected( + e.target.checked, + m.encounterId, + m.individualId, + ) + } + /> +
-
- ))} + ); + })}
))}
- +
This encounter
- This encounter +
handleMouseDown("left", e)} + > + This encounter +
- - - +
handleZoomIn("left")} + style={styles.iconButton} + title="Zoom In" + > + +
+
handleZoomOut("left")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
togglePanMode("left")} + style={{ + ...styles.iconButton, + backgroundColor: leftPanEnabled + ? themeColor.primaryColors.primary200 + : "white", + }} + title="Pan Image (Click to toggle)" + > + +
@@ -185,25 +354,65 @@ const MatchProspectTable = observer(
Possible Match
- Possible match +
handleMouseDown("right", e)} + > + Possible match +
- - - - - +
handleZoomIn("right")} + style={styles.iconButton} + title="Zoom In" + > + +
+
handleZoomOut("right")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
togglePanMode("right")} + style={{ + ...styles.iconButton, + backgroundColor: rightPanEnabled + ? themeColor.primaryColors.primary200 + : "white", + }} + title="Pan Image (Click to toggle)" + > + +
+
+ +
+
+ +
); - }, -); + } export default MatchProspectTable; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 2301a4b4d0..f70ed11302 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -133,6 +133,7 @@ const MatchResults = observer(() => { return ( { onToggleSelected={(checked, encounterId, individualId) => store.setSelectedMatch(checked, encounterId, individualId) } + onRowClick={(imageUrl) => store.setPreviewImageUrl(algo.id, imageUrl)} thisEncounterImageUrl={store.thisEncounterImageUrl} - possibleMatchImageUrl={ - store.selectedMatchImageUrl || store.thisEncounterImageUrl - } + selectedMatchImageUrl={store.getSelectedMatchImageUrl(algo.id)} themeColor={themeColor} /> ); diff --git a/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx index 785c18de40..80e504a45b 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx @@ -2,6 +2,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; import { Form, Button } from "react-bootstrap"; +import MainButton from "../../components/MainButton"; const styles = { bottomBar: (themeColor) => ({ @@ -39,6 +40,7 @@ const styles = { }; const MatchResultsBottomBar = observer(({ store, themeColor }) => { + const renderActions = () => { const matchingState = store.matchingState; @@ -46,28 +48,28 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { case "no_selection": return ( <> - - + ); @@ -76,23 +78,26 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { <> store.setNoMatchReason(e.target.value)} + placeholder="New Individual Name" + value={store.newIndividualName} + onChange={(e) => store.setNewIndividualName(e.target.value)} style={{ maxWidth: "300px" }} size="sm" /> - + ); diff --git a/frontend/src/pages/MatchResultsPage/constants.js b/frontend/src/pages/MatchResultsPage/constants.js index ff0738ef04..4ce2c684b3 100644 --- a/frontend/src/pages/MatchResultsPage/constants.js +++ b/frontend/src/pages/MatchResultsPage/constants.js @@ -1,22 +1,2 @@ -export const MATCHING_STATES = { - NO_SELECTION: "no_selection", - NO_INDIVIDUALS: "no_individuals", - SINGLE_INDIVIDUAL: "single_individual", - TWO_INDIVIDUALS: "two_individuals", - TOO_MANY_INDIVIDUALS: "too_many_individuals", -}; - -export const VIEW_MODES = { - INDIVIDUAL: "individual", - IMAGE: "image", -}; export const MAX_ROWS_PER_COLUMN = 4; - -export const API_ENDPOINTS = { - MATCH_RESULTS: "/api/match-results", - CONFIRM_MATCH: "/api/confirm-match", - CONFIRM_NO_MATCH: "/api/confirm-no-match", - MERGE_INDIVIDUALS: "/api/merge-individuals", - MARK_NEW_INDIVIDUAL: "/api/mark-new-individual", -}; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/mockupdata.js b/frontend/src/pages/MatchResultsPage/mockupdata.js index 8539647bc9..e8eed4f9c6 100644 --- a/frontend/src/pages/MatchResultsPage/mockupdata.js +++ b/frontend/src/pages/MatchResultsPage/mockupdata.js @@ -1,242 +1,112 @@ export const MOCK_DATA = { viewMode: "individual", - encounterId: "sdf9-sdaw-f624-4d3", - individualId: null, - projectName: "Giraffe Conservation Project", - evaluatedAt: "2024/02/29 7:34 PM", + encounterId: "enc-current-001", + individualId: "whale-current", + projectName: "Marine Wildlife Conservation", + evaluatedAt: "2024-12-03 10:30:00", numResults: 12, - numCandidates: 2343, - thisEncounterImageUrl: - "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", - possibleMatchImageUrl: - "https://images.pexels.com/photos/667205/pexels-photo-667205.jpeg", - + numCandidates: 150, + thisEncounterImageUrl: "https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=800&q=80", algorithms: [ { - id: "miewId", - label: "Matches Based on MIEW ID Algorithm", + id: "miew-id", + label: "MIEW ID", matches: [ { - rank: 1, - score: 0.8315, - encounterId: "123", - individualId: "TC_00124", + encounterId: "enc-001", + individualId: "whale-123", + score: 0.9876, + imageUrl: "https://images.unsplash.com/photo-1568430462989-44163eb1752f?w=800&q=80", }, { - rank: 2, - score: 0.8315, - encounterId: "456", - individualId: "TC_00126", + encounterId: "enc-002", + individualId: "whale-456", + score: 0.9234, + imageUrl: "https://images.unsplash.com/photo-1607153333879-c174d265f1d2?w=800&q=80", }, { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", + encounterId: "enc-003", + individualId: "dolphin-789", + score: 0.8765, + imageUrl: "https://images.unsplash.com/photo-1570551917276-4e9d4d8a8ddc?w=800&q=80", }, { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", + encounterId: "enc-004", + individualId: "whale-123", + score: 0.8543, + imageUrl: "https://images.unsplash.com/photo-1547721064-da6cfb341d50?w=800&q=80", }, { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", + encounterId: "enc-005", + individualId: "giraffe-321", + score: 0.8234, + imageUrl: "https://images.unsplash.com/photo-1534759846116-5799c33ce22a?w=800&q=80", }, { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", + encounterId: "enc-006", + individualId: "elephant-654", + score: 0.7987, + imageUrl: "https://images.unsplash.com/photo-1564760055775-d63b17a55c44?w=800&q=80", }, { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", + encounterId: "enc-007", + individualId: "elephant-987", + score: 0.7654, + imageUrl: "https://images.unsplash.com/photo-1551969014-7d2c4cddf0b6?w=800&q=80", }, { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", + encounterId: "enc-008", + individualId: "zebra-111", + score: 0.7321, + imageUrl: "https://images.unsplash.com/photo-1526336024174-e58f5cdd8e13?w=800&q=80", }, { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", + encounterId: "enc-009", + individualId: "zebra-222", + score: 0.7098, + imageUrl: "https://images.unsplash.com/photo-1437622368342-7a3d73a34c8f?w=800&q=80", }, { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", - }, - { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", - }, - { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", - }, - { - rank: 1, - score: 0.8315, - encounterId: "123", - individualId: "TC_00124", - }, - { - rank: 2, - score: 0.8315, - encounterId: "456", - individualId: "TC_00126", - }, - { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", - }, - { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", - }, - { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", - }, - { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", - }, - { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", - }, - { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", - }, - { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", - }, - { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", - }, - { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", - }, - { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", + encounterId: "enc-010", + individualId: "whale-999", + score: 0.6876, + imageUrl: "https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=800&q=80", }, ], }, { id: "hotspotter", - label: "Matches Based on Hotspotter", + label: "Hotspotter", matches: [ { - rank: 1, - score: 0.8315, - encounterId: "789", - individualId: "TC_00124", - }, - { - rank: 2, - score: 0.8315, - encounterId: "000", - individualId: "TC_00126", - }, - { - rank: 3, - score: 0.8315, - encounterId: "test", - individualId: "TC_00125", - }, - { - rank: 4, - score: 0.8315, - encounterId: "test", - individualId: "TC_00127", - }, - { - rank: 5, - score: 0.8315, - encounterId: "test", - individualId: "TC_00130", - }, - { - rank: 6, - score: 0.8315, - encounterId: "test", - individualId: "TC_00129", - }, - { - rank: 7, - score: 0.8315, - encounterId: "test", - individualId: "TC_00131", - }, - { - rank: 8, - score: 0.8315, - encounterId: "test", - individualId: "TC_00128", + encounterId: "enc-101", + individualId: "whale-456", + score: 0.9543, + imageUrl: "https://images.unsplash.com/photo-1568430462989-44163eb1752f?w=800&q=80", }, { - rank: 9, - score: 0.8315, - encounterId: "test", - individualId: "TC_00135", + encounterId: "enc-102", + individualId: "dolphin-789", + score: 0.9321, + imageUrl: "https://images.unsplash.com/photo-1607153333879-c174d265f1d2?w=800&q=80", }, { - rank: 10, - score: 0.8315, - encounterId: "test", - individualId: "TC_00133", + encounterId: "enc-103", + individualId: "whale-123", + score: 0.9012, + imageUrl: "https://images.unsplash.com/photo-1570551917276-4e9d4d8a8ddc?w=800&q=80", }, { - rank: 11, - score: 0.8315, - encounterId: "test", - individualId: "TC_00134", + encounterId: "enc-104", + individualId: "giraffe-321", + score: 0.8765, + imageUrl: "https://images.unsplash.com/photo-1547721064-da6cfb341d50?w=800&q=80", }, { - rank: 12, - score: 0.8315, - encounterId: "test", - individualId: "TC_00132", + encounterId: "enc-105", + individualId: "elephant-654", + score: 0.8543, + imageUrl: "https://images.unsplash.com/photo-1534759846116-5799c33ce22a?w=800&q=80", }, ], }, diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index eae0121ec6..c001f578cd 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -7,17 +7,17 @@ import { MOCK_DATA } from "../mockupdata"; export default class MatchResultsStore { _viewMode = "individual"; _encounterId = ""; - _individualId = null; + _individualId = null; _projectName = ""; _evaluatedAt = ""; _numResults = 12; _numCandidates = 0; - _thisEncounterImageUrl = ""; - _possibleMatchImageUrl = ""; + _thisEncounterImageUrl = ""; + _selectedMatchImageUrlByAlgo = new Map(); _algorithms = []; _selectedMatch = []; _taskId = null; - _noMatchReason = ""; + _newIndividualName = ""; constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -38,6 +38,15 @@ export default class MatchResultsStore { ...algo, matches: algo.matches.map((m) => ({ ...m })), })); + + this._algorithms.forEach((algo) => { + if (algo.matches && algo.matches.length > 0) { + const firstMatchImage = algo.matches[0].imageUrl; + if (firstMatchImage) { + this._selectedMatchImageUrlByAlgo.set(algo.id, firstMatchImage); + } + } + }); } get viewMode() { @@ -72,16 +81,20 @@ export default class MatchResultsStore { return this._thisEncounterImageUrl; } - get possibleMatchImageUrl() { - return this._possibleMatchImageUrl; + getSelectedMatchImageUrl(algorithmId) { + return this._selectedMatchImageUrlByAlgo.get(algorithmId) || ""; + } + + setPreviewImageUrl(algorithmId, url) { + this._selectedMatchImageUrlByAlgo.set(algorithmId, url); } get algorithms() { return this._algorithms; } - get noMatchReason() { - return this._noMatchReason; + get newIndividualName() { + return this._newIndividualName; } get taskId() { @@ -113,8 +126,8 @@ export default class MatchResultsStore { this._projectName = name; } - setNoMatchReason(reason) { - this._noMatchReason = reason; + setNewIndividualName(reason) { + this._newIndividualName = reason; } get selectedMatch() { @@ -123,10 +136,7 @@ export default class MatchResultsStore { setSelectedMatch(selected, encounterId, individualId) { if (selected) { - this._selectedMatch.push({ - encounterId, - individualId, - }); + this._selectedMatch = [...this._selectedMatch, { encounterId, individualId }]; } else { this._selectedMatch = this._selectedMatch.filter( (data) => data.encounterId !== encounterId, @@ -136,36 +146,36 @@ export default class MatchResultsStore { get uniqueIndividualIds() { const ids = new Set(); - + if (this._individualId) { ids.add(this._individualId); } - + this._selectedMatch.forEach((match) => { if (match.individualId) { ids.add(match.individualId); } }); - + return Array.from(ids); } get matchingState() { if (this._selectedMatch.length === 0) { - return "no_selection"; + return "no_selection"; } const uniqueIds = this.uniqueIndividualIds; const idCount = uniqueIds.length; if (idCount === 0) { - return "no_individuals"; + return "no_individuals"; } else if (idCount === 1) { - return "single_individual"; + return "single_individual"; } else if (idCount === 2) { - return "two_individuals"; + return "two_individuals"; } else { - return "too_many_individuals"; + return "too_many_individuals"; } } From 3c5df9d473a54aaf95f283b905c8a26192fa4ef0 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 16:36:59 -0700 Subject: [PATCH 008/192] convenience call to get json_result chunk of results --- .../java/org/ecocean/identity/IdentityServiceLog.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/org/ecocean/identity/IdentityServiceLog.java b/src/main/java/org/ecocean/identity/IdentityServiceLog.java index a92fdc7fc4..4493bb560b 100644 --- a/src/main/java/org/ecocean/identity/IdentityServiceLog.java +++ b/src/main/java/org/ecocean/identity/IdentityServiceLog.java @@ -296,6 +296,16 @@ public static void save(IdentityServiceLog l, Shepherd myShepherd) { myShepherd.getPM().makePersistent(l); } */ + public JSONObject getJsonResult() { + JSONObject status = getStatusJson(); + + if (status == null) return null; + if (status.optJSONObject("_response") == null) return null; + if (status.getJSONObject("_response").optJSONObject("response") == null) return null; + return status.getJSONObject("_response").getJSONObject("response").optJSONObject( + "json_result"); + } + public JSONObject toJSONObject() { return toJSONObject(false); } From 41905860bef5a268a0369bf058db72f21a10d7e1 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 16:37:55 -0700 Subject: [PATCH 009/192] one log version; linting --- src/main/java/org/ecocean/identity/IBEISIA.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/ecocean/identity/IBEISIA.java b/src/main/java/org/ecocean/identity/IBEISIA.java index afc625dff8..62643ed961 100644 --- a/src/main/java/org/ecocean/identity/IBEISIA.java +++ b/src/main/java/org/ecocean/identity/IBEISIA.java @@ -62,7 +62,6 @@ // date time - public class IBEISIA { // move this ish to its own class asap! private static final Map speciesMap; @@ -413,7 +412,7 @@ public static JSONArray imageUUIDList(List mas) { JSONArray uuidList = new JSONArray(); for (MediaAsset ma : mas) { - if(ma.getAcmId()!=null)uuidList.put(toFancyUUID(ma.getAcmId())); + if (ma.getAcmId() != null) uuidList.put(toFancyUUID(ma.getAcmId())); } return uuidList; } @@ -729,6 +728,14 @@ public static JSONObject getTaskResultsBasic(String taskID, return null; // if we fall through, it means we are still waiting ...... } + // singular log version + public static JSONObject getTaskResultsBasic(String taskID, IdentityServiceLog log) { + ArrayList one = new ArrayList(); + + one.add(log); + return getTaskResultsBasic(taskID, one); + } + public static HashMap getTaskResultsAsHashMap(String taskID, String context) { JSONObject jres = getTaskResults(taskID, context); HashMap res = new HashMap(); @@ -1493,8 +1500,7 @@ public static JSONObject processCallback(String taskID, JSONObject resp, String subParentTask.setParameters(taskParameters); myShepherd2.storeNewTask(subParentTask); myShepherd2.updateDBTransaction(); - - + Task childTask = IA.intakeAnnotations(myShepherd2, annots, subParentTask, false); myShepherd2.storeNewTask(childTask); @@ -1574,7 +1580,8 @@ private static JSONObject processCallbackDetect(String taskID, MediaAsset asset = null; for (MediaAsset ma : mas) { if (ma.getAcmId() == null) continue; // was likely an asset rejected (e.g. video) - if (ma.getAcmId().equals(iuuid) && !alreadyDetected.contains(ma.getIdInt())) { + if (ma.getAcmId().equals(iuuid) && + !alreadyDetected.contains(ma.getIdInt())) { alreadyDetected.add(ma.getIdInt()); asset = ma; break; From 6fad625ad4cccc144129d73e241580e0da52adbe Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 16:39:10 -0700 Subject: [PATCH 010/192] more MatchResult tinkering --- src/main/java/org/ecocean/ia/MatchResult.java | 123 ++++++++++++++++-- .../org/ecocean/ia/MatchResultProspect.java | 10 +- .../org/ecocean/shepherd/core/Shepherd.java | 22 +++- src/main/resources/org/ecocean/ia/package.jdo | 9 ++ 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index ab70ac0585..dbc8cd2e11 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -1,12 +1,6 @@ package org.ecocean.ia; import java.io.IOException; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.ServletException; - -import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -18,7 +12,6 @@ import org.json.JSONObject; /* - import org.ecocean.Annotation; import org.ecocean.Base; import org.ecocean.Encounter; import org.ecocean.media.AssetStore; @@ -30,12 +23,15 @@ import org.ecocean.resumableupload.UploadServlet; import org.ecocean.servlet.ReCAPTCHA; import org.ecocean.servlet.ServletUtilities; - import org.ecocean.shepherd.core.Shepherd; import org.ecocean.shepherd.core.ShepherdPMF; import org.ecocean.User; */ +import org.ecocean.Annotation; import org.ecocean.ia.Task; +import org.ecocean.identity.IBEISIA; +import org.ecocean.identity.IdentityServiceLog; +import org.ecocean.shepherd.core.Shepherd; import org.ecocean.Util; public class MatchResult implements java.io.Serializable { @@ -43,6 +39,8 @@ public class MatchResult implements java.io.Serializable { private long created; private Task task; private Set prospects; + private Annotation queryAnnotation; + private Set candidates; public MatchResult() { id = Util.generateUUID(); @@ -53,4 +51,113 @@ public MatchResult(Task task) { this(); this.task = task; } + + public MatchResult(IdentityServiceLog isLog, Shepherd myShepherd) + throws IOException { + this(); + this.createFromIdentityServiceLog(isLog, myShepherd); + } + + public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd myShepherd) + throws IOException { + if (isLog == null) throw new IOException("log passed is null"); + String taskId = isLog.getTaskID(); + this.task = myShepherd.getTask(taskId); + if (this.task == null) throw new IOException("task is null for taskId=" + taskId); + JSONObject res = isLog.getJsonResult(); + if (res == null) { + System.out.println("ERROR: getJsonResult() failed on " + isLog + " with status=" + + isLog.getStatusJson()); + throw new IOException("could not get json result"); + } + if (res.optJSONArray("query_annot_uuid_list") == null) + throw new IOException("no query annot list"); + if (res.getJSONArray("query_annot_uuid_list").length() < 1) + throw new IOException("empty query annot list"); + // for now we are assuming a single query annot. sorrynotsorry. + String queryAnnotId = IBEISIA.fromFancyUUID(res.getJSONArray( + "query_annot_uuid_list").optJSONObject(0)); + this.queryAnnotation = getAnnotationFromAcmId(queryAnnotId, myShepherd); + if (this.queryAnnotation == null) + throw new IOException("failed to load query annot from id=" + queryAnnotId); + if (res.optJSONObject("cm_dict") == null) + throw new IOException("no cm_dict found in " + res); + // results is the real scores (etc) we are looking for.... finally! + JSONObject results = res.getJSONObject("cm_dict").optJSONObject(queryAnnotId); + if (results == null) throw new IOException("no actual results found"); + // TODO load candidates from "database_annot_uuid_list" but maybe after prospects since there is overlap there??? +/* + annot_score_list <=> dannot_uuid_list + score_list is for indiv scores but on dannot_uuid_list (same length) + name_score_list <=> unique_name_uuid_list ??? + */ + this.prospects = new HashSet(); + this.populateProspects("annot", results.optJSONArray("dannot_uuid_list"), + results.optJSONArray("annot_score_list"), myShepherd); + this.populateProspects("indiv", results.optJSONArray("dannot_uuid_list"), + results.optJSONArray("score_list"), myShepherd); + System.out.println("[DEBUG] createFromIdentityServiceLog() created " + this); + } + + // must initialize this.propsects first!! + private int populateProspects(String type, JSONArray annotIds, JSONArray scores, + Shepherd myShepherd) + throws IOException { + if ((annotIds == null) || (scores == null)) + throw new IOException("null annotIds or scores"); + if (annotIds.length() != scores.length()) + throw new IOException("mismatch in size of annotIds/scores"); + int num = 0; + for (int i = 0; i < annotIds.length(); i++) { + double score = scores.optDouble(i, -Double.MAX_VALUE); + String id = IBEISIA.fromFancyUUID(annotIds.optJSONObject(i)); + Annotation ann = getAnnotationFromAcmId(id, myShepherd); + if (ann == null) { + System.out.println("WARNING: populateProspect failed to load annotId=" + id + + "; skipping; score=" + score); + continue; + } + this.prospects.add(new MatchResultProspect(ann, score, type)); + num++; + } + return num; + } + + private Annotation getAnnotationFromAcmId(String acmId, Shepherd myShepherd) { + if (acmId == null) return null; + List anns = myShepherd.getAnnotationsWithACMId(acmId, true); + if ((anns == null) || (anns.size() < 1)) return null; + return anns.get(0); + } + + public JSONObject getTaskParameters() { + if (task == null) return null; + return task.getParameters(); + } + + public JSONObject getTaskMatchingSetFilter() { + if (task == null) return null; + JSONObject params = task.getParameters(); + if (params == null) return null; + return params.optJSONObject("matchingSetFilter"); + } + + public int numberCandidates() { + return Util.collectionSize(candidates); + } + + public int numberProspects() { + return Util.collectionSize(prospects); + } + + public String toString() { + String s = "MatchResult " + id; + + s += " [" + new java.util.Date(created) + "]"; + s += " query " + queryAnnotation; + s += "; numCandidates=" + this.numberCandidates(); + s += "; numProspects=" + this.numberProspects(); + s += "; " + task; + return s; + } } diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index 334b9e78af..03b6d0e154 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -12,7 +12,8 @@ public class MatchResultProspect implements java.io.Serializable { private Annotation annotation; - private double score; + private double score = 0.0d; + private String scoreType; private MediaAsset asset; private MatchResult matchResult; @@ -22,4 +23,11 @@ public MatchResultProspect(Annotation ann) { this(); this.annotation = ann; } + + public MatchResultProspect(Annotation ann, double score, String type) { + this(); + this.annotation = ann; + this.score = score; + this.scoreType = type; + } } diff --git a/src/main/java/org/ecocean/shepherd/core/Shepherd.java b/src/main/java/org/ecocean/shepherd/core/Shepherd.java index 16f5616da1..42ea00a749 100644 --- a/src/main/java/org/ecocean/shepherd/core/Shepherd.java +++ b/src/main/java/org/ecocean/shepherd/core/Shepherd.java @@ -16,6 +16,7 @@ import org.ecocean.genetics.*; import org.ecocean.grid.ScanTask; import org.ecocean.grid.ScanWorkItem; +import org.ecocean.ia.MatchResult; import org.ecocean.ia.Task; import org.ecocean.media.*; import org.ecocean.movement.Path; @@ -2220,7 +2221,8 @@ public List getAllOrganizationsForUser(User user) { public List getAllCommonOrganizationsForTwoUsers(User user1, User user2) { ArrayList al = new ArrayList(); - if(user1==null||user2==null) return al; + + if (user1 == null || user2 == null) return al; try { Query q = getPM().newQuery( "SELECT FROM org.ecocean.Organization WHERE members.contains(user1) && members.contains(user2) && user1.uuid == \"" @@ -2229,12 +2231,10 @@ public List getAllCommonOrganizationsForTwoUsers(User user1, User Collection results = (Collection)q.execute(); al = new ArrayList(results); q.closeAll(); - } - catch (javax.jdo.JDOException x) { + } catch (javax.jdo.JDOException x) { x.printStackTrace(); return al; - } - catch (Exception xe) { + } catch (Exception xe) { xe.printStackTrace(); return al; } @@ -2800,6 +2800,18 @@ public List getIdentificationTasksForUser(User user) { return all; } + public MatchResult getMatchResult(String id) { + MatchResult mr = null; + + try { + mr = (MatchResult)(pm.getObjectById(pm.newObjectIdInstance(MatchResult.class, id), + true)); + } catch (Exception ex) { + ex.printStackTrace(); + } + return mr; + } + public MarkedIndividual getMarkedIndividualQuiet(String name) { MarkedIndividual indiv = null; diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo index 67e305692f..835a0f720c 100755 --- a/src/main/resources/org/ecocean/ia/package.jdo +++ b/src/main/resources/org/ecocean/ia/package.jdo @@ -84,6 +84,15 @@ alter table "TASK" alter column "PARAMETERS" type text; + + + + + + + + + From 6520566217daaf2f9802955470479708f98935b3 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 18:09:37 -0700 Subject: [PATCH 011/192] why dont we have this yet? --- src/main/java/org/ecocean/Util.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/ecocean/Util.java b/src/main/java/org/ecocean/Util.java index d513dccf99..04ca4b3fab 100644 --- a/src/main/java/org/ecocean/Util.java +++ b/src/main/java/org/ecocean/Util.java @@ -716,6 +716,12 @@ public static String prettyTimeStamp() { return sdf.format(new Date()); } + public static String millisToISO8601String(Long millis) { + if (millis == null) return null; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + return sdf.format(new Date(millis)); + } + public static boolean dateTimeIsOnlyDate(DateTime dt) { try { return (dt.millisOfDay().get() == 0); From f7763a51a498f691d99070fed9f2bd093dc79b60 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 18:10:31 -0700 Subject: [PATCH 012/192] scores and sorting and tasks oh my --- src/main/java/org/ecocean/ia/MatchResult.java | 47 ++++++++++++++++++- .../org/ecocean/ia/MatchResultProspect.java | 36 +++++++++++++- .../org/ecocean/shepherd/core/Shepherd.java | 14 ++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index dbc8cd2e11..db3114a03c 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -150,10 +151,54 @@ public int numberProspects() { return Util.collectionSize(prospects); } + public Set prospectScoreTypes() { + Set types = new HashSet(); + + if (numberProspects() == 0) return types; + for (MatchResultProspect mrp : prospects) { + types.add(mrp.getType()); + } + return types; + } + + public List prospectsSorted(String type, int cutoff) { + List pros = new ArrayList(); + + if (numberProspects() == 0) return pros; + for (MatchResultProspect mrp : prospects) { + if (mrp.isType(type)) pros.add(mrp); + } + Collections.sort(pros); + if (pros.size() > cutoff) return pros.subList(0, cutoff); + return pros; + } + + public JSONObject prospectsForApiGet(int cutoff) { + JSONObject sj = new JSONObject(); + + for (String type : prospectScoreTypes()) { + JSONArray jarr = new JSONArray(); + for (MatchResultProspect mrp : prospectsSorted(type, cutoff)) { + jarr.put(mrp.jsonForApiGet()); + } + sj.put(type, jarr); + } + return sj; + } + + public JSONObject jsonForApiGet(int cutoff) { + JSONObject rtn = new JSONObject(); + + rtn.put("id", id); + rtn.put("created", Util.millisToISO8601String(created)); + rtn.put("prospects", prospectsForApiGet(cutoff)); + return rtn; + } + public String toString() { String s = "MatchResult " + id; - s += " [" + new java.util.Date(created) + "]"; + s += " [" + Util.millisToISO8601String(created) + "]"; s += " query " + queryAnnotation; s += "; numCandidates=" + this.numberCandidates(); s += "; numProspects=" + this.numberProspects(); diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index 03b6d0e154..cb3155db8a 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -10,7 +10,7 @@ import org.ecocean.media.MediaAsset; import org.ecocean.Util; -public class MatchResultProspect implements java.io.Serializable { +public class MatchResultProspect implements java.io.Serializable, Comparable { private Annotation annotation; private double score = 0.0d; private String scoreType; @@ -30,4 +30,38 @@ public MatchResultProspect(Annotation ann, double score, String type) { this.score = score; this.scoreType = type; } + + public double getScore() { + return score; + } + + public String getType() { + return scoreType; + } + + public boolean isType(String type) { + if (type == null) return (this.scoreType == null); + return type.equals(this.scoreType); + } + + public String toString() { + return scoreType + ": " + score + " on " + annotation; + } + + public JSONObject jsonForApiGet() { + JSONObject rtn = new JSONObject(); + JSONObject annj = new JSONObject(); + + // TODO really fill out all we need for annotation! (shepherd) + annj.put("id", annotation.getId()); + rtn.put("annotation", annj); + rtn.put("score", score); + // skipping scoreType since this is currently only used filtered by scoreType already + return rtn; + } + + @Override public int compareTo(MatchResultProspect other) { + // we invert this so higher score is first + return Double.compare(other.score, this.score); + } } diff --git a/src/main/java/org/ecocean/shepherd/core/Shepherd.java b/src/main/java/org/ecocean/shepherd/core/Shepherd.java index 42ea00a749..86512510ec 100644 --- a/src/main/java/org/ecocean/shepherd/core/Shepherd.java +++ b/src/main/java/org/ecocean/shepherd/core/Shepherd.java @@ -2812,6 +2812,20 @@ public MatchResult getMatchResult(String id) { return mr; } + public List getMatchResults(Task task) { + List all = new ArrayList(); + + if (task == null) return all; + String filter = "SELECT FROM org.ecocean.ia.MatchResult WHERE task.id == '" + task.getId() + + "'"; + Query query = pm.newQuery(filter); + query.setOrdering("created DESC"); + Collection c = (Collection)query.execute(); + if (c != null) all = new ArrayList(c); + query.closeAll(); + return all; + } + public MarkedIndividual getMarkedIndividualQuiet(String name) { MarkedIndividual indiv = null; From 290bffe6572e9c00b3e9308a97f2024d5ab795be Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 3 Dec 2025 18:38:47 -0700 Subject: [PATCH 013/192] some recursion. sorta. some. --- src/main/java/org/ecocean/ia/Task.java | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index d840c5a561..b600b89396 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -594,4 +594,35 @@ public void setQueueResumeMessage(String message) { queueResumeMessage = message; } } + + // convenience + public List getMatchResults(Shepherd myShepherd) { + return myShepherd.getMatchResults(this); + } + + public MatchResult getLatestMatchResult(Shepherd myShepherd) { + List all = myShepherd.getMatchResults(this); + + if (Util.collectionIsEmptyOrNull(all)) return null; + return all.get(0); + } + + public JSONObject matchResultsJson(Shepherd myShepherd) { + JSONObject rtn = new JSONObject(); + + rtn.put("id", getId()); + rtn.put("parameters", getParameters()); + // TODO fill out generic task meta here -- query annot, matching set filter, etc + MatchResult mr = getLatestMatchResult(myShepherd); + if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(50)); + if (hasChildren()) { + JSONArray charr = new JSONArray(); + for (Task child : children) { + // TODO decide if we need to process child???? + charr.put(child.matchResultsJson(myShepherd)); + } + rtn.put("children", charr); + } + return rtn; + } } From 2a6b2b6b788de0dfd8a00f9bed369c541a6add1a Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 11:41:30 -0700 Subject: [PATCH 014/192] wip: api endpoint for tasks (only match-results now) --- .../java/org/ecocean/api/GenericObject.java | 30 +++++++++++++++++++ src/main/java/org/ecocean/ia/MatchResult.java | 5 +++- src/main/java/org/ecocean/ia/Task.java | 6 ++-- src/main/webapp/WEB-INF/web.xml | 4 +++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/ecocean/api/GenericObject.java b/src/main/java/org/ecocean/api/GenericObject.java index afef3377ab..0621cb9e23 100644 --- a/src/main/java/org/ecocean/api/GenericObject.java +++ b/src/main/java/org/ecocean/api/GenericObject.java @@ -11,6 +11,7 @@ import org.ecocean.Annotation; import org.ecocean.Encounter; +import org.ecocean.ia.Task; import org.ecocean.media.Feature; import org.ecocean.media.MediaAsset; import org.ecocean.media.MediaAssetFactory; @@ -89,6 +90,35 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } } break; + case "tasks": + if (currentUser == null) { + rtn.put("statusCode", 401); + rtn.put("error", "access denied"); + } else { + if ((args.length > 2) && ("match-results".equals(args[2]))) { + Task task = myShepherd.getTask(args[1]); + if (task == null) { + rtn.put("statusCode", 404); + rtn.put("error", "not found"); + } else { + // TODO do we have security on match results ?? + int prospectsSize = org.ecocean.ia.MatchResult.DEFAULT_PROSPECTS_CUTOFF; + try { + // note: negative size means all of them (no cutoff) + prospectsSize = Integer.parseInt(request.getParameter( + "prospectsSize")); + } catch (NumberFormatException ex) {} + rtn.put("prospectsSize", prospectsSize); + rtn.put("matchResults", + task.matchResultsJson(prospectsSize, myShepherd)); + rtn.put("success", true); + rtn.put("statusCode", 200); + } + } else { + throw new ApiException("invalid tasks operation"); + } + } + break; default: throw new ApiException("bad class"); } diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index db3114a03c..ffcb52ef4d 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -42,6 +42,8 @@ public class MatchResult implements java.io.Serializable { private Set prospects; private Annotation queryAnnotation; private Set candidates; + // fallback number to cutoff number of prospects to return + public static final int DEFAULT_PROSPECTS_CUTOFF = 100; public MatchResult() { id = Util.generateUUID(); @@ -161,6 +163,7 @@ public Set prospectScoreTypes() { return types; } + // if cutoff < 0 then it will not be truncated at all public List prospectsSorted(String type, int cutoff) { List pros = new ArrayList(); @@ -169,7 +172,7 @@ public List prospectsSorted(String type, int cutoff) { if (mrp.isType(type)) pros.add(mrp); } Collections.sort(pros); - if (pros.size() > cutoff) return pros.subList(0, cutoff); + if ((cutoff > 0) && (pros.size() > cutoff)) return pros.subList(0, cutoff); return pros; } diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index b600b89396..247d489591 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -607,19 +607,19 @@ public MatchResult getLatestMatchResult(Shepherd myShepherd) { return all.get(0); } - public JSONObject matchResultsJson(Shepherd myShepherd) { + public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { JSONObject rtn = new JSONObject(); rtn.put("id", getId()); rtn.put("parameters", getParameters()); // TODO fill out generic task meta here -- query annot, matching set filter, etc MatchResult mr = getLatestMatchResult(myShepherd); - if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(50)); + if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); if (hasChildren()) { JSONArray charr = new JSONArray(); for (Task child : children) { // TODO decide if we need to process child???? - charr.put(child.matchResultsJson(myShepherd)); + charr.put(child.matchResultsJson(cutoff, myShepherd)); } rtn.put("children", charr); } diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 05fe2d1dad..9670b5029e 100755 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -557,6 +557,10 @@ ApiGenericObject /api/v3/media-assets/* + + ApiGenericObject + /api/v3/tasks/* + ApiBaseObject From 66950c41309231b7be6d2c62b1d1001e1d1dda40 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 12:04:06 -0700 Subject: [PATCH 015/192] new name --- src/main/java/org/ecocean/api/GenericObject.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/ecocean/api/GenericObject.java b/src/main/java/org/ecocean/api/GenericObject.java index 0621cb9e23..c1a65d6d01 100644 --- a/src/main/java/org/ecocean/api/GenericObject.java +++ b/src/main/java/org/ecocean/api/GenericObject.java @@ -109,7 +109,7 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) "prospectsSize")); } catch (NumberFormatException ex) {} rtn.put("prospectsSize", prospectsSize); - rtn.put("matchResults", + rtn.put("matchResultsRoot", task.matchResultsJson(prospectsSize, myShepherd)); rtn.put("success", true); rtn.put("statusCode", 200); From 67cbe3d3931bf487003648b7c139728654c37257 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 16:54:19 -0700 Subject: [PATCH 016/192] api improvements --- src/main/java/org/ecocean/ia/MatchResult.java | 1 + src/main/java/org/ecocean/ia/Task.java | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index ffcb52ef4d..c756ea3dc3 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -193,6 +193,7 @@ public JSONObject jsonForApiGet(int cutoff) { JSONObject rtn = new JSONObject(); rtn.put("id", id); + rtn.put("numberTotalProspects", numberProspects()); rtn.put("created", Util.millisToISO8601String(created)); rtn.put("prospects", prospectsForApiGet(cutoff)); return rtn; diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index 247d489591..80cb13d53f 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -578,7 +578,7 @@ public void setStatus(String newStatus) { else { status = newStatus; } } - public java.lang.Long getCompletionDateInMilliseconds() { return completionDateInMilliseconds; } + public Long getCompletionDateInMilliseconds() { return completionDateInMilliseconds; } // this will set all date stuff based on ms since epoch public void setCompletionDateInMilliseconds(Long ms) { @@ -595,6 +595,29 @@ public void setQueueResumeMessage(String message) { } } + public JSONObject getMatchingSetFilter() { + if (getParameters() == null) return null; + return getParameters().optJSONObject("matchingSetFilter"); + } + + public JSONObject getIdentificationMethodInfo() { + if (getParameters() == null) return null; + if (getParameters().optJSONObject("ibeis.identification") == null) return null; + // it seems both of these are in most logs (and are identical), but being safe in case there are + // examples in the wild with only one + JSONObject conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( + "query_config_dict"); + if (conf == null) + conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( + "queryConfigDict"); + JSONObject rtn = new JSONObject(); + if (conf != null) rtn.put("name", conf.optString("pipeline_root", null)); // null conf means that we have no name + rtn.put("description", + getParameters().getJSONObject("ibeis.identification").optString("description", + "unknown algorith/method")); + return rtn; + } + // convenience public List getMatchResults(Shepherd myShepherd) { return myShepherd.getMatchResults(this); @@ -611,8 +634,18 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { JSONObject rtn = new JSONObject(); rtn.put("id", getId()); - rtn.put("parameters", getParameters()); - // TODO fill out generic task meta here -- query annot, matching set filter, etc + rtn.put("dateCreated", Util.millisToISO8601String(getCreatedLong())); + rtn.put("dateCompleted", Util.millisToISO8601String(getCompletionDateInMilliseconds())); + + JSONObject methodInfo = getIdentificationMethodInfo(); + // we basically use this to determine if we are "identification-like" enough + // to display extended details + if (methodInfo != null) { + rtn.put("method", getIdentificationMethodInfo()); + rtn.put("matchingSetFilter", getMatchingSetFilter()); + rtn.put("status", getStatus(myShepherd)); + rtn.put("statusOverall", getOverallStatus(myShepherd)); + } MatchResult mr = getLatestMatchResult(myShepherd); if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); if (hasChildren()) { From 24fc57f1a639301031f283b56d7252d9734d4d66 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 17:24:00 -0700 Subject: [PATCH 017/192] a little query annotation investigation --- src/main/java/org/ecocean/ia/MatchResult.java | 10 ++++++++++ src/main/java/org/ecocean/ia/MatchResultProspect.java | 5 +---- src/main/java/org/ecocean/ia/Task.java | 8 +++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index c756ea3dc3..f8aa5d12d8 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -193,12 +193,22 @@ public JSONObject jsonForApiGet(int cutoff) { JSONObject rtn = new JSONObject(); rtn.put("id", id); + rtn.put("queryAnnotation", annotationDetails(queryAnnotation)); rtn.put("numberTotalProspects", numberProspects()); rtn.put("created", Util.millisToISO8601String(created)); rtn.put("prospects", prospectsForApiGet(cutoff)); return rtn; } + public static JSONObject annotationDetails(Annotation ann) { + JSONObject aj = new JSONObject(); + + aj.put("TODO", "fill this out with encounter, asset, indiv, etc etc etc"); + if (ann == null) return aj; + aj.put("id", ann.getId()); + return aj; + } + public String toString() { String s = "MatchResult " + id; diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index cb3155db8a..d35ec4028b 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -50,11 +50,8 @@ public String toString() { public JSONObject jsonForApiGet() { JSONObject rtn = new JSONObject(); - JSONObject annj = new JSONObject(); - // TODO really fill out all we need for annotation! (shepherd) - annj.put("id", annotation.getId()); - rtn.put("annotation", annj); + rtn.put("annotation", MatchResult.annotationDetails(annotation)); rtn.put("score", score); // skipping scoreType since this is currently only used filtered by scoreType already return rtn; diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index 80cb13d53f..ac85e9fdf0 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -636,7 +636,13 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { rtn.put("id", getId()); rtn.put("dateCreated", Util.millisToISO8601String(getCreatedLong())); rtn.put("dateCompleted", Util.millisToISO8601String(getCompletionDateInMilliseconds())); - + if (hasObjectAnnotations()) { + JSONArray annotArr = new JSONArray(); + for (Annotation ann : getObjectAnnotations()) { + if (ann != null) annotArr.put(ann.getId()); + } + rtn.put("__taskAnnotations", annotArr); + } JSONObject methodInfo = getIdentificationMethodInfo(); // we basically use this to determine if we are "identification-like" enough // to display extended details From 2a850cd40a6d5065e498faaf9e659d9997b149f0 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 18:06:28 -0700 Subject: [PATCH 018/192] if a task has no MatchResults, we now generate them on the fly. or so i claim. --- src/main/java/org/ecocean/ia/Task.java | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index ac85e9fdf0..a61aabc7eb 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -630,12 +630,41 @@ public MatchResult getLatestMatchResult(Shepherd myShepherd) { return all.get(0); } + // logs are returned in chronological order here, so if the latest is desired, take the LAST one + public List generateMatchResults(Shepherd myShepherd) { + List mrs = new ArrayList(); + ArrayList logs = IdentityServiceLog.loadByTaskID(this.id, "IBEISIA", + myShepherd); + + if (logs == null) return mrs; + for (IdentityServiceLog log : logs) { + JSONObject res = log.getJsonResult(); + // in theory this is how we can tell if it is an ident result log versus detection + if ((res != null) && (res.optJSONObject("cm_dict") != null)) { + try { + MatchResult mr = new MatchResult(log, myShepherd); + System.out.println("[INFO] generateMatchResults() [log t=" + + log.getTimestamp() + "] on " + this + " generated: " + mr); + myShepherd.getPM().makePersistent(mr); + mrs.add(mr); + } catch (java.io.IOException ex) { + System.out.println("[ERROR] generateMatchResults() [log t=" + + log.getTimestamp() + "] on " + this + " failed: " + ex); + ex.printStackTrace(); + } + } + } + return mrs; + } + public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { JSONObject rtn = new JSONObject(); rtn.put("id", getId()); rtn.put("dateCreated", Util.millisToISO8601String(getCreatedLong())); rtn.put("dateCompleted", Util.millisToISO8601String(getCompletionDateInMilliseconds())); + // TODO theory is that we might not need to use/store queryAnnotation on MatchResult as + // we should have it here, hence this debugging value ... possible optimization for later if (hasObjectAnnotations()) { JSONArray annotArr = new JSONArray(); for (Annotation ann : getObjectAnnotations()) { @@ -649,11 +678,21 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { if (methodInfo != null) { rtn.put("method", getIdentificationMethodInfo()); rtn.put("matchingSetFilter", getMatchingSetFilter()); + // unsure which of these two things is more accurate or useful; thus including both rtn.put("status", getStatus(myShepherd)); rtn.put("statusOverall", getOverallStatus(myShepherd)); + // we only care about (and importantly try to generate) MatchResults for ident type too + MatchResult mr = getLatestMatchResult(myShepherd); + if (mr == null) { + System.out.println( + "[DEBUG] matchResultsJson() found no MatchResults; generating on " + this); + List mrs = generateMatchResults(myShepherd); + rtn.put("_generatedMatchResultsSize", mrs.size()); // leave a clue that we did the work! + if (mrs.size() > 0) mr = mrs.get(mrs.size() - 1); + } + if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); } - MatchResult mr = getLatestMatchResult(myShepherd); - if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); + // now we recurse thru children if applicable if (hasChildren()) { JSONArray charr = new JSONArray(); for (Task child : children) { From c4b65ed79919647dad87db46e4e8e687df488764 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 4 Dec 2025 18:39:25 -0700 Subject: [PATCH 019/192] oops when we create on-the-fly, we have to commit the shepherd to persist into db --- .../java/org/ecocean/api/GenericObject.java | 16 +++++++++++++--- src/main/java/org/ecocean/ia/Task.java | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/ecocean/api/GenericObject.java b/src/main/java/org/ecocean/api/GenericObject.java index c1a65d6d01..4ec9b261e3 100644 --- a/src/main/java/org/ecocean/api/GenericObject.java +++ b/src/main/java/org/ecocean/api/GenericObject.java @@ -25,6 +25,9 @@ public class GenericObject extends ApiBase { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String context = ServletUtilities.getContext(request); + // normally false for GET, but some deep behavior creates objects on-the-fly + // and therefore needs to commit to db + boolean commitShepherd = false; Shepherd myShepherd = new Shepherd(context); myShepherd.setAction("api.GenericObject.doGet"); @@ -109,10 +112,13 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) "prospectsSize")); } catch (NumberFormatException ex) {} rtn.put("prospectsSize", prospectsSize); - rtn.put("matchResultsRoot", - task.matchResultsJson(prospectsSize, myShepherd)); + JSONObject mrJson = task.matchResultsJson(prospectsSize, myShepherd); + rtn.put("matchResultsRoot", mrJson); rtn.put("success", true); rtn.put("statusCode", 200); + // this means we created on-the-fly some MatchResult(s) that need persisting + commitShepherd = mrJson.optBoolean("_commitShepherd", false); + if (commitShepherd) myShepherd.commitDBTransaction(); } } else { throw new ApiException("invalid tasks operation"); @@ -127,7 +133,11 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) rtn.put("errors", apiEx.getErrors()); rtn.put("debug", apiEx.toString()); } finally { - myShepherd.rollbackAndClose(); + if (commitShepherd) { + myShepherd.closeDBTransaction(); + } else { + myShepherd.rollbackAndClose(); + } } response.setStatus(rtn.optInt("statusCode", 500)); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index a61aabc7eb..985a8b4407 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -211,6 +211,11 @@ public Task getParent() { return parent; } + public String getParentId() { + if (parent == null) return null; + return parent.getId(); + } + public int numChildren() { return (children == null) ? 0 : children.size(); } @@ -661,6 +666,7 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { JSONObject rtn = new JSONObject(); rtn.put("id", getId()); + rtn.put("parentTaskId", getParentId()); rtn.put("dateCreated", Util.millisToISO8601String(getCreatedLong())); rtn.put("dateCompleted", Util.millisToISO8601String(getCompletionDateInMilliseconds())); // TODO theory is that we might not need to use/store queryAnnotation on MatchResult as @@ -688,7 +694,10 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { "[DEBUG] matchResultsJson() found no MatchResults; generating on " + this); List mrs = generateMatchResults(myShepherd); rtn.put("_generatedMatchResultsSize", mrs.size()); // leave a clue that we did the work! - if (mrs.size() > 0) mr = mrs.get(mrs.size() - 1); + if (mrs.size() > 0) { + mr = mrs.get(mrs.size() - 1); + rtn.put("_commitShepherd", true); + } } if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); } @@ -697,7 +706,11 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { JSONArray charr = new JSONArray(); for (Task child : children) { // TODO decide if we need to process child???? - charr.put(child.matchResultsJson(cutoff, myShepherd)); + JSONObject childJson = child.matchResultsJson(cutoff, myShepherd); + // we have to bubble this up all the way to the toplevel :/ + if (childJson.optBoolean("_commitShepherd", false)) + rtn.put("_commitShepherd", true); + charr.put(childJson); } rtn.put("children", charr); } From 6eadf84008586b96101aae39abc2d62aea705cdf Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Fri, 5 Dec 2025 12:02:18 -0700 Subject: [PATCH 020/192] deal with candidate count --- src/main/java/org/ecocean/ia/MatchResult.java | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index f8aa5d12d8..0ffcdcd117 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -12,22 +12,6 @@ import org.json.JSONArray; import org.json.JSONObject; -/* - import org.ecocean.Base; - import org.ecocean.Encounter; - import org.ecocean.media.AssetStore; - import org.ecocean.media.MediaAsset; - import org.ecocean.media.MediaAssetFactory; - import org.ecocean.MarkedIndividual; - import org.ecocean.Occurrence; - import org.ecocean.OpenSearch; - import org.ecocean.resumableupload.UploadServlet; - import org.ecocean.servlet.ReCAPTCHA; - import org.ecocean.servlet.ServletUtilities; - import org.ecocean.shepherd.core.ShepherdPMF; - import org.ecocean.User; - */ - import org.ecocean.Annotation; import org.ecocean.ia.Task; import org.ecocean.identity.IBEISIA; @@ -41,6 +25,10 @@ public class MatchResult implements java.io.Serializable { private Task task; private Set prospects; private Annotation queryAnnotation; + private int numberCandidates = 0; + // not sure we really *need* true fk link to these annots + // they might be gone now and will we ever use this? + // so for now we just populate numberCandidates private Set candidates; // fallback number to cutoff number of prospects to return public static final int DEFAULT_PROSPECTS_CUTOFF = 100; @@ -61,6 +49,10 @@ public MatchResult(IdentityServiceLog isLog, Shepherd myShepherd) this.createFromIdentityServiceLog(isLog, myShepherd); } + public int getNumberCandidates() { + return numberCandidates; + } + public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd myShepherd) throws IOException { if (isLog == null) throw new IOException("log passed is null"); @@ -88,7 +80,9 @@ public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd mySh // results is the real scores (etc) we are looking for.... finally! JSONObject results = res.getJSONObject("cm_dict").optJSONObject(queryAnnotId); if (results == null) throw new IOException("no actual results found"); - // TODO load candidates from "database_annot_uuid_list" but maybe after prospects since there is overlap there??? + // see note at top about true annot list of candidates vs number + if (res.optJSONArray("database_annot_uuid_list") != null) + this.numberCandidates = res.getJSONArray("database_annot_uuid_list").length(); /* annot_score_list <=> dannot_uuid_list score_list is for indiv scores but on dannot_uuid_list (same length) @@ -145,10 +139,12 @@ public JSONObject getTaskMatchingSetFilter() { return params.optJSONObject("matchingSetFilter"); } +/* + see note at top about candidates vs numberCandidates public int numberCandidates() { return Util.collectionSize(candidates); } - + */ public int numberProspects() { return Util.collectionSize(prospects); } @@ -195,6 +191,7 @@ public JSONObject jsonForApiGet(int cutoff) { rtn.put("id", id); rtn.put("queryAnnotation", annotationDetails(queryAnnotation)); rtn.put("numberTotalProspects", numberProspects()); + rtn.put("numberCandidates", getNumberCandidates()); rtn.put("created", Util.millisToISO8601String(created)); rtn.put("prospects", prospectsForApiGet(cutoff)); return rtn; @@ -214,7 +211,7 @@ public String toString() { s += " [" + Util.millisToISO8601String(created) + "]"; s += " query " + queryAnnotation; - s += "; numCandidates=" + this.numberCandidates(); + s += "; numCandidates=" + this.getNumberCandidates(); s += "; numProspects=" + this.numberProspects(); s += "; " + task; return s; From 4fcd697a6c8742b83e9601d360669e73583a00e1 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Fri, 5 Dec 2025 12:55:33 -0700 Subject: [PATCH 021/192] populate out the annotation data --- src/main/java/org/ecocean/ia/MatchResult.java | 60 ++++++++++++++++--- .../org/ecocean/ia/MatchResultProspect.java | 5 +- src/main/java/org/ecocean/ia/Task.java | 2 +- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index 0ffcdcd117..c327d307ed 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -13,9 +13,13 @@ import org.json.JSONObject; import org.ecocean.Annotation; +import org.ecocean.Encounter; import org.ecocean.ia.Task; import org.ecocean.identity.IBEISIA; import org.ecocean.identity.IdentityServiceLog; +import org.ecocean.media.Feature; +import org.ecocean.media.MediaAsset; +import org.ecocean.MarkedIndividual; import org.ecocean.shepherd.core.Shepherd; import org.ecocean.Util; @@ -172,36 +176,78 @@ public List prospectsSorted(String type, int cutoff) { return pros; } - public JSONObject prospectsForApiGet(int cutoff) { + public JSONObject prospectsForApiGet(int cutoff, Shepherd myShepherd) { JSONObject sj = new JSONObject(); for (String type : prospectScoreTypes()) { JSONArray jarr = new JSONArray(); for (MatchResultProspect mrp : prospectsSorted(type, cutoff)) { - jarr.put(mrp.jsonForApiGet()); + jarr.put(mrp.jsonForApiGet(myShepherd)); } sj.put(type, jarr); } return sj; } - public JSONObject jsonForApiGet(int cutoff) { + public JSONObject jsonForApiGet(int cutoff, Shepherd myShepherd) { JSONObject rtn = new JSONObject(); rtn.put("id", id); - rtn.put("queryAnnotation", annotationDetails(queryAnnotation)); + rtn.put("queryAnnotation", annotationDetails(queryAnnotation, myShepherd)); rtn.put("numberTotalProspects", numberProspects()); rtn.put("numberCandidates", getNumberCandidates()); rtn.put("created", Util.millisToISO8601String(created)); - rtn.put("prospects", prospectsForApiGet(cutoff)); + rtn.put("prospects", prospectsForApiGet(cutoff, myShepherd)); return rtn; } - public static JSONObject annotationDetails(Annotation ann) { + public static JSONObject annotationDetails(Annotation ann, Shepherd myShepherd) { JSONObject aj = new JSONObject(); - aj.put("TODO", "fill this out with encounter, asset, indiv, etc etc etc"); if (ann == null) return aj; + MediaAsset ma = ann.getMediaAsset(); + // populate bounding box stuff (note: it may reset aj so must be done first) + if (ann.getFeatures() != null) { + for (Feature ft : ann.getFeatures()) { + if (ft.isUnity()) { + aj.put("trivial", true); + aj.put("x", 0); + aj.put("y", 0); + // would be weird to be null, but..... + if (ma != null) { + aj.put("width", (int)ma.getWidth()); + aj.put("height", (int)ma.getHeight()); + } + } else { + // basically if we have more than one feature, only one wins + if (ft.getParameters() != null) aj = ft.getParameters(); + } + } + } + if (ma != null) { + JSONObject mj = ma.toSimpleJSONObject(); + mj.put("rotationInfo", ma.getRotationInfo()); + aj.put("asset", mj); + } + Encounter enc = ann.findEncounter(myShepherd); + if (enc != null) { + JSONObject ej = new JSONObject(); + // FIXME add "access" permission value + ej.put("id", enc.getId()); + ej.put("taxonomy", enc.getTaxonomyString()); + aj.put("encounter", ej); + MarkedIndividual indiv = enc.getIndividual(); + if (indiv != null) { + JSONObject ij = new JSONObject(); + ij.put("id", indiv.getId()); + ij.put("taxonomy", indiv.getTaxonomyString()); + ij.put("displayName", indiv.getDisplayName()); + ij.put("nickname", indiv.getNickName()); + ij.put("sex", indiv.getSex()); + ij.put("numberEncounters", indiv.getNumEncounters()); + aj.put("individual", ij); + } + } aj.put("id", ann.getId()); return aj; } diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index d35ec4028b..7c4fc2ae46 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -8,6 +8,7 @@ import org.ecocean.Annotation; import org.ecocean.media.MediaAsset; +import org.ecocean.shepherd.core.Shepherd; import org.ecocean.Util; public class MatchResultProspect implements java.io.Serializable, Comparable { @@ -48,10 +49,10 @@ public String toString() { return scoreType + ": " + score + " on " + annotation; } - public JSONObject jsonForApiGet() { + public JSONObject jsonForApiGet(Shepherd myShepherd) { JSONObject rtn = new JSONObject(); - rtn.put("annotation", MatchResult.annotationDetails(annotation)); + rtn.put("annotation", MatchResult.annotationDetails(annotation, myShepherd)); rtn.put("score", score); // skipping scoreType since this is currently only used filtered by scoreType already return rtn; diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index 985a8b4407..e8fee916bb 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -699,7 +699,7 @@ public JSONObject matchResultsJson(int cutoff, Shepherd myShepherd) { rtn.put("_commitShepherd", true); } } - if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff)); + if (mr != null) rtn.put("matchResults", mr.jsonForApiGet(cutoff, myShepherd)); } // now we recurse thru children if applicable if (hasChildren()) { From 9726c263c7d72be6eba16bcb2966488b7f8d8818 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Fri, 5 Dec 2025 15:38:14 -0700 Subject: [PATCH 022/192] wip: getting inspection image --- src/main/java/org/ecocean/ia/MatchResult.java | 27 +++++++++++++++---- .../org/ecocean/ia/MatchResultProspect.java | 8 +++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index c327d307ed..a57ed0d391 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -94,15 +94,17 @@ score_list is for indiv scores but on dannot_uuid_list (same length) */ this.prospects = new HashSet(); this.populateProspects("annot", results.optJSONArray("dannot_uuid_list"), - results.optJSONArray("annot_score_list"), myShepherd); + results.optJSONArray("annot_score_list"), results.optJSONArray("dannot_extern_list"), + results.optString("dannot_extern_reference", null), myShepherd); this.populateProspects("indiv", results.optJSONArray("dannot_uuid_list"), - results.optJSONArray("score_list"), myShepherd); + results.optJSONArray("score_list"), results.optJSONArray("dannot_extern_list"), + results.optString("dannot_extern_reference", null), myShepherd); System.out.println("[DEBUG] createFromIdentityServiceLog() created " + this); } // must initialize this.propsects first!! private int populateProspects(String type, JSONArray annotIds, JSONArray scores, - Shepherd myShepherd) + JSONArray externs, String externRef, Shepherd myShepherd) throws IOException { if ((annotIds == null) || (scores == null)) throw new IOException("null annotIds or scores"); @@ -118,7 +120,11 @@ private int populateProspects(String type, JSONArray annotIds, JSONArray scores, "; skipping; score=" + score); continue; } - this.prospects.add(new MatchResultProspect(ann, score, type)); + MediaAsset ma = null; + // we only try if we have a true value in externs[i] + if ((externs != null) && (externs.length() > i) && externs.optBoolean(i, false)) + ma = createInspectionHeatmapAsset(externRef, id); + this.prospects.add(new MatchResultProspect(ann, score, type, ma)); num++; } return num; @@ -131,6 +137,17 @@ private Annotation getAnnotationFromAcmId(String acmId, Shepherd myShepherd) { return anns.get(0); } + // if it exists, we just return the thing, other wise we attempt to create it + public MediaAsset createInspectionHeatmapAsset(String externRef, String annotId) { + if (externRef == null) return null; + String url = "/api/query/graph/match/thumb/?extern_reference=" + externRef; + url += "&query_annot_uuid=" + this.queryAnnotation.getId(); + url += "&database_annot_uuid=" + annotId; + url += "&version=heatmap"; + System.out.println("[DEBUG] trying extern url=" + url); + return null; + } + public JSONObject getTaskParameters() { if (task == null) return null; return task.getParameters(); @@ -232,7 +249,7 @@ public static JSONObject annotationDetails(Annotation ann, Shepherd myShepherd) Encounter enc = ann.findEncounter(myShepherd); if (enc != null) { JSONObject ej = new JSONObject(); - // FIXME add "access" permission value + // TODO add "access" permission value if needed? ej.put("id", enc.getId()); ej.put("taxonomy", enc.getTaxonomyString()); aj.put("encounter", ej); diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index 7c4fc2ae46..d9a9805861 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -25,11 +25,12 @@ public MatchResultProspect(Annotation ann) { this.annotation = ann; } - public MatchResultProspect(Annotation ann, double score, String type) { + public MatchResultProspect(Annotation ann, double score, String type, MediaAsset asset) { this(); this.annotation = ann; this.score = score; this.scoreType = type; + this.asset = asset; } public double getScore() { @@ -55,6 +56,11 @@ public JSONObject jsonForApiGet(Shepherd myShepherd) { rtn.put("annotation", MatchResult.annotationDetails(annotation, myShepherd)); rtn.put("score", score); // skipping scoreType since this is currently only used filtered by scoreType already + if (asset != null) { + JSONObject aj = asset.toSimpleJSONObject(); + aj.put("rotationInfo", asset.getRotationInfo()); + rtn.put("asset", aj); + } return rtn; } From f21f4238f5f3608f3e53027c6ec7ad28eb1cf0ee Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Sat, 6 Dec 2025 11:07:59 -0700 Subject: [PATCH 023/192] fetch the image, make the asset --- src/main/java/org/ecocean/ia/MatchResult.java | 30 ++++++++++++++++--- .../org/ecocean/ia/MatchResultProspect.java | 2 +- .../java/org/ecocean/identity/IBEISIA.java | 3 -- .../java/org/ecocean/media/MediaAsset.java | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index a57ed0d391..fbb89f34a0 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -1,6 +1,8 @@ package org.ecocean.ia; +import java.io.File; import java.io.IOException; +import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -12,6 +14,7 @@ import org.json.JSONArray; import org.json.JSONObject; +import org.ecocean.api.UploadedFiles; import org.ecocean.Annotation; import org.ecocean.Encounter; import org.ecocean.ia.Task; @@ -19,6 +22,7 @@ import org.ecocean.identity.IdentityServiceLog; import org.ecocean.media.Feature; import org.ecocean.media.MediaAsset; +import org.ecocean.media.URLAssetStore; import org.ecocean.MarkedIndividual; import org.ecocean.shepherd.core.Shepherd; import org.ecocean.Util; @@ -123,7 +127,7 @@ private int populateProspects(String type, JSONArray annotIds, JSONArray scores, MediaAsset ma = null; // we only try if we have a true value in externs[i] if ((externs != null) && (externs.length() > i) && externs.optBoolean(i, false)) - ma = createInspectionHeatmapAsset(externRef, id); + ma = createInspectionHeatmapAsset(externRef, id, myShepherd); this.prospects.add(new MatchResultProspect(ann, score, type, ma)); num++; } @@ -138,14 +142,32 @@ private Annotation getAnnotationFromAcmId(String acmId, Shepherd myShepherd) { } // if it exists, we just return the thing, other wise we attempt to create it - public MediaAsset createInspectionHeatmapAsset(String externRef, String annotId) { + public MediaAsset createInspectionHeatmapAsset(String externRef, String annotId, + Shepherd myShepherd) { if (externRef == null) return null; String url = "/api/query/graph/match/thumb/?extern_reference=" + externRef; url += "&query_annot_uuid=" + this.queryAnnotation.getId(); url += "&database_annot_uuid=" + annotId; url += "&version=heatmap"; - System.out.println("[DEBUG] trying extern url=" + url); - return null; + URL fullUrl = IBEISIA.iaURL(myShepherd.getContext(), url); + File tmpFile = new File("/tmp/extern-" + this.id + "-" + externRef + "-" + + this.queryAnnotation.getId() + "-" + annotId + ".jpg"); + System.out.println("[DEBUG] trying extern fetch url=" + fullUrl + " => " + tmpFile); + MediaAsset ma = null; + try { + URLAssetStore.fetchFileFromURL(fullUrl, tmpFile); + ma = UploadedFiles.makeMediaAsset(this.id, tmpFile, myShepherd); + ma.addLabel("matchInspectionHeatmap"); + System.out.println("[INFO] createInspectionHeatmapAsset() fetched " + fullUrl + + " and created " + ma); + tmpFile.delete(); + } catch (Exception ex) { + System.out.println( + "[ERROR] createInspectionHeatmapAsset() asset creation failed using " + fullUrl + + " => " + tmpFile + ": " + ex); + ex.printStackTrace(); + } + return ma; } public JSONObject getTaskParameters() { diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java index d9a9805861..8b83398413 100644 --- a/src/main/java/org/ecocean/ia/MatchResultProspect.java +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -58,7 +58,7 @@ public JSONObject jsonForApiGet(Shepherd myShepherd) { // skipping scoreType since this is currently only used filtered by scoreType already if (asset != null) { JSONObject aj = asset.toSimpleJSONObject(); - aj.put("rotationInfo", asset.getRotationInfo()); + aj.put("url", asset.webURL()); // we have no "safe" url rtn.put("asset", aj); } return rtn; diff --git a/src/main/java/org/ecocean/identity/IBEISIA.java b/src/main/java/org/ecocean/identity/IBEISIA.java index 62643ed961..1683fd91e0 100644 --- a/src/main/java/org/ecocean/identity/IBEISIA.java +++ b/src/main/java/org/ecocean/identity/IBEISIA.java @@ -2035,9 +2035,6 @@ public static URL iaURL(String context, String urlSuffix) { System.out.println("INFO: setting iaBaseURL=" + iaBaseURL); } String ustr = iaBaseURL; - - System.out.println("!!!ustr: " + iaBaseURL); - System.out.println("!!!urlSuffix: " + urlSuffix); if (urlSuffix != null) { if (urlSuffix.indexOf("/") == 0) urlSuffix = urlSuffix.substring(1); // get rid of leading / ustr += urlSuffix; diff --git a/src/main/java/org/ecocean/media/MediaAsset.java b/src/main/java/org/ecocean/media/MediaAsset.java index b0d4136b57..bf2c9803e6 100644 --- a/src/main/java/org/ecocean/media/MediaAsset.java +++ b/src/main/java/org/ecocean/media/MediaAsset.java @@ -950,7 +950,7 @@ public org.datanucleus.api.rest.orgjson.JSONObject sanitizeJson(HttpServletReque public JSONObject toSimpleJSONObject() { JSONObject j = new JSONObject(); - j.put("id", getId()); + j.put("id", getIdInt()); j.put("uuid", getUUID()); j.put("url", safeURL()); if ((getMetadata() != null) && (getMetadata().getData() != null) && From e5f29c2f69f8553577f2177d4ebfede2c0a9ac9b Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Mon, 8 Dec 2025 20:42:45 +0000 Subject: [PATCH 024/192] baby steps to apply real json --- .../MatchResultsPage/MatchProspectTable.jsx | 132 +++++++++--------- .../pages/MatchResultsPage/MatchResults.jsx | 56 +++++--- .../store/matchResultsStore.js | 81 ++++++----- 3 files changed, 153 insertions(+), 116 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index 8536f36064..1b78ba962f 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -1,6 +1,5 @@ import React from "react"; import { Row, Col, Form } from "react-bootstrap"; -import { observer } from "mobx-react-lite"; import ZoomInIcon from "./icons/ZoomInIcon"; import ZoomOutIcon from "./icons/ZoomOutIcon"; import Icon3 from "./icons/Icon3"; @@ -68,6 +67,7 @@ const styles = { padding: "2px 8px", borderRadius: "2px", fontSize: "0.75rem", + zIndex: 1000, }), toolsBarLeft: { position: "absolute", @@ -107,20 +107,19 @@ const styles = { const MatchProspectTable = ({ - label, - matchColumns, + label = "", numCandidates, - evaluatedAt, + date, selectedMatch, - onToggleSelected, - onRowClick, + onToggleSelected = {}, + onRowClick = {}, thisEncounterImageUrl, selectedMatchImageUrl, themeColor, + candidates, + algorithm, }) => { - const [previewedEncounterId, setPreviewedEncounterId] = React.useState( - matchColumns[0]?.[0]?.encounterId || null - ); + const [previewedEncounterId, setPreviewedEncounterId] = React.useState(); const [leftImageZoom, setLeftImageZoom] = React.useState(1); const [rightImageZoom, setRightImageZoom] = React.useState(1); @@ -152,7 +151,7 @@ const MatchProspectTable = const [rightPanEnabled, setRightPanEnabled] = React.useState(false); const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = React.useState(null); + const [isDragging, setIsDragging] = React.useState(null); const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); const togglePanMode = (side) => { @@ -228,69 +227,72 @@ const MatchProspectTable = }; return ( -
+
-
{label}
-
- against {numCandidates} candidates - {evaluatedAt} + +
+
{algorithm}
+
against {numCandidates} candidates {date}
+
- +
- {matchColumns.map((column, columnIndex) => ( -
- {column.map((m) => { - const isRowSelected = isSelected(m.encounterId); - const isRowPreviewed = m.encounterId === previewedEncounterId; - - return ( -
handleRowClick(m.encounterId, m.imageUrl)} - > - {m.id}. - - {m.score.toFixed(4)} - + {candidates.map((candidate, columnIndex) => { + const candidateEncounterId = candidate.annotation?.encounter?.id; + const candidateIndividualId = candidate.annotation?.individual?.id; + const candidateIndividualDisplayName = candidate.annotation?.individual?.displayName; + const candidateImageUrl = candidate.annotation?.asset?.url; + + const isRowSelected = isSelected(candidateEncounterId); + const isRowPreviewed = candidateEncounterId === previewedEncounterId; + return
+
handleRowClick(candidateEncounterId, candidateImageUrl)} + > + {/* {candidateEncounterId}. */} + + {candidate.score.toFixed(4)} + - + -
e.stopPropagation()} - > - - onToggleSelected( - e.target.checked, - m.encounterId, - m.individualId, - ) - } - /> -
-
- ); - })} +
e.stopPropagation()} + > + + onToggleSelected( + e.target.checked, + candidateEncounterId, + candidateIndividualDisplayName, + ) + } + /> +
+
- ))} + })}
diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index f70ed11302..87ddf4f0df 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useEffect } from "react"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; import { Container, Form, Modal } from "react-bootstrap"; @@ -6,11 +6,24 @@ import ThemeColorContext from "../../ThemeColorProvider"; import MatchResultsStore from "./store/matchResultsStore"; import MatchProspectTable from "./MatchProspectTable"; import MatchResultsBottomBar from "./MatchResultsBottomBar"; +import { useSearchParams } from "react-router-dom"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); const store = useMemo(() => new MatchResultsStore(), []); const [instructionsVisible, setInstructionsVisible] = React.useState(false); + const [params] = useSearchParams(); + const taskId = params.get("taskId"); + + // useEffect(() => { + // if (taskId) { + // store.setTaskId(taskId); + // store.fetchMatchResults(); + // } + // return () => { + // // store.resetStore(); + // }; + // }, [taskId]); return ( @@ -127,29 +140,38 @@ const MatchResults = observer(() => {
- {store.algorithms.map((algo) => { - const matchColumns = store.organizeMatchesIntoColumns(algo.matches); - - return ( + {store.viewMode === "individual" ? [...store.groupedIndivs].map(([algorithmName, data]) => ( +
store.setSelectedMatch(checked, encounterId, individualId) } - onRowClick={(imageUrl) => store.setPreviewImageUrl(algo.id, imageUrl)} - thisEncounterImageUrl={store.thisEncounterImageUrl} - selectedMatchImageUrl={store.getSelectedMatchImageUrl(algo.id)} - themeColor={themeColor} + onRowClick={(imageUrl) => store.setPreviewImageUrl(algorithmName, imageUrl)} + selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} /> - ); - })} +
+ )) : [...store.groupedAnnots].map(([algorithmName, data]) => ( +
+ +
+ ))} ); diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index c001f578cd..5a428b9fb7 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -1,7 +1,7 @@ import { makeAutoObservable } from "mobx"; import axios from "axios"; import { MAX_ROWS_PER_COLUMN } from "../constants"; -import { MOCK_DATA } from "../mockupdata"; +import { MOCK_DATA1, getAllAnnot, getAllIndiv } from "../mockupdata"; export default class MatchResultsStore { @@ -12,12 +12,13 @@ export default class MatchResultsStore { _evaluatedAt = ""; _numResults = 12; _numCandidates = 0; - _thisEncounterImageUrl = ""; - _selectedMatchImageUrlByAlgo = new Map(); - _algorithms = []; + _thisEncounterImageUrl = ""; + _selectedMatchImageUrlByAlgo = new Map(); _selectedMatch = []; _taskId = null; _newIndividualName = ""; + _groupedAnnots = []; + _groupedIndivs = []; constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -25,28 +26,38 @@ export default class MatchResultsStore { } loadData(result) { - this._viewMode = MOCK_DATA.viewMode; - this._encounterId = MOCK_DATA.encounterId; - this._individualId = MOCK_DATA.individualId; - this._projectName = MOCK_DATA.projectName; - this._evaluatedAt = MOCK_DATA.evaluatedAt; - this._numResults = MOCK_DATA.numResults; - this._numCandidates = MOCK_DATA.numCandidates; - this._thisEncounterImageUrl = MOCK_DATA.thisEncounterImageUrl; - this._possibleMatchImageUrl = MOCK_DATA.possibleMatchImageUrl; - this._algorithms = MOCK_DATA.algorithms.map((algo) => ({ - ...algo, - matches: algo.matches.map((m) => ({ ...m })), - })); - - this._algorithms.forEach((algo) => { - if (algo.matches && algo.matches.length > 0) { - const firstMatchImage = algo.matches[0].imageUrl; - if (firstMatchImage) { - this._selectedMatchImageUrlByAlgo.set(algo.id, firstMatchImage); + const annotResults = getAllAnnot(MOCK_DATA1.matchResultsRoot); + const indivResults = getAllIndiv(MOCK_DATA1.matchResultsRoot); + + this._encounterId = annotResults[0].queryEncounterId || indivResults[0].queryEncounterId; + this._individualId = annotResults[0].queryIndividualId || indivResults[0].queryIndividualId; + this._projectName = "test_project"; + this._matchDate = annotResults[0].date || indivResults[0].date; + this._numCandidates = annotResults[0].numberCandidates || indivResults[0].numberCandidates; + this._thisEncounterImageUrl = annotResults[0].queryEncounterImageUrl || indivResults[0].queryEncounterImageUrl; + this._possibleMatchImageUrl = this.viewMode === "individual" ? annotResults[0].annotation?.asset?.url : indivResults[0].annotation?.asset?.url; + + const groupByAlgorithm = (data) => { + const grouped = new Map(); + data.forEach(item => { + const algorithm = item.algorithm; + if (!grouped.has(algorithm)) { + grouped.set(algorithm, []); } - } - }); + grouped.get(algorithm).push(item); + }); + return grouped; + }; + + this._groupedAnnots = groupByAlgorithm(annotResults); + this._groupedIndivs = groupByAlgorithm(indivResults); + } + + get groupedAnnots(){ + return this._groupedAnnots; + } + get groupedIndivs(){ + return this._groupedIndivs; } get viewMode() { @@ -89,10 +100,6 @@ export default class MatchResultsStore { this._selectedMatchImageUrlByAlgo.set(algorithmId, url); } - get algorithms() { - return this._algorithms; - } - get newIndividualName() { return this._newIndividualName; } @@ -105,12 +112,15 @@ export default class MatchResultsStore { this._taskId = id; } - async getMatchResults() { + async fetchMatchResults() { try { - const result = await axios.get(); + const result = await axios.get( + `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=1` + ); + console.log("Match results fetched:", JSON.stringify(result.data)); this.loadData(result); } catch (e) { - console.log(); + console.log(e); } } @@ -180,7 +190,7 @@ export default class MatchResultsStore { } organizeMatchesIntoColumns(matches) { - const totalMatches = matches.length; + const totalMatches = matches?.length || 0; if (totalMatches === 0) return []; const columns = []; for (let i = 0; i < totalMatches; i += MAX_ROWS_PER_COLUMN) { @@ -194,4 +204,7 @@ export default class MatchResultsStore { } return columns; } -} \ No newline at end of file +} + + + From 138887b05aba8e10a0ba3cbfe5dbf7239ec9880a Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Mon, 8 Dec 2025 16:58:36 -0700 Subject: [PATCH 025/192] allow creation from just json_result data too --- src/main/java/org/ecocean/ia/MatchResult.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index fbb89f34a0..9408573b03 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -57,6 +57,13 @@ public MatchResult(IdentityServiceLog isLog, Shepherd myShepherd) this.createFromIdentityServiceLog(isLog, myShepherd); } + public MatchResult(Task task, JSONObject jsonResult, Shepherd myShepherd) + throws IOException { + this(); + this.task = task; + this.createFromJsonResult(jsonResult, myShepherd); + } + public int getNumberCandidates() { return numberCandidates; } @@ -73,6 +80,13 @@ public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd mySh isLog.getStatusJson()); throw new IOException("could not get json result"); } + createFromJsonResult(res, myShepherd); + } + + // json_result section should be passed here + public void createFromJsonResult(JSONObject res, Shepherd myShepherd) + throws IOException { + if (res == null) throw new IOException("null json_result passed"); if (res.optJSONArray("query_annot_uuid_list") == null) throw new IOException("no query annot list"); if (res.getJSONArray("query_annot_uuid_list").length() < 1) @@ -103,7 +117,7 @@ score_list is for indiv scores but on dannot_uuid_list (same length) this.populateProspects("indiv", results.optJSONArray("dannot_uuid_list"), results.optJSONArray("score_list"), results.optJSONArray("dannot_extern_list"), results.optString("dannot_extern_reference", null), myShepherd); - System.out.println("[DEBUG] createFromIdentityServiceLog() created " + this); + System.out.println("[DEBUG] createFromJsonResult() created " + this); } // must initialize this.propsects first!! From a3764f780befe8b77e43f1182995462fc4ba422a Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Mon, 8 Dec 2025 16:59:28 -0700 Subject: [PATCH 026/192] if identification is complete, now we also (try to) make a MatchResult --- src/main/java/org/ecocean/identity/IBEISIA.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/org/ecocean/identity/IBEISIA.java b/src/main/java/org/ecocean/identity/IBEISIA.java index 1683fd91e0..656a0e954c 100644 --- a/src/main/java/org/ecocean/identity/IBEISIA.java +++ b/src/main/java/org/ecocean/identity/IBEISIA.java @@ -1849,6 +1849,15 @@ private static JSONObject processCallbackIdentify(String taskID, if (task != null) { task.setStatus("completed"); task.setCompletionDateInMilliseconds(Long.valueOf(System.currentTimeMillis())); + try { + MatchResult mr = new MatchResult(task, j, myShepherd); + System.out.println("processCallbackIdentify() created " + mr + " on " + task); + myShepherd.getPM().makePersistent(mr); + } catch (IOException ex) { + System.out.println("processCallbackIdentify() failed to create MatchResult on " + + task + ": " + ex); + ex.printStackTrace(); + } } myShepherd.commitDBTransaction(); myShepherd.closeDBTransaction(); From d4ec6c16b26f790e7b8ea6e9b501975ba4b110c0 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 9 Dec 2025 17:07:26 +0000 Subject: [PATCH 027/192] add candidates table functions --- .../MatchResultsPage/MatchProspectTable.jsx | 46 ++++++++----------- .../pages/MatchResultsPage/MatchResults.jsx | 40 ++++++++++------ .../store/matchResultsStore.js | 36 +++++++++------ 3 files changed, 67 insertions(+), 55 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index 1b78ba962f..b43be23d14 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -107,21 +107,26 @@ const styles = { const MatchProspectTable = ({ - label = "", + key, numCandidates, date, selectedMatch, onToggleSelected = {}, - onRowClick = {}, thisEncounterImageUrl, - selectedMatchImageUrl, themeColor, candidates, algorithm, }) => { - const [previewedEncounterId, setPreviewedEncounterId] = React.useState(); + const [previewedEncounterId, setPreviewedEncounterId] = React.useState(candidates[0].annotation?.encounter.id); + const [selectedMatchImageUrl, setSelectedMatchImageUrl] = React.useState(candidates[0].annotation?.asset.url); const [leftImageZoom, setLeftImageZoom] = React.useState(1); const [rightImageZoom, setRightImageZoom] = React.useState(1); + const [leftPanEnabled, setLeftPanEnabled] = React.useState(false); + const [rightPanEnabled, setRightPanEnabled] = React.useState(false); + const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); + const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = React.useState(null); + const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); const handleZoomIn = (side) => { if (side === "left") { @@ -147,13 +152,6 @@ const MatchProspectTable = } }; - const [leftPanEnabled, setLeftPanEnabled] = React.useState(false); - const [rightPanEnabled, setRightPanEnabled] = React.useState(false); - const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); - const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = React.useState(null); - const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); - const togglePanMode = (side) => { if (side === "left") { setLeftPanEnabled((prev) => !prev); @@ -207,6 +205,11 @@ const MatchProspectTable = setIsDragging(null); }; + const handleRowClick = (encounterId, imageUrl) => { + setPreviewedEncounterId(encounterId); + setSelectedMatchImageUrl(imageUrl); + }; + React.useEffect(() => { if (isDragging) { window.addEventListener("mousemove", handleMouseMove); @@ -221,24 +224,17 @@ const MatchProspectTable = const isSelected = (encounterId) => selectedMatch?.some((d) => d.encounterId === encounterId); - const handleRowClick = (encounterId, imageUrl) => { - setPreviewedEncounterId(encounterId); - onRowClick(imageUrl); - }; - return ( -
+
- -
+
{algorithm}
-
against {numCandidates} candidates {date}
-
- +
{candidates.map((candidate, columnIndex) => { @@ -246,7 +242,6 @@ const MatchProspectTable = const candidateIndividualId = candidate.annotation?.individual?.id; const candidateIndividualDisplayName = candidate.annotation?.individual?.displayName; const candidateImageUrl = candidate.annotation?.asset?.url; - const isRowSelected = isSelected(candidateEncounterId); const isRowPreviewed = candidateEncounterId === previewedEncounterId; return
@@ -261,11 +256,9 @@ const MatchProspectTable = }} onClick={() => handleRowClick(candidateEncounterId, candidateImageUrl)} > - {/* {candidateEncounterId}. */} {candidate.score.toFixed(4)} - -
e.stopPropagation()} diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 87ddf4f0df..0bab31ad6d 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -15,15 +15,19 @@ const MatchResults = observer(() => { const [params] = useSearchParams(); const taskId = params.get("taskId"); - // useEffect(() => { - // if (taskId) { - // store.setTaskId(taskId); - // store.fetchMatchResults(); - // } - // return () => { - // // store.resetStore(); - // }; - // }, [taskId]); + useEffect(() => { + if (taskId) { + store.setTaskId(taskId); + store.fetchMatchResults(); + } + return () => { + // store.resetStore(); + }; + }, [taskId]); + + if (store.loading) { + return

Loading

+ } return ( @@ -119,7 +123,10 @@ const MatchResults = observer(() => { size="sm" min="1" value={store.numResults} - onChange={(e) => store.setNumResults(Number(e.target.value))} + onChange={(e) => { + store.setNumResults(Number(e.target.value)); + store.fetchMatchResults(); + }} style={{ width: "80px" }} /> @@ -143,8 +150,8 @@ const MatchResults = observer(() => { {store.viewMode === "individual" ? [...store.groupedIndivs].map(([algorithmName, data]) => (
{ store.setSelectedMatch(checked, encounterId, individualId) } onRowClick={(imageUrl) => store.setPreviewImageUrl(algorithmName, imageUrl)} - selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} + selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} />
)) : [...store.groupedAnnots].map(([algorithmName, data]) => (
+ store.setSelectedMatch(checked, encounterId, individualId) + } + onRowClick={(imageUrl) => store.setPreviewImageUrl(algorithmName, imageUrl)} + selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} />
))} diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index 5a428b9fb7..f1a7ae5489 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -9,8 +9,7 @@ export default class MatchResultsStore { _encounterId = ""; _individualId = null; _projectName = ""; - _evaluatedAt = ""; - _numResults = 12; + _numResults = 2; _numCandidates = 0; _thisEncounterImageUrl = ""; _selectedMatchImageUrlByAlgo = new Map(); @@ -19,19 +18,22 @@ export default class MatchResultsStore { _newIndividualName = ""; _groupedAnnots = []; _groupedIndivs = []; + _loading = true; constructor() { makeAutoObservable(this, {}, { autoBind: true }); - this.loadData(); + // this.loadData(); } loadData(result) { - const annotResults = getAllAnnot(MOCK_DATA1.matchResultsRoot); - const indivResults = getAllIndiv(MOCK_DATA1.matchResultsRoot); + const annotResults1 = getAllAnnot(MOCK_DATA1.matchResultsRoot); + const indivResults1 = getAllIndiv(MOCK_DATA1.matchResultsRoot); + + const annotResults = getAllAnnot(result.matchResultsRoot); + const indivResults = getAllIndiv(result.matchResultsRoot); this._encounterId = annotResults[0].queryEncounterId || indivResults[0].queryEncounterId; this._individualId = annotResults[0].queryIndividualId || indivResults[0].queryIndividualId; - this._projectName = "test_project"; this._matchDate = annotResults[0].date || indivResults[0].date; this._numCandidates = annotResults[0].numberCandidates || indivResults[0].numberCandidates; this._thisEncounterImageUrl = annotResults[0].queryEncounterImageUrl || indivResults[0].queryEncounterImageUrl; @@ -76,10 +78,6 @@ export default class MatchResultsStore { return this._projectName; } - get evaluatedAt() { - return this._evaluatedAt; - } - get numResults() { return this._numResults; } @@ -92,6 +90,14 @@ export default class MatchResultsStore { return this._thisEncounterImageUrl; } + get loading(){ + return this._loading; + } + + setLoading(loading) { + this._loading = loading; + } + getSelectedMatchImageUrl(algorithmId) { return this._selectedMatchImageUrlByAlgo.get(algorithmId) || ""; } @@ -113,14 +119,16 @@ export default class MatchResultsStore { } async fetchMatchResults() { + this.setLoading(true); try { const result = await axios.get( - `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=1` + `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=${this.numResults}` ); - console.log("Match results fetched:", JSON.stringify(result.data)); - this.loadData(result); + this.loadData(result.data); } catch (e) { - console.log(e); + console.error(e); + } finally{ + this.setLoading(false); } } From fd8c092dae444a56025346a9074a1aa439609f7d Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 9 Dec 2025 20:56:32 +0000 Subject: [PATCH 028/192] get site settings from context to reduce repeating calls --- frontend/src/App.jsx | 31 +++++++++++++++++----------- frontend/src/FrontDesk.jsx | 5 +++-- frontend/src/SiteSettingsContext.jsx | 29 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 frontend/src/SiteSettingsContext.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3027a9ef38..93f323d167 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import LocaleContext from "./IntlProvider"; import FooterVisibilityContext from "./FooterVisibilityContext"; import Cookies from "js-cookie"; import FilterContext from "./FilterContextProvider"; +import { SiteSettingsProvider } from "./SiteSettingsContext"; function App() { const messageMap = { @@ -51,9 +52,11 @@ function App() { setFilters({}); }; - const publicUrl = process.env.PUBLIC_URL - ? (process.env.PUBLIC_URL.startsWith('http') ? new URL(process.env.PUBLIC_URL).pathname : process.env.PUBLIC_URL) - : "/"; + const publicUrl = process.env.PUBLIC_URL + ? process.env.PUBLIC_URL.startsWith("http") + ? new URL(process.env.PUBLIC_URL).pathname + : process.env.PUBLIC_URL + : "/"; return ( @@ -70,16 +73,20 @@ function App() { defaultLocale="en" messages={messageMap[locale]} > - - + - - - + + + + +
diff --git a/frontend/src/FrontDesk.jsx b/frontend/src/FrontDesk.jsx index e1f162bec2..e3c5b1f987 100644 --- a/frontend/src/FrontDesk.jsx +++ b/frontend/src/FrontDesk.jsx @@ -13,8 +13,9 @@ import { sessionWarningTime, sessionCountdownTime, } from "./constants/sessionWarning"; -import useGetSiteSettings from "./models/useGetSiteSettings"; +// import useGetSiteSettings from "./models/useGetSiteSettings"; import useDocumentTitle from "./hooks/useDocumentTitle"; +import { useSiteSettings } from "./SiteSettingsContext"; export default function FrontDesk() { useDocumentTitle(); @@ -24,7 +25,7 @@ export default function FrontDesk() { const [mergeData, setMergeData] = useState([]); const [count, setCount] = useState(0); const [loading, setLoading] = useState(true); - const { data } = useGetSiteSettings(); + const data = useSiteSettings(); const showclassicsubmit = data?.showClassicSubmit; const showClassicEncounterSearch = data?.showClassicEncounters; const checkLoginStatus = () => { diff --git a/frontend/src/SiteSettingsContext.jsx b/frontend/src/SiteSettingsContext.jsx new file mode 100644 index 0000000000..28668672f7 --- /dev/null +++ b/frontend/src/SiteSettingsContext.jsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext } from "react"; +import useGetSiteSettings from "./models/useGetSiteSettings"; +import LoadingScreen from "./components/LoadingScreen"; + +const SiteSettingsContext = createContext(null); + +export const SiteSettingsProvider = ({ children }) => { + const { data, isLoading, error } = useGetSiteSettings(); + + if (isLoading) return ; + + if (error) { + console.error("Failed to load site settings:", error); + } + + return ( + + {children} + + ); +}; + +export const useSiteSettings = () => { + const context = useContext(SiteSettingsContext); + if (context === undefined) { + throw new Error("useSiteSettings must be used within SiteSettingsProvider"); + } + return context; +}; From 2743ca892be146f6e0d7662d65b1caf2c25346d3 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 9 Dec 2025 20:57:22 +0000 Subject: [PATCH 029/192] cleaner functions to rocess data --- .../store/matchResultsStore.js | 109 +++++++++++------- 1 file changed, 68 insertions(+), 41 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index f1a7ae5489..264be7e4d5 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -16,13 +16,12 @@ export default class MatchResultsStore { _selectedMatch = []; _taskId = null; _newIndividualName = ""; - _groupedAnnots = []; - _groupedIndivs = []; + _rawAnnots = []; + _rawIndivs = []; _loading = true; constructor() { makeAutoObservable(this, {}, { autoBind: true }); - // this.loadData(); } loadData(result) { @@ -39,27 +38,63 @@ export default class MatchResultsStore { this._thisEncounterImageUrl = annotResults[0].queryEncounterImageUrl || indivResults[0].queryEncounterImageUrl; this._possibleMatchImageUrl = this.viewMode === "individual" ? annotResults[0].annotation?.asset?.url : indivResults[0].annotation?.asset?.url; - const groupByAlgorithm = (data) => { - const grouped = new Map(); - data.forEach(item => { - const algorithm = item.algorithm; - if (!grouped.has(algorithm)) { - grouped.set(algorithm, []); + this._rawAnnots = annotResults; + this._rawIndivs = indivResults; + } + + _processData(rawData) { + // 1.filter by project name if set + const filtered = this._projectName + ? rawData.filter(item => item.projectName === this._projectName) + : rawData; + + // 2. group by algorithm + const grouped = new Map(); + filtered.forEach(item => { + const algorithm = item.algorithm; + if (!grouped.has(algorithm)) { + grouped.set(algorithm, []); + } + grouped.get(algorithm).push(item); + }); + + // 3. organize into columns + const organized = new Map(); + for (const [algorithm, data] of grouped) { + const columns = []; + for (let i = 0; i < data.length; i += MAX_ROWS_PER_COLUMN) { + const columnData = data + .slice(i, i + MAX_ROWS_PER_COLUMN) + .map((match, index) => ({ + ...match, + displayIndex: i + index + 1, + })); + columns.push(columnData); + } + organized.set(algorithm, { + columns, + metadata: { + numCandidates: data[0].numberCandidates, + date: data[0].date, + queryImageUrl: data[0].queryEncounterImageAsset?.url, } - grouped.get(algorithm).push(item); }); - return grouped; - }; + } + return organized; + } - this._groupedAnnots = groupByAlgorithm(annotResults); - this._groupedIndivs = groupByAlgorithm(indivResults); + get processedAnnots() { + return this._processData(this._rawAnnots); } - get groupedAnnots(){ - return this._groupedAnnots; + get processedIndivs() { + return this._processData(this._rawIndivs); } - get groupedIndivs(){ - return this._groupedIndivs; + + get currentViewData() { + return this._viewMode === "individual" + ? this.processedIndivs + : this.processedAnnots; } get viewMode() { @@ -90,7 +125,7 @@ export default class MatchResultsStore { return this._thisEncounterImageUrl; } - get loading(){ + get loading() { return this._loading; } @@ -98,14 +133,6 @@ export default class MatchResultsStore { this._loading = loading; } - getSelectedMatchImageUrl(algorithmId) { - return this._selectedMatchImageUrlByAlgo.get(algorithmId) || ""; - } - - setPreviewImageUrl(algorithmId, url) { - this._selectedMatchImageUrlByAlgo.set(algorithmId, url); - } - get newIndividualName() { return this._newIndividualName; } @@ -127,7 +154,7 @@ export default class MatchResultsStore { this.loadData(result.data); } catch (e) { console.error(e); - } finally{ + } finally { this.setLoading(false); } } @@ -197,20 +224,20 @@ export default class MatchResultsStore { } } - organizeMatchesIntoColumns(matches) { - const totalMatches = matches?.length || 0; - if (totalMatches === 0) return []; - const columns = []; - for (let i = 0; i < totalMatches; i += MAX_ROWS_PER_COLUMN) { - const columnData = matches - .slice(i, i + MAX_ROWS_PER_COLUMN) - .map((match, index) => ({ - ...match, - id: i + index + 1, - })); - columns.push(columnData); + get organizedAnnotColumns() { + const organized = new Map(); + for (const [algorithm, data] of this.filteredGroupedAnnots) { + organized.set(algorithm, this.organizeMatchesIntoColumns(data)); + } + return organized; + } + + get organizedIndivColumns() { + const organized = new Map(); + for (const [algorithm, data] of this.filteredGroupedIndivs) { + organized.set(algorithm, this.organizeMatchesIntoColumns(data)); } - return columns; + return organized; } } From 920df64e3d6301247f97b25099c28128cfad7458 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 9 Dec 2025 20:58:40 +0000 Subject: [PATCH 030/192] filter data by projects, use processed columns from store --- .../MatchResultsPage/MatchProspectTable.jsx | 557 +++++++++--------- .../pages/MatchResultsPage/MatchResults.jsx | 54 +- 2 files changed, 314 insertions(+), 297 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index b43be23d14..3d59a1326b 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -105,308 +105,329 @@ const styles = { }, }; -const MatchProspectTable = - ({ - key, - numCandidates, - date, - selectedMatch, - onToggleSelected = {}, - thisEncounterImageUrl, - themeColor, - candidates, - algorithm, - }) => { - const [previewedEncounterId, setPreviewedEncounterId] = React.useState(candidates[0].annotation?.encounter.id); - const [selectedMatchImageUrl, setSelectedMatchImageUrl] = React.useState(candidates[0].annotation?.asset.url); - const [leftImageZoom, setLeftImageZoom] = React.useState(1); - const [rightImageZoom, setRightImageZoom] = React.useState(1); - const [leftPanEnabled, setLeftPanEnabled] = React.useState(false); - const [rightPanEnabled, setRightPanEnabled] = React.useState(false); - const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); - const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = React.useState(null); - const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); +const MatchProspectTable = ({ + key, + numCandidates, + date, + selectedMatch, + onToggleSelected, + thisEncounterImageUrl, + themeColor, + columns, + algorithm, +}) => { + const [previewedEncounterId, setPreviewedEncounterId] = React.useState( + columns[0]?.[0]?.annotation?.encounter?.id + ); + const [selectedMatchImageUrl, setSelectedMatchImageUrl] = React.useState( + columns[0]?.[0]?.annotation?.asset?.url + ); - const handleZoomIn = (side) => { - if (side === "left") { - setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); - } else { - setRightImageZoom((prev) => Math.min(prev + 0.25, 3)); - } - }; + const [leftImageZoom, setLeftImageZoom] = React.useState(1); + const [rightImageZoom, setRightImageZoom] = React.useState(1); + const [leftPanEnabled, setLeftPanEnabled] = React.useState(false); + const [rightPanEnabled, setRightPanEnabled] = React.useState(false); + const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); + const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = React.useState(null); + const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); - const handleZoomOut = (side) => { - if (side === "left") { - setLeftImageZoom((prev) => Math.max(prev - 0.25, 0.5)); - } else { - setRightImageZoom((prev) => Math.max(prev - 0.25, 0.5)); - } - }; + + const handleZoomIn = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); + } else { + setRightImageZoom((prev) => Math.min(prev + 0.25, 3)); + } + }; - const handleResetZoom = (side) => { - if (side === "left") { - setLeftImageZoom(1); - } else { - setRightImageZoom(1); - } - }; + const handleZoomOut = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } else { + setRightImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } + }; - const togglePanMode = (side) => { - if (side === "left") { - setLeftPanEnabled((prev) => !prev); - if (leftPanEnabled) { - setLeftPanPosition({ x: 0, y: 0 }); - } - } else { - setRightPanEnabled((prev) => !prev); - if (rightPanEnabled) { - setRightPanPosition({ x: 0, y: 0 }); - } + const handleResetZoom = (side) => { + if (side === "left") { + setLeftImageZoom(1); + } else { + setRightImageZoom(1); + } + }; + + const togglePanMode = (side) => { + if (side === "left") { + setLeftPanEnabled((prev) => !prev); + if (leftPanEnabled) { + setLeftPanPosition({ x: 0, y: 0 }); + } + } else { + setRightPanEnabled((prev) => !prev); + if (rightPanEnabled) { + setRightPanPosition({ x: 0, y: 0 }); } - }; + } + }; - const handleMouseDown = (side, e) => { - const panEnabled = side === "left" ? leftPanEnabled : rightPanEnabled; - if (!panEnabled) return; + const handleMouseDown = (side, e) => { + const panEnabled = side === "left" ? leftPanEnabled : rightPanEnabled; + if (!panEnabled) return; - setIsDragging(side); - setDragStart({ - x: e.clientX, - y: e.clientY, - }); - }; + setIsDragging(side); + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; - const handleMouseMove = (e) => { - if (!isDragging) return; + const handleMouseMove = (e) => { + if (!isDragging) return; - const deltaX = e.clientX - dragStart.x; - const deltaY = e.clientY - dragStart.y; + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; - if (isDragging === "left") { - setLeftPanPosition((prev) => ({ - x: prev.x + deltaX, - y: prev.y + deltaY, - })); - } else { - setRightPanPosition((prev) => ({ - x: prev.x + deltaX, - y: prev.y + deltaY, - })); - } + if (isDragging === "left") { + setLeftPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } else { + setRightPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } - setDragStart({ - x: e.clientX, - y: e.clientY, - }); - }; + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; - const handleMouseUp = () => { - setIsDragging(null); - }; + const handleMouseUp = () => { + setIsDragging(null); + }; - const handleRowClick = (encounterId, imageUrl) => { - setPreviewedEncounterId(encounterId); - setSelectedMatchImageUrl(imageUrl); - }; + const handleRowClick = (encounterId, imageUrl) => { + setPreviewedEncounterId(encounterId); + setSelectedMatchImageUrl(imageUrl); + }; - React.useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, dragStart]); + React.useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, dragStart]); - const isSelected = (encounterId) => - selectedMatch?.some((d) => d.encounterId === encounterId); + const isSelected = (encounterId) => + selectedMatch?.some((d) => d.encounterId === encounterId); - return ( -
-
-
-
{algorithm}
-
against {numCandidates} candidates {date}
+ return ( +
+
+
+
{algorithm}
+
+ against {numCandidates} candidates {date}
+
-
-
- {candidates.map((candidate, columnIndex) => { - const candidateEncounterId = candidate.annotation?.encounter?.id; - const candidateIndividualId = candidate.annotation?.individual?.id; - const candidateIndividualDisplayName = candidate.annotation?.individual?.displayName; - const candidateImageUrl = candidate.annotation?.asset?.url; - const isRowSelected = isSelected(candidateEncounterId); - const isRowPreviewed = candidateEncounterId === previewedEncounterId; - return
-
handleRowClick(candidateEncounterId, candidateImageUrl)} - > - - {candidate.score.toFixed(4)} - - +
+
+ {columns.map((columnData, columnIndex) => ( +
+ {columnData.map((candidate) => { + const candidateEncounterId = candidate.annotation?.encounter?.id; + const candidateIndividualId = candidate.annotation?.individual?.id; + const candidateIndividualDisplayName = + candidate.annotation?.individual?.displayName; + const candidateImageUrl = candidate.annotation?.asset?.url; + const isRowSelected = isSelected(candidateEncounterId); + const isRowPreviewed = candidateEncounterId === previewedEncounterId; + + return (
e.stopPropagation()} + key={candidateEncounterId} + style={{ + ...styles.matchRow(isRowSelected, themeColor), + cursor: "pointer", + backgroundColor: isRowPreviewed + ? themeColor.primaryColors.primary50 + : "transparent", + }} + onClick={() => + handleRowClick(candidateEncounterId, candidateImageUrl) + } > - - onToggleSelected( - e.target.checked, - candidateEncounterId, - candidateIndividualDisplayName, - ) - } - /> + {candidate.displayIndex} + + {candidate.score.toFixed(4)} + + +
e.stopPropagation()}> + + onToggleSelected( + e.target.checked, + candidateEncounterId, + candidateIndividualDisplayName + ) + } + /> +
-
-
- })} -
+ ); + })} +
+ ))}
+
- - -
-
This encounter
-
+ +
+
This encounter
+
handleMouseDown("left", e)} + > + This encounter handleMouseDown("left", e)} - > - This encounter -
+ draggable={false} + />
+
-
-
handleZoomIn("left")} - style={styles.iconButton} - title="Zoom In" - > - -
-
handleZoomOut("left")} - style={styles.iconButton} - title="Zoom Out" - > - -
-
togglePanMode("left")} - style={{ - ...styles.iconButton, - backgroundColor: leftPanEnabled - ? themeColor.primaryColors.primary200 - : "white", - }} - title="Pan Image (Click to toggle)" - > - -
+
+
handleZoomIn("left")} + style={styles.iconButton} + title="Zoom In" + > +
- +
handleZoomOut("left")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
togglePanMode("left")} + style={{ + ...styles.iconButton, + backgroundColor: leftPanEnabled + ? themeColor.primaryColors.primary200 + : "white", + }} + title="Pan Image (Click to toggle)" + > + +
+
+ - -
-
- Possible Match -
-
+
+
+ Possible Match +
+
handleMouseDown("right", e)} + > + Possible match handleMouseDown("right", e)} - > - Possible match -
+ draggable={false} + />
+
-
-
handleZoomIn("right")} - style={styles.iconButton} - title="Zoom In" - > - -
-
handleZoomOut("right")} - style={styles.iconButton} - title="Zoom Out" - > - -
-
togglePanMode("right")} - style={{ - ...styles.iconButton, - backgroundColor: rightPanEnabled - ? themeColor.primaryColors.primary200 - : "white", - }} - title="Pan Image (Click to toggle)" - > - -
-
- -
-
- -
+
+
handleZoomIn("right")} + style={styles.iconButton} + title="Zoom In" + > +
- - -
- ); - } +
handleZoomOut("right")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
togglePanMode("right")} + style={{ + ...styles.iconButton, + backgroundColor: rightPanEnabled + ? themeColor.primaryColors.primary200 + : "white", + }} + title="Pan Image (Click to toggle)" + > + +
+
+ +
+
+ +
+
+ + +
+ ); +}; export default MatchProspectTable; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 0bab31ad6d..ef71e9ce13 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -7,6 +7,7 @@ import MatchResultsStore from "./store/matchResultsStore"; import MatchProspectTable from "./MatchProspectTable"; import MatchResultsBottomBar from "./MatchResultsBottomBar"; import { useSearchParams } from "react-router-dom"; +import { useSiteSettings } from "../../SiteSettingsContext"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -14,6 +15,7 @@ const MatchResults = observer(() => { const [instructionsVisible, setInstructionsVisible] = React.useState(false); const [params] = useSearchParams(); const taskId = params.get("taskId"); + const { projectsForUser = {} } = useSiteSettings() || {}; useEffect(() => { if (taskId) { @@ -125,7 +127,11 @@ const MatchResults = observer(() => { value={store.numResults} onChange={(e) => { store.setNumResults(Number(e.target.value)); - store.fetchMatchResults(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + store.fetchMatchResults(); + } }} style={{ width: "80px" }} /> @@ -138,49 +144,39 @@ const MatchResults = observer(() => { store.setProjectName(e.target.value)} + onChange={(e) => { + store.setProjectName(e.target.value); + + }} style={{ minWidth: "220px" }} > - + + {Object.entries(projectsForUser).map(([key, value]) => ( + + ))}
- {store.viewMode === "individual" ? [...store.groupedIndivs].map(([algorithmName, data]) => ( -
- - store.setSelectedMatch(checked, encounterId, individualId) - } - onRowClick={(imageUrl) => store.setPreviewImageUrl(algorithmName, imageUrl)} - selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} - /> -
- )) : [...store.groupedAnnots].map(([algorithmName, data]) => ( + {[...store.currentViewData].map(([algorithmName, { columns, metadata }]) => (
store.setSelectedMatch(checked, encounterId, individualId) } - onRowClick={(imageUrl) => store.setPreviewImageUrl(algorithmName, imageUrl)} - selectedMatchImageUrl={store.getSelectedMatchImageUrl(algorithmName)} />
))} From 6d2da10bd188ba07529f695df519da95d9935c9b Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Tue, 9 Dec 2025 17:56:10 -0700 Subject: [PATCH 031/192] heatmask path fixes --- src/main/java/org/ecocean/ia/MatchResult.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index 9408573b03..88ad2d5925 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -160,9 +160,9 @@ public MediaAsset createInspectionHeatmapAsset(String externRef, String annotId, Shepherd myShepherd) { if (externRef == null) return null; String url = "/api/query/graph/match/thumb/?extern_reference=" + externRef; - url += "&query_annot_uuid=" + this.queryAnnotation.getId(); + url += "&query_annot_uuid=" + this.queryAnnotation.getAcmId(); url += "&database_annot_uuid=" + annotId; - url += "&version=heatmap"; + url += "&version=heatmask"; URL fullUrl = IBEISIA.iaURL(myShepherd.getContext(), url); File tmpFile = new File("/tmp/extern-" + this.id + "-" + externRef + "-" + this.queryAnnotation.getId() + "-" + annotId + ".jpg"); From 35c23f47df224ed42e4fc834f3377db73d56565a Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 10 Dec 2025 15:54:10 +0000 Subject: [PATCH 032/192] add encounter/individual links, update default numResult value --- .../MatchResultsPage/MatchProspectTable.jsx | 27 ++++++++++++++----- .../store/matchResultsStore.js | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index 3d59a1326b..2282b47715 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -22,6 +22,7 @@ const styles = { matchRank: { width: "24px", textAlign: "right", + marginRight: "8px", }, matchScore: { width: "64px", @@ -232,10 +233,10 @@ const MatchProspectTable = ({ return (
-
-
{algorithm}
-
- against {numCandidates} candidates {date} +
+
{`Matches based on ${algorithm} Algorithm`}
+
+ against {numCandidates} candidates {date.slice(0,16).replace("T", " ")}
@@ -267,14 +268,26 @@ const MatchProspectTable = ({ handleRowClick(candidateEncounterId, candidateImageUrl) } > - {candidate.displayIndex} - + {candidate.displayIndex}{"."} + diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index 264be7e4d5..7a272c5907 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -9,7 +9,7 @@ export default class MatchResultsStore { _encounterId = ""; _individualId = null; _projectName = ""; - _numResults = 2; + _numResults = 12; _numCandidates = 0; _thisEncounterImageUrl = ""; _selectedMatchImageUrlByAlgo = new Map(); From c65f8ab379baabef5648957f944b58c2e8bab1ee Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 11 Dec 2025 16:10:01 +0000 Subject: [PATCH 033/192] functions to collect prospects --- .../pages/MatchResultsPage/helperFunctions.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 frontend/src/pages/MatchResultsPage/helperFunctions.js diff --git a/frontend/src/pages/MatchResultsPage/helperFunctions.js b/frontend/src/pages/MatchResultsPage/helperFunctions.js new file mode 100644 index 0000000000..2549e48cbd --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/helperFunctions.js @@ -0,0 +1,54 @@ +const collectProspects = (node, type, result = []) => { + const hasMethod = !!node.method; + const methodName = node.method?.name ?? null; + const methodDescription = node.method?.description ?? null; + + const prospects = node.matchResults?.prospects?.[type]; + + const hasResults = Array.isArray(prospects) && prospects.length > 0; + + if (hasResults && hasMethod) { + const common = { + algorithm: methodName, + date: node.matchResults.created, + numberCandidates: node.matchResults.numberCandidates || 0, + queryEncounterId: + node.matchResults.queryAnnotation?.encounter?.id || null, + queryIndividualId: + node.matchResults.queryAnnotation?.individual?.id || null, + queryIndividualDisplayName: + node.matchResults.queryAnnotation?.individual?.displayName || null, + queryEncounterImageAsset: + node.matchResults.queryAnnotation?.asset || null, + queryEncounterImageUrl: + node.matchResults.queryAnnotation?.asset?.url || null, + + methodName, + methodDescription, + method: node.method || null, + taskId: node.id ?? null, + taskStatus: node.status ?? null, + taskStatusOverall: node.statusOverall ?? null, + + hasResults: true, + }; + + result.push( + ...prospects.map((item) => ({ + ...item, + ...common, + })), + ); + } + + if (Array.isArray(node.children)) { + node.children.forEach((child) => collectProspects(child, type, result)); + } + + return result; +}; + +export const getAllIndiv = (node, result) => + collectProspects(node, "indiv", result); +export const getAllAnnot = (node, result) => + collectProspects(node, "annot", result); From 7bd6688167831e35d828ec3ff43da038f047a72f Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 11 Dec 2025 16:11:44 +0000 Subject: [PATCH 034/192] consider method as match result ready flag --- .../MatchResultsPage/MatchProspectTable.jsx | 18 +++++++---- .../pages/MatchResultsPage/MatchResults.jsx | 5 +++ .../store/matchResultsStore.js | 32 ++++++++++++------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index 2282b47715..e3d30263c0 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -114,7 +114,7 @@ const MatchProspectTable = ({ onToggleSelected, thisEncounterImageUrl, themeColor, - columns, + columns, algorithm, }) => { const [previewedEncounterId, setPreviewedEncounterId] = React.useState( @@ -133,7 +133,7 @@ const MatchProspectTable = ({ const [isDragging, setIsDragging] = React.useState(null); const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); - + const handleZoomIn = (side) => { if (side === "left") { setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); @@ -213,7 +213,7 @@ const MatchProspectTable = ({ const handleRowClick = (encounterId, imageUrl) => { setPreviewedEncounterId(encounterId); - setSelectedMatchImageUrl(imageUrl); + setSelectedMatchImageUrl(imageUrl); }; React.useEffect(() => { @@ -234,9 +234,15 @@ const MatchProspectTable = ({
-
{`Matches based on ${algorithm} Algorithm`}
+
+ {methodName + ? `Matches based on ${methodName}` + : `Matches based on ${algorithm}`} + {/* {methodDescription ? ` – ${methodDescription}` : ""} */} +
- against {numCandidates} candidates {date.slice(0,16).replace("T", " ")} + against {numCandidates} candidates{" "} + {date?.slice(0, 16).replace("T", " ")}
@@ -270,7 +276,7 @@ const MatchProspectTable = ({ > {candidate.displayIndex}{"."}
))} diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js index 7a272c5907..6373e92f95 100644 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js @@ -1,7 +1,7 @@ import { makeAutoObservable } from "mobx"; import axios from "axios"; import { MAX_ROWS_PER_COLUMN } from "../constants"; -import { MOCK_DATA1, getAllAnnot, getAllIndiv } from "../mockupdata"; +import { getAllAnnot, getAllIndiv } from "../helperFunctions"; export default class MatchResultsStore { @@ -16,8 +16,8 @@ export default class MatchResultsStore { _selectedMatch = []; _taskId = null; _newIndividualName = ""; - _rawAnnots = []; - _rawIndivs = []; + _rawAnnots = []; + _rawIndivs = []; _loading = true; constructor() { @@ -25,12 +25,18 @@ export default class MatchResultsStore { } loadData(result) { - const annotResults1 = getAllAnnot(MOCK_DATA1.matchResultsRoot); - const indivResults1 = getAllIndiv(MOCK_DATA1.matchResultsRoot); - const annotResults = getAllAnnot(result.matchResultsRoot); const indivResults = getAllIndiv(result.matchResultsRoot); + const firstAnnot = annotResults[0]; + const firstIndiv = indivResults[0]; + const first = firstAnnot ?? firstIndiv; + + if (!first) { + this._loading = false; + return; + } + this._encounterId = annotResults[0].queryEncounterId || indivResults[0].queryEncounterId; this._individualId = annotResults[0].queryIndividualId || indivResults[0].queryIndividualId; this._matchDate = annotResults[0].date || indivResults[0].date; @@ -39,12 +45,12 @@ export default class MatchResultsStore { this._possibleMatchImageUrl = this.viewMode === "individual" ? annotResults[0].annotation?.asset?.url : indivResults[0].annotation?.asset?.url; this._rawAnnots = annotResults; - this._rawIndivs = indivResults; + this._rawIndivs = indivResults; } _processData(rawData) { // 1.filter by project name if set - const filtered = this._projectName + const filtered = this._projectName ? rawData.filter(item => item.projectName === this._projectName) : rawData; @@ -67,7 +73,7 @@ export default class MatchResultsStore { .slice(i, i + MAX_ROWS_PER_COLUMN) .map((match, index) => ({ ...match, - displayIndex: i + index + 1, + displayIndex: i + index + 1, })); columns.push(columnData); } @@ -77,6 +83,10 @@ export default class MatchResultsStore { numCandidates: data[0].numberCandidates, date: data[0].date, queryImageUrl: data[0].queryEncounterImageAsset?.url, + methodName: data[0].methodName, + methodDescription: data[0].methodDescription, + taskStatus: data[0].taskStatus, + taskStatusOverall: data[0].taskStatusOverall, } }); } @@ -92,8 +102,8 @@ export default class MatchResultsStore { } get currentViewData() { - return this._viewMode === "individual" - ? this.processedIndivs + return this._viewMode === "individual" + ? this.processedIndivs : this.processedAnnots; } From b9950ca03d917c437e735b64e3b13daa7759a438 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 11 Dec 2025 16:45:50 +0000 Subject: [PATCH 035/192] update store, update ui --- .../MatchResultsPage/MatchProspectTable.jsx | 24 +- .../pages/MatchResultsPage/MatchResults.jsx | 13 + .../src/pages/MatchResultsPage/mockupdata.js | 114 ----- .../store/matchResultsStore.js | 395 +++++++++++++++--- 4 files changed, 377 insertions(+), 169 deletions(-) delete mode 100644 frontend/src/pages/MatchResultsPage/mockupdata.js diff --git a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx index e3d30263c0..23db593485 100644 --- a/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchProspectTable.jsx @@ -116,6 +116,10 @@ const MatchProspectTable = ({ themeColor, columns, algorithm, + methodName, + methodDescription, + taskStatus, + taskStatusOverall, }) => { const [previewedEncounterId, setPreviewedEncounterId] = React.useState( columns[0]?.[0]?.annotation?.encounter?.id @@ -274,17 +278,23 @@ const MatchProspectTable = ({ handleRowClick(candidateEncounterId, candidateImageUrl) } > - {candidate.displayIndex}{"."} - + -
e.stopPropagation()}> - - onToggleSelected( - e.target.checked, - candidateEncounterId, - candidateIndividualDisplayName - ) - } - /> -
-
- ); - })} -
- ))} -
-
- - - -
-
This encounter
-
handleMouseDown("left", e)} - > - This encounter -
-
- -
-
handleZoomIn("left")} - style={styles.iconButton} - title="Zoom In" - > - -
-
handleZoomOut("left")} - style={styles.iconButton} - title="Zoom Out" - > - -
-
togglePanMode("left")} - style={{ - ...styles.iconButton, - backgroundColor: leftPanEnabled - ? themeColor.primaryColors.primary200 - : "white", - }} - title="Pan Image (Click to toggle)" - > - -
-
- - - -
-
- Possible Match -
-
handleMouseDown("right", e)} - > - Possible match -
-
- -
-
handleZoomIn("right")} - style={styles.iconButton} - title="Zoom In" - > - -
-
handleZoomOut("right")} - style={styles.iconButton} - title="Zoom Out" - > - -
-
togglePanMode("right")} - style={{ - ...styles.iconButton, - backgroundColor: rightPanEnabled - ? themeColor.primaryColors.primary200 - : "white", - }} - title="Pan Image (Click to toggle)" - > - -
-
- -
-
- -
-
- -
-
- ); -}; - -export default MatchProspectTable; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 15093608a8..4026fdd34b 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -3,9 +3,9 @@ import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; import { Container, Form, Modal } from "react-bootstrap"; import ThemeColorContext from "../../ThemeColorProvider"; -import MatchResultsStore from "./store/matchResultsStore"; -import MatchProspectTable from "./MatchProspectTable"; -import MatchResultsBottomBar from "./MatchResultsBottomBar"; +import MatchResultsStore from "./stores/matchResultsStore"; +import MatchProspectTable from "./components/MatchProspectTable"; +import MatchResultsBottomBar from "./components/MatchResultsBottomBar"; import { useSearchParams } from "react-router-dom"; import { useSiteSettings } from "../../SiteSettingsContext"; @@ -28,7 +28,7 @@ const MatchResults = observer(() => { }, [taskId]); if (store.loading) { - return

Loading

+ return

Loading

; } if (!store.hasResults) { @@ -37,9 +37,7 @@ const MatchResults = observer(() => {

-

- No match results available for this job. -

+

No match results available for this job.

); } @@ -159,7 +157,6 @@ const MatchResults = observer(() => { value={store.projectName} onChange={(e) => { store.setProjectName(e.target.value); - }} style={{ minWidth: "220px" }} > @@ -176,31 +173,32 @@ const MatchResults = observer(() => {
- {[...store.currentViewData].map(([algorithmName, { columns, metadata }]) => ( -
- - store.setSelectedMatch(checked, encounterId, individualId) - } - /> - -
- ))} + {[...store.currentViewData].map( + ([algorithmName, { columns, metadata }]) => ( +
+ + store.setSelectedMatch(checked, encounterId, individualId) + } + /> +
+ ), + )} ); }); -export default MatchResults; \ No newline at end of file +export default MatchResults; diff --git a/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx deleted file mode 100644 index 80e504a45b..0000000000 --- a/frontend/src/pages/MatchResultsPage/MatchResultsBottomBar.jsx +++ /dev/null @@ -1,167 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -import { FormattedMessage } from "react-intl"; -import { Form, Button } from "react-bootstrap"; -import MainButton from "../../components/MainButton"; - -const styles = { - bottomBar: (themeColor) => ({ - position: "fixed", - left: 0, - right: 0, - bottom: 0, - background: themeColor.primaryColors.primary50, - borderTop: "1px solid #dee2e6", - padding: "10px 24px", - display: "flex", - gap: "24px", - zIndex: 1000, - }), - bottomText: { - fontSize: "0.9rem", - }, - idPill: (themeColor) => ({ - borderRadius: "5px", - border: "none", - padding: "2px 10px", - fontSize: "0.8rem", - background: themeColor.wildMeColors.teal100, - color: themeColor.wildMeColors.teal800, - }), - idPillOutline: { - background: "transparent", - border: "1px solid #ccc", - }, - warningText: { - color: "#dc3545", - fontSize: "0.9rem", - fontWeight: "500", - }, -}; - -const MatchResultsBottomBar = observer(({ store, themeColor }) => { - - const renderActions = () => { - const matchingState = store.matchingState; - - switch (matchingState) { - case "no_selection": - return ( - <> - store.setNewIndividualName(e.target.value)} - style={{ maxWidth: "300px" }} - size="sm" - /> - - - - - - ); - - case "no_individuals": - return ( - <> - store.setNewIndividualName(e.target.value)} - style={{ maxWidth: "300px" }} - size="sm" - /> - - - - - - ); - - case "single_individual": - return ( - - ); - - case "two_individuals": - return ( - - ); - - case "too_many_individuals": - return ( -
- - -
- ); - - default: - return null; - } - }; - - return ( -
-
- {" "} - - {store.encounterId} - -
-
- {renderActions()} -
-
- ); -}); - -export default MatchResultsBottomBar; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js deleted file mode 100644 index f38bd10470..0000000000 --- a/frontend/src/pages/MatchResultsPage/store/matchResultsStore.js +++ /dev/null @@ -1,554 +0,0 @@ -// import { makeAutoObservable } from "mobx"; -// import axios from "axios"; -// import { MAX_ROWS_PER_COLUMN } from "../constants"; -// import { getAllAnnot, getAllIndiv } from "../helperFunctions"; - - -// export default class MatchResultsStore { -// _viewMode = "individual"; -// _encounterId = ""; -// _individualId = null; -// _projectName = ""; -// _numResults = 12; -// _numCandidates = 0; -// _thisEncounterImageUrl = ""; -// _selectedMatchImageUrlByAlgo = new Map(); -// _selectedMatch = []; -// _taskId = null; -// _newIndividualName = ""; -// _rawAnnots = []; -// _rawIndivs = []; -// _loading = true; -// _hasResults = false; - -// constructor() { -// makeAutoObservable(this, {}, { autoBind: true }); -// } - -// loadData(result) { -// const annotResults = getAllAnnot(result.matchResultsRoot); -// const indivResults = getAllIndiv(result.matchResultsRoot); - -// if ((!annotResults || annotResults.length === 0) && -// (!indivResults || indivResults.length === 0)) { -// this._rawAnnots = []; -// this._rawIndivs = []; -// this._encounterId = null; -// this._individualId = null; -// this._thisEncounterImageUrl = ""; -// this._numCandidates = 0; -// this._hasResults = false; -// return; -// } - -// const first = annotResults[0] ?? indivResults[0]; -// this._encounterId = first.queryEncounterId; -// this._individualId = first.queryIndividualId; -// this._matchDate = first.date; -// this._numCandidates = first.numberCandidates; -// this._thisEncounterImageUrl = first.queryEncounterImageUrl; -// this._possibleMatchImageUrl = first.annotation?.asset?.url; - -// this._rawAnnots = annotResults; -// this._rawIndivs = indivResults; -// this._hasResults = true; -// } - -// _processData(rawData) { -// // 1.filter by project name if set -// const filtered = this._projectName -// ? rawData.filter(item => item.projectName === this._projectName) -// : rawData; - -// // 2. group by algorithm -// const grouped = new Map(); -// filtered.forEach(item => { -// const algorithm = item.algorithm; -// if (!grouped.has(algorithm)) { -// grouped.set(algorithm, []); -// } -// grouped.get(algorithm).push(item); -// }); - -// // 3. organize into columns -// const organized = new Map(); -// for (const [algorithm, data] of grouped) { -// const columns = []; -// for (let i = 0; i < data.length; i += MAX_ROWS_PER_COLUMN) { -// const columnData = data -// .slice(i, i + MAX_ROWS_PER_COLUMN) -// .map((match, index) => ({ -// ...match, -// displayIndex: i + index + 1, -// })); -// columns.push(columnData); -// } -// organized.set(algorithm, { -// columns, -// metadata: { -// numCandidates: data[0].numberCandidates, -// date: data[0].date, -// queryImageUrl: data[0].queryEncounterImageAsset?.url, -// methodName: data[0].methodName, -// methodDescription: data[0].methodDescription, -// taskStatus: data[0].taskStatus, -// taskStatusOverall: data[0].taskStatusOverall, -// } -// }); -// } -// return organized; -// } - -// get processedAnnots() { -// return this._processData(this._rawAnnots); -// } - -// get processedIndivs() { -// return this._processData(this._rawIndivs); -// } - -// get currentViewData() { -// return this._viewMode === "individual" -// ? this.processedIndivs -// : this.processedAnnots; -// } - -// get viewMode() { -// return this._viewMode; -// } - -// get encounterId() { -// return this._encounterId; -// } - -// get individualId() { -// return this._individualId; -// } - -// get projectName() { -// return this._projectName; -// } - -// get numResults() { -// return this._numResults; -// } - -// get numCandidates() { -// return this._numCandidates; -// } - -// get thisEncounterImageUrl() { -// return this._thisEncounterImageUrl; -// } - -// get loading() { -// return this._loading; -// } - -// setLoading(loading) { -// this._loading = loading; -// } - -// get hasResults() { -// return this._hasResults; -// } - -// get newIndividualName() { -// return this._newIndividualName; -// } - -// get taskId() { -// return this._taskId; -// } - -// setTaskId(id) { -// this._taskId = id; -// } - -// async fetchMatchResults() { -// this.setLoading(true); -// this._hasResults = false; -// try { -// const result = await axios.get( -// `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=${this.numResults}` -// ); -// this.loadData(result.data); -// } catch (e) { -// console.error(e); -// } finally { -// this.setLoading(false); -// } -// } - -// setViewMode(mode) { -// this._viewMode = mode; -// } - -// setNumResults(n) { -// this._numResults = n; -// } - -// setProjectName(name) { -// this._projectName = name; -// } - -// setNewIndividualName(reason) { -// this._newIndividualName = reason; -// } - -// get selectedMatch() { -// return this._selectedMatch; -// } - -// setSelectedMatch(selected, encounterId, individualId) { -// if (selected) { -// this._selectedMatch = [...this._selectedMatch, { encounterId, individualId }]; -// } else { -// this._selectedMatch = this._selectedMatch.filter( -// (data) => data.encounterId !== encounterId, -// ); -// } -// } - -// get uniqueIndividualIds() { -// const ids = new Set(); - -// if (this._individualId) { -// ids.add(this._individualId); -// } - -// this._selectedMatch.forEach((match) => { -// if (match.individualId) { -// ids.add(match.individualId); -// } -// }); - -// return Array.from(ids); -// } - -// get matchingState() { -// if (this._selectedMatch.length === 0) { -// return "no_selection"; -// } - -// const uniqueIds = this.uniqueIndividualIds; -// const idCount = uniqueIds.length; - -// if (idCount === 0) { -// return "no_individuals"; -// } else if (idCount === 1) { -// return "single_individual"; -// } else if (idCount === 2) { -// return "two_individuals"; -// } else { -// return "too_many_individuals"; -// } -// } - -// get organizedAnnotColumns() { -// const organized = new Map(); -// for (const [algorithm, data] of this.filteredGroupedAnnots) { -// organized.set(algorithm, this.organizeMatchesIntoColumns(data)); -// } -// return organized; -// } - -// get organizedIndivColumns() { -// const organized = new Map(); -// for (const [algorithm, data] of this.filteredGroupedIndivs) { -// organized.set(algorithm, this.organizeMatchesIntoColumns(data)); -// } -// return organized; -// } -// } - - - -import { makeAutoObservable } from "mobx"; -import axios from "axios"; -import { MAX_ROWS_PER_COLUMN } from "../constants"; -import { getAllAnnot, getAllIndiv } from "../helperFunctions"; - -export default class MatchResultsStore { - // --- observable state (API & view state) --- - _viewMode = "individual"; // "individual" | "image" - _encounterId = ""; - _individualId = null; - _projectName = ""; - _numResults = 12; // how many prospects to request from backend - _numCandidates = 0; - _matchDate = null; - _thisEncounterImageUrl = ""; - _possibleMatchImageUrl = ""; - _selectedMatchImageUrlByAlgo = new Map(); // reserved for per-algorithm selection - _selectedMatch = []; - _taskId = null; - _newIndividualName = ""; - - // raw data from API, before grouping / processing - _rawAnnots = []; - _rawIndivs = []; - - // loading / result flags - _loading = true; - _hasResults = false; - - constructor() { - makeAutoObservable(this, {}, { autoBind: true }); - } - - // --- data loading & transformation --- - - loadData(result) { - const annotResults = getAllAnnot(result.matchResultsRoot); - const indivResults = getAllIndiv(result.matchResultsRoot); - - // safety: there might be no results at all - if ((!annotResults || annotResults.length === 0) && - (!indivResults || indivResults.length === 0)) { - this._rawAnnots = []; - this._rawIndivs = []; - this._encounterId = null; - this._individualId = null; - this._thisEncounterImageUrl = ""; - this._possibleMatchImageUrl = ""; - this._numCandidates = 0; - this._matchDate = null; - this._hasResults = false; - return; - } - - const first = annotResults[0] ?? indivResults[0]; - - this._encounterId = first.queryEncounterId; - this._individualId = first.queryIndividualId; - this._matchDate = first.date; - this._numCandidates = first.numberCandidates; - this._thisEncounterImageUrl = first.queryEncounterImageUrl; - this._possibleMatchImageUrl = first.annotation?.asset?.url ?? ""; - - this._rawAnnots = annotResults; - this._rawIndivs = indivResults; - this._hasResults = true; - } - - /** - * Normalize raw prospect list into: - * - grouped by algorithm - * - split into columns with displayIndex - */ - _processData(rawData) { - // 1. filter by project name if set - const filtered = this._projectName - ? rawData.filter((item) => item.projectName === this._projectName) - : rawData; - - // 2. group by algorithm - const grouped = new Map(); - filtered.forEach((item) => { - const algorithm = item.algorithm; - if (!grouped.has(algorithm)) { - grouped.set(algorithm, []); - } - grouped.get(algorithm).push(item); - }); - - // 3. organize into columns with metadata per algorithm - const organized = new Map(); - for (const [algorithm, data] of grouped) { - const columns = []; - for (let i = 0; i < data.length; i += MAX_ROWS_PER_COLUMN) { - const columnData = data - .slice(i, i + MAX_ROWS_PER_COLUMN) - .map((match, index) => ({ - ...match, - displayIndex: i + index + 1, - })); - columns.push(columnData); - } - organized.set(algorithm, { - columns, - metadata: { - numCandidates: data[0].numberCandidates, - date: data[0].date, - queryImageUrl: data[0].queryEncounterImageAsset?.url, - methodName: data[0].methodName, - methodDescription: data[0].methodDescription, - taskStatus: data[0].taskStatus, - taskStatusOverall: data[0].taskStatusOverall, - }, - }); - } - return organized; - } - - // --- computed data for UI --- - - get processedAnnots() { - return this._processData(this._rawAnnots); - } - - get processedIndivs() { - return this._processData(this._rawIndivs); - } - - get currentViewData() { - return this._viewMode === "individual" - ? this.processedIndivs - : this.processedAnnots; - } - - get viewMode() { - return this._viewMode; - } - - get encounterId() { - return this._encounterId; - } - - get individualId() { - return this._individualId; - } - - get projectName() { - return this._projectName; - } - - get numResults() { - return this._numResults; - } - - get numCandidates() { - return this._numCandidates; - } - - get matchDate() { - return this._matchDate; - } - - get thisEncounterImageUrl() { - return this._thisEncounterImageUrl; - } - - get possibleMatchImageUrl() { - return this._possibleMatchImageUrl; - } - - get loading() { - return this._loading; - } - - get hasResults() { - return this._hasResults; - } - - get newIndividualName() { - return this._newIndividualName; - } - - get taskId() { - return this._taskId; - } - - // --- async actions --- - - async fetchMatchResults() { - this.setLoading(true); - this._hasResults = false; - try { - const result = await axios.get( - `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=${this.numResults}`, - ); - this.loadData(result.data); - } catch (e) { - console.error(e); - // for now: just log and fall back to "no results" state - } finally { - this.setLoading(false); - } - } - - // --- simple setters / UI actions --- - - setLoading(loading) { - this._loading = loading; - } - - setTaskId(id) { - this._taskId = id; - } - - setViewMode(mode) { - this._viewMode = mode; - } - - setNumResults(n) { - this._numResults = n; - } - - setProjectName(name) { - this._projectName = name; - } - - setNewIndividualName(name) { - this._newIndividualName = name; - } - - // --- selection state (for bottom bar logic) --- - - get selectedMatch() { - return this._selectedMatch; - } - - /** - * Track which candidates the user has selected, by encounter + individual id. - */ - setSelectedMatch(selected, encounterId, individualId) { - if (selected) { - this._selectedMatch = [...this._selectedMatch, { encounterId, individualId }]; - } else { - this._selectedMatch = this._selectedMatch.filter( - (data) => data.encounterId !== encounterId, - ); - } - } - - get uniqueIndividualIds() { - const ids = new Set(); - - if (this._individualId) { - ids.add(this._individualId); - } - - this._selectedMatch.forEach((match) => { - if (match.individualId) { - ids.add(match.individualId); - } - }); - - return Array.from(ids); - } - - /** - * High-level matching state used by bottom bar - * (e.g. merge / link / create new individual). - */ - get matchingState() { - if (this._selectedMatch.length === 0) { - return "no_selection"; - } - - const uniqueIds = this.uniqueIndividualIds; - const idCount = uniqueIds.length; - - if (idCount === 0) { - return "no_individuals"; - } else if (idCount === 1) { - return "single_individual"; - } else if (idCount === 2) { - return "two_individuals"; - } else { - return "too_many_individuals"; - } - } -} From 3673fa1b3082f7bf545aaab497494bff9704c354 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 17 Dec 2025 15:39:00 +0000 Subject: [PATCH 039/192] move to sub folder --- .../components/MatchProspectTable.jsx | 429 ++++++++++++++++++ .../components/MatchResultsBottomBar.jsx | 167 +++++++ .../stores/matchResultsStore.js | 288 ++++++++++++ 3 files changed, 884 insertions(+) create mode 100644 frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx create mode 100644 frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx create mode 100644 frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx new file mode 100644 index 0000000000..52429e8837 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -0,0 +1,429 @@ +import React, { useState } from "react"; +import { Row, Col, Form } from "react-bootstrap"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import Icon4 from "../icons/Icon4"; +import Icon5 from "../icons/Icon5"; +import Icon7 from "../icons/Icon7"; +import { FormattedMessage } from "react-intl"; + +const styles = { + matchRow: (selected, themeColor) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 10px", + fontSize: "0.9rem", + marginTop: "4px", + borderRadius: "5px", + backgroundColor: selected + ? themeColor.primaryColors.primary50 + : "transparent", + }), + matchRank: { + width: "24px", + textAlign: "right", + marginRight: "8px", + }, + matchScore: { + width: "64px", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + }), + matchImageCard: { + position: "relative", + borderRadius: "8px", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + overflow: "hidden", + height: "400px", + }, + imageContainer: { + width: "100%", + height: "100%", + overflow: "hidden", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f8f9fa", + }, + matchImage: { + width: "100%", + height: "100% ", + display: "block", + objectFit: "contain", + backgroundColor: "#f8f9fa", + transformOrigin: "center center", + }, + cornerLabel: (themeColor) => ({ + position: "absolute", + top: "8px", + left: "-8px", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + padding: "2px 8px", + borderRadius: "2px", + fontSize: "0.75rem", + zIndex: 1000, + }), + toolsBarLeft: { + position: "absolute", + top: "50%", + left: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + toolsBarRight: { + position: "absolute", + top: "50%", + right: "-40px", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + matchListScrollContainer: { + overflowX: "auto", + overflowY: "hidden", + marginBottom: "1rem", + }, + matchListGrid: { + display: "flex", + gap: "12px", + width: "100%", + }, + matchColumn: { + flex: 1, + minWidth: "30%", + display: "flex", + flexDirection: "column", + }, +}; + +const MatchProspectTable = ({ + key, + numCandidates, + date, + selectedMatch, + onToggleSelected, + thisEncounterImageUrl, + themeColor, + columns, + algorithm, + methodName, + methodDescription, + taskStatus, + taskStatusOverall, +}) => { + console.log("columns", JSON.stringify(columns)); + + const [selectedRow, setSelectedRow] = React.useState( + columns[0]?.[0] + ); + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + + const [leftImageZoom, setLeftImageZoom] = React.useState(1); + const [rightImageZoom, setRightImageZoom] = React.useState(1); + const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); + const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = React.useState(null); + const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); + + const handleZoomIn = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); + } else { + setRightImageZoom((prev) => Math.min(prev + 0.25, 3)); + } + }; + + const handleZoomOut = (side) => { + if (side === "left") { + setLeftImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } else { + setRightImageZoom((prev) => Math.max(prev - 0.25, 0.5)); + } + }; + + const handleResetZoom = (side) => { + if (side === "left") { + setLeftImageZoom(1); + } else { + setRightImageZoom(1); + } + }; + + const handleMouseDown = (side, e) => { + setIsDragging(side); + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; + + const handleMouseMove = (e) => { + if (!isDragging) return; + + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + if (isDragging === "left") { + setLeftPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } else { + setRightPanPosition((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + } + + setDragStart({ + x: e.clientX, + y: e.clientY, + }); + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + const handleRowClick = (rowData) => { + setSelectedRow(rowData); + }; + + React.useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, dragStart]); + + const isSelected = (encounterId) => + selectedMatch?.some((d) => d.encounterId === encounterId); + + return ( +
+
+
+
+ {methodName + ? `Matches based on ${methodName}` + : `Matches based on ${algorithm}`} + {/* {methodDescription ? ` – ${methodDescription}` : ""} */} +
+
+ against {numCandidates} candidates{" "} + {date?.slice(0, 16).replace("T", " ")} +
+
+
+ +
+
+ {columns.map((columnData, columnIndex) => ( +
+ {columnData.map((candidate) => { + const candidateEncounterId = candidate.annotation?.encounter?.id; + const candidateIndividualId = candidate.annotation?.individual?.id; + const candidateIndividualDisplayName = + candidate.annotation?.individual?.displayName; + const isRowSelected = isSelected(candidateEncounterId); + const isRowPreviewed = candidateEncounterId === selectedRow?.annotation?.encounter?.id; + + return ( +
+ handleRowClick(candidate) + } + > + {candidate.displayIndex}{"."} + { + e.stopPropagation(); + }} + > + {candidate.score.toFixed(4)} + + + { candidate?.asset?.url && + + } +
e.stopPropagation()}> + + onToggleSelected( + e.target.checked, + candidateEncounterId, + candidateIndividualDisplayName + ) + } + /> +
+
+ ); + })} +
+ ))} +
+
+ + + +
+
This encounter
+
handleMouseDown("left", e)} + > + This encounter +
+
+ +
+
handleZoomIn("left")} + style={styles.iconButton} + title="Zoom In" + > + +
+
handleZoomOut("left")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
+ + + +
+
+ Possible Match +
+
handleMouseDown("right", e)} + > + Possible match +
+
+ +
+
handleZoomIn("right")} + style={styles.iconButton} + title="Zoom In" + > + +
+
handleZoomOut("right")} + style={styles.iconButton} + title="Zoom Out" + > + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +}; + +export default MatchProspectTable; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx new file mode 100644 index 0000000000..b649359549 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -0,0 +1,167 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { FormattedMessage } from "react-intl"; +import { Form, Button } from "react-bootstrap"; +import MainButton from "../../../components/MainButton"; + +const styles = { + bottomBar: (themeColor) => ({ + position: "fixed", + left: 0, + right: 0, + bottom: 0, + background: themeColor.primaryColors.primary50, + borderTop: "1px solid #dee2e6", + padding: "10px 24px", + display: "flex", + gap: "24px", + zIndex: 1000, + }), + bottomText: { + fontSize: "0.9rem", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + }), + idPillOutline: { + background: "transparent", + border: "1px solid #ccc", + }, + warningText: { + color: "#dc3545", + fontSize: "0.9rem", + fontWeight: "500", + }, +}; + +const MatchResultsBottomBar = observer(({ store, themeColor }) => { + + const renderActions = () => { + const matchingState = store.matchingState; + + switch (matchingState) { + case "no_selection": + return ( + <> + store.setNewIndividualName(e.target.value)} + style={{ maxWidth: "300px" }} + size="sm" + /> + + + + + + ); + + case "no_individuals": + return ( + <> + store.setNewIndividualName(e.target.value)} + style={{ maxWidth: "300px" }} + size="sm" + /> + + + + + + ); + + case "single_individual": + return ( + + ); + + case "two_individuals": + return ( + + ); + + case "too_many_individuals": + return ( +
+ + +
+ ); + + default: + return null; + } + }; + + return ( +
+
+ {" "} + + {store.encounterId} + +
+
+ {renderActions()} +
+
+ ); +}); + +export default MatchResultsBottomBar; \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js new file mode 100644 index 0000000000..594f81f089 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -0,0 +1,288 @@ + +import { makeAutoObservable } from "mobx"; +import axios from "axios"; +import { MAX_ROWS_PER_COLUMN } from "../constants"; +import { getAllAnnot, getAllIndiv } from "../helperFunctions"; + +export default class MatchResultsStore { + _viewMode = "individual"; // "individual" | "image" + _encounterId = ""; + _individualId = null; + _projectName = ""; + _numResults = 12; + _numCandidates = 0; + _matchDate = null; + _thisEncounterImageUrl = ""; + _possibleMatchImageUrl = ""; + _selectedMatchImageUrlByAlgo = new Map(); + _selectedMatch = []; + _taskId = null; + _newIndividualName = ""; + + // raw data from API, before grouping / processing + _rawAnnots = []; + _rawIndivs = []; + + // loading / result flags + _loading = true; + _hasResults = false; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + // --- data loading & transformation --- + + loadData(result) { + const annotResults = getAllAnnot(result.matchResultsRoot); + const indivResults = getAllIndiv(result.matchResultsRoot); + + // safety: there might be no results at all + if ((!annotResults || annotResults.length === 0) && + (!indivResults || indivResults.length === 0)) { + this._rawAnnots = []; + this._rawIndivs = []; + this._encounterId = null; + this._individualId = null; + this._thisEncounterImageUrl = ""; + this._possibleMatchImageUrl = ""; + this._numCandidates = 0; + this._matchDate = null; + this._hasResults = false; + return; + } + + const first = annotResults[0] ?? indivResults[0]; + + this._encounterId = first.queryEncounterId; + this._individualId = first.queryIndividualId; + this._matchDate = first.date; + this._numCandidates = first.numberCandidates; + this._thisEncounterImageUrl = first.queryEncounterImageUrl; + this._possibleMatchImageUrl = first.annotation?.asset?.url ?? ""; + + this._rawAnnots = annotResults; + this._rawIndivs = indivResults; + this._hasResults = true; + } + + /** + * Normalize raw prospect list into: + * - grouped by algorithm + * - split into columns with displayIndex + */ + _processData(rawData) { + // 1. filter by project name if set + const filtered = this._projectName + ? rawData.filter((item) => item.projectName === this._projectName) + : rawData; + + // 2. group by algorithm + const grouped = new Map(); + filtered.forEach((item) => { + const algorithm = item.algorithm; + if (!grouped.has(algorithm)) { + grouped.set(algorithm, []); + } + grouped.get(algorithm).push(item); + }); + + // 3. organize into columns with metadata per algorithm + const organized = new Map(); + for (const [algorithm, data] of grouped) { + const columns = []; + for (let i = 0; i < data.length; i += MAX_ROWS_PER_COLUMN) { + const columnData = data + .slice(i, i + MAX_ROWS_PER_COLUMN) + .map((match, index) => ({ + ...match, + displayIndex: i + index + 1, + })); + columns.push(columnData); + } + organized.set(algorithm, { + columns, + metadata: { + numCandidates: data[0].numberCandidates, + date: data[0].date, + queryImageUrl: data[0].queryEncounterImageAsset?.url, + methodName: data[0].methodName, + methodDescription: data[0].methodDescription, + taskStatus: data[0].taskStatus, + taskStatusOverall: data[0].taskStatusOverall, + }, + }); + } + return organized; + } + + // --- computed data for UI --- + + get processedAnnots() { + return this._processData(this._rawAnnots); + } + + get processedIndivs() { + return this._processData(this._rawIndivs); + } + + get currentViewData() { + return this._viewMode === "individual" + ? this.processedIndivs + : this.processedAnnots; + } + + get viewMode() { + return this._viewMode; + } + + get encounterId() { + return this._encounterId; + } + + get individualId() { + return this._individualId; + } + + get projectName() { + return this._projectName; + } + + get numResults() { + return this._numResults; + } + + get numCandidates() { + return this._numCandidates; + } + + get matchDate() { + return this._matchDate; + } + + get thisEncounterImageUrl() { + return this._thisEncounterImageUrl; + } + + get possibleMatchImageUrl() { + return this._possibleMatchImageUrl; + } + + get loading() { + return this._loading; + } + + get hasResults() { + return this._hasResults; + } + + get newIndividualName() { + return this._newIndividualName; + } + + get taskId() { + return this._taskId; + } + + // --- async actions --- + + async fetchMatchResults() { + this.setLoading(true); + this._hasResults = false; + try { + const result = await axios.get( + `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=${this.numResults}`, + ); + this.loadData(result.data); + } catch (e) { + console.error(e); + // for now: just log and fall back to "no results" state + } finally { + this.setLoading(false); + } + } + + // --- simple setters / UI actions --- + + setLoading(loading) { + this._loading = loading; + } + + setTaskId(id) { + this._taskId = id; + } + + setViewMode(mode) { + this._viewMode = mode; + } + + setNumResults(n) { + this._numResults = n; + } + + setProjectName(name) { + this._projectName = name; + } + + setNewIndividualName(name) { + this._newIndividualName = name; + } + + // --- selection state (for bottom bar logic) --- + + get selectedMatch() { + return this._selectedMatch; + } + + /** + * Track which candidates the user has selected, by encounter + individual id. + */ + setSelectedMatch(selected, encounterId, individualId) { + if (selected) { + this._selectedMatch = [...this._selectedMatch, { encounterId, individualId }]; + } else { + this._selectedMatch = this._selectedMatch.filter( + (data) => data.encounterId !== encounterId, + ); + } + } + + get uniqueIndividualIds() { + const ids = new Set(); + + if (this._individualId) { + ids.add(this._individualId); + } + + this._selectedMatch.forEach((match) => { + if (match.individualId) { + ids.add(match.individualId); + } + }); + + return Array.from(ids); + } + + /** + * High-level matching state used by bottom bar + * (e.g. merge / link / create new individual). + */ + get matchingState() { + if (this._selectedMatch.length === 0) { + return "no_selection"; + } + + const uniqueIds = this.uniqueIndividualIds; + const idCount = uniqueIds.length; + + if (idCount === 0) { + return "no_individuals"; + } else if (idCount === 1) { + return "single_individual"; + } else if (idCount === 2) { + return "two_individuals"; + } else { + return "too_many_individuals"; + } + } +} From e11b9fa322ec960b0797478168d70ef70d230cf5 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 17 Dec 2025 15:41:16 +0000 Subject: [PATCH 040/192] move to sub folder --- .../stores/matchResultsStore.js | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 594f81f089..72d4576a92 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -23,7 +23,6 @@ export default class MatchResultsStore { _rawAnnots = []; _rawIndivs = []; - // loading / result flags _loading = true; _hasResults = false; @@ -31,7 +30,6 @@ export default class MatchResultsStore { makeAutoObservable(this, {}, { autoBind: true }); } - // --- data loading & transformation --- loadData(result) { const annotResults = getAllAnnot(result.matchResultsRoot); @@ -66,11 +64,6 @@ export default class MatchResultsStore { this._hasResults = true; } - /** - * Normalize raw prospect list into: - * - grouped by algorithm - * - split into columns with displayIndex - */ _processData(rawData) { // 1. filter by project name if set const filtered = this._projectName @@ -184,7 +177,7 @@ export default class MatchResultsStore { return this._taskId; } - // --- async actions --- + // actions async fetchMatchResults() { this.setLoading(true); @@ -196,13 +189,12 @@ export default class MatchResultsStore { this.loadData(result.data); } catch (e) { console.error(e); - // for now: just log and fall back to "no results" state } finally { this.setLoading(false); } } - // --- simple setters / UI actions --- + // setters and actions setLoading(loading) { this._loading = loading; @@ -228,15 +220,10 @@ export default class MatchResultsStore { this._newIndividualName = name; } - // --- selection state (for bottom bar logic) --- - get selectedMatch() { return this._selectedMatch; } - /** - * Track which candidates the user has selected, by encounter + individual id. - */ setSelectedMatch(selected, encounterId, individualId) { if (selected) { this._selectedMatch = [...this._selectedMatch, { encounterId, individualId }]; @@ -263,10 +250,6 @@ export default class MatchResultsStore { return Array.from(ids); } - /** - * High-level matching state used by bottom bar - * (e.g. merge / link / create new individual). - */ get matchingState() { if (this._selectedMatch.length === 0) { return "no_selection"; From bb89bc5c94eaa2347c62d58c83565eb83c66bf8f Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 17 Dec 2025 19:38:57 +0000 Subject: [PATCH 041/192] add a reusable image and annotation overlay component --- frontend/src/components/AnnotationOverlay.jsx | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 frontend/src/components/AnnotationOverlay.jsx diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx new file mode 100644 index 0000000000..f812f9a1c8 --- /dev/null +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -0,0 +1,160 @@ +import React, { useRef, useEffect } from "react"; + +const InteractiveAnnotationOverlay = ({ + imageUrl, + annotations = [], + originalWidth, + originalHeight, + zoom = 1, + panPosition = { x: 0, y: 0 }, + isDragging = false, + rotationInfo = null, + strokeColor = "yellow", + lineWidth = 2, + containerStyle = {}, + imageStyle = {}, + alt = "Image with annotations", + ...imageProps +}) => { + const imgRef = useRef(null); + const canvasRef = useRef(null); + + useEffect(() => { + const drawAnnotations = () => { + if (!imgRef.current || !canvasRef.current) return; + + const img = imgRef.current; + const canvas = canvasRef.current; + const context = canvas.getContext("2d"); + + const displayWidth = img.clientWidth; + const displayHeight = img.clientHeight; + + canvas.width = displayWidth; + canvas.height = displayHeight; + + const baseScaleX = displayWidth / originalWidth; + const baseScaleY = displayHeight / originalHeight; + + context.clearRect(0, 0, canvas.width, canvas.height); + + const validAnnotations = annotations.filter( + (annotation) => !annotation.trivial, + ); + + for (const annotation of validAnnotations) { + let { x, y, width, height, theta = 0 } = annotation; + + let scaledRect = { + x: x * baseScaleX, + y: y * baseScaleY, + width: width * baseScaleX, + height: height * baseScaleY, + }; + + if (rotationInfo) { + const adjW = originalHeight / originalWidth; + const adjH = originalWidth / originalHeight; + scaledRect.x *= adjW; + scaledRect.width *= adjW; + scaledRect.y *= adjH; + scaledRect.height *= adjH; + } + + scaledRect.x *= zoom; + scaledRect.y *= zoom; + scaledRect.width *= zoom; + scaledRect.height *= zoom; + + scaledRect.x += panPosition.x; + scaledRect.y += panPosition.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 = strokeColor; + context.lineWidth = lineWidth; + + context.strokeRect( + -scaledRect.width / 2, + -scaledRect.height / 2, + scaledRect.width, + scaledRect.height, + ); + + context.restore(); + } + }; + + const imgElement = imgRef.current; + + if (imgElement && imgElement.complete) { + drawAnnotations(); + } else if (imgElement) { + imgElement.addEventListener("load", drawAnnotations); + } + + return () => { + if (imgElement) { + imgElement.removeEventListener("load", drawAnnotations); + } + }; + }, [ + imageUrl, + annotations, + originalWidth, + originalHeight, + zoom, + panPosition, + rotationInfo, + strokeColor, + lineWidth, + ]); + + return ( +
+ {alt} + +
+ ); +}; + +export default InteractiveAnnotationOverlay; From 53b473c83d55fd0762845ca4e3408c0a65079431 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 17 Dec 2025 19:43:12 +0000 Subject: [PATCH 042/192] add hover effect to inspect button, add atemporary workaround to display images --- .../components/MatchProspectTable.jsx | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 52429e8837..eca685af32 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -5,7 +5,8 @@ import ZoomOutIcon from "../icons/ZoomOutIcon"; import Icon4 from "../icons/Icon4"; import Icon5 from "../icons/Icon5"; import Icon7 from "../icons/Icon7"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage } from "react-intl"; +import InteractiveAnnotationOverlay from "../components/MatchResultsBottomBar"; const styles = { matchRow: (selected, themeColor) => ({ @@ -122,12 +123,12 @@ const MatchProspectTable = ({ taskStatus, taskStatusOverall, }) => { - console.log("columns", JSON.stringify(columns)); const [selectedRow, setSelectedRow] = React.useState( columns[0]?.[0] ); const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [hoveredRow, setHoveredRow] = React.useState(null); const [leftImageZoom, setLeftImageZoom] = React.useState(1); const [rightImageZoom, setRightImageZoom] = React.useState(1); @@ -242,6 +243,7 @@ const MatchProspectTable = ({ candidate.annotation?.individual?.displayName; const isRowSelected = isSelected(candidateEncounterId); const isRowPreviewed = candidateEncounterId === selectedRow?.annotation?.encounter?.id; + const isHovered = hoveredRow === candidateEncounterId; return (
handleRowClick(candidate) } + onMouseEnter={() => { + setHoveredRow(candidateEncounterId) + }} + onMouseLeave={() => setHoveredRow(null)} > {candidate.displayIndex}{"."} {candidateIndividualDisplayName} - { candidate?.asset?.url && - - } -
e.stopPropagation()}> +
+ +
e.stopPropagation()}> + {candidate?.asset?.url && isHovered && ( + + )} handleMouseDown("left", e)} > This encounter handleMouseDown("right", e)} > Possible match Date: Tue, 6 Jan 2026 00:51:27 +0000 Subject: [PATCH 043/192] experimenting something --- frontend/src/App.jsx | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 93f323d167..e07d882639 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,7 +14,7 @@ import LocaleContext from "./IntlProvider"; import FooterVisibilityContext from "./FooterVisibilityContext"; import Cookies from "js-cookie"; import FilterContext from "./FilterContextProvider"; -import { SiteSettingsProvider } from "./SiteSettingsContext"; +import { ToastContainer } from "react-toastify"; function App() { const messageMap = { @@ -73,20 +73,27 @@ function App() { defaultLocale="en" messages={messageMap[locale]} > - - + - - - - - + + + +
From 9ce71efd97ca6a52004c8fcdc9b5ef5aab803ce5 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 6 Jan 2026 01:00:03 +0000 Subject: [PATCH 044/192] reduce calls of site-settings --- frontend/src/App.jsx | 57 ++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e07d882639..0b9f83c4c7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { IntlProvider } from "react-intl"; import messagesEn from "./locale/en.json"; import messagesEs from "./locale/es.json"; @@ -14,7 +14,9 @@ import LocaleContext from "./IntlProvider"; import FooterVisibilityContext from "./FooterVisibilityContext"; import Cookies from "js-cookie"; import FilterContext from "./FilterContextProvider"; +import { SiteSettingsProvider } from "./SiteSettingsContext"; import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; function App() { const messageMap = { @@ -24,16 +26,20 @@ function App() { it: messagesIt, de: messagesDe, }; + const initialLocale = Cookies.get("wildbookLangCode") || "en"; const [locale, setLocale] = useState(initialLocale); + const [visible, setVisible] = useState(true); + const containerStyle = { display: "flex", flexDirection: "column", minHeight: "100vh", }; - const queryClient = new QueryClient(); + const queryClientRef = useRef(null); + if (!queryClientRef.current) queryClientRef.current = new QueryClient(); const handleLocaleChange = (newLocale) => { setLocale(newLocale); @@ -59,7 +65,7 @@ function App() { : "/"; return ( - + @@ -73,27 +79,32 @@ function App() { defaultLocale="en" messages={messageMap[locale]} > - - + - - - - + + + + + + +
From 333e8474a1158402165c4d8db4d4cd2a177383b5 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 6 Jan 2026 16:17:46 +0000 Subject: [PATCH 045/192] remove unused variables --- frontend/src/AuthenticatedSwitch.jsx | 6 ++--- frontend/src/components/icons/EditIcon.jsx | 28 +++++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx index 917eecce3a..8b73249f55 100644 --- a/frontend/src/AuthenticatedSwitch.jsx +++ b/frontend/src/AuthenticatedSwitch.jsx @@ -8,7 +8,6 @@ import useGetMe from "./models/auth/users/useGetMe"; // Lazy load pages const Login = lazy(() => import("./pages/Login")); -const Profile = lazy(() => import("./pages/Profile")); const Home = lazy(() => import("./pages/Home")); const EncounterSearch = lazy( () => import("./pages/SearchPages/EncounterSearch"), @@ -27,7 +26,9 @@ const EditAnnotation = lazy(() => import("./pages/EditAnnotation")); const BulkImport = lazy(() => import("./pages/BulkImport/BulkImport")); const BulkImportTask = lazy(() => import("./pages/BulkImport/BulkImportTask")); -const MatchResults = lazy(() => import("./pages/MatchResultsPage/MatchResults")); +const MatchResults = lazy( + () => import("./pages/MatchResultsPage/MatchResults"), +); const Encounter = lazy(() => import("./pages/Encounter/Encounter")); @@ -73,7 +74,6 @@ export default function AuthenticatedSwitch({ > Loading...
}> - } /> } /> } /> diff --git a/frontend/src/components/icons/EditIcon.jsx b/frontend/src/components/icons/EditIcon.jsx index e93ade2d37..42900874c9 100644 --- a/frontend/src/components/icons/EditIcon.jsx +++ b/frontend/src/components/icons/EditIcon.jsx @@ -1,14 +1,20 @@ import React from "react"; -import ThemeColorContext from "../../ThemeColorProvider"; export default function EditIcon() { - const theme = React.useContext(ThemeColorContext); - return ( -
- - - -
- ); -} \ No newline at end of file + return ( +
+ + + +
+ ); +} From 93fb11493a627d6584a820536701cd5517e14540 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 7 Jan 2026 21:30:20 +0000 Subject: [PATCH 046/192] case: single individual --- .../components/MatchProspectTable.jsx | 2 +- .../components/MatchResultsBottomBar.jsx | 46 +++++++++----- .../stores/matchResultsStore.js | 62 ++++++++++++++++++- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index eca685af32..cce4fa5861 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -265,7 +265,7 @@ const MatchProspectTable = ({ > {candidate.displayIndex}{"."}
{ > @@ -89,36 +88,57 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { noArrow={true} backgroundColor={themeColor.primaryColors.primary500} color="white" - onClick={store.handleConfirmNoMatch} + onClick={() => { }} disabled={!store.newIndividualName.trim()} style={{ marginTop: "0", marginBottom: "0" }} > ); + //don't forget another case: + //All encounters already assigned to the same individual ID. No further action is needed to confirm this match. + case "single_individual": return ( - + {store.matchRequestLoading &&
); @@ -142,7 +161,6 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => {
{" "} d.individualId)?.individualId ?? + null; + const selectedEncounterIds = (this._selectedMatch || []) + .filter((d) => d?.encounterId) + .filter((d) => (this._encounterId ? d.encounterId !== this._encounterId : true)) + .filter((d) => !d?.individualId) + .map((d) => d.encounterId); + const params = new URLSearchParams(); + if (this._encounterId) params.set("number", this._encounterId); + if (this._taskId) params.set("taskId", this._taskId); + if (selectedIndividualId) params.set("individualID", selectedIndividualId); + selectedEncounterIds.forEach((id) => params.append("encOther", id)); + + const url = `/iaResultsSetID.jsp?${params.toString()}`; + + const res = await axios.get(url, { + headers: { Accept: "application/json" }, + }) + + return res.data; + } catch (e) { + console.error(e); + this._matchRequestError = e; + return null; + } finally { + this._matchRequestLoading = false; + } + } + get uniqueIndividualIds() { const ids = new Set(); From f95e51a264cc2b89aadb412521dc358356c127a1 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 8 Jan 2026 02:19:40 +0000 Subject: [PATCH 047/192] case: confirm no match --- .../components/MatchProspectTable.jsx | 2 +- .../components/MatchResultsBottomBar.jsx | 2 +- .../stores/matchResultsStore.js | 49 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index cce4fa5861..d8d1211907 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -286,7 +286,7 @@ const MatchProspectTable = ({ className="btn btn-sm p-0 px-2" onClick={(e) => { e.stopPropagation(); - const url = `/individuals.jsp?id=${candidateEncounterId}`; + const url = `/individuals.jsp?id=${candidateIndividualId}`; window.open(url, "_blank"); }} > diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 549cd6d125..11729848e6 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -62,7 +62,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { backgroundColor={themeColor.primaryColors.primary500} color="white" onClick={store.handleConfirmNoMatch} - disabled={!store.newIndividualName.trim()} + disabled={(!store.newIndividualName || "").trim() || !!store.individualId} style={{ marginTop: "0", marginBottom: "0" }} > m?.encounterId).filter(Boolean)), + ); + + if (encounterIds.length === 0) { + this._matchRequestError = "NO_SELECTED_ENCOUNTERS"; + return null; + } + + const patchOps = [{ op: "replace", path: "/individual", value: newName }]; + + for (const id of encounterIds) { + try { + await axios.patch( + `/api/v3/encounters/${encodeURIComponent(id)}`, + patchOps, + { + headers: { + "Content-Type": "application/json-patch+json", + Accept: "application/json", + }, + }, + ); + } catch (e) { + console.error("patch failed:", id, e); + this._matchRequestError = "PATCH_FAILED"; + return null; + } + } + + this._selectedMatch = []; + this._newIndividualName = ""; + + } finally { + this._matchRequestLoading = false; + } +} get uniqueIndividualIds() { const ids = new Set(); From bc01c1d5f7dbd9f399312e3690eb3fab8a7e09a2 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 8 Jan 2026 12:24:02 -0700 Subject: [PATCH 048/192] build out more info on projectsForUser in site-settings --- src/main/java/org/ecocean/api/SiteSettings.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ecocean/api/SiteSettings.java b/src/main/java/org/ecocean/api/SiteSettings.java index 194690d74a..322d539994 100644 --- a/src/main/java/org/ecocean/api/SiteSettings.java +++ b/src/main/java/org/ecocean/api/SiteSettings.java @@ -182,9 +182,10 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) settings.put("keywordId", kwIdArr); // we map to a Set here so we keep values unique (remove duplicates: issue 1279) - Map> allLK = new HashMap>(); + Map > allLK = new HashMap >(); for (LabeledKeyword lkw : myShepherd.getAllLabeledKeywords()) { - if (!allLK.containsKey(lkw.getLabel())) allLK.put(lkw.getLabel(), new HashSet()); + if (!allLK.containsKey(lkw.getLabel())) + allLK.put(lkw.getLabel(), new HashSet()); allLK.get(lkw.getLabel()).add(lkw.getValue()); } JSONObject lkeyword = new JSONObject(); @@ -294,7 +295,11 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) ArrayList projs = myShepherd.getProjectsForUser(currentUser); if (projs != null) { for (Project proj : projs) { - jp.put(proj.getId(), proj.getResearchProjectName()); + JSONObject info = new JSONObject(); + info.put("id", proj.getId()); + info.put("name", proj.getResearchProjectName()); + info.put("prefix", proj.getProjectIdPrefix()); + jp.put(proj.getId(), info); } } settings.put("projectsForUser", jp); From a57368e39d2717d362b71e195509501d9a2d4dc9 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 8 Jan 2026 21:41:29 +0000 Subject: [PATCH 049/192] merge two individuals --- .../pages/MatchResultsPage/MatchResults.jsx | 28 +++- .../components/MatchProspectTable.jsx | 2 +- .../components/MatchResultsBottomBar.jsx | 5 +- .../stores/matchResultsStore.js | 128 ++++++++++++------ 4 files changed, 116 insertions(+), 47 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 4026fdd34b..580bc0544f 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -8,6 +8,7 @@ import MatchProspectTable from "./components/MatchProspectTable"; import MatchResultsBottomBar from "./components/MatchResultsBottomBar"; import { useSearchParams } from "react-router-dom"; import { useSiteSettings } from "../../SiteSettingsContext"; +import MainButton from "../../components/MainButton"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -62,9 +63,30 @@ const MatchResults = observer(() => {
-

- -

+
+

+ +

+
{` for ${store.encounterId}`} + { + store._individualDisplayName && { + const url = `/individuals.jsp?id=${store.individualId}`; + window.open(url, "_blank"); + }} + > + {store._individualDisplayName} + + } +
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 11729848e6..2877c59205 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -62,7 +62,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { backgroundColor={themeColor.primaryColors.primary500} color="white" onClick={store.handleConfirmNoMatch} - disabled={(!store.newIndividualName || "").trim() || !!store.individualId} + disabled={!(store.newIndividualName || "").trim() || !!store.individualId} style={{ marginTop: "0", marginBottom: "0" }} > { color="white" onClick={async () => { const data = await store.handleMatch(); - console.log("match response:", data); }} disabled={(!store.individualId && !store.selectedMatch.some(data => data.individualId)) || store.matchRequestLoading} style={{ marginTop: "0", marginBottom: "0" }} @@ -135,7 +134,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { color="white" backgroundColor={themeColor.primaryColors.primary700} noArrow - onClick={() => { }} + onClick={store.handleMerge} > diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index bc467049b8..db1820c14c 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -253,6 +253,55 @@ export default class MatchResultsStore { // merge functions + + //no match + async handleConfirmNoMatch() { + this._matchRequestLoading = true; + this._matchRequestError = null; + + try { + const newName = (this._newIndividualName || "").trim(); + if (!newName) { + this._matchRequestError = "ENTER_INDIVIDUAL_NAME"; + return null; + } + + const selectedEncounterIds = Array.from( + new Set((this._selectedMatch || []).map((m) => m?.encounterId).filter(Boolean)), + ) + const allEncountedIds = selectedEncounterIds.push({ + encounterId: this._encounterId ?? null, + }); + + const patchOps = [{ op: "replace", path: "/individual", value: newName }]; + + for (const id of allEncountedIds) { + try { + await axios.patch( + `/api/v3/encounters/${encodeURIComponent(id)}`, + patchOps, + { + headers: { + "Content-Type": "application/json-patch+json", + Accept: "application/json", + }, + }, + ); + } catch (e) { + console.error("patch failed:", id, e); + this._matchRequestError = "PATCH_FAILED"; + return null; + } + } + + this._selectedMatch = []; + this._newIndividualName = ""; + + } finally { + this._matchRequestLoading = false; + } + } + //one individual async handleMatch() { this._matchRequestLoading = true; @@ -290,55 +339,54 @@ export default class MatchResultsStore { } } - //no match - async handleConfirmNoMatch() { - this._matchRequestLoading = true; - this._matchRequestError = null; + //two individuals + optional encounters + async handleMerge() { + this._matchRequestLoading = true; + this._matchRequestError = null; - try { - const newName = (this._newIndividualName || "").trim(); - if (!newName) { - this._matchRequestError = "ENTER_INDIVIDUAL_NAME"; - return null; - } + try { + const selected = Array.isArray(this._selectedMatch) ? this._selectedMatch : []; - const encounterIds = Array.from( - new Set((this._selectedMatch || []).map((m) => m?.encounterId).filter(Boolean)), - ); + const individualIds = selected + .filter((d) => d?.individualId) + .map((d) => d.individualId) + if(this._individualId) { + individualIds.push(this._individualId); + } - if (encounterIds.length === 0) { - this._matchRequestError = "NO_SELECTED_ENCOUNTERS"; - return null; - } + const uniqueIndividuals = Array.from(new Set(individualIds)).filter(Boolean); - const patchOps = [{ op: "replace", path: "/individual", value: newName }]; - - for (const id of encounterIds) { - try { - await axios.patch( - `/api/v3/encounters/${encodeURIComponent(id)}`, - patchOps, - { - headers: { - "Content-Type": "application/json-patch+json", - Accept: "application/json", - }, - }, - ); - } catch (e) { - console.error("patch failed:", id, e); - this._matchRequestError = "PATCH_FAILED"; + if (uniqueIndividuals.length !== 2) { + this._matchRequestError = "MERGE_REQUIRES_TWO_INDIVIDUALS"; return null; } - } - this._selectedMatch = []; - this._newIndividualName = ""; + const [individualA, individualB] = uniqueIndividuals; + + const encounterIds = selected + .filter((d) => d?.encounterId && !d?.individualId) + .map((d) => d.encounterId); + + const uniqueEncounterIds = Array.from(new Set(encounterIds)).filter(Boolean); - } finally { - this._matchRequestLoading = false; + const params = new URLSearchParams(); + params.set("individualA", individualA); + params.set("individualB", individualB); + uniqueEncounterIds.forEach((id) => params.append("encounterId", id)); + + const url = `/merge.jsp?${params.toString()}`; + window.open(url, "_blank"); + + this._selectedMatch = []; + } catch (e) { + console.error(e); + this._matchRequestError = "MERGE_FAILED"; + return null; + } finally { + this._matchRequestLoading = false; + } } -} + get uniqueIndividualIds() { const ids = new Set(); From 01cbeb954109e0b1eee5d9f184070d28d4915e3a Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 9 Jan 2026 19:16:46 +0000 Subject: [PATCH 050/192] more cases --- .../pages/MatchResultsPage/MatchResults.jsx | 14 +- .../components/MatchProspectTable.jsx | 3 + .../components/MatchResultsBottomBar.jsx | 106 ++++--- .../stores/matchResultsStore.js | 286 ++++++++++++------ 4 files changed, 254 insertions(+), 155 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 580bc0544f..83ac7978ca 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -119,7 +119,10 @@ const MatchResults = observer(() => { ? "white" : themeColor.primaryColors.primary500, }} - onClick={() => store.setViewMode("individual")} + onClick={() => { + store.setViewMode("individual"); + store.resetSelectionToQuery(); + }} > @@ -139,7 +142,10 @@ const MatchResults = observer(() => { ? "white" : themeColor.primaryColors.primary700, }} - onClick={() => store.setViewMode("image")} + onClick={() => { + store.setViewMode("image"); + store.resetSelectionToQuery(); + }} > @@ -185,11 +191,11 @@ const MatchResults = observer(() => { - {Object.entries(projectsForUser).map(([key, value]) => ( + {/* {Object.entries(projectsForUser).map(([key, value]) => ( - ))} + ))} */}
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 006cd874c5..e1c1eed0a5 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -302,6 +302,9 @@ const MatchProspectTable = ({ className="btn btn-sm p-0 px-2" onClick={() => { setInspectionModalOpen(true); + const url = candidate?.asset?.url; + console.log("url", JSON.stringify(url)); + window.open(url, "_blank"); }} > {} diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 2877c59205..0e2eb3ea40 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -40,38 +40,11 @@ const styles = { }; const MatchResultsBottomBar = observer(({ store, themeColor }) => { - + const renderActions = () => { const matchingState = store.matchingState; switch (matchingState) { - case "no_selection": - return ( - <> - store.setNewIndividualName(e.target.value)} - style={{ maxWidth: "300px" }} - size="sm" - /> - - - - - - ); - case "no_individuals": return ( <> @@ -85,46 +58,47 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { /> { }} - disabled={!store.newIndividualName.trim()} + onClick={store.handleConfirmNoMatch} + disabled={!String(store.newIndividualName || "").trim() || store.matchRequestLoading} style={{ marginTop: "0", marginBottom: "0" }} > - + + {store.matchRequestLoading && ( + ); - //don't forget another case: - //All encounters already assigned to the same individual ID. No further action is needed to confirm this match. - case "single_individual": return ( { - const data = await store.handleMatch(); - }} - disabled={(!store.individualId && !store.selectedMatch.some(data => data.individualId)) || store.matchRequestLoading} + onClick={store.handleMatch} + disabled={store.matchRequestLoading} style={{ marginTop: "0", marginBottom: "0" }} > - - {store.matchRequestLoading && ); @@ -135,8 +109,19 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { backgroundColor={themeColor.primaryColors.primary700} noArrow onClick={store.handleMerge} + disabled={store.matchRequestLoading} + style={{ marginTop: "0", marginBottom: "0" }} > - + + {store.matchRequestLoading && ( +
-
- -
From 36faada3fa5ba01839be44daab63066945afa4c4 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Mon, 12 Jan 2026 16:27:48 +0000 Subject: [PATCH 053/192] group by task, not algorithm --- .../pages/MatchResultsPage/MatchResults.jsx | 52 +++++++-------- .../components/MatchProspectTable.jsx | 11 ++-- .../stores/matchResultsStore.js | 63 ++++++++++--------- 3 files changed, 68 insertions(+), 58 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 20b3d739d9..470cc2b64e 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -9,7 +9,7 @@ import MatchResultsBottomBar from "./components/MatchResultsBottomBar"; import { useSearchParams } from "react-router-dom"; import { useSiteSettings } from "../../SiteSettingsContext"; import MainButton from "../../components/MainButton"; - import FullScreenLoader from "../../components/FullScreenLoader"; +import FullScreenLoader from "../../components/FullScreenLoader"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -27,7 +27,7 @@ const MatchResults = observer(() => { return () => { // store.resetStore(); }; - }, [taskId]); + }, [taskId, store]); if (store.loading) { return ; @@ -75,7 +75,7 @@ const MatchResults = observer(() => { rel="noopener noreferrer" >{` for ${store.encounterId}`} { - store._individualDisplayName && {
- {[...store.currentViewData].map( - ([algorithmName, { columns, metadata }]) => ( -
- - store.setSelectedMatch(checked, encounterId, individualId) - } - /> -
- ), - )} + {store.currentViewData.map(({ taskId, columns, metadata }) => ( +
+ + store.setSelectedMatch(checked, encounterId, individualId) + } + /> +
+ ))} + ); diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 99f2d2d949..dfc1f0fd85 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -108,7 +108,7 @@ const styles = { }; const MatchProspectTable = ({ - key, + sectionId, numCandidates, date, selectedMatch, @@ -126,6 +126,9 @@ const MatchProspectTable = ({ const [selectedRow, setSelectedRow] = React.useState( columns[0]?.[0] ); + React.useEffect(() => { + setSelectedRow(columns?.[0]?.[0] ?? null); + }, [columns]); const [inspectionModalOpen, setInspectionModalOpen] = useState(false); const [hoveredRow, setHoveredRow] = React.useState(null); @@ -215,7 +218,7 @@ const MatchProspectTable = ({ selectedMatch?.some((d) => d.encounterId === encounterId); return ( -
+
@@ -294,14 +297,14 @@ const MatchProspectTable = ({
e.stopPropagation()}> - {candidate?.asset?.url && isHovered && ( + {candidate.annotation?.asset?.url && isHovered && (
diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 584a69e361..4725651afb 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -330,7 +330,6 @@ export default class MatchResultsStore { setSelectedMatch(selected, key, encounterId, individualId) { if (!key || !encounterId) return; - // if (encounterId === this._encounterId && !selected) return; if (selected) { if (this._selectedMatch.some((m) => m.key === key)) return; From 2c5ef30f25fb4c989bcd23d1223b5690e07b74eb Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 13 Jan 2026 20:03:13 +0000 Subject: [PATCH 056/192] show/hide/pan/zoom annotations --- frontend/src/components/AnnotationOverlay.jsx | 377 +++++++++++------- .../pages/MatchResultsPage/MatchResults.jsx | 2 + .../components/MatchProspectTable.jsx | 304 ++++++-------- .../pages/MatchResultsPage/helperFunctions.js | 10 +- .../stores/matchResultsStore.js | 92 ++--- 5 files changed, 406 insertions(+), 379 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index f812f9a1c8..57c41cf1bc 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -1,160 +1,241 @@ -import React, { useRef, useEffect } from "react"; - -const InteractiveAnnotationOverlay = ({ - imageUrl, - annotations = [], - originalWidth, - originalHeight, - zoom = 1, - panPosition = { x: 0, y: 0 }, - isDragging = false, - rotationInfo = null, - strokeColor = "yellow", - lineWidth = 2, - containerStyle = {}, - imageStyle = {}, - alt = "Image with annotations", - ...imageProps -}) => { - const imgRef = useRef(null); - const canvasRef = useRef(null); - - useEffect(() => { - const drawAnnotations = () => { - if (!imgRef.current || !canvasRef.current) return; - - const img = imgRef.current; - const canvas = canvasRef.current; - const context = canvas.getContext("2d"); - - const displayWidth = img.clientWidth; - const displayHeight = img.clientHeight; - - canvas.width = displayWidth; - canvas.height = displayHeight; - - const baseScaleX = displayWidth / originalWidth; - const baseScaleY = displayHeight / originalHeight; - - context.clearRect(0, 0, canvas.width, canvas.height); - - const validAnnotations = annotations.filter( - (annotation) => !annotation.trivial, - ); - - for (const annotation of validAnnotations) { - let { x, y, width, height, theta = 0 } = annotation; - - let scaledRect = { - x: x * baseScaleX, - y: y * baseScaleY, - width: width * baseScaleX, - height: height * baseScaleY, - }; - - if (rotationInfo) { - const adjW = originalHeight / originalWidth; - const adjH = originalWidth / originalHeight; - scaledRect.x *= adjW; - scaledRect.width *= adjW; - scaledRect.y *= adjH; - scaledRect.height *= adjH; - } - - scaledRect.x *= zoom; - scaledRect.y *= zoom; - scaledRect.width *= zoom; - scaledRect.height *= zoom; - - scaledRect.x += panPosition.x; - scaledRect.y += panPosition.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 = strokeColor; - context.lineWidth = lineWidth; - - context.strokeRect( - -scaledRect.width / 2, - -scaledRect.height / 2, - scaledRect.width, - scaledRect.height, - ); - - context.restore(); +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +const InteractiveAnnotationOverlay = forwardRef( + ( + { + imageUrl, + annotations = [], + originalWidth, + originalHeight, + rotationInfo = null, + initialZoom = 1, + minZoom = 0.5, + maxZoom = 3, + zoomStep = 0.25, + showAnnotations: showAnnotationsProp, + strokeColor = "yellow", + lineWidth = 2, + containerStyle = {}, + imageStyle = {}, + overlayStyle = {}, + alt = "Image with annotations", + }, + ref, + ) => { + const containerRef = useRef(null); + const [box, setBox] = useState({ w: 0, h: 0 }); + const [zoom, setZoom] = useState( + Number.isFinite(initialZoom) ? initialZoom : 1, + ); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const dragStartRef = useRef({ x: 0, y: 0 }); + const panStartRef = useRef({ x: 0, y: 0 }); + + const [internalShowAnn, setInternalShowAnn] = useState(true); + const showAnn = + typeof showAnnotationsProp === "boolean" + ? showAnnotationsProp + : internalShowAnn; + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const update = () => { + const r = el.getBoundingClientRect(); + setBox({ w: r.width, h: r.height }); + }; + + update(); + + let ro; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(update); + ro.observe(el); + } else { + window.addEventListener("resize", update); } - }; - const imgElement = imgRef.current; + return () => { + if (ro) ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); - if (imgElement && imgElement.complete) { - drawAnnotations(); - } else if (imgElement) { - imgElement.addEventListener("load", drawAnnotations); - } + const fit = useMemo(() => { + const cw = box.w; + const ch = box.h; + const iw = Number(originalWidth) || 0; + const ih = Number(originalHeight) || 0; - return () => { - if (imgElement) { - imgElement.removeEventListener("load", drawAnnotations); + if (!cw || !ch || !iw || !ih) { + return { scale: 1, offsetX: 0, offsetY: 0, renderW: cw, renderH: ch }; } + + const scale = Math.min(cw / iw, ch / ih); + const renderW = iw * scale; + const renderH = ih * scale; + const offsetX = (cw - renderW) / 2; + const offsetY = (ch - renderH) / 2; + + return { scale, offsetX, offsetY, renderW, renderH }; + }, [box.w, box.h, originalWidth, originalHeight]); + + const visibleAnnotations = useMemo(() => { + if (!Array.isArray(annotations)) return []; + return annotations.filter((a) => a && !a.trivial && !a.isTrivial); + }, [annotations]); + + const clampZoom = (z) => Math.max(minZoom, Math.min(maxZoom, z)); + + const zoomIn = () => setZoom((z) => clampZoom(z + zoomStep)); + const zoomOut = () => setZoom((z) => clampZoom(z - zoomStep)); + const reset = () => { + setZoom(clampZoom(initialZoom || 1)); + setPan({ x: 0, y: 0 }); + }; + const toggleAnnotations = () => { + if (typeof showAnnotationsProp === "boolean") return; + setInternalShowAnn((v) => !v); }; - }, [ - imageUrl, - annotations, - originalWidth, - originalHeight, - zoom, - panPosition, - rotationInfo, - strokeColor, - lineWidth, - ]); - - return ( -
- {alt} { + if (typeof showAnnotationsProp === "boolean") return; + setInternalShowAnn(!!v); + }; + + useImperativeHandle(ref, () => ({ + zoomIn, + zoomOut, + reset, + toggleAnnotations, + setAnnotationsVisible, + getState: () => ({ zoom, pan, showAnn }), + })); + + const onMouseDown = (e) => { + setDragging(true); + dragStartRef.current = { x: e.clientX, y: e.clientY }; + panStartRef.current = { ...pan }; + }; + + useEffect(() => { + if (!dragging) return; + + const onMove = (e) => { + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + setPan({ + x: panStartRef.current.x + dx, + y: panStartRef.current.y + dy, + }); + }; + + const onUp = () => setDragging(false); + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [dragging, pan]); + + const panZoomTransform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`; + + return ( +
- -
- ); -}; + onMouseDown={onMouseDown} + > +
+ {alt} + + {showAnn && ( +
+ {visibleAnnotations.map((a, idx) => { + const x0 = a.x; + const y0 = a.y; + const w0 = a.width; + const h0 = a.height; + + const x = fit.offsetX + x0 * fit.scale; + const y = fit.offsetY + y0 * fit.scale; + const w = w0 * fit.scale; + const h = h0 * fit.scale; + + const theta = Number(a.theta || 0); + + const key = + a.id ?? + a.annotationId ?? + `${idx}-${x0}-${y0}-${w0}-${h0}`; + + return ( +
+ ); + })} +
+ )} +
+
+ ); + }, +); export default InteractiveAnnotationOverlay; diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 404b9badd6..d2359249c4 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -211,6 +211,8 @@ const MatchResults = observer(() => { numCandidates={metadata.numCandidates} date={metadata.date} thisEncounterImageUrl={metadata.queryImageUrl} + thisEncounterAnnotations={[metadata.queryEncounterAnnotation]} + thisEncounterImageAsset={metadata.queryEncounterImageAsset} methodName={metadata.methodName} methodDescription={metadata.methodDescription} taskStatus={metadata.taskStatus} diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 08e0b21ba4..3493aeb4a3 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -1,11 +1,11 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Row, Col, Form } from "react-bootstrap"; import ZoomInIcon from "../icons/ZoomInIcon"; import ZoomOutIcon from "../icons/ZoomOutIcon"; +import Icon4 from "../icons/Icon4"; import Icon5 from "../icons/Icon5"; import Icon7 from "../icons/Icon7"; -import { FormattedMessage } from "react-intl"; -import InteractiveAnnotationOverlay from "../components/MatchResultsBottomBar"; +import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; const styles = { matchRow: (selected, themeColor) => ({ @@ -16,18 +16,13 @@ const styles = { fontSize: "0.9rem", marginTop: "4px", borderRadius: "5px", - backgroundColor: selected - ? themeColor.primaryColors.primary50 - : "transparent", + backgroundColor: selected ? themeColor.primaryColors.primary50 : "transparent", }), matchRank: { width: "24px", textAlign: "right", marginRight: "8px", }, - matchScore: { - width: "64px", - }, idPill: (themeColor) => ({ borderRadius: "5px", border: "none", @@ -52,14 +47,6 @@ const styles = { justifyContent: "center", backgroundColor: "#f8f9fa", }, - matchImage: { - width: "100%", - height: "100% ", - display: "block", - objectFit: "contain", - backgroundColor: "#f8f9fa", - transformOrigin: "center center", - }, cornerLabel: (themeColor) => ({ position: "absolute", top: "8px", @@ -89,6 +76,18 @@ const styles = { flexDirection: "column", gap: "6px", }, + iconButton: { + width: "32px", + height: "32px", + borderRadius: "8px", + background: "white", + border: "1px solid #dee2e6", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + boxShadow: "0 1px 4px rgba(0,0,0,0.08)", + }, matchListScrollContainer: { overflowX: "auto", overflowY: "hidden", @@ -114,18 +113,23 @@ const MatchProspectTable = ({ selectedMatch, onToggleSelected, thisEncounterImageUrl, + thisEncounterAnnotations, + thisEncounterImageAsset, themeColor, columns, algorithm, methodName, - methodDescription, - taskStatus, - taskStatusOverall, }) => { + const leftOverlayRef = useRef(null); + const rightOverlayRef = useRef(null); + + const [selectedRow, setSelectedRow] = useState(() => { + const first = columns?.[0]?.[0] ?? null; + if (!first) return null; + const firstKey = `${first.annotation?.id}-${first.displayIndex}`; + return { ...first, _rowKey: firstKey }; + }); - const [selectedRow, setSelectedRow] = React.useState( - columns[0]?.[0] - ); React.useEffect(() => { const first = columns?.[0]?.[0] ?? null; if (!first) { @@ -136,102 +140,61 @@ const MatchProspectTable = ({ setSelectedRow({ ...first, _rowKey: firstKey }); }, [columns]); - const [inspectionModalOpen, setInspectionModalOpen] = useState(false); const [hoveredRow, setHoveredRow] = React.useState(null); - const [leftImageZoom, setLeftImageZoom] = React.useState(1); - const [rightImageZoom, setRightImageZoom] = React.useState(1); - const [leftPanPosition, setLeftPanPosition] = React.useState({ x: 0, y: 0 }); - const [rightPanPosition, setRightPanPosition] = React.useState({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = React.useState(null); - const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }); - - const handleZoomIn = (side) => { - if (side === "left") { - setLeftImageZoom((prev) => Math.min(prev + 0.25, 3)); - } else { - setRightImageZoom((prev) => Math.min(prev + 0.25, 3)); - } - }; - - const handleZoomOut = (side) => { - if (side === "left") { - setLeftImageZoom((prev) => Math.max(prev - 0.25, 0.5)); - } else { - setRightImageZoom((prev) => Math.max(prev - 0.25, 0.5)); - } - }; - - const handleResetZoom = (side) => { - if (side === "left") { - setLeftImageZoom(1); - } else { - setRightImageZoom(1); - } - }; - - const handleMouseDown = (side, e) => { - setIsDragging(side); - setDragStart({ - x: e.clientX, - y: e.clientY, - }); + const handleRowClick = (rowData, rowKey) => { + setSelectedRow({ ...rowData, _rowKey: rowKey }); + rightOverlayRef.current?.reset?.(); }; - const handleMouseMove = (e) => { - if (!isDragging) return; - - const deltaX = e.clientX - dragStart.x; - const deltaY = e.clientY - dragStart.y; + const isSelected = (rowKey) => selectedMatch?.some((d) => d.key === rowKey); - if (isDragging === "left") { - setLeftPanPosition((prev) => ({ - x: prev.x + deltaX, - y: prev.y + deltaY, - })); - } else { - setRightPanPosition((prev) => ({ - x: prev.x + deltaX, - y: prev.y + deltaY, - })); - } + const rightAnnotations = React.useMemo(() => { + const ann = selectedRow?.annotation; + if (!ann) return []; + return [ + { + id: ann.id, + boundingBox: ann.boundingBox, + x: ann.x, + y: ann.y, + width: ann.width, + height: ann.height, + theta: ann.theta, + trivial: ann.isTrivial || ann.trivial, + }, + ]; + }, [selectedRow]); - setDragStart({ - x: e.clientX, - y: e.clientY, - }); - }; + const rightImageUrl = + selectedRow?.annotation?.asset?.url?.replace( + "http://frontend.scribble.com", + "https://zebra.wildme.org", + ) || ""; - const handleMouseUp = () => { - setIsDragging(null); - }; + const leftOrigW = thisEncounterImageAsset?.attributes?.width ?? thisEncounterImageAsset?.width; + const leftOrigH = thisEncounterImageAsset?.attributes?.height ?? thisEncounterImageAsset?.height; - const handleRowClick = (rowData, rowKey) => { - setSelectedRow({ ...rowData, _rowKey: rowKey }); - }; + const leftAnnotations = thisEncounterAnnotations; - React.useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, dragStart]); + const rightOrigW = + selectedRow?.annotation?.asset?.width ?? selectedRow?.annotation?.asset?.attributes?.width; + const rightOrigH = + selectedRow?.annotation?.asset?.height ?? selectedRow?.annotation?.asset?.attributes?.height; - const isSelected = (rowKey) => selectedMatch?.some((d) => d.key === rowKey); + // +++++++++ temporary workaround +++++++++ + const leftImageUrl = + (thisEncounterImageUrl || "").replace( + "http://frontend.scribble.com", + "https://zebra.wildme.org", + ) || ""; return (
- {methodName - ? `Matches based on ${methodName}` - : `Matches based on ${algorithm}`} - {/* {methodDescription ? ` – ${methodDescription}` : ""} */} + {methodName ? `Matches based on ${methodName}` : `Matches based on ${algorithm}`}
against {numCandidates} candidates{" "} @@ -250,11 +213,9 @@ const MatchProspectTable = ({ const candidateIndividualDisplayName = candidate.annotation?.individual?.displayName; - const rowKey = - `${candidate.annotation?.id}-${candidate.displayIndex}`; + const rowKey = `${candidate.annotation?.id}-${candidate.displayIndex}`; const isRowSelected = isSelected(rowKey); const isRowPreviewed = rowKey === selectedRow?._rowKey; - const isHovered = hoveredRow === rowKey; return (
setHoveredRow(rowKey)} onMouseLeave={() => setHoveredRow(null)} > - {candidate.displayIndex}{"."} + {candidate.displayIndex}. + { - e.stopPropagation(); - }} + style={{ textDecoration: "none" }} + onClick={(e) => e.stopPropagation()} > {(Math.trunc(candidate.score * 10000) / 10000).toFixed(4)} + +
-
e.stopPropagation()}> - {candidate.annotation?.asset?.url && isHovered && ( - - )} +
e.stopPropagation()} + > + {/* Left */}
This encounter
-
handleMouseDown("left", e)} - > - This encounter +
handleZoomIn("left")} + onClick={() => leftOverlayRef.current?.zoomIn?.()} style={styles.iconButton} title="Zoom In" >
handleZoomOut("left")} + onClick={() => leftOverlayRef.current?.zoomOut?.()} style={styles.iconButton} title="Zoom Out" > @@ -386,54 +318,58 @@ const MatchProspectTable = ({
+ {/* Right */}
-
- Possible Match -
-
handleMouseDown("right", e)} - > - Possible matchPossible Match
+
+
handleZoomIn("right")} + onClick={() => rightOverlayRef.current?.zoomIn?.()} style={styles.iconButton} title="Zoom In" >
handleZoomOut("right")} + onClick={() => rightOverlayRef.current?.zoomOut?.()} style={styles.iconButton} title="Zoom Out" >
-
+ +
{ + if (!selectedRow?.asset?.url) return; + const url = selectedRow.asset.url; + window.open(url, "_blank"); + }} + > + +
+ +
rightOverlayRef.current?.toggleAnnotations?.()} + >
+
@@ -444,4 +380,4 @@ const MatchProspectTable = ({ ); }; -export default MatchProspectTable; \ No newline at end of file +export default MatchProspectTable; diff --git a/frontend/src/pages/MatchResultsPage/helperFunctions.js b/frontend/src/pages/MatchResultsPage/helperFunctions.js index 861e642502..174b72bd97 100644 --- a/frontend/src/pages/MatchResultsPage/helperFunctions.js +++ b/frontend/src/pages/MatchResultsPage/helperFunctions.js @@ -2,9 +2,7 @@ const collectProspects = (node, type, result = []) => { const hasMethod = !!node.method; const methodName = node.method?.name ?? node.method?.description; const methodDescription = node.method?.description ?? null; - const prospects = node.matchResults?.prospects?.[type]; - const hasResults = Array.isArray(prospects) && prospects.length > 0; if (hasResults && hasMethod) { @@ -22,6 +20,14 @@ const collectProspects = (node, type, result = []) => { node.matchResults.queryAnnotation?.asset || null, queryEncounterImageUrl: node.matchResults.queryAnnotation?.asset?.url || null, + queryEncounterAnnotation: + { + x: node.matchResults.queryAnnotation?.x, + y: node.matchResults.queryAnnotation?.y, + width: node.matchResults.queryAnnotation?.width, + height: node.matchResults.queryAnnotation?.height, + theta:node.matchResults.queryAnnotation?.theta, + }, methodName, methodDescription, diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 4725651afb..222aba0069 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -10,7 +10,7 @@ export default class MatchResultsStore { _individualId = null; _individualDisplayName = null; _projectName = ""; - _numResults = 12; + _numResults = 1; _numCandidates = 0; _matchDate = null; _thisEncounterImageUrl = ""; @@ -108,6 +108,8 @@ export default class MatchResultsStore { numCandidates: first.numberCandidates, date: first.date, queryImageUrl: first.queryEncounterImageAsset?.url || first.queryEncounterImageUrl, + queryEncounterImageAsset: first.queryEncounterImageAsset, + queryEncounterAnnotation: first.queryEncounterAnnotation, methodName: first.methodName, methodDescription: first.methodDescription, taskStatus: first.taskStatus, @@ -200,50 +202,50 @@ export default class MatchResultsStore { return this._taskId; } - get selectedEncounterIds() { - const ids = (this._selectedMatch || []) - .map((m) => m?.encounterId) - .filter(Boolean); - return Array.from(new Set(ids)); - } - - get selectedUnnamedEncounterIds() { - const ids = (this._selectedMatch || []) - .filter((m) => m?.encounterId && !m?.individualId) - .map((m) => m.encounterId); - return Array.from(new Set(ids)); - } - - get selectedIndividualIdsOnly() { - const ids = (this._selectedMatch || []) - .map((m) => m?.individualId) - .filter(Boolean); - return Array.from(new Set(ids)); - } - - get uniqueIndividualsIncludingQuery() { - const ids = new Set(); - if (this._individualId) ids.add(this._individualId); - for (const id of this.selectedIndividualIdsOnly) ids.add(id); - return Array.from(ids); - } - - get singleIndividualIdToUse() { - const unique = this.uniqueIndividualsIncludingQuery; - return unique.length === 1 ? unique[0] : null; - } - - get allSelectedAlreadySameIndividual() { - const single = this.singleIndividualIdToUse; - if (!single) return false; - if (this.selectedUnnamedEncounterIds.length > 0) return false; - - if (this._individualId && this._individualId !== single) return false; - - return (this._selectedMatch || []) - .filter((m) => m?.individualId) - .every((m) => m.individualId === single); - } + // get selectedEncounterIds() { + // const ids = (this._selectedMatch || []) + // .map((m) => m?.encounterId) + // .filter(Boolean); + // return Array.from(new Set(ids)); + // } + + // get selectedUnnamedEncounterIds() { + // const ids = (this._selectedMatch || []) + // .filter((m) => m?.encounterId && !m?.individualId) + // .map((m) => m.encounterId); + // return Array.from(new Set(ids)); + // } + + // get selectedIndividualIdsOnly() { + // const ids = (this._selectedMatch || []) + // .map((m) => m?.individualId) + // .filter(Boolean); + // return Array.from(new Set(ids)); + // } + + // get uniqueIndividualsIncludingQuery() { + // const ids = new Set(); + // if (this._individualId) ids.add(this._individualId); + // for (const id of this.selectedIndividualIdsOnly) ids.add(id); + // return Array.from(ids); + // } + + // get singleIndividualIdToUse() { + // const unique = this.uniqueIndividualsIncludingQuery; + // return unique.length === 1 ? unique[0] : null; + // } + + // get allSelectedAlreadySameIndividual() { + // const single = this.singleIndividualIdToUse; + // if (!single) return false; + // if (this.selectedUnnamedEncounterIds.length > 0) return false; + + // if (this._individualId && this._individualId !== single) return false; + + // return (this._selectedMatch || []) + // .filter((m) => m?.individualId) + // .every((m) => m.individualId === single); + // } get selectedMatch() { return this._selectedMatch; From b09167b34042f7eb2a2991563185c7a7b0a88ca1 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 13 Jan 2026 21:19:24 +0000 Subject: [PATCH 057/192] update project options to get aligned on backend changes --- frontend/src/components/Chip.jsx | 2 +- frontend/src/components/DataTable.jsx | 2 +- .../filterFields/MetadataFilter.jsx | 2 +- frontend/src/pages/Encounter/ProjectsCard.jsx | 2 +- .../pages/MatchResultsPage/MatchResults.jsx | 25 ++++++++----------- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Chip.jsx b/frontend/src/components/Chip.jsx index 5389a2ff4f..099cd05b59 100644 --- a/frontend/src/components/Chip.jsx +++ b/frontend/src/components/Chip.jsx @@ -28,7 +28,7 @@ function Chip({ children }) { Object.entries(data?.projectsForUser || {})?.map((item) => { return { value: item[0], - label: item[1], + label: item[1]?.name, }; }) || []; diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index f2f3d6f1df..cdc2ac0834 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -268,7 +268,7 @@ const MyDataTable = observer( const projectOptions = Object.entries( store?.siteSettingsData?.projectsForUser ?? {}, - ).map(([value, label]) => ({ value, label })); + ).map(([value, label]) => ({ value, label: label?.name })); const handleSort = (column, sortDirection) => { const columnName = diff --git a/frontend/src/components/filterFields/MetadataFilter.jsx b/frontend/src/components/filterFields/MetadataFilter.jsx index 73d7f48ea9..48fdd6c5b2 100644 --- a/frontend/src/components/filterFields/MetadataFilter.jsx +++ b/frontend/src/components/filterFields/MetadataFilter.jsx @@ -26,7 +26,7 @@ export default function MetadataFilter({ data, store }) { Object.entries(data?.projectsForUser || {})?.map((item) => { return { value: item[0], - label: item[1], + label: item[1]?.name, }; }) || []; diff --git a/frontend/src/pages/Encounter/ProjectsCard.jsx b/frontend/src/pages/Encounter/ProjectsCard.jsx index 087e4418b7..451fa76828 100644 --- a/frontend/src/pages/Encounter/ProjectsCard.jsx +++ b/frontend/src/pages/Encounter/ProjectsCard.jsx @@ -14,7 +14,7 @@ export const ProjectsCard = observer(({ store = {} }) => { const allProjectsRaw = store.siteSettingsData?.projectsForUser || {}; const allProjects = Object.entries(allProjectsRaw).map(([key, value]) => ({ id: key, - name: value, + name: value?.name, })); const encounterProjects = store.encounterData?.projects || []; const currentEncounterProjects = allProjects.filter((project) => diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index d2359249c4..175fe380a0 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -74,8 +74,8 @@ const MatchResults = observer(() => { target="_blank" rel="noopener noreferrer" >{` for ${store.encounterId}`} - { - store.individualDisplayName && { > {store._individualDisplayName} - } + )}
{
- + { onChange={(e) => { store.setProjectName(e.target.value); }} - style={{ minWidth: "220px" }} + style={{ minWidth: "220px", maxWidth: "400px" }} > - {/* {Object.entries(projectsForUser).map(([key, value]) => ( + {Object.entries(projectsForUser).map(([key, value]) => ( - ))} */} + ))}
@@ -220,11 +217,9 @@ const MatchResults = observer(() => { themeColor={themeColor} columns={columns} selectedMatch={store.selectedMatch} - onToggleSelected={(checked, key, encounterId, individualId) =>{ + onToggleSelected={(checked, key, encounterId, individualId) => { store.setSelectedMatch(checked, key, encounterId, individualId); - } - - } + }} />
))} From a1a07254db15b35b3e84c82e3363902ed4c67304 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 13 Jan 2026 21:20:29 +0000 Subject: [PATCH 058/192] i18n placeholders --- frontend/src/components/AnnotationOverlay.jsx | 2 +- .../components/MatchProspectTable.jsx | 11 +++++++---- .../components/MatchResultsBottomBar.jsx | 1 + .../src/pages/MatchResultsPage/helperFunctions.js | 2 +- .../MatchResultsPage/stores/matchResultsStore.js | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index 57c41cf1bc..d7ce15df08 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -20,7 +20,7 @@ const InteractiveAnnotationOverlay = forwardRef( maxZoom = 3, zoomStep = 0.25, showAnnotations: showAnnotationsProp, - strokeColor = "yellow", + strokeColor = "red", lineWidth = 2, containerStyle = {}, imageStyle = {}, diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 3493aeb4a3..87e748a758 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -6,6 +6,7 @@ import Icon4 from "../icons/Icon4"; import Icon5 from "../icons/Icon5"; import Icon7 from "../icons/Icon7"; import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; +import { FormattedMessage, useIntl } from "react-intl"; const styles = { matchRow: (selected, themeColor) => ({ @@ -120,6 +121,8 @@ const MatchProspectTable = ({ algorithm, methodName, }) => { + const intl = useIntl(); + const matchesBasedOnText = intl.formatMessage({id: "MATCHED_BASED_ON"}); const leftOverlayRef = useRef(null); const rightOverlayRef = useRef(null); @@ -194,10 +197,10 @@ const MatchProspectTable = ({
- {methodName ? `Matches based on ${methodName}` : `Matches based on ${algorithm}`} + {methodName ? `${matchesBasedOnText}${" "} ${methodName}` : `${matchesBasedOnText}${" "} ${algorithm}`}
- against {numCandidates} candidates{" "} + {numCandidates} {" "} {date?.slice(0, 16).replace("T", " ")}
@@ -287,7 +290,7 @@ const MatchProspectTable = ({ {/* Left */}
-
This encounter
+
-
Possible Match
+
{ if (hasResults && hasMethod) { const common = { algorithm: methodName, - date: node.matchResults.created, + date: node.dateCreated, numberCandidates: node.matchResults.numberCandidates || 0, queryEncounterId: node.matchResults.queryAnnotation?.encounter?.id || null, diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 222aba0069..c980275653 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -10,7 +10,7 @@ export default class MatchResultsStore { _individualId = null; _individualDisplayName = null; _projectName = ""; - _numResults = 1; + _numResults = 12; _numCandidates = 0; _matchDate = null; _thisEncounterImageUrl = ""; From eba13e481d65b49f499bbc38e37447d4d1164ecb Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 13 Jan 2026 21:39:35 +0000 Subject: [PATCH 059/192] filter by project --- .../stores/matchResultsStore.js | 129 +++++++----------- 1 file changed, 52 insertions(+), 77 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index c980275653..b243491ce3 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -1,4 +1,3 @@ - import { makeAutoObservable } from "mobx"; import axios from "axios"; import { MAX_ROWS_PER_COLUMN } from "../constants"; @@ -33,13 +32,14 @@ export default class MatchResultsStore { makeAutoObservable(this, {}, { autoBind: true }); } - loadData(result) { const annotResults = getAllAnnot(result.matchResultsRoot); const indivResults = getAllIndiv(result.matchResultsRoot); - if ((!annotResults || annotResults.length === 0) && - (!indivResults || indivResults.length === 0)) { + if ( + (!annotResults || annotResults.length === 0) && + (!indivResults || indivResults.length === 0) + ) { this._rawAnnots = []; this._rawIndivs = []; this._encounterId = null; @@ -71,21 +71,15 @@ export default class MatchResultsStore { } _processData(rawData) { - - // 1. filter by project name if set - const filtered = this._projectName - ? rawData.filter((item) => item.projectName === this._projectName) - : rawData; - - // 2. group by task + // 1. group by task const groupedByTask = new Map(); - for (const item of filtered) { + for (const item of rawData) { const taskId = item.taskId || "unknown-task"; if (!groupedByTask.has(taskId)) groupedByTask.set(taskId, []); groupedByTask.get(taskId).push(item); } - //3. divide to columns + //2. divide to columns const sections = []; for (const [taskId, items] of groupedByTask) { @@ -93,10 +87,12 @@ export default class MatchResultsStore { const columns = []; for (let i = 0; i < sorted.length; i += MAX_ROWS_PER_COLUMN) { - const columnData = sorted.slice(i, i + MAX_ROWS_PER_COLUMN).map((data, index) => ({ - ...data, - displayIndex: i + index + 1, - })); + const columnData = sorted + .slice(i, i + MAX_ROWS_PER_COLUMN) + .map((data, index) => ({ + ...data, + displayIndex: i + index + 1, + })); columns.push(columnData); } @@ -107,7 +103,8 @@ export default class MatchResultsStore { metadata: { numCandidates: first.numberCandidates, date: first.date, - queryImageUrl: first.queryEncounterImageAsset?.url || first.queryEncounterImageUrl, + queryImageUrl: + first.queryEncounterImageAsset?.url || first.queryEncounterImageUrl, queryEncounterImageAsset: first.queryEncounterImageAsset, queryEncounterAnnotation: first.queryEncounterAnnotation, methodName: first.methodName, @@ -202,51 +199,6 @@ export default class MatchResultsStore { return this._taskId; } - // get selectedEncounterIds() { - // const ids = (this._selectedMatch || []) - // .map((m) => m?.encounterId) - // .filter(Boolean); - // return Array.from(new Set(ids)); - // } - - // get selectedUnnamedEncounterIds() { - // const ids = (this._selectedMatch || []) - // .filter((m) => m?.encounterId && !m?.individualId) - // .map((m) => m.encounterId); - // return Array.from(new Set(ids)); - // } - - // get selectedIndividualIdsOnly() { - // const ids = (this._selectedMatch || []) - // .map((m) => m?.individualId) - // .filter(Boolean); - // return Array.from(new Set(ids)); - // } - - // get uniqueIndividualsIncludingQuery() { - // const ids = new Set(); - // if (this._individualId) ids.add(this._individualId); - // for (const id of this.selectedIndividualIdsOnly) ids.add(id); - // return Array.from(ids); - // } - - // get singleIndividualIdToUse() { - // const unique = this.uniqueIndividualsIncludingQuery; - // return unique.length === 1 ? unique[0] : null; - // } - - // get allSelectedAlreadySameIndividual() { - // const single = this.singleIndividualIdToUse; - // if (!single) return false; - // if (this.selectedUnnamedEncounterIds.length > 0) return false; - - // if (this._individualId && this._individualId !== single) return false; - - // return (this._selectedMatch || []) - // .filter((m) => m?.individualId) - // .every((m) => m.individualId === single); - // } - get selectedMatch() { return this._selectedMatch; } @@ -276,7 +228,9 @@ export default class MatchResultsStore { } get selectedIncludingQuery() { - const selected = Array.isArray(this._selectedMatch) ? this._selectedMatch : []; + const selected = Array.isArray(this._selectedMatch) + ? this._selectedMatch + : []; const q = this.querySelectionItem; if (!q) return selected; @@ -287,14 +241,22 @@ export default class MatchResultsStore { return [q, ...withoutQueryDup]; } - // actions + // actions async fetchMatchResults() { + if (!this._taskId) return; + this.setLoading(true); this._hasResults = false; + try { + const params = new URLSearchParams(); + params.set("prospectsSize", String(this.numResults)); + if (this._projectName) { + params.set("projectId", this._projectName); + } const result = await axios.get( - `/api/v3/tasks/${this._taskId}/match-results?prospectsSize=${this.numResults}`, + `/api/v3/tasks/${this._taskId}/match-results?${params.toString()}`, ); this.loadData(result.data); } catch (e) { @@ -304,7 +266,7 @@ export default class MatchResultsStore { } } - // setters and actions + // setters and actions setLoading(loading) { this._loading = loading; @@ -323,7 +285,11 @@ export default class MatchResultsStore { } setProjectName(name) { + if (this._projectName === name) return; this._projectName = name; + if (this._taskId) { + this.fetchMatchResults(); + } } setNewIndividualName(name) { @@ -351,7 +317,7 @@ export default class MatchResultsStore { // merge functions - //no further action needed, two cases: + //no further action needed, two cases: //1. query encounter has individual ID, no match result selected //2. all encounters have same individual ID handleNoFurtherActionNeeded() { @@ -372,18 +338,24 @@ export default class MatchResultsStore { } const encounterIds = Array.from( - new Set(this.selectedIncludingQuery.map((m) => m.encounterId).filter(Boolean)), + new Set( + this.selectedIncludingQuery.map((m) => m.encounterId).filter(Boolean), + ), ); const patchOps = [{ op: "replace", path: "/individual", value: newName }]; for (const id of encounterIds) { - await axios.patch(`/api/v3/encounters/${encodeURIComponent(id)}`, patchOps, { - headers: { - "Content-Type": "application/json-patch+json", - Accept: "application/json", + await axios.patch( + `/api/v3/encounters/${encodeURIComponent(id)}`, + patchOps, + { + headers: { + "Content-Type": "application/json-patch+json", + Accept: "application/json", + }, }, - }); + ); } this._newIndividualName = ""; @@ -437,7 +409,9 @@ export default class MatchResultsStore { const url = `/iaResultsSetID.jsp?${params.toString()}`; - const res = await axios.get(url, { headers: { Accept: "application/json" } }); + const res = await axios.get(url, { + headers: { Accept: "application/json" }, + }); this.resetSelectionToQuery(); return res.data; } catch (e) { @@ -513,10 +487,11 @@ export default class MatchResultsStore { if (uniqueIndividuals.length === 0) return "no_individuals"; if (uniqueIndividuals.length === 1) { - return allHaveIndividual ? "no_further_action_needed" : "single_individual"; + return allHaveIndividual + ? "no_further_action_needed" + : "single_individual"; } if (uniqueIndividuals.length === 2) return "two_individuals"; return "too_many_individuals"; } } - From e4ebabe1a3ed408d2dab98daf4ab78a8cc094425 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Tue, 13 Jan 2026 23:55:27 +0000 Subject: [PATCH 060/192] add full screen mode --- .../components/MatchProspectTable.jsx | 201 +++++++++++++++++- 1 file changed, 193 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 87e748a758..db40e3cfc7 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -1,5 +1,5 @@ import React, { useRef, useState } from "react"; -import { Row, Col, Form } from "react-bootstrap"; +import { Row, Col, Form, Modal } from "react-bootstrap"; import ZoomInIcon from "../icons/ZoomInIcon"; import ZoomOutIcon from "../icons/ZoomOutIcon"; import Icon4 from "../icons/Icon4"; @@ -105,6 +105,64 @@ const styles = { display: "flex", flexDirection: "column", }, + fullscreenBody: { + padding: 12, + background: "#111", + height: "100vh", + }, + fullscreenGrid: { + height: "calc(100vh - 24px)", + display: "flex", + gap: 12, + }, + fullscreenPanel: { + flex: 1, + minWidth: 0, + borderRadius: 10, + overflow: "hidden", + background: "#1a1a1a", + position: "relative", + boxShadow: "0 2px 14px rgba(0,0,0,0.35)", + }, + fullscreenLabel: { + position: "absolute", + top: 10, + left: 10, + zIndex: 5, + background: "rgba(255,255,255,0.92)", + padding: "3px 10px", + borderRadius: 6, + fontSize: 12, + }, + fullscreenImageWrap: { + position: "relative", + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#111", + }, + fullscreenTopRight: { + position: "absolute", + top: 10, + right: 10, + zIndex: 80, + display: "flex", + gap: 8, + }, + fullscreenIconBtn: { + width: 34, + height: 34, + borderRadius: 10, + background: "rgba(255,255,255,0.92)", + border: "1px solid rgba(0,0,0,0.10)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + boxShadow: "0 2px 10px rgba(0,0,0,0.25)", + }, }; const MatchProspectTable = ({ @@ -122,10 +180,14 @@ const MatchProspectTable = ({ methodName, }) => { const intl = useIntl(); - const matchesBasedOnText = intl.formatMessage({id: "MATCHED_BASED_ON"}); + const matchesBasedOnText = intl.formatMessage({ id: "MATCHED_BASED_ON" }); const leftOverlayRef = useRef(null); const rightOverlayRef = useRef(null); + const [fullscreenOpen, setFullscreenOpen] = useState(false); + const fsLeftRef = useRef(null); + const fsRightRef = useRef(null); + const [selectedRow, setSelectedRow] = useState(() => { const first = columns?.[0]?.[0] ?? null; if (!first) return null; @@ -192,6 +254,14 @@ const MatchProspectTable = ({ "https://zebra.wildme.org", ) || ""; + const openFullscreen = () => { + setFullscreenOpen(true); + setTimeout(() => { + fsLeftRef.current?.reset?.(); + fsRightRef.current?.reset?.(); + }, 0); + }; + return (
@@ -200,7 +270,7 @@ const MatchProspectTable = ({ {methodName ? `${matchesBasedOnText}${" "} ${methodName}` : `${matchesBasedOnText}${" "} ${algorithm}`}
- {numCandidates} {" "} + {numCandidates} {" "} {date?.slice(0, 16).replace("T", " ")}
@@ -287,10 +357,9 @@ const MatchProspectTable = ({
- {/* Left */}
-
+
- {/* Right */}
-
+
-
+
{ + e.stopPropagation(); + if (!selectedRow) return; + openFullscreen(); + }} + >
+ setFullscreenOpen(false)} + fullscreen + centered={false} + keyboard + contentClassName="border-0 rounded-0" + > +
+
+
+
+
+ +
+ +
+
fsLeftRef.current?.zoomIn?.()} + > + +
+
fsLeftRef.current?.zoomOut?.()} + > + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
fsRightRef.current?.zoomIn?.()} + > + +
+
fsRightRef.current?.zoomOut?.()} + > + +
+
{ + if (!selectedRow?.asset?.url) return; + const url = selectedRow.asset.url; + window.open(url, "_blank"); + }} + > + +
+
{ + fsRightRef.current?.toggleAnnotations?.(); + rightOverlayRef.current?.toggleAnnotations?.(); + }} + > + +
+
setFullscreenOpen(false)} + > + + + +
+
+ +
+
+
+
+
); }; From 4c80651f5445bf2a38bc8c4cbf2494e704d2ba6f Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 14 Jan 2026 00:11:18 +0000 Subject: [PATCH 061/192] add title to buttons --- .../components/MatchProspectTable.jsx | 91 +++++++++++++------ 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index db40e3cfc7..ad9a8d1614 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -17,7 +17,9 @@ const styles = { fontSize: "0.9rem", marginTop: "4px", borderRadius: "5px", - backgroundColor: selected ? themeColor.primaryColors.primary50 : "transparent", + backgroundColor: selected + ? themeColor.primaryColors.primary50 + : "transparent", }), matchRank: { width: "24px", @@ -237,15 +239,21 @@ const MatchProspectTable = ({ "https://zebra.wildme.org", ) || ""; - const leftOrigW = thisEncounterImageAsset?.attributes?.width ?? thisEncounterImageAsset?.width; - const leftOrigH = thisEncounterImageAsset?.attributes?.height ?? thisEncounterImageAsset?.height; + const leftOrigW = + thisEncounterImageAsset?.attributes?.width ?? + thisEncounterImageAsset?.width; + const leftOrigH = + thisEncounterImageAsset?.attributes?.height ?? + thisEncounterImageAsset?.height; const leftAnnotations = thisEncounterAnnotations; const rightOrigW = - selectedRow?.annotation?.asset?.width ?? selectedRow?.annotation?.asset?.attributes?.width; + selectedRow?.annotation?.asset?.width ?? + selectedRow?.annotation?.asset?.attributes?.width; const rightOrigH = - selectedRow?.annotation?.asset?.height ?? selectedRow?.annotation?.asset?.attributes?.height; + selectedRow?.annotation?.asset?.height ?? + selectedRow?.annotation?.asset?.attributes?.height; // +++++++++ temporary workaround +++++++++ const leftImageUrl = @@ -267,10 +275,13 @@ const MatchProspectTable = ({
- {methodName ? `${matchesBasedOnText}${" "} ${methodName}` : `${matchesBasedOnText}${" "} ${algorithm}`} + {methodName + ? `${matchesBasedOnText}${" "} ${methodName}` + : `${matchesBasedOnText}${" "} ${algorithm}`}
- {numCandidates} {" "} + {numCandidates}{" "} + {" "} {date?.slice(0, 16).replace("T", " ")}
@@ -281,30 +292,36 @@ const MatchProspectTable = ({ {columns.map((columnData, columnIndex) => (
{columnData.map((candidate) => { - const candidateEncounterId = candidate.annotation?.encounter?.id; - const candidateIndividualId = candidate.annotation?.individual?.id; + const candidateEncounterId = + candidate.annotation?.encounter?.id; + const candidateIndividualId = + candidate.annotation?.individual?.id; const candidateIndividualDisplayName = candidate.annotation?.individual?.displayName; const rowKey = `${candidate.annotation?.id}-${candidate.displayIndex}`; const isRowSelected = isSelected(rowKey); const isRowPreviewed = rowKey === selectedRow?._rowKey; + const isRowHovered = rowKey === hoveredRow; return (
handleRowClick(candidate, rowKey)} style={{ ...styles.matchRow(isRowSelected, themeColor), cursor: "pointer", - backgroundColor: isRowPreviewed - ? themeColor.primaryColors.primary50 - : "transparent", + backgroundColor: + isRowPreviewed || isRowHovered || isRowSelected + ? themeColor.primaryColors.primary50 + : "transparent", }} - onClick={() => handleRowClick(candidate, rowKey)} onMouseEnter={() => setHoveredRow(rowKey)} onMouseLeave={() => setHoveredRow(null)} > - {candidate.displayIndex}. + + {candidate.displayIndex}. +
e.stopPropagation()} >
-
+
+ +
-
+
+ +
@@ -423,7 +450,7 @@ const MatchProspectTable = ({
{ if (!selectedRow?.asset?.url) return; const url = selectedRow.asset.url; @@ -435,7 +462,7 @@ const MatchProspectTable = ({
rightOverlayRef.current?.toggleAnnotations?.()} > @@ -443,7 +470,7 @@ const MatchProspectTable = ({
{ e.stopPropagation(); if (!selectedRow) return; @@ -522,7 +549,7 @@ const MatchProspectTable = ({
{ if (!selectedRow?.asset?.url) return; const url = selectedRow.asset.url; @@ -533,10 +560,9 @@ const MatchProspectTable = ({
{ fsRightRef.current?.toggleAnnotations?.(); - rightOverlayRef.current?.toggleAnnotations?.(); }} > @@ -546,8 +572,17 @@ const MatchProspectTable = ({ title="Exit fullscreen" onClick={() => setFullscreenOpen(false)} > - - + +
@@ -557,7 +592,9 @@ const MatchProspectTable = ({ originalWidth={rightOrigW} originalHeight={rightOrigH} annotations={rightAnnotations} - rotationInfo={selectedRow?.annotation?.asset?.rotationInfo ?? null} + rotationInfo={ + selectedRow?.annotation?.asset?.rotationInfo ?? null + } />
From 8cfc5464976b7d8fa86ef5f2efcc0b268b002f41 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 14 Jan 2026 11:06:18 -0700 Subject: [PATCH 062/192] real logic to find if enc is in projs --- src/main/java/org/ecocean/Encounter.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/ecocean/Encounter.java b/src/main/java/org/ecocean/Encounter.java index a4b6909f7f..272a0a1f04 100644 --- a/src/main/java/org/ecocean/Encounter.java +++ b/src/main/java/org/ecocean/Encounter.java @@ -2488,8 +2488,15 @@ public boolean isInProjects(Set projectIds, Shepherd myShepherd) { // if we dont have any ids, here we are going to consider it false // NOTE: opposite logic in MatchResultProspect.isInProject() if (Util.collectionIsEmptyOrNull(projectIds)) return false; - // FIXME do actual logic via jdo query - return true; + String sql = "select count(*) from \"PROJECT_ENCOUNTERS\" where \"CATALOGNUMBER_EID\" = '" + + this.getId() + "' and \"ID_OID\" in ('" + String.join("', '", projectIds) + "')"; + Query q = myShepherd.getPM().newQuery("javax.jdo.query.SQL", sql); + List results = (List)q.execute(); + Iterator it = results.iterator(); + if (!it.hasNext()) return false; + Long count = (Long)it.next(); + q.closeAll(); + return (count > 0); } public void addTissueSample(TissueSample dce) { From 19b3ccaf440955cf80fdd142e25ce309bbbabddd Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 14 Jan 2026 18:26:50 +0000 Subject: [PATCH 063/192] hide inspection image button if there is no image --- .../components/MatchProspectTable.jsx | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index ad9a8d1614..947a40339c 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -190,7 +190,7 @@ const MatchProspectTable = ({ const fsLeftRef = useRef(null); const fsRightRef = useRef(null); - const [selectedRow, setSelectedRow] = useState(() => { + const [previewedRow, setPreviewedRow] = useState(() => { const first = columns?.[0]?.[0] ?? null; if (!first) return null; const firstKey = `${first.annotation?.id}-${first.displayIndex}`; @@ -200,24 +200,24 @@ const MatchProspectTable = ({ React.useEffect(() => { const first = columns?.[0]?.[0] ?? null; if (!first) { - setSelectedRow(null); + setPreviewedRow(null); return; } const firstKey = `${first.annotation?.id}-${first.displayIndex}`; - setSelectedRow({ ...first, _rowKey: firstKey }); + setPreviewedRow({ ...first, _rowKey: firstKey }); }, [columns]); const [hoveredRow, setHoveredRow] = React.useState(null); const handleRowClick = (rowData, rowKey) => { - setSelectedRow({ ...rowData, _rowKey: rowKey }); + setPreviewedRow({ ...rowData, _rowKey: rowKey }); rightOverlayRef.current?.reset?.(); }; const isSelected = (rowKey) => selectedMatch?.some((d) => d.key === rowKey); const rightAnnotations = React.useMemo(() => { - const ann = selectedRow?.annotation; + const ann = previewedRow?.annotation; if (!ann) return []; return [ { @@ -231,10 +231,10 @@ const MatchProspectTable = ({ trivial: ann.isTrivial || ann.trivial, }, ]; - }, [selectedRow]); + }, [previewedRow]); const rightImageUrl = - selectedRow?.annotation?.asset?.url?.replace( + previewedRow?.annotation?.asset?.url?.replace( "http://frontend.scribble.com", "https://zebra.wildme.org", ) || ""; @@ -249,11 +249,11 @@ const MatchProspectTable = ({ const leftAnnotations = thisEncounterAnnotations; const rightOrigW = - selectedRow?.annotation?.asset?.width ?? - selectedRow?.annotation?.asset?.attributes?.width; + previewedRow?.annotation?.asset?.width ?? + previewedRow?.annotation?.asset?.attributes?.width; const rightOrigH = - selectedRow?.annotation?.asset?.height ?? - selectedRow?.annotation?.asset?.attributes?.height; + previewedRow?.annotation?.asset?.height ?? + previewedRow?.annotation?.asset?.attributes?.height; // +++++++++ temporary workaround +++++++++ const leftImageUrl = @@ -301,7 +301,7 @@ const MatchProspectTable = ({ const rowKey = `${candidate.annotation?.id}-${candidate.displayIndex}`; const isRowSelected = isSelected(rowKey); - const isRowPreviewed = rowKey === selectedRow?._rowKey; + const isRowPreviewed = rowKey === previewedRow?._rowKey; const isRowHovered = rowKey === hoveredRow; return ( @@ -312,7 +312,7 @@ const MatchProspectTable = ({ ...styles.matchRow(isRowSelected, themeColor), cursor: "pointer", backgroundColor: - isRowPreviewed || isRowHovered || isRowSelected + isRowPreviewed || isRowHovered ? themeColor.primaryColors.primary50 : "transparent", }} @@ -426,7 +426,7 @@ const MatchProspectTable = ({ originalHeight={rightOrigH} annotations={rightAnnotations} rotationInfo={ - selectedRow?.annotation?.asset?.rotationInfo ?? null + previewedRow?.annotation?.asset?.rotationInfo ?? null } />
@@ -447,18 +447,19 @@ const MatchProspectTable = ({ >
- -
{ - if (!selectedRow?.asset?.url) return; - const url = selectedRow.asset.url; - window.open(url, "_blank"); - }} - > - -
+ {previewedRow?.asset?.url && ( +
{ + if (!previewedRow?.asset?.url) return; + const url = previewedRow.asset.url; + window.open(url, "_blank"); + }} + > + +
+ )}
{ e.stopPropagation(); - if (!selectedRow) return; + if (!previewedRow) return; openFullscreen(); }} > @@ -547,17 +548,19 @@ const MatchProspectTable = ({ >
-
{ - if (!selectedRow?.asset?.url) return; - const url = selectedRow.asset.url; - window.open(url, "_blank"); - }} - > - -
+ {previewedRow?.asset?.url && ( +
{ + if (!previewedRow?.asset?.url) return; + const url = previewedRow.asset.url; + window.open(url, "_blank"); + }} + > + +
+ )}
From e258ccf7f2fadfde31e19d7d955b8ed0345e7319 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 14 Jan 2026 11:38:11 -0700 Subject: [PATCH 064/192] fix name:HotSpotter --- src/main/java/org/ecocean/ia/Task.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index 14b646bcb9..31ba00f2f6 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -617,7 +617,8 @@ public JSONObject getIdentificationMethodInfo() { conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( "queryConfigDict"); JSONObject rtn = new JSONObject(); - if (conf != null) rtn.put("name", conf.optString("pipeline_root", null)); // null conf means that we have no name + // we set HotSpotter if pipeline_root is not set here + if (conf != null) rtn.put("name", conf.optString("pipeline_root", "HotSpotter")); rtn.put("description", getParameters().getJSONObject("ibeis.identification").optString("description", "unknown algorith/method")); From 554b3099e951f702522183ca33b605f2a272b3c6 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 14 Jan 2026 20:13:06 +0000 Subject: [PATCH 065/192] add inspector image modal --- .../components/InspectorModal.jsx | 137 ++++++++++++++++++ .../components/MatchProspectTable.jsx | 35 ++++- 2 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx diff --git a/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx new file mode 100644 index 0000000000..8e7684f98b --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import { Modal } from "react-bootstrap"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; + +const styles = { + body: { + padding: 12, + background: "#111", + height: "100vh", + }, + grid: { + height: "calc(100vh - 24px)", + display: "flex", + gap: 12, + }, + panel: { + flex: 1, + minWidth: 0, + borderRadius: 10, + overflow: "hidden", + background: "#1a1a1a", + position: "relative", + boxShadow: "0 2px 14px rgba(0,0,0,0.35)", + }, + imageWrap: { + position: "relative", + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#111", + }, + topRight: { + position: "absolute", + top: 10, + right: 10, + zIndex: 80, + display: "flex", + gap: 8, + }, + iconBtn: { + width: 34, + height: 34, + borderRadius: 10, + background: "rgba(255,255,255,0.92)", + border: "1px solid rgba(0,0,0,0.10)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + boxShadow: "0 2px 10px rgba(0,0,0,0.25)", + }, +}; + +const CloseIcon = () => ( + + + +); + +export default function InspectorModal({ + show, + onHide, + imageUrl, + originalWidth, + originalHeight, +}) { + const overlayRef = React.useRef(null); + + React.useEffect(() => { + if (!show) return; + const t = setTimeout(() => overlayRef.current?.reset?.(), 0); + return () => clearTimeout(t); + }, [show, imageUrl]); + + return ( + +
+
+
+
+
+
overlayRef.current?.zoomIn?.()} + > + +
+
overlayRef.current?.zoomOut?.()} + > + +
+
+ +
+
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 947a40339c..43960f8dd8 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -7,6 +7,7 @@ import Icon5 from "../icons/Icon5"; import Icon7 from "../icons/Icon7"; import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; import { FormattedMessage, useIntl } from "react-intl"; +import InspectorModal from "./InspectorModal"; const styles = { matchRow: (selected, themeColor) => ({ @@ -197,6 +198,11 @@ const MatchProspectTable = ({ return { ...first, _rowKey: firstKey }; }); + const [inspectorOpen, setInspectorOpen] = useState(false); + const inspectorUrl = previewedRow?.asset?.url; + const inspectorOrigW = previewedRow?.asset?.width; + const inspectorOrigH = previewedRow?.asset?.height; + React.useEffect(() => { const first = columns?.[0]?.[0] ?? null; if (!first) { @@ -447,7 +453,7 @@ const MatchProspectTable = ({ >
- {previewedRow?.asset?.url && ( + {/* {previewedRow?.asset?.url && (
+ )} */} + {inspectorUrl && ( +
setInspectorOpen(true)} + > + +
)}
- {previewedRow?.asset?.url && ( + {/* {previewedRow?.asset?.url && (
+ )} */} + {inspectorUrl && ( +
setInspectorOpen(true)} + > + +
)}
+ setInspectorOpen(false)} + imageUrl={inspectorUrl} + originalWidth={inspectorOrigW} + originalHeight={inspectorOrigH} + />
); }; From 7cd2ca1462982334d063733fa40ff93aef062601 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 14 Jan 2026 20:14:24 +0000 Subject: [PATCH 066/192] deal with empty results --- .../pages/MatchResultsPage/MatchResults.jsx | 71 ++++++++----------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 175fe380a0..45deb366f9 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -33,17 +33,6 @@ const MatchResults = observer(() => { return ; } - if (!store.hasResults) { - return ( - -

- -

-

No match results available for this job.

-
- ); - } - return ( {

-
{ > {store._individualDisplayName} - )} + )} */}
{
- - {store.currentViewData.map(({ taskId, columns, metadata }) => ( -
- { - store.setSelectedMatch(checked, key, encounterId, individualId); - }} - /> -
- ))} - - + {!store.hasResults ? ( +

No match results available for this job.

+ ) : ( + store.currentViewData.map(({ taskId, columns, metadata }) => ( +
+ { + store.setSelectedMatch(checked, key, encounterId, individualId); + }} + /> +
+ )))} + {store.hasResults && + } ); }); From 9be83eeb7904ab0bf8c98882665f4c1810c938c1 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 15:25:11 +0000 Subject: [PATCH 067/192] do not show annotation while data is invalid --- frontend/src/components/AnnotationOverlay.jsx | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index d7ce15df08..5d4514436d 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -89,11 +89,44 @@ const InteractiveAnnotationOverlay = forwardRef( return { scale, offsetX, offsetY, renderW, renderH }; }, [box.w, box.h, originalWidth, originalHeight]); + const canRenderAnnotations = useMemo(() => { + const iw = Number(originalWidth); + const ih = Number(originalHeight); + return ( + showAnn && + Number.isFinite(iw) && + Number.isFinite(ih) && + iw > 0 && + ih > 0 && + box.w > 0 && + box.h > 0 && + Number.isFinite(fit.scale) && + fit.scale > 0 + ); + }, [showAnn, originalWidth, originalHeight, box.w, box.h, fit.scale]); + + const visibleAnnotations = useMemo(() => { if (!Array.isArray(annotations)) return []; - return annotations.filter((a) => a && !a.trivial && !a.isTrivial); + + const isFiniteNum = (v) => Number.isFinite(Number(v)); + + return annotations + .filter((a) => a && !a.trivial && !a.isTrivial) + .filter((a) => { + const x = Number(a.x); + const y = Number(a.y); + const w = Number(a.width); + const h = Number(a.height); + + if (![x, y, w, h].every(isFiniteNum)) return false; + if (w <= 0 || h <= 0) return false; + + return true; + }); }, [annotations]); + const clampZoom = (z) => Math.max(minZoom, Math.min(maxZoom, z)); const zoomIn = () => setZoom((z) => clampZoom(z + zoomStep)); @@ -186,7 +219,7 @@ const InteractiveAnnotationOverlay = forwardRef( }} /> - {showAnn && ( + {canRenderAnnotations && (
{visibleAnnotations.map((a, idx) => { - const x0 = a.x; - const y0 = a.y; - const w0 = a.width; - const h0 = a.height; + const x0 = Number(a.x); + const y0 = Number(a.y); + const w0 = Number(a.width); + const h0 = Number(a.height); + const x = fit.offsetX + x0 * fit.scale; const y = fit.offsetY + y0 * fit.scale; const w = w0 * fit.scale; const h = h0 * fit.scale; + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return null; + } + const theta = Number(a.theta || 0); const key = From 045c4fbda7c4daf518b3cb957f8e68e9f73ea81f Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 15:29:48 +0000 Subject: [PATCH 068/192] update icon names and styles --- .../components/MatchProspectTable.jsx | 82 ++++--------------- .../icons/ExitFullScreenIcon.jsx | 51 ++++++++++++ .../MatchResultsPage/icons/FullScreenIcon.jsx | 51 ++++++++++++ .../MatchResultsPage/icons/HatchMarkIcon.jsx | 51 ++++++++++++ .../pages/MatchResultsPage/icons/Icon4.jsx | 31 ------- .../pages/MatchResultsPage/icons/Icon5.jsx | 31 ------- .../pages/MatchResultsPage/icons/Icon7.jsx | 31 ------- .../icons/ToggoleAnnotationIcon.jsx | 51 ++++++++++++ 8 files changed, 218 insertions(+), 161 deletions(-) create mode 100644 frontend/src/pages/MatchResultsPage/icons/ExitFullScreenIcon.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx delete mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon4.jsx delete mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon5.jsx delete mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon7.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/ToggoleAnnotationIcon.jsx diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 43960f8dd8..4d66ff6755 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -2,12 +2,13 @@ import React, { useRef, useState } from "react"; import { Row, Col, Form, Modal } from "react-bootstrap"; import ZoomInIcon from "../icons/ZoomInIcon"; import ZoomOutIcon from "../icons/ZoomOutIcon"; -import Icon4 from "../icons/Icon4"; -import Icon5 from "../icons/Icon5"; -import Icon7 from "../icons/Icon7"; +import HatchMarkIcon from "../icons/HatchMarkIcon"; +import ToggoleAnnotationIcon from "../icons/ToggoleAnnotationIcon"; +import FullScreenIcon from "../icons/FullScreenIcon"; import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; import { FormattedMessage, useIntl } from "react-intl"; import InspectorModal from "./InspectorModal"; +import ExitFullScreenIcon from "../icons/ExitFullScreenIcon"; const styles = { matchRow: (selected, themeColor) => ({ @@ -84,13 +85,7 @@ const styles = { width: "32px", height: "32px", borderRadius: "8px", - background: "white", - border: "1px solid #dee2e6", - display: "flex", - alignItems: "center", - justifyContent: "center", cursor: "pointer", - boxShadow: "0 1px 4px rgba(0,0,0,0.08)", }, matchListScrollContainer: { overflowX: "auto", @@ -154,18 +149,6 @@ const styles = { display: "flex", gap: 8, }, - fullscreenIconBtn: { - width: 34, - height: 34, - borderRadius: 10, - background: "rgba(255,255,255,0.92)", - border: "1px solid rgba(0,0,0,0.10)", - display: "flex", - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - boxShadow: "0 2px 10px rgba(0,0,0,0.25)", - }, }; const MatchProspectTable = ({ @@ -453,26 +436,13 @@ const MatchProspectTable = ({ >
- {/* {previewedRow?.asset?.url && ( -
{ - if (!previewedRow?.asset?.url) return; - const url = previewedRow.asset.url; - window.open(url, "_blank"); - }} - > - -
- )} */} {inspectorUrl && (
setInspectorOpen(true)} > - +
)} @@ -481,7 +451,7 @@ const MatchProspectTable = ({ title="View Annotations" onClick={() => rightOverlayRef.current?.toggleAnnotations?.()} > - +
- +
@@ -550,67 +520,43 @@ const MatchProspectTable = ({
fsRightRef.current?.zoomIn?.()} >
fsRightRef.current?.zoomOut?.()} >
- {/* {previewedRow?.asset?.url && ( -
{ - if (!previewedRow?.asset?.url) return; - const url = previewedRow.asset.url; - window.open(url, "_blank"); - }} - > - -
- )} */} {inspectorUrl && (
setInspectorOpen(true)} > - +
)}
{ fsRightRef.current?.toggleAnnotations?.(); }} > - +
setFullscreenOpen(false)} > - - - +
{}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx new file mode 100644 index 0000000000..435d1cff73 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function FullScreenIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx new file mode 100644 index 0000000000..1aa6297da1 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function HatchMarkIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx deleted file mode 100644 index f571171657..0000000000 --- a/frontend/src/pages/MatchResultsPage/icons/Icon4.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ThemeColorContext from "../../../ThemeColorProvider"; - -export default function ZoomInIcon({ - onClick = () => {}, - style = {}, - className = "" -}) { - const themeColor = React.useContext(ThemeColorContext); - return ( -
{ - }}> - - - -
) -} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx deleted file mode 100644 index a609c95918..0000000000 --- a/frontend/src/pages/MatchResultsPage/icons/Icon5.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ThemeColorContext from "../../../ThemeColorProvider"; - -export default function ZoomInIcon({ - onClick = () => {}, - style = {}, - className = "" -}) { - const themeColor = React.useContext(ThemeColorContext); - return ( -
{ - }}> - - - -
) -} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx deleted file mode 100644 index 59e6615247..0000000000 --- a/frontend/src/pages/MatchResultsPage/icons/Icon7.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ThemeColorContext from "../../../ThemeColorProvider"; - -export default function ZoomInIcon({ - onClick = () => {}, - style = {}, - className = "" -}) { - const themeColor = React.useContext(ThemeColorContext); - return ( -
{ - }}> - - - -
) -} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/ToggoleAnnotationIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ToggoleAnnotationIcon.jsx new file mode 100644 index 0000000000..95824ff5cd --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ToggoleAnnotationIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ToggoleAnnotationIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} From b18821d1b69e75ffb532fb603b8834bde9ca4687 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 15:50:09 +0000 Subject: [PATCH 069/192] i18n part1 --- frontend/src/locale/en.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index da42efe22e..9be3480dab 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -702,5 +702,15 @@ "ADD_ENCOUNTER_TO_PROJECT_DESC": "Select one or more encounters to add to a project.", "INVALID_DATE": "Invalid date", "INVALID_LAT_LON": "Please enter a valid latitude and longitude", - "INVALID_MEASUREMENTS": "Please enter valid values" + "INVALID_MEASUREMENTS": "Please enter valid values", + "MATCH_RESULT": "Match Result", + "INDIVIDUAL_SCORE": "Individual Score", + "IMAGE_SCORE": "Image Score", + "NUMBER_OF_RESULTS": "Number of results", + "SELECT_A_PROJECT": "select a project", + "MATCHED_BASED_ON": "Matched based on ", + "POSSIBLE_MATCH": "Possible Match", + "AGAINST": "Against", + "CANDIDATES": "candidates", + "MATCHING_PAGE_INSTRUCTIONS": "Matching Page Instructions" } \ No newline at end of file From beedc545200e996772bad208c58337d2831691fd Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 15:50:30 +0000 Subject: [PATCH 070/192] remove unused icons --- .../pages/MatchResultsPage/icons/Icon3.jsx | 38 ------------------- .../pages/MatchResultsPage/icons/Icon6.jsx | 31 --------------- 2 files changed, 69 deletions(-) delete mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon3.jsx delete mode 100644 frontend/src/pages/MatchResultsPage/icons/Icon6.jsx diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx deleted file mode 100644 index 89bf38a591..0000000000 --- a/frontend/src/pages/MatchResultsPage/icons/Icon3.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import ThemeColorContext from "../../../ThemeColorProvider"; - -export default function ZoomInIcon({ - onClick = () => {}, - style = {}, - className = "" -}) { - const themeColor = React.useContext(ThemeColorContext); - return ( -
{ - }}> - - - - - - - - - - -
) -} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx b/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx deleted file mode 100644 index a51b6d5fa6..0000000000 --- a/frontend/src/pages/MatchResultsPage/icons/Icon6.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ThemeColorContext from "../../../ThemeColorProvider"; - -export default function ZoomInIcon({ - onClick = () => {}, - style = {}, - className = "" -}) { - const themeColor = React.useContext(ThemeColorContext); - return ( -
{ - }}> - - - -
) -} \ No newline at end of file From 918095c8f7e39ad3616e7d38b2b12c2e0820dfb0 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 17:10:28 +0000 Subject: [PATCH 071/192] add instruction modal --- .../pages/MatchResultsPage/MatchResults.jsx | 40 ++-- .../components/InstructionsModal.jsx | 182 ++++++++++++++++++ .../pages/MatchResultsPage/icons/InfoIcon.jsx | 35 ++++ 3 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 45deb366f9..288ed685fd 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -1,15 +1,16 @@ import React, { useMemo, useEffect } from "react"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; -import { Container, Form, Modal } from "react-bootstrap"; +import { Container, Form } from "react-bootstrap"; import ThemeColorContext from "../../ThemeColorProvider"; import MatchResultsStore from "./stores/matchResultsStore"; import MatchProspectTable from "./components/MatchProspectTable"; import MatchResultsBottomBar from "./components/MatchResultsBottomBar"; import { useSearchParams } from "react-router-dom"; import { useSiteSettings } from "../../SiteSettingsContext"; -import MainButton from "../../components/MainButton"; import FullScreenLoader from "../../components/FullScreenLoader"; +import InstructionsModal from "./components/InstructionsModal"; +import InfoIcon from "./icons/InfoIcon"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -35,23 +36,12 @@ const MatchResults = observer(() => { return ( - setInstructionsVisible(false)} - > - setInstructionsVisible(false)}> - - - - - -

- -

-
-
- + taskId={taskId} + themeColor={themeColor} + />

@@ -78,7 +68,7 @@ const MatchResults = observer(() => { )} */}

- { }} title="Help" onClick={() => setInstructionsVisible(true)} - > + >
*/} + +
+ setInstructionsVisible(true)} /> +
@@ -213,9 +207,11 @@ const MatchResults = observer(() => { }} />
- )))} - {store.hasResults && - } + )) + )} + {store.hasResults && ( + + )} ); }); diff --git a/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx b/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx new file mode 100644 index 0000000000..142d4f7c15 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx @@ -0,0 +1,182 @@ +import React from "react"; +import { Modal } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import ToggoleAnnotationIcon from "../icons/ToggoleAnnotationIcon"; +import HatchMarkIcon from "../icons/HatchMarkIcon"; +import FullScreenIcon from "../icons/FullScreenIcon"; + +const SectionTitle = ({ id }) => ( +
+ +
+); + +const BulletList = ({ items }) => ( +
    + {items.map((id) => ( +
  • + +
  • + ))} +
+); + +export default function InstructionsModal({ + show, + onHide, + taskId, + themeColor, +}) { + const primary = themeColor?.primaryColors?.primary500 || "#0d6efd"; + const [copied, setCopied] = React.useState(false); + + const handleCopy = async () => { + if (!taskId) return; + try { + await navigator.clipboard.writeText(taskId); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch (e) { + console.error(e); + } + }; + + return ( + + + + + + + + +
+
+ + : + + + {taskId || "-"} + + + +
+
+ + +
+ +
+ + + Match Result Example + + +
+ +
+ + + + + + + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ + + ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx new file mode 100644 index 0000000000..a7144afa9b --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +export default function InfoIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + return ( +
{}} + > + + + +
+ ); +} From 727bcb9cf2599b7cfeab819023af9408496a9ac1 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 19:22:18 +0000 Subject: [PATCH 072/192] update table styles --- .../components/MatchProspectTable.jsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 4d66ff6755..b011dfbdf6 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -268,10 +268,26 @@ const MatchProspectTable = ({ ? `${matchesBasedOnText}${" "} ${methodName}` : `${matchesBasedOnText}${" "} ${algorithm}`}
-
- {numCandidates}{" "} - {" "} - {date?.slice(0, 16).replace("T", " ")} +
+
+ {numCandidates}{" "} + {" "} +
+
+ {date?.slice(0, 16).replace("T", " ")} +
From 4698f347d12ae23b1e4adcc352db77a01985dd2f Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 20:06:16 +0000 Subject: [PATCH 073/192] add criteria drawer placeholder --- frontend/public/images/MatchResultExample.png | Bin 0 -> 91521 bytes .../pages/MatchResultsPage/MatchResults.jsx | 30 ++++++++++----- .../components/MatchCriteriaDrawer.jsx | 24 ++++++++++++ .../MatchResultsPage/icons/FilterIcon.jsx | 35 ++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 frontend/public/images/MatchResultExample.png create mode 100644 frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx create mode 100644 frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx diff --git a/frontend/public/images/MatchResultExample.png b/frontend/public/images/MatchResultExample.png new file mode 100644 index 0000000000000000000000000000000000000000..127e15e9d15a3a91b2201824741daea838234f98 GIT binary patch literal 91521 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 288ed685fd..6f0050dbc3 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -11,6 +11,8 @@ import { useSiteSettings } from "../../SiteSettingsContext"; import FullScreenLoader from "../../components/FullScreenLoader"; import InstructionsModal from "./components/InstructionsModal"; import InfoIcon from "./icons/InfoIcon"; +import FilterIcon from "./icons/FilterIcon"; +import MatchCriteriaDrawer from "./components/MatchCriteriaDrawer"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -19,6 +21,7 @@ const MatchResults = observer(() => { const [params] = useSearchParams(); const taskId = params.get("taskId"); const { projectsForUser = {} } = useSiteSettings() || {}; + const [filterVisible, setFilterVisible] = React.useState(false); useEffect(() => { if (taskId) { @@ -42,6 +45,12 @@ const MatchResults = observer(() => { taskId={taskId} themeColor={themeColor} /> + + setFilterVisible(false)} + /> +

@@ -68,18 +77,21 @@ const MatchResults = observer(() => { )} */}

- {/* setInstructionsVisible(true)} - > */} - -
+ onClick={() => setFilterVisible(true)} + > + +
+
setInstructionsVisible(true)} />
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx new file mode 100644 index 0000000000..7a505dd948 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Offcanvas } from "react-bootstrap"; + +export default function MatchCriteriaDrawer({ show, onHide }) { + return ( + + + Match Criteria + + +
Filters placeholder…
+
+
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx new file mode 100644 index 0000000000..920ab1aa0e --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +export default function FilterIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + return ( +
{}} + > + + + +
+ ); +} From 68bb7420e3cddb69884a0d09a82f0a7a365dc1a9 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 15 Jan 2026 22:37:44 +0000 Subject: [PATCH 074/192] add displayname and rotation info --- frontend/src/components/AnnotationOverlay.jsx | 64 ++++++++++++++----- .../components/MatchProspectTable.jsx | 3 + 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index 5d4514436d..272cf89ecd 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -45,6 +45,8 @@ const InteractiveAnnotationOverlay = forwardRef( ? showAnnotationsProp : internalShowAnn; + const hasRotation = !!rotationInfo; + useEffect(() => { const el = containerRef.current; if (!el) return; @@ -73,8 +75,12 @@ const InteractiveAnnotationOverlay = forwardRef( const fit = useMemo(() => { const cw = box.w; const ch = box.h; - const iw = Number(originalWidth) || 0; - const ih = Number(originalHeight) || 0; + + const baseW = Number(originalWidth) || 0; + const baseH = Number(originalHeight) || 0; + + const iw = hasRotation ? baseH : baseW; + const ih = hasRotation ? baseW : baseH; if (!cw || !ch || !iw || !ih) { return { scale: 1, offsetX: 0, offsetY: 0, renderW: cw, renderH: ch }; @@ -87,11 +93,15 @@ const InteractiveAnnotationOverlay = forwardRef( const offsetY = (ch - renderH) / 2; return { scale, offsetX, offsetY, renderW, renderH }; - }, [box.w, box.h, originalWidth, originalHeight]); + }, [box.w, box.h, originalWidth, originalHeight, hasRotation]); const canRenderAnnotations = useMemo(() => { - const iw = Number(originalWidth); - const ih = Number(originalHeight); + const baseW = Number(originalWidth); + const baseH = Number(originalHeight); + + const iw = hasRotation ? baseH : baseW; + const ih = hasRotation ? baseW : baseH; + return ( showAnn && Number.isFinite(iw) && @@ -103,8 +113,15 @@ const InteractiveAnnotationOverlay = forwardRef( Number.isFinite(fit.scale) && fit.scale > 0 ); - }, [showAnn, originalWidth, originalHeight, box.w, box.h, fit.scale]); - + }, [ + showAnn, + originalWidth, + originalHeight, + hasRotation, + box.w, + box.h, + fit.scale, + ]); const visibleAnnotations = useMemo(() => { if (!Array.isArray(annotations)) return []; @@ -126,7 +143,6 @@ const InteractiveAnnotationOverlay = forwardRef( }); }, [annotations]); - const clampZoom = (z) => Math.max(minZoom, Math.min(maxZoom, z)); const zoomIn = () => setZoom((z) => clampZoom(z + zoomStep)); @@ -229,18 +245,35 @@ const InteractiveAnnotationOverlay = forwardRef( }} > {visibleAnnotations.map((a, idx) => { - const x0 = Number(a.x); - const y0 = Number(a.y); - const w0 = Number(a.width); - const h0 = Number(a.height); - + const baseW = Number(originalWidth) || 0; + const baseH = Number(originalHeight) || 0; + + let x0 = Number(a.x); + let y0 = Number(a.y); + let w0 = Number(a.width); + let h0 = Number(a.height); + + if (hasRotation && baseW > 0 && baseH > 0) { + const adjW = baseH / baseW; + const adjH = baseW / baseH; + + x0 = x0 / adjW; + w0 = w0 / adjW; + y0 = y0 / adjH; + h0 = h0 / adjH; + } const x = fit.offsetX + x0 * fit.scale; const y = fit.offsetY + y0 * fit.scale; const w = w0 * fit.scale; const h = h0 * fit.scale; - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + if ( + !Number.isFinite(w) || + !Number.isFinite(h) || + w <= 0 || + h <= 0 + ) { return null; } @@ -249,7 +282,7 @@ const InteractiveAnnotationOverlay = forwardRef( const key = a.id ?? a.annotationId ?? - `${idx}-${x0}-${y0}-${w0}-${h0}`; + `${idx}-${Number(a.x)}-${Number(a.y)}-${Number(a.width)}-${Number(a.height)}`; return (
@@ -524,6 +526,7 @@ const MatchProspectTable = ({ originalHeight={leftOrigH} annotations={leftAnnotations} showAnnotations + rotationInfo={leftRotationInfo} />
From bf84e735207c7254b586f7480e6610e3afaf519b Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 21 Jan 2026 16:39:29 +0000 Subject: [PATCH 075/192] handle image rotation info --- frontend/src/components/AnnotationOverlay.jsx | 187 ++++++++---------- .../components/MatchProspectTable.jsx | 4 +- 2 files changed, 84 insertions(+), 107 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index 272cf89ecd..fdd0a37676 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -29,8 +29,8 @@ const InteractiveAnnotationOverlay = forwardRef( }, ref, ) => { - const containerRef = useRef(null); - const [box, setBox] = useState({ w: 0, h: 0 }); + const outerContainerRef = useRef(null); + const imgRef = useRef(null); const [zoom, setZoom] = useState( Number.isFinite(initialZoom) ? initialZoom : 1, ); @@ -38,6 +38,8 @@ const InteractiveAnnotationOverlay = forwardRef( const [dragging, setDragging] = useState(false); const dragStartRef = useRef({ x: 0, y: 0 }); const panStartRef = useRef({ x: 0, y: 0 }); + const [scaleX, setScaleX] = useState(1); + const [scaleY, setScaleY] = useState(1); const [internalShowAnn, setInternalShowAnn] = useState(true); const showAnn = @@ -48,80 +50,45 @@ const InteractiveAnnotationOverlay = forwardRef( const hasRotation = !!rotationInfo; useEffect(() => { - const el = containerRef.current; - if (!el) return; - - const update = () => { - const r = el.getBoundingClientRect(); - setBox({ w: r.width, h: r.height }); + if (!imgRef.current) return; + + const handleImageLoad = () => { + if (imgRef.current) { + const naturalWidth = Number(originalWidth); + const naturalHeight = Number(originalHeight); + const displayWidth = imgRef.current.clientWidth; + const displayHeight = imgRef.current.clientHeight; + + if (naturalWidth && naturalHeight && displayWidth && displayHeight) { + setScaleX(naturalWidth / displayWidth); + setScaleY(naturalHeight / displayHeight); + } + } }; - update(); - - let ro; - if (typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(update); - ro.observe(el); - } else { - window.addEventListener("resize", update); + const imgElement = imgRef.current; + if (imgElement && imgElement.complete) { + handleImageLoad(); + } else if (imgElement) { + imgElement.addEventListener("load", handleImageLoad); } return () => { - if (ro) ro.disconnect(); - window.removeEventListener("resize", update); + if (imgElement) { + imgElement.removeEventListener("load", handleImageLoad); + } }; - }, []); - - const fit = useMemo(() => { - const cw = box.w; - const ch = box.h; - - const baseW = Number(originalWidth) || 0; - const baseH = Number(originalHeight) || 0; - - const iw = hasRotation ? baseH : baseW; - const ih = hasRotation ? baseW : baseH; - - if (!cw || !ch || !iw || !ih) { - return { scale: 1, offsetX: 0, offsetY: 0, renderW: cw, renderH: ch }; - } - - const scale = Math.min(cw / iw, ch / ih); - const renderW = iw * scale; - const renderH = ih * scale; - const offsetX = (cw - renderW) / 2; - const offsetY = (ch - renderH) / 2; - - return { scale, offsetX, offsetY, renderW, renderH }; - }, [box.w, box.h, originalWidth, originalHeight, hasRotation]); + }, [originalWidth, originalHeight, imageUrl]); const canRenderAnnotations = useMemo(() => { - const baseW = Number(originalWidth); - const baseH = Number(originalHeight); - - const iw = hasRotation ? baseH : baseW; - const ih = hasRotation ? baseW : baseH; - return ( showAnn && - Number.isFinite(iw) && - Number.isFinite(ih) && - iw > 0 && - ih > 0 && - box.w > 0 && - box.h > 0 && - Number.isFinite(fit.scale) && - fit.scale > 0 + Number.isFinite(scaleX) && + Number.isFinite(scaleY) && + scaleX > 0 && + scaleY > 0 ); - }, [ - showAnn, - originalWidth, - originalHeight, - hasRotation, - box.w, - box.h, - fit.scale, - ]); + }, [showAnn, scaleX, scaleY]); const visibleAnnotations = useMemo(() => { if (!Array.isArray(annotations)) return []; @@ -201,11 +168,10 @@ const InteractiveAnnotationOverlay = forwardRef( return (
{alt} {visibleAnnotations.map((a, idx) => { - const baseW = Number(originalWidth) || 0; - const baseH = Number(originalHeight) || 0; - - let x0 = Number(a.x); - let y0 = Number(a.y); - let w0 = Number(a.width); - let h0 = Number(a.height); - - if (hasRotation && baseW > 0 && baseH > 0) { - const adjW = baseH / baseW; - const adjH = baseW / baseH; - - x0 = x0 / adjW; - w0 = w0 / adjW; - y0 = y0 / adjH; - h0 = h0 / adjH; + let rect = { + x: Number(a.x), + y: Number(a.y), + width: Number(a.width), + height: Number(a.height), + rotation: Number(a.theta || 0), + }; + + if (hasRotation) { + const imgW = Number(originalWidth); + const imgH = Number(originalHeight); + const adjW = imgH / imgW; + const adjH = imgW / imgH; + + rect = { + x: rect.x / scaleX / adjW, + width: rect.width / scaleX / adjW, + y: rect.y / scaleY / adjH, + height: rect.height / scaleY / adjH, + rotation: rect.rotation, + }; + } else { + rect = { + x: rect.x / scaleX, + y: rect.y / scaleY, + width: rect.width / scaleX, + height: rect.height / scaleY, + rotation: rect.rotation, + }; } - const x = fit.offsetX + x0 * fit.scale; - const y = fit.offsetY + y0 * fit.scale; - const w = w0 * fit.scale; - const h = h0 * fit.scale; - if ( - !Number.isFinite(w) || - !Number.isFinite(h) || - w <= 0 || - h <= 0 + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) || + rect.width <= 0 || + rect.height <= 0 ) { return null; } - const theta = Number(a.theta || 0); - const key = a.id ?? a.annotationId ?? @@ -289,13 +264,15 @@ const InteractiveAnnotationOverlay = forwardRef( key={key} style={{ position: "absolute", - left: x, - top: y, - width: w, - height: h, + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, border: `${lineWidth}px solid ${strokeColor}`, boxSizing: "border-box", - transform: theta ? `rotate(${theta}rad)` : undefined, + transform: rect.rotation + ? `rotate(${(rect.rotation * 180) / Math.PI}deg)` + : undefined, transformOrigin: "center", }} /> diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 097b9bfaf5..98a4f8b5d9 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -41,7 +41,7 @@ const styles = { borderRadius: "8px", boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", overflow: "hidden", - height: "400px", + // height: "400px", }, imageContainer: { width: "100%", @@ -236,7 +236,7 @@ const MatchProspectTable = ({ thisEncounterImageAsset?.height; const leftAnnotations = thisEncounterAnnotations; - const leftRotationInfo = thisEncounterImageAsset?.attributes?.rotationInfo; + const leftRotationInfo = thisEncounterImageAsset?.rotationInfo; const rightOrigW = previewedRow?.annotation?.asset?.width ?? From fc45837b68ede71288ca50b9ca0f5a0e5fa36029 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 21 Jan 2026 11:59:54 -0700 Subject: [PATCH 076/192] prep for vector-based results --- src/main/java/org/ecocean/ia/MatchResult.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index 6ec0130e35..b8f9ed4a76 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -83,6 +83,15 @@ public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd mySh createFromJsonResult(res, myShepherd); } + // this is for vector-based list of matches (annots) + // TODO FIXME also list of scores of prospects (vector hit?) + public void createFromProspectAnnotations(List annots, int numberCandidates, + Shepherd myShepherd) + throws IOException { + this.numberCandidates = numberCandidates; + this.populateProspects(annots); + } + // json_result section should be passed here public void createFromJsonResult(JSONObject res, Shepherd myShepherd) throws IOException { @@ -148,6 +157,20 @@ private int populateProspects(String type, JSONArray annotIds, JSONArray scores, return num; } + // we just have a list of annots which matched (e.g. via vectors in opensearch) + private int populateProspects(List annots) + throws IOException { + if (Util.collectionIsEmptyOrNull(annots)) return 0; + if (this.prospects == null) + this.prospects = new HashSet(); + for (Annotation ann : annots) { + // FIXME what is score for vectors??? + // inspect asset is null for vector matching i guess? + this.prospects.add(new MatchResultProspect(ann, 0.0d, "annot", null)); + } + return annots.size(); + } + private Annotation getAnnotationFromAcmId(String acmId, Shepherd myShepherd) { if (acmId == null) return null; List anns = myShepherd.getAnnotationsWithACMId(acmId, true); From 77de84b59656f965438088cd20095e5fe3857e9a Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 21 Jan 2026 21:46:26 +0000 Subject: [PATCH 077/192] matchingset filter baby step 1 --- .../src/pages/MatchResultsPage/MatchResults.jsx | 3 ++- .../components/MatchCriteriaDrawer.jsx | 9 +++++++-- .../pages/MatchResultsPage/helperFunctions.js | 16 ++++++++-------- .../MatchResultsPage/stores/matchResultsStore.js | 11 +++++++++-- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 6f0050dbc3..89154e9fa6 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -49,6 +49,7 @@ const MatchResults = observer(() => { setFilterVisible(false)} + filter={store.matchingSetFilter} />
@@ -107,7 +108,7 @@ const MatchResults = observer(() => { backgroundColor: store.viewMode === "individual" ? themeColor.primaryColors.primary500 - : "white", + : themeColor.primaryColors.primary50, border: "none", padding: "5px 10px", color: diff --git a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx index 7a505dd948..c278c79fe2 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx @@ -1,7 +1,7 @@ import React from "react"; import { Offcanvas } from "react-bootstrap"; -export default function MatchCriteriaDrawer({ show, onHide }) { +export default function MatchCriteriaDrawer({ show, onHide, filter }) { return ( Match Criteria -
Filters placeholder…
+
+ {filter?.locationIds && filter?.locationIds.length > 0 && ( +
Location IDs: {filter?.locationIds?.join(", ")}
+ )} + {filter?.owner &&
Owner: filter?.owner
} +
); diff --git a/frontend/src/pages/MatchResultsPage/helperFunctions.js b/frontend/src/pages/MatchResultsPage/helperFunctions.js index eafd3d877f..708b2d9af5 100644 --- a/frontend/src/pages/MatchResultsPage/helperFunctions.js +++ b/frontend/src/pages/MatchResultsPage/helperFunctions.js @@ -12,6 +12,7 @@ const collectProspects = (node, type, result = []) => { numberCandidates: node.matchResults.numberCandidates || 0, queryEncounterId: node.matchResults.queryAnnotation?.encounter?.id || null, + matchingSetFilter: node.matchingSetFilter, queryIndividualId: node.matchResults.queryAnnotation?.individual?.id || null, queryIndividualDisplayName: @@ -20,14 +21,13 @@ const collectProspects = (node, type, result = []) => { node.matchResults.queryAnnotation?.asset || null, queryEncounterImageUrl: node.matchResults.queryAnnotation?.asset?.url || null, - queryEncounterAnnotation: - { - x: node.matchResults.queryAnnotation?.x, - y: node.matchResults.queryAnnotation?.y, - width: node.matchResults.queryAnnotation?.width, - height: node.matchResults.queryAnnotation?.height, - theta:node.matchResults.queryAnnotation?.theta, - }, + queryEncounterAnnotation: { + x: node.matchResults.queryAnnotation?.x, + y: node.matchResults.queryAnnotation?.y, + width: node.matchResults.queryAnnotation?.width, + height: node.matchResults.queryAnnotation?.height, + theta: node.matchResults.queryAnnotation?.theta, + }, methodName, methodDescription, diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index b243491ce3..7f3526b638 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -6,6 +6,7 @@ import { getAllAnnot, getAllIndiv } from "../helperFunctions"; export default class MatchResultsStore { _viewMode = "individual"; // "individual" | "image" _encounterId = ""; + _matchingSetFilter = {}; _individualId = null; _individualDisplayName = null; _projectName = ""; @@ -43,6 +44,7 @@ export default class MatchResultsStore { this._rawAnnots = []; this._rawIndivs = []; this._encounterId = null; + this._matchingSetFilter = {}; this._individualId = null; this._individualDisplayName = null; this._thisEncounterImageUrl = ""; @@ -53,9 +55,11 @@ export default class MatchResultsStore { return; } - const first = annotResults[0] ?? indivResults[0]; + const first = + this._viewMode === "image" ? annotResults[0] : indivResults[0]; this._encounterId = first.queryEncounterId; + this._matchingSetFilter = first.matchingSetFilter; this._individualId = first.queryIndividualId; this._individualDisplayName = first.queryIndividualDisplayName; this._matchDate = first.date; @@ -115,7 +119,6 @@ export default class MatchResultsStore { }, }); } - // sections.sort((a, b) => new Date(b.metadata.date || 0) - new Date(a.metadata.date || 0)); return sections; } @@ -143,6 +146,10 @@ export default class MatchResultsStore { return this._encounterId; } + get matchingSetFilter() { + return this._matchingSetFilter; + } + get individualId() { return this._individualId; } From da3d2f9daa08a552adc14ada6dbda03c3983b694 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 21 Jan 2026 23:16:49 +0000 Subject: [PATCH 078/192] add 18n --- frontend/src/locale/de.json | 34 +++++++++++++++++- frontend/src/locale/en.json | 26 ++++++++++++-- frontend/src/locale/es.json | 34 +++++++++++++++++- frontend/src/locale/fr.json | 36 +++++++++++++++++-- frontend/src/locale/it.json | 34 +++++++++++++++++- .../components/MatchCriteriaDrawer.jsx | 3 ++ 6 files changed, 160 insertions(+), 7 deletions(-) diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index e9861199ad..b4de7fe34c 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -702,5 +702,37 @@ "ADD_ENCOUNTER_TO_PROJECT_DESC": "Wähle ein oder mehrere Encounters aus, um sie zu einem Projekt hinzuzufügen.", "INVALID_DATE": "Ungültiges Datum", "INVALID_LAT_LON": "Bitte geben Sie einen gültigen Breiten- und Längengrad ein", - "INVALID_MEASUREMENTS": "Bitte geben Sie gültige Werte ein" + "INVALID_MEASUREMENTS": "Bitte geben Sie gültige Werte ein", + "MATCH_RESULT": "Übereinstimmungsergebnis", + "INDIVIDUAL_SCORE": "Individueller Score", + "IMAGE_SCORE": "Bild-Score", + "NUMBER_OF_RESULTS": "Anzahl der Ergebnisse", + "SELECT_A_PROJECT": "ein Projekt auswählen", + "MATCHED_BASED_ON": "Übereinstimmung basierend auf ", + "POSSIBLE_MATCH": "Mögliche Übereinstimmung", + "AGAINST": "Gegen", + "CANDIDATES": "Kandidaten", + "MATCHING_PAGE_INSTRUCTIONS": "Anweisungen zur Übereinstimmungsseite", + "SCORES_DESC": "Die in den Rechtecken angezeigten Werte stellen den Wahrscheinlichkeitswert für eine Übereinstimmung dar. Ein höherer Wert deutet auf eine stärkere Wahrscheinlichkeit hin, dass die beiden Individuen dasselbe Tier sind.", + "SCORES_IMAGE_SCORE": "Bild-Score: Übereinstimmungswert für jedes Bild im Vergleich zur Abfrage.", + "SCORES_INDIVIDUAL_SCORE": "Individueller Score: Aggregierter Score für alle Bilder dieses Individuums.", + "PROJECT_DESC": "Wählen Sie ein Projekt aus, um die Übereinstimmungskandidaten nach Projekt zu filtern.", + "COMPARE_AND_SELECT_MATCHES_B1": "Klicken Sie auf eine beliebige Zeile, um den Übereinstimmungskandidaten mit dem Ziel zu vergleichen.", + "COMPARE_AND_SELECT_MATCHES_B2": "Links zu Begegnungen und Individuen befinden sich neben jedem Übereinstimmungswert.", + "COMPARE_AND_SELECT_MATCHES_B3": "Wählen Sie die richtige Übereinstimmung aus, indem Sie das Kontrollkästchen aktivieren.", + "ASSIGN_ID_B1": "Klicken Sie auf \"Übereinstimmung bestätigen\", wenn Sie eine Übereinstimmung mit der Begegnung gefunden haben.", + "ASSIGN_ID_B2": "Klicken Sie auf \"Als neues Individuum markieren\", wenn die Zielbegegnung keine Übereinstimmungen hat", + "ASSIGN_ID_B3": "Klicken Sie auf \"Individuen zusammenführen\", wenn Sie denken, dass die beiden Individuen identisch sind.", + "INSPECT_TOOL_DESC": "Inspektionswerkzeug: Zeigt XAI-Erklärungen (PAIRX für MIEW ID und Hotspotter für PIE)", + "ANNOTATION_TOOL_DESC": "Annotationswerkzeug: Annotation für aktuelle Begegnung und mögliche Übereinstimmungen ein- oder ausschalten", + "FULL_SCREEN_MODE_DESC": "Vollbildmodus: Fokussierung auf nur zwei Bilder", + "FOR_FULL_INSTRUCTIONS_PREFIX": "Für vollständige Anweisungen und Details siehe", + "SCORES": "Scores", + "PROJECT": "Projekt", + "COMPARE_AND_SELECT_MATCHES": "Übereinstimmungen vergleichen und auswählen", + "ASSIGN_ID": "ID zuweisen", + "TOOLS": "Werkzeuge", + "ZOOM_IN": "Vergrößern", + "ZOOM_OUT": "Verkleinern", + "WILDBOOK_DOCUMENTATION": "Wildbook-Dokumentation" } \ No newline at end of file diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9be3480dab..1ee47a4fc2 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -314,7 +314,7 @@ "LOCATION_ID_DESCRIPTION": "Find the location id using the text or the map. Try to be as specific as possible.", "LOCATION_ID_REQUIRED_WARNING": "Location ID is required to establish match sets.", "MISSING_REQUIRED_FIELDS": "Missing required fields or incorrect field values. Please review the form and try again.", - "INVALID_EMAIL_FORMAT": "Invalid email format", + "INVALID_EMAIL_FORMAT": "Invalid email format", "INVALID_LAT": "Latitude must be between -90 and 90", "INVALID_LONG": "Longitude must be between -180 and 180", "BEERROR_REQUIRED": "Could not submit; missing required field: ", @@ -712,5 +712,27 @@ "POSSIBLE_MATCH": "Possible Match", "AGAINST": "Against", "CANDIDATES": "candidates", - "MATCHING_PAGE_INSTRUCTIONS": "Matching Page Instructions" + "MATCHING_PAGE_INSTRUCTIONS": "Matching Page Instructions", + "SCORES_DESC": "The values displayed within the rectangles represent the match probability score. A higher score indicates a stronger likelihood that the two individuals are the same animal.", + "SCORES_IMAGE_SCORE": "Image Score: Match score for each image compared to the query.", + "SCORES_INDIVIDUAL_SCORE": "Individual Score: Aggregated score for all images of that individual.", + "PROJECT_DESC": "Select Project to filter the match candidates by Project.", + "COMPARE_AND_SELECT_MATCHES_B1": "Click on any row to compare match candidate to the target.", + "COMPARE_AND_SELECT_MATCHES_B2": "Links to encounters and individuals are next to each match score.", + "COMPARE_AND_SELECT_MATCHES_B3": "Select correct match by checking off the check box.", + "ASSIGN_ID_B1": "Click \"Confirm Match\" if you found a match to the encounter.", + "ASSIGN_ID_B2": "Click \"Mark as New Individual\" if the target encounter does not have any matches", + "ASSIGN_ID_B3": "Click \"Merge Individuals\" if you think the two individuals are the same.", + "INSPECT_TOOL_DESC": "Inspect Tool: Shows XAI explanations (PAIRX for MIEW ID and Hotspotter for PIE)", + "ANNOTATION_TOOL_DESC": "Annotation Tool: Toggle annotation on or off for both current encounter and possible matches", + "FULL_SCREEN_MODE_DESC": "Full Screen Mode: Focus on only two images", + "FOR_FULL_INSTRUCTIONS_PREFIX": "For full instructions and details, see", + "SCORES": "Scores", + "PROJECT": "Project", + "COMPARE_AND_SELECT_MATCHES": "Compare and select matches", + "ASSIGN_ID": "Assign ID", + "TOOLS": "Tools", + "ZOOM_IN": "Zoom In", + "ZOOM_OUT": "Zoom Out", + "WILDBOOK_DOCUMENTATION": "Wildbook Documentation" } \ No newline at end of file diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 098d39ee1b..1ebc20bc62 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -701,5 +701,37 @@ "ADD_ENCOUNTER_TO_PROJECT_DESC": "Selecciona uno o más encuentros para añadirlos a un proyecto.", "INVALID_DATE": "Fecha no válida", "INVALID_LAT_LON": "Por favor, introduce una latitud y una longitud válidas", - "INVALID_MEASUREMENTS": "Por favor, introduce valores válidos" + "INVALID_MEASUREMENTS": "Por favor, introduce valores válidos", + "MATCH_RESULT": "Resultado de Coincidencia", + "INDIVIDUAL_SCORE": "Puntuación Individual", + "IMAGE_SCORE": "Puntuación de Imagen", + "NUMBER_OF_RESULTS": "Número de resultados", + "SELECT_A_PROJECT": "seleccionar un proyecto", + "MATCHED_BASED_ON": "Coincidencia basada en ", + "POSSIBLE_MATCH": "Posible Coincidencia", + "AGAINST": "Contra", + "CANDIDATES": "candidatos", + "MATCHING_PAGE_INSTRUCTIONS": "Instrucciones de Página de Coincidencias", + "SCORES_DESC": "Los valores mostrados dentro de los rectángulos representan la puntuación de probabilidad de coincidencia. Una puntuación más alta indica una mayor probabilidad de que los dos individuos sean el mismo animal.", + "SCORES_IMAGE_SCORE": "Puntuación de Imagen: Puntuación de coincidencia para cada imagen comparada con la consulta.", + "SCORES_INDIVIDUAL_SCORE": "Puntuación Individual: Puntuación agregada para todas las imágenes de ese individuo.", + "PROJECT_DESC": "Seleccione un Proyecto para filtrar los candidatos de coincidencia por Proyecto.", + "COMPARE_AND_SELECT_MATCHES_B1": "Haga clic en cualquier fila para comparar el candidato de coincidencia con el objetivo.", + "COMPARE_AND_SELECT_MATCHES_B2": "Los enlaces a encuentros e individuos están junto a cada puntuación de coincidencia.", + "COMPARE_AND_SELECT_MATCHES_B3": "Seleccione la coincidencia correcta marcando la casilla de verificación.", + "ASSIGN_ID_B1": "Haga clic en \"Confirmar Coincidencia\" si encontró una coincidencia con el encuentro.", + "ASSIGN_ID_B2": "Haga clic en \"Marcar como Nuevo Individual\" si el encuentro objetivo no tiene ninguna coincidencia", + "ASSIGN_ID_B3": "Haga clic en \"Fusionar Individuos\" si cree que los dos individuos son el mismo.", + "INSPECT_TOOL_DESC": "Herramienta de Inspección: Muestra explicaciones XAI (PAIRX para MIEW ID y Hotspotter para PIE)", + "ANNOTATION_TOOL_DESC": "Herramienta de Anotación: Activar o desactivar la anotación tanto para el encuentro actual como para las posibles coincidencias", + "FULL_SCREEN_MODE_DESC": "Modo de Pantalla Completa: Enfocarse solo en dos imágenes", + "FOR_FULL_INSTRUCTIONS_PREFIX": "Para instrucciones completas y detalles, consulte", + "SCORES": "Puntuaciones", + "PROJECT": "Proyecto", + "COMPARE_AND_SELECT_MATCHES": "Comparar y seleccionar coincidencias", + "ASSIGN_ID": "Asignar ID", + "TOOLS": "Herramientas", + "ZOOM_IN": "Acercar", + "ZOOM_OUT": "Alejar", + "WILDBOOK_DOCUMENTATION": "Documentación de Wildbook" } \ No newline at end of file diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index cfb3def23a..59d739c51e 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -312,7 +312,7 @@ "FILTER_BY_MAP": "Filtrer par carte", "LOCATION_ID_DESCRIPTION": "Trouvez l'ID de l'emplacement en utilisant le texte ou la carte. Essayez d'être aussi précis que possible.", "LOCATION_ID_REQUIRED_WARNING": "L'ID de l'emplacement est requis pour établir des ensembles de correspondances.", - "MISSING_REQUIRED_FIELDS": "Champs obligatoires manquants ou valeurs incorrectes. Veuillez vérifier le formulaire et réessayer.", "INVALID_LAT": "La latitude doit être comprise entre -90 et 90", + "MISSING_REQUIRED_FIELDS": "Champs obligatoires manquants ou valeurs incorrectes. Veuillez vérifier le formulaire et réessayer.", "INVALID_EMAIL_FORMAT": "Format d'email invalide", "INVALID_LAT": "La latitude doit être comprise entre -90 et 90", "INVALID_LONG": "La longitude doit être comprise entre -180 et 180", @@ -701,5 +701,37 @@ "ADD_ENCOUNTER_TO_PROJECT_DESC": "Sélectionnez une ou plusieurs rencontres à ajouter à un projet.", "INVALID_DATE": "Date invalide", "INVALID_LAT_LON": "Veuillez saisir une latitude et une longitude valides", - "INVALID_MEASUREMENTS": "Veuillez saisir des valeurs valides" + "INVALID_MEASUREMENTS": "Veuillez saisir des valeurs valides", + "MATCH_RESULT": "Résultat de Correspondance", + "INDIVIDUAL_SCORE": "Score Individuel", + "IMAGE_SCORE": "Score d'Image", + "NUMBER_OF_RESULTS": "Nombre de résultats", + "SELECT_A_PROJECT": "sélectionner un projet", + "MATCHED_BASED_ON": "Correspondance basée sur ", + "POSSIBLE_MATCH": "Correspondance Possible", + "AGAINST": "Contre", + "CANDIDATES": "candidats", + "MATCHING_PAGE_INSTRUCTIONS": "Instructions de la Page de Correspondance", + "SCORES_DESC": "Les valeurs affichées dans les rectangles représentent le score de probabilité de correspondance. Un score plus élevé indique une plus forte probabilité que les deux individus soient le même animal.", + "SCORES_IMAGE_SCORE": "Score d'Image : Score de correspondance pour chaque image comparée à la requête.", + "SCORES_INDIVIDUAL_SCORE": "Score Individuel : Score agrégé pour toutes les images de cet individu.", + "PROJECT_DESC": "Sélectionnez un Projet pour filtrer les candidats de correspondance par Projet.", + "COMPARE_AND_SELECT_MATCHES_B1": "Cliquez sur n'importe quelle ligne pour comparer le candidat de correspondance à la cible.", + "COMPARE_AND_SELECT_MATCHES_B2": "Les liens vers les rencontres et les individus sont à côté de chaque score de correspondance.", + "COMPARE_AND_SELECT_MATCHES_B3": "Sélectionnez la correspondance correcte en cochant la case.", + "ASSIGN_ID_B1": "Cliquez sur \"Confirmer la Correspondance\" si vous avez trouvé une correspondance avec la rencontre.", + "ASSIGN_ID_B2": "Cliquez sur \"Marquer comme Nouvel Individu\" si la rencontre cible n'a aucune correspondance", + "ASSIGN_ID_B3": "Cliquez sur \"Fusionner les Individus\" si vous pensez que les deux individus sont identiques.", + "INSPECT_TOOL_DESC": "Outil d'Inspection : Affiche les explications XAI (PAIRX pour MIEW ID et Hotspotter pour PIE)", + "ANNOTATION_TOOL_DESC": "Outil d'Annotation : Activer ou désactiver l'annotation pour la rencontre actuelle et les correspondances possibles", + "FULL_SCREEN_MODE_DESC": "Mode Plein Écran : Se concentrer uniquement sur deux images", + "FOR_FULL_INSTRUCTIONS_PREFIX": "Pour les instructions complètes et les détails, voir", + "SCORES": "Scores", + "PROJECT": "Projet", + "COMPARE_AND_SELECT_MATCHES": "Comparer et sélectionner les correspondances", + "ASSIGN_ID": "Attribuer un ID", + "TOOLS": "Outils", + "ZOOM_IN": "Zoomer", + "ZOOM_OUT": "Dézoomer", + "WILDBOOK_DOCUMENTATION": "Documentation Wildbook" } \ No newline at end of file diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index ece7e6017e..69cdfa1bc3 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -701,5 +701,37 @@ "ADD_ENCOUNTER_TO_PROJECT_DESC": "Seleziona uno o più encounter da aggiungere a un progetto.", "INVALID_DATE": "Data non valida", "INVALID_LAT_LON": "Inserisci una latitudine e una longitudine valide", - "INVALID_MEASUREMENTS": "Inserisci valori validi" + "INVALID_MEASUREMENTS": "Inserisci valori validi", + "MATCH_RESULT": "Risultato di Corrispondenza", + "INDIVIDUAL_SCORE": "Punteggio Individuale", + "IMAGE_SCORE": "Punteggio Immagine", + "NUMBER_OF_RESULTS": "Numero di risultati", + "SELECT_A_PROJECT": "seleziona un progetto", + "MATCHED_BASED_ON": "Corrispondenza basata su ", + "POSSIBLE_MATCH": "Possibile Corrispondenza", + "AGAINST": "Contro", + "CANDIDATES": "candidati", + "MATCHING_PAGE_INSTRUCTIONS": "Istruzioni Pagina di Corrispondenza", + "SCORES_DESC": "I valori visualizzati all'interno dei rettangoli rappresentano il punteggio di probabilità di corrispondenza. Un punteggio più alto indica una maggiore probabilità che i due individui siano lo stesso animale.", + "SCORES_IMAGE_SCORE": "Punteggio Immagine: Punteggio di corrispondenza per ogni immagine confrontata con la query.", + "SCORES_INDIVIDUAL_SCORE": "Punteggio Individuale: Punteggio aggregato per tutte le immagini di quell'individuo.", + "PROJECT_DESC": "Seleziona un Progetto per filtrare i candidati di corrispondenza per Progetto.", + "COMPARE_AND_SELECT_MATCHES_B1": "Fai clic su qualsiasi riga per confrontare il candidato di corrispondenza con il target.", + "COMPARE_AND_SELECT_MATCHES_B2": "I link agli incontri e agli individui sono accanto a ogni punteggio di corrispondenza.", + "COMPARE_AND_SELECT_MATCHES_B3": "Seleziona la corrispondenza corretta spuntando la casella di controllo.", + "ASSIGN_ID_B1": "Fai clic su \"Conferma Corrispondenza\" se hai trovato una corrispondenza con l'incontro.", + "ASSIGN_ID_B2": "Fai clic su \"Segna come Nuovo Individuo\" se l'incontro target non ha corrispondenze", + "ASSIGN_ID_B3": "Fai clic su \"Unisci Individui\" se pensi che i due individui siano lo stesso.", + "INSPECT_TOOL_DESC": "Strumento di Ispezione: Mostra spiegazioni XAI (PAIRX per MIEW ID e Hotspotter per PIE)", + "ANNOTATION_TOOL_DESC": "Strumento di Annotazione: Attiva o disattiva l'annotazione sia per l'incontro corrente che per le possibili corrispondenze", + "FULL_SCREEN_MODE_DESC": "Modalità Schermo Intero: Concentrati solo su due immagini", + "FOR_FULL_INSTRUCTIONS_PREFIX": "Per istruzioni complete e dettagli, vedi", + "SCORES": "Punteggi", + "PROJECT": "Progetto", + "COMPARE_AND_SELECT_MATCHES": "Confronta e seleziona corrispondenze", + "ASSIGN_ID": "Assegna ID", + "TOOLS": "Strumenti", + "ZOOM_IN": "Ingrandisci", + "ZOOM_OUT": "Riduci", + "WILDBOOK_DOCUMENTATION": "Documentazione Wildbook" } \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx index c278c79fe2..c48a2b5f81 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx @@ -22,6 +22,9 @@ export default function MatchCriteriaDrawer({ show, onHide, filter }) {
Location IDs: {filter?.locationIds?.join(", ")}
)} {filter?.owner &&
Owner: filter?.owner
} + {!filter?.owner && !filter?.locationIds && ( +
no filter set for this task
+ )}
From 432f171913b9b328cc83655ebb201f26d46285e4 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 22 Jan 2026 22:48:59 +0000 Subject: [PATCH 079/192] update bottom bar --- .../pages/MatchResultsPage/MatchResults.jsx | 16 +- .../components/MatchResultsBottomBar.jsx | 344 +++++++++++++----- .../stores/matchResultsStore.js | 18 +- 3 files changed, 273 insertions(+), 105 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 89154e9fa6..079f044c18 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -215,8 +215,20 @@ const MatchResults = observer(() => { themeColor={themeColor} columns={columns} selectedMatch={store.selectedMatch} - onToggleSelected={(checked, key, encounterId, individualId) => { - store.setSelectedMatch(checked, key, encounterId, individualId); + onToggleSelected={( + checked, + key, + encounterId, + individualId, + individualDisplayName, + ) => { + store.setSelectedMatch( + checked, + key, + encounterId, + individualId, + individualDisplayName, + ); }} />
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index ac9d4f68b3..4559c2fa04 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -16,23 +16,11 @@ const styles = { display: "flex", gap: "24px", zIndex: 1000, - height: "70px" + height: "70px", }), bottomText: { fontSize: "0.9rem", }, - idPill: (themeColor) => ({ - borderRadius: "5px", - border: "none", - padding: "2px 10px", - fontSize: "0.8rem", - background: themeColor.wildMeColors.teal100, - color: themeColor.wildMeColors.teal800, - }), - idPillOutline: { - background: "transparent", - border: "1px solid #ccc", - }, warningText: { color: "#dc3545", fontSize: "0.9rem", @@ -41,32 +29,64 @@ const styles = { }; const MatchResultsBottomBar = observer(({ store, themeColor }) => { - - const renderActions = () => { - const matchingState = store.matchingState; + const matchingState = store.matchingState; + const getActionContent = () => { switch (matchingState) { - case "no_individuals": - return ( + case "no_individuals": { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); + + const left = ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {shortEncId} + + )} + +
+ ); + + const right = ( <> store.setNewIndividualName(e.target.value)} - style={{ maxWidth: "300px" }} size="sm" + style={{ width: 280 }} /> - - + {store.matchRequestLoading && ( { ); - case "single_individual": - return ( - - - {store.matchRequestLoading && ( - + ), + }; + } + + case "two_individuals": { + const all = store.selectedIncludingQuery || []; + + const individualsRaw = all.filter((x) => x?.individualId); + const individuals = Array.from( + new Map(individualsRaw.map((x) => [x.individualId, x])).values(), ); - case "two_individuals": - return ( - - - {store.matchRequestLoading && ( - + individuals.sort((x) => + x?.encounterId === store.encounterId ? -1 : 1, ); - case "too_many_individuals": - return ( -
- - -
+ const a = individuals[0]; + const b = individuals[1]; + + const nameA = + (a?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + a?.individualDisplayName || + a?.individualId || + "Individual A"; + + const nameB = + (b?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + b?.individualDisplayName || + b?.individualId || + "Individual B"; + + const encounters = all.filter( + (x) => x?.encounterId && !x?.individualId, ); + const mergeMessage = + encounters.length > 0 + ? `Merge ${nameA} and ${nameB} and ${encounters.length} encounters` + : `Merge ${nameA} and ${nameB}`; + + return { + left: ( +
+ {mergeMessage} +
+ ), + right: ( + + + {store.matchRequestLoading && ( + + ), + }; + } + + case "too_many_individuals": + return { + left: ( +
+ +
+ ), + right: null, + }; case "no_further_action_needed": - return ( -
- -
- ); + if (store.selectedMatch.length === 0) { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); + return { + left: ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {shortEncId} + + )} +
+ ), + right: null, + }; + } + return { + left: ( +
+ +
+ ), + right: null, + }; default: - return null; + return { left: null, right: null }; } }; + const { left, right } = getActionContent(); + return (
-
- {" "} - - {store.encounterId} - -
- {renderActions()} +
+ {left} +
+ +
+ {right} + window.close()} + style={{ marginTop: 0, marginBottom: 0 }} + > + + +
); }); -export default MatchResultsBottomBar; \ No newline at end of file +export default MatchResultsBottomBar; diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 7f3526b638..87b4b6bacd 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -231,6 +231,7 @@ export default class MatchResultsStore { return { encounterId: this._encounterId, individualId: this._individualId || null, + individualDisplayName: this.individualDisplayName || null, }; } @@ -303,14 +304,25 @@ export default class MatchResultsStore { this._newIndividualName = name; } - setSelectedMatch(selected, key, encounterId, individualId) { + setSelectedMatch( + selected, + key, + encounterId, + individualId, + individualDisplayName, + ) { if (!key || !encounterId) return; if (selected) { if (this._selectedMatch.some((m) => m.key === key)) return; this._selectedMatch = [ ...this._selectedMatch, - { key, encounterId, individualId: individualId || null }, + { + key, + encounterId, + individualId: individualId || null, + individualDisplayName: individualDisplayName || null, + }, ]; } else { this._selectedMatch = this._selectedMatch.filter((m) => m.key !== key); @@ -325,7 +337,7 @@ export default class MatchResultsStore { // merge functions //no further action needed, two cases: - //1. query encounter has individual ID, no match result selected + //1. query encounter has individual ID, no match result selected -- in this case we display set match for xxx //2. all encounters have same individual ID handleNoFurtherActionNeeded() { this.clearSelection(); From 8ded839fa0db1af42b46c79b870498d0d09ca4c0 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 22 Jan 2026 22:51:27 +0000 Subject: [PATCH 080/192] handle empty filter --- .../src/pages/MatchResultsPage/components/MatchProspectTable.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 98a4f8b5d9..5f2327b460 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -371,6 +371,7 @@ const MatchProspectTable = ({ rowKey, candidateEncounterId, candidateIndividualId, + candidateIndividualDisplayName, ) } /> From 86769e9282a1dff73b65d359808959d67e7d91fb Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 22 Jan 2026 23:06:13 +0000 Subject: [PATCH 081/192] i18n --- frontend/src/locale/de.json | 17 ++- frontend/src/locale/en.json | 15 ++- frontend/src/locale/es.json | 17 ++- frontend/src/locale/fr.json | 21 +++- frontend/src/locale/it.json | 17 ++- .../components/MatchResultsBottomBar.jsx | 108 ++++++++++++------ 6 files changed, 147 insertions(+), 48 deletions(-) diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index b4de7fe34c..cc99a24914 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -314,7 +314,7 @@ "LOCATION_ID_DESCRIPTION": "Finden Sie die Standort-ID mithilfe des Textes oder der Karte. Versuchen Sie, so spezifisch wie möglich zu sein.", "LOCATION_ID_REQUIRED_WARNING": "Die Standort-ID ist erforderlich, um Abgleichsgruppen zu erstellen.", "MISSING_REQUIRED_FIELDS": "Erforderliche Felder fehlen oder Feldwerte sind falsch. Bitte überprüfen Sie das Formular und versuchen Sie es erneut.", - "INVALID_EMAIL_FORMAT": "Ungültiges E-Mail-Format", + "INVALID_EMAIL_FORMAT": "Ungültiges E-Mail-Format", "INVALID_LAT": "Der Breitengrad muss zwischen -90 und 90 liegen", "INVALID_LONG": "Der Längengrad muss zwischen -180 und 180 liegen", "BEERROR_REQUIRED": "Konnte nicht übermittelt werden; erforderliches Feld fehlt: ", @@ -734,5 +734,18 @@ "TOOLS": "Werkzeuge", "ZOOM_IN": "Vergrößern", "ZOOM_OUT": "Verkleinern", - "WILDBOOK_DOCUMENTATION": "Wildbook-Dokumentation" + "WILDBOOK_DOCUMENTATION": "Wildbook-Dokumentation", + "SET_MATCH_FOR": "Zuordnung festlegen für", + "NO_FURTHER_ACTION_NEEDED": "Keine weitere Aktion erforderlich", + "OR": "oder", + "MARK_AS_NEW_INDIVIDUAL": "Als neues Individuum markieren", + "CONFIRM_MATCH": "Übereinstimmung bestätigen", + "MERGE_INDIVIDUALS": "Individuen zusammenführen", + "CANNOT_MERGE_MORE_THAN_TWO": "Es können nicht mehr als zwei Individuen zusammengeführt werden", + "MERGE": "Zusammenführen", + "MERGE_INDIVIDUAL": "Individuum zusammenführen", + "AND": "und", + "AND_N_ENCOUNTERS": "und {count, plural, one {# Begegnung} other {# Begegnungen}}", + "INDIVIDUAL_A": "Individuum A", + "INDIVIDUAL_B": "Individuum B" } \ No newline at end of file diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 1ee47a4fc2..c21cd05f7e 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -734,5 +734,18 @@ "TOOLS": "Tools", "ZOOM_IN": "Zoom In", "ZOOM_OUT": "Zoom Out", - "WILDBOOK_DOCUMENTATION": "Wildbook Documentation" + "WILDBOOK_DOCUMENTATION": "Wildbook Documentation", + "SET_MATCH_FOR": "Set match for", + "NO_FURTHER_ACTION_NEEDED": "No further action needed", + "OR": "or", + "MARK_AS_NEW_INDIVIDUAL": "Mark As New Individual", + "CONFIRM_MATCH": "Confrim Match", + "MERGE_INDIVIDUALS": "Merge Individuals", + "CANNOT_MERGE_MORE_THAN_TWO": "Cannot merge more than two individuals", + "MERGE": "Merge", + "MERGE_INDIVIDUAL": "Merge individual", + "AND": "and", + "AND_N_ENCOUNTERS": "and {count, plural, one {# encounter} other {# encounters}}", + "INDIVIDUAL_A": "Individual A", + "INDIVIDUAL_B": "Individual B" } \ No newline at end of file diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 1ebc20bc62..3344533c0c 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -313,7 +313,7 @@ "LOCATION_ID_DESCRIPTION": "Encuentra el ID de ubicación utilizando el texto o el mapa. Intenta ser lo más específico posible.", "LOCATION_ID_REQUIRED_WARNING": "Se requiere el ID de ubicación para establecer conjuntos de coincidencias.", "MISSING_REQUIRED_FIELDS": "Faltan campos obligatorios o los valores son incorrectos. Por favor, revise el formulario e inténtelo de nuevo.", - "INVALID_EMAIL_FORMAT": "Formato de correo electrónico inválido", + "INVALID_EMAIL_FORMAT": "Formato de correo electrónico inválido", "INVALID_LAT": "La latitud debe estar entre -90 y 90", "INVALID_LONG": "La longitud debe estar entre -180 y 180", "BEERROR_REQUIRED": "No se ha podido enviar; falta el campo obligatorio: ", @@ -733,5 +733,18 @@ "TOOLS": "Herramientas", "ZOOM_IN": "Acercar", "ZOOM_OUT": "Alejar", - "WILDBOOK_DOCUMENTATION": "Documentación de Wildbook" + "WILDBOOK_DOCUMENTATION": "Documentación de Wildbook", + "SET_MATCH_FOR": "Establecer coincidencia para", + "NO_FURTHER_ACTION_NEEDED": "No se requiere ninguna acción adicional", + "OR": "o", + "MARK_AS_NEW_INDIVIDUAL": "Marcar como nuevo individuo", + "CONFIRM_MATCH": "Confirmar coincidencia", + "MERGE_INDIVIDUALS": "Fusionar individuos", + "CANNOT_MERGE_MORE_THAN_TWO": "No se pueden fusionar más de dos individuos", + "MERGE": "Fusionar", + "MERGE_INDIVIDUAL": "Fusionar individuo", + "AND": "y", + "AND_N_ENCOUNTERS": "y {count, plural, one {# encuentro} other {# encuentros}}", + "INDIVIDUAL_A": "Individuo A", + "INDIVIDUAL_B": "Individuo B" } \ No newline at end of file diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index 59d739c51e..c072ae59d6 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -312,9 +312,9 @@ "FILTER_BY_MAP": "Filtrer par carte", "LOCATION_ID_DESCRIPTION": "Trouvez l'ID de l'emplacement en utilisant le texte ou la carte. Essayez d'être aussi précis que possible.", "LOCATION_ID_REQUIRED_WARNING": "L'ID de l'emplacement est requis pour établir des ensembles de correspondances.", - "MISSING_REQUIRED_FIELDS": "Champs obligatoires manquants ou valeurs incorrectes. Veuillez vérifier le formulaire et réessayer.", - "INVALID_EMAIL_FORMAT": "Format d'email invalide", - "INVALID_LAT": "La latitude doit être comprise entre -90 et 90", + "MISSING_REQUIRED_FIELDS": "Champs obligatoires manquants ou valeurs incorrectes. Veuillez vérifier le formulaire et réessayer.", + "INVALID_EMAIL_FORMAT": "Format d'email invalide", + "INVALID_LAT": "La latitude doit être comprise entre -90 et 90", "INVALID_LONG": "La longitude doit être comprise entre -180 et 180", "BEERROR_REQUIRED": "Impossible de soumettre ; champ requis manquant : ", "SUBMISSION_FAILED": "Échec de la soumission", @@ -733,5 +733,18 @@ "TOOLS": "Outils", "ZOOM_IN": "Zoomer", "ZOOM_OUT": "Dézoomer", - "WILDBOOK_DOCUMENTATION": "Documentation Wildbook" + "WILDBOOK_DOCUMENTATION": "Documentation Wildbook", + "SET_MATCH_FOR": "Définir une correspondance pour", + "NO_FURTHER_ACTION_NEEDED": "Aucune action supplémentaire requise", + "OR": "ou", + "MARK_AS_NEW_INDIVIDUAL": "Marquer comme nouvel individu", + "CONFIRM_MATCH": "Confirmer la correspondance", + "MERGE_INDIVIDUALS": "Fusionner des individus", + "CANNOT_MERGE_MORE_THAN_TWO": "Impossible de fusionner plus de deux individus", + "MERGE": "Fusionner", + "MERGE_INDIVIDUAL": "Fusionner l’individu", + "AND": "et", + "AND_N_ENCOUNTERS": "et {count, plural, one {# rencontre} other {# rencontres}}", + "INDIVIDUAL_A": "Individu A", + "INDIVIDUAL_B": "Individu B" } \ No newline at end of file diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index 69cdfa1bc3..2f2a707acd 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -313,7 +313,7 @@ "LOCATION_ID_DESCRIPTION": "Trova l'ID della posizione usando il testo o la mappa. Cerca di essere il più specifico possibile.", "LOCATION_ID_REQUIRED_WARNING": "L'ID della posizione è necessario per stabilire set di corrispondenze.", "MISSING_REQUIRED_FIELDS": "Campi obbligatori mancanti o valori non corretti. Si prega di rivedere il modulo e riprovare.", - "INVALID_EMAIL_FORMAT": "Formato email non valido", + "INVALID_EMAIL_FORMAT": "Formato email non valido", "INVALID_LAT": "La latitudine deve essere compresa tra -90 e 90", "INVALID_LONG": "La longitudine deve essere compresa tra -180 e 180", "BEERROR_REQUIRED": "Impossibile inviare; manca un campo obbligatorio: ", @@ -733,5 +733,18 @@ "TOOLS": "Strumenti", "ZOOM_IN": "Ingrandisci", "ZOOM_OUT": "Riduci", - "WILDBOOK_DOCUMENTATION": "Documentazione Wildbook" + "WILDBOOK_DOCUMENTATION": "Documentazione Wildbook", + "SET_MATCH_FOR": "Imposta corrispondenza per", + "NO_FURTHER_ACTION_NEEDED": "Nessuna ulteriore azione necessaria", + "OR": "o", + "MARK_AS_NEW_INDIVIDUAL": "Contrassegna come nuovo individuo", + "CONFIRM_MATCH": "Conferma corrispondenza", + "MERGE_INDIVIDUALS": "Unisci individui", + "CANNOT_MERGE_MORE_THAN_TWO": "Non è possibile unire più di due individui", + "MERGE": "Unisci", + "MERGE_INDIVIDUAL": "Unisci individuo", + "AND": "e", + "AND_N_ENCOUNTERS": "e {count, plural, one {# incontro} other {# incontri}}", + "INDIVIDUAL_A": "Individuo A", + "INDIVIDUAL_B": "Individuo B" } \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 4559c2fa04..41c8bc2883 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -42,11 +42,12 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { {" "} {store.individualDisplayName ? ( {store.individualDisplayName} @@ -56,11 +57,10 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { target="_blank" rel="noopener noreferrer" className="text-decoration-none" - title={encId} > {shortEncId} - )} + )}{" "}
); @@ -105,8 +105,8 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { case "single_individual": { const all = store.selectedIncludingQuery || []; - const individualItem = all.find((x) => x?.individualId); + const individualName = (individualItem?.encounterId === store.encounterId ? store.individualDisplayName @@ -122,9 +122,26 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { return { left: (
- {`Merge individual ${individualName}${ - encounterNum ? ` and ${encounterNum} encounters` : "" - }`} + {" "} + + {individualName} + + {encounterNum > 0 && ( + <> + {" "} + + + )}
), right: ( @@ -136,10 +153,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { disabled={store.matchRequestLoading} style={{ marginTop: 0, marginBottom: 0 }} > - + {store.matchRequestLoading && ( { case "two_individuals": { const all = store.selectedIncludingQuery || []; - const individualsRaw = all.filter((x) => x?.individualId); const individuals = Array.from( new Map(individualsRaw.map((x) => [x.individualId, x])).values(), @@ -169,34 +182,56 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { const a = individuals[0]; const b = individuals[1]; - const nameA = - (a?.encounterId === store.encounterId - ? store.individualDisplayName - : null) || + const nameA = (a?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || a?.individualDisplayName || - a?.individualId || - "Individual A"; + a?.individualId || ; - const nameB = - (b?.encounterId === store.encounterId - ? store.individualDisplayName - : null) || + const nameB = (b?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || b?.individualDisplayName || - b?.individualId || - "Individual B"; + b?.individualId || ; const encounters = all.filter( (x) => x?.encounterId && !x?.individualId, ); - const mergeMessage = - encounters.length > 0 - ? `Merge ${nameA} and ${nameB} and ${encounters.length} encounters` - : `Merge ${nameA} and ${nameB}`; return { left: (
- {mergeMessage} + {" "} + + {nameA} + {" "} + {" "} + + {nameB} + + {encounters.length > 0 && ( + <> + {" "} + + + )}
), right: ( @@ -208,10 +243,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { disabled={store.matchRequestLoading} style={{ marginTop: 0, marginBottom: 0 }} > - + {store.matchRequestLoading && ( { if (store.selectedMatch.length === 0) { const encId = store.encounterId || ""; const shortEncId = encId.slice(0, 5); + return { left: (
{" "} {store.individualDisplayName ? ( {store.individualDisplayName} @@ -263,7 +297,6 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { target="_blank" rel="noopener noreferrer" className="text-decoration-none" - title={encId} > {shortEncId} @@ -273,6 +306,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { right: null, }; } + return { left: (
From e87465e161ffc5d16e5843b5a13c30a9d60c0f72 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 22 Jan 2026 16:39:45 -0700 Subject: [PATCH 082/192] constructor for embed-based list of prospect annots --- src/main/java/org/ecocean/ia/MatchResult.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index b8f9ed4a76..955db909d6 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -64,6 +64,15 @@ public MatchResult(Task task, JSONObject jsonResult, Shepherd myShepherd) this.createFromJsonResult(jsonResult, myShepherd); } + // FIXME will need scores and other stuff here? + public MatchResult(Task task, List annots, int numberCandidates, + Shepherd myShepherd) + throws IOException { + this(); + this.task = task; + this.createFromProspectAnnotations(annots, numberCandidates, myShepherd); + } + public int getNumberCandidates() { return numberCandidates; } From 9dba2c3454914aa8c71289e7e65cea407942eebe Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 23 Jan 2026 17:03:47 +0000 Subject: [PATCH 083/192] put action bar on the top --- .../pages/MatchResultsPage/MatchResults.jsx | 56 ++++++++----------- .../components/MatchResultsBottomBar.jsx | 5 +- 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 079f044c18..c42ca46b22 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -38,7 +38,7 @@ const MatchResults = observer(() => { } return ( - + setInstructionsVisible(false)} @@ -52,43 +52,23 @@ const MatchResults = observer(() => { filter={store.matchingSetFilter} /> + {store.hasResults && ( + + )} + + {store.hasResults &&
} +

- {/* {` for ${store.encounterId}`} - {store.individualDisplayName && ( - { - const url = `/individuals.jsp?id=${store.individualId}`; - window.open(url, "_blank"); - }} - > - {store._individualDisplayName} - - )} */}
-
setFilterVisible(true)} - > - -
{ ))} +
setFilterVisible(true)} + > + +
{!store.hasResults ? ( @@ -234,9 +225,6 @@ const MatchResults = observer(() => {
)) )} - {store.hasResults && ( - - )}
); }); diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 41c8bc2883..eebd8d6bd6 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -9,14 +9,14 @@ const styles = { position: "fixed", left: 0, right: 0, - bottom: 0, + top: "50px", background: themeColor.primaryColors.primary50, borderTop: "1px solid #dee2e6", padding: "10px 24px", display: "flex", gap: "24px", zIndex: 1000, - height: "70px", + height: "60px", }), bottomText: { fontSize: "0.9rem", @@ -344,6 +344,7 @@ const MatchResultsBottomBar = observer(({ store, themeColor }) => { window.close()} style={{ marginTop: 0, marginBottom: 0 }} From 8cbb1fffe2f75487f78de4821dab1cfe1d99006b Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 23 Jan 2026 18:11:04 +0000 Subject: [PATCH 084/192] add modals for confirm no match --- .../pages/MatchResultsPage/MatchResults.jsx | 5 +- .../components/CreateNewIndividualModal.jsx | 153 +++++ .../components/MatchResultsBottomBar.jsx | 621 +++++++++--------- .../components/NewIndividualCreatedModal.jsx | 50 ++ .../stores/matchResultsStore.js | 55 ++ 5 files changed, 587 insertions(+), 297 deletions(-) create mode 100644 frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx create mode 100644 frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index c42ca46b22..624fd2319b 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -20,7 +20,8 @@ const MatchResults = observer(() => { const [instructionsVisible, setInstructionsVisible] = React.useState(false); const [params] = useSearchParams(); const taskId = params.get("taskId"); - const { projectsForUser = {} } = useSiteSettings() || {}; + const { projectsForUser = {}, identificationRemarks = [] } = + useSiteSettings() || {}; const [filterVisible, setFilterVisible] = React.useState(false); useEffect(() => { @@ -56,7 +57,7 @@ const MatchResults = observer(() => { )} diff --git a/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx b/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx new file mode 100644 index 0000000000..f783fe7233 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx @@ -0,0 +1,153 @@ +import React from "react"; +import { Modal, Form, Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +const CreateNewIndividualModal = ({ + show, + onHide, + encounterId, + newIndividualName, + onNameChange, + onConfirm, + loading, + themeColor, + identificationRemarks = [], +}) => { + const suggestedId = Math.floor(Math.random() * 90000) + 10000; + const [selectedRemark, setSelectedRemark] = React.useState(""); + + React.useEffect(() => { + if (!show) { + setSelectedRemark(""); + } + }, [show]); + + const handleConfirm = () => { + onConfirm(selectedRemark); + }; + + return ( + + + + + + + + +

+ {" "} + + {encounterId} + +

+ +
+ + + + + setSelectedRemark(e.target.value)} + > + + {identificationRemarks.map((remark, index) => ( + + ))} + + + + + + + + onNameChange(e.target.value)} + placeholder="Enter name" + /> + + +
+ + + : {suggestedId} + {" "} + +
+ + {/* + + + + + */} +
+
+ + + + + +
+ ); +}; + +export default CreateNewIndividualModal; diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index eebd8d6bd6..8f88105429 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -1,8 +1,10 @@ -import React from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import { FormattedMessage } from "react-intl"; -import { Form, Spinner } from "react-bootstrap"; +import { Spinner } from "react-bootstrap"; import MainButton from "../../../components/MainButton"; +import CreateNewIndividualModal from "./CreateNewIndividualModal"; +import NewIndividualCreatedModal from "./NewIndividualCreatedModal"; const styles = { bottomBar: (themeColor) => ({ @@ -28,333 +30,362 @@ const styles = { }, }; -const MatchResultsBottomBar = observer(({ store, themeColor }) => { - const matchingState = store.matchingState; +const MatchResultsBottomBar = observer( + ({ store, themeColor, identificationRemarks }) => { + const matchingState = store.matchingState; + const [showCreateModal, setShowCreateModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); - const getActionContent = () => { - switch (matchingState) { - case "no_individuals": { - const encId = store.encounterId || ""; - const shortEncId = encId.slice(0, 5); + const handleCreateNewIndividual = async (selectedRemark) => { + const result = await store.handleCreateNewIndividual(selectedRemark); - const left = ( -
- {" "} - {store.individualDisplayName ? ( - - {store.individualDisplayName} - - ) : ( - - {shortEncId} - - )}{" "} - -
- ); + if (result?.ok) { + setShowCreateModal(false); + setShowSuccessModal(true); + } else { + console.error("Failed to create new individual:", result?.error); + } + }; - const right = ( - <> - store.setNewIndividualName(e.target.value)} - size="sm" - style={{ width: 280 }} - /> - - - {store.matchRequestLoading && ( - - - ); + const getActionContent = () => { + switch (matchingState) { + case "no_individuals": { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); - return { left, right }; - } + const left = ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {"encounter"} {shortEncId} + + )}{" "} + +
+ ); - case "single_individual": { - const all = store.selectedIncludingQuery || []; - const individualItem = all.find((x) => x?.individualId); + const right = ( + <> + setShowCreateModal(true)} + disabled={store.matchRequestLoading} + style={{ marginTop: 0, marginBottom: 0 }} + > + + {store.matchRequestLoading && ( + + + ); - const individualName = - (individualItem?.encounterId === store.encounterId - ? store.individualDisplayName - : null) || - individualItem?.individualDisplayName || - individualItem?.individualId || - ""; + return { left, right }; + } - const encounterNum = all.filter( - (x) => x?.encounterId && !x?.individualId, - ).length; + case "single_individual": { + const all = store.selectedIncludingQuery || []; + const individualItem = all.find((x) => x?.individualId); - return { - left: ( -
- {" "} - x?.encounterId && !x?.individualId, + ).length; + + return { + left: ( +
+ {" "} + + {individualName} + + {encounterNum > 0 && ( + <> + {" "} + + + )} +
+ ), + right: ( + - {individualName} - - {encounterNum > 0 && ( - <> - {" "} - + {store.matchRequestLoading && ( +
- ), - right: ( - - - {store.matchRequestLoading && ( - - ), - }; - } + )} +
+ ), + }; + } - case "two_individuals": { - const all = store.selectedIncludingQuery || []; - const individualsRaw = all.filter((x) => x?.individualId); - const individuals = Array.from( - new Map(individualsRaw.map((x) => [x.individualId, x])).values(), - ); + case "two_individuals": { + const all = store.selectedIncludingQuery || []; + const individualsRaw = all.filter((x) => x?.individualId); + const individuals = Array.from( + new Map(individualsRaw.map((x) => [x.individualId, x])).values(), + ); - individuals.sort((x) => - x?.encounterId === store.encounterId ? -1 : 1, - ); + individuals.sort((x) => + x?.encounterId === store.encounterId ? -1 : 1, + ); - const a = individuals[0]; - const b = individuals[1]; + const a = individuals[0]; + const b = individuals[1]; - const nameA = (a?.encounterId === store.encounterId - ? store.individualDisplayName - : null) || - a?.individualDisplayName || - a?.individualId || ; + const nameA = (a?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + a?.individualDisplayName || + a?.individualId || ; - const nameB = (b?.encounterId === store.encounterId - ? store.individualDisplayName - : null) || - b?.individualDisplayName || - b?.individualId || ; + const nameB = (b?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + b?.individualDisplayName || + b?.individualId || ; - const encounters = all.filter( - (x) => x?.encounterId && !x?.individualId, - ); + const encounters = all.filter( + (x) => x?.encounterId && !x?.individualId, + ); - return { - left: ( -
- {" "} - - {nameA} - {" "} - {" "} - + {" "} + + {nameA} + {" "} + {" "} + + {nameB} + + {encounters.length > 0 && ( + <> + {" "} + + + )} +
+ ), + right: ( + - {nameB} - - {encounters.length > 0 && ( - <> - {" "} - + {store.matchRequestLoading && ( +
- ), - right: ( - - - {store.matchRequestLoading && ( - - ), - }; - } + )} + + ), + }; + } - case "too_many_individuals": - return { - left: ( -
- -
- ), - right: null, - }; + case "too_many_individuals": + return { + left: ( +
+ +
+ ), + right: null, + }; - case "no_further_action_needed": - if (store.selectedMatch.length === 0) { - const encId = store.encounterId || ""; - const shortEncId = encId.slice(0, 5); + case "no_further_action_needed": + if (store.selectedMatch.length === 0) { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); + + return { + left: ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {"encounter"} {shortEncId} + + )} +
+ ), + right: null, + }; + } return { left: (
- {" "} - {store.individualDisplayName ? ( - - {store.individualDisplayName} - - ) : ( - - {shortEncId} - - )} +
), right: null, }; - } - return { - left: ( -
- -
- ), - right: null, - }; + default: + return { left: null, right: null }; + } + }; - default: - return { left: null, right: null }; - } - }; + const { left, right } = getActionContent(); - const { left, right } = getActionContent(); + return ( + <> +
+
+
+ {left} +
- return ( -
-
-
- {left} +
+ {right} + window.close()} + style={{ marginTop: 0, marginBottom: 0 }} + > + + +
+
-
- {right} - window.close()} - style={{ marginTop: 0, marginBottom: 0 }} - > - - -
-
-
- ); -}); + setShowCreateModal(false)} + encounterId={store.encounterId} + newIndividualName={store.newIndividualName} + onNameChange={store.setNewIndividualName} + onConfirm={handleCreateNewIndividual} + loading={store.matchRequestLoading} + themeColor={themeColor} + identificationRemarks={identificationRemarks} + /> + + { + setShowSuccessModal(false); + window.location.reload(); + }} + encounterId={store.encounterId} + individualName={store.newIndividualName} + themeColor={themeColor} + /> + + ); + }, +); export default MatchResultsBottomBar; diff --git a/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx b/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx new file mode 100644 index 0000000000..ff48e3be9e --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +const NewIndividualCreatedModal = ({ + show, + onHide, + encounterId, + individualName, + themeColor, +}) => { + return ( + + + + + + + + +

+ {" "} + + {encounterId} + {" "} + {individualName}. +

+
+ + + + +
+ ); +}; + +export default NewIndividualCreatedModal; diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js index 87b4b6bacd..c3d033b533 100644 --- a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -304,6 +304,61 @@ export default class MatchResultsStore { this._newIndividualName = name; } + async handleCreateNewIndividual(selectedRemark) { + this._matchRequestLoading = true; + this._matchRequestError = null; + + try { + const newName = (this._newIndividualName || "").trim(); + if (!newName) { + this._matchRequestError = "ENTER_INDIVIDUAL_NAME"; + return { ok: false, error: "ENTER_INDIVIDUAL_NAME" }; + } + + const encounterIds = Array.from( + new Set( + this.selectedIncludingQuery + .filter((m) => !m.individualId) + .map((m) => m.encounterId), + ), + ); + + const patchOps = [ + { op: "replace", path: "individualId", value: newName }, + ]; + + if (selectedRemark && selectedRemark.trim() !== "") { + patchOps.push({ + op: "replace", + path: "identificationRemarks", + value: selectedRemark, + }); + } + + for (const id of encounterIds) { + await axios.patch( + `/api/v3/encounters/${encodeURIComponent(id)}`, + patchOps, + { + headers: { + "Content-Type": "application/json-patch+json", + Accept: "application/json", + }, + }, + ); + } + + this.resetSelectionToQuery(); + return { ok: true }; + } catch (e) { + console.error(e); + this._matchRequestError = "CREATE_NEW_INDIVIDUAL_FAILED"; + return { ok: false, error: "CREATE_NEW_INDIVIDUAL_FAILED" }; + } finally { + this._matchRequestLoading = false; + } + } + setSelectedMatch( selected, key, From 8074b5bc98a72b7b9b1fa9d3e5da3f5e9e47f918 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 23 Jan 2026 19:14:23 +0000 Subject: [PATCH 085/192] add confirm match modal --- .../components/MatchConfirmedModal.jsx | 85 +++++++++++++++++++ .../components/MatchResultsBottomBar.jsx | 47 +++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx diff --git a/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx b/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx new file mode 100644 index 0000000000..d8db00477a --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +const MatchConfirmedModal = ({ + show, + onHide, + encounterId, + encounterCount, + individualId, + individualName, + themeColor, +}) => { + const handleClose = () => { + onHide(); + window.location.reload(); + }; + + return ( + + + + + + + + +

+ {encounterCount > 0 ? ( + <> + {" "} + + ) : ( + <> + {" "} + + {encounterId} + {" "} + {" "} + + )} + + {individualName || individualId} + +

+
+ + + + +
+ ); +}; + +export default MatchConfirmedModal; diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 8f88105429..fe683d70dc 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -5,6 +5,7 @@ import { Spinner } from "react-bootstrap"; import MainButton from "../../../components/MainButton"; import CreateNewIndividualModal from "./CreateNewIndividualModal"; import NewIndividualCreatedModal from "./NewIndividualCreatedModal"; +import MatchConfirmedModal from "./MatchConfirmedModal"; const styles = { bottomBar: (themeColor) => ({ @@ -35,6 +36,9 @@ const MatchResultsBottomBar = observer( const matchingState = store.matchingState; const [showCreateModal, setShowCreateModal] = useState(false); const [showSuccessModal, setShowSuccessModal] = useState(false); + const [showMatchConfirmedModal, setShowMatchConfirmedModal] = + useState(false); + const [matchConfirmedData, setMatchConfirmedData] = useState(null); const handleCreateNewIndividual = async (selectedRemark) => { const result = await store.handleCreateNewIndividual(selectedRemark); @@ -47,6 +51,32 @@ const MatchResultsBottomBar = observer( } }; + const handleMatch = async () => { + const all = store.selectedIncludingQuery || []; + const individualItem = all.find((x) => x?.individualId); + + const encounterCount = all.filter( + (x) => x?.encounterId && !x?.individualId, + ).length; + + const modalData = { + encounterId: store.encounterId, + encounterCount, + individualId: individualItem?.individualId, + individualName: + individualItem?.individualDisplayName || individualItem?.individualId, + }; + + const result = await store.handleMatch(); + + if (result) { + setMatchConfirmedData(modalData); + setShowMatchConfirmedModal(true); + } else { + console.error("Match failed"); + } + }; + const getActionContent = () => { switch (matchingState) { case "no_individuals": { @@ -154,7 +184,7 @@ const MatchResultsBottomBar = observer( noArrow backgroundColor={themeColor.primaryColors.primary500} color="white" - onClick={store.handleMatch} + onClick={handleMatch} disabled={store.matchRequestLoading} style={{ marginTop: 0, marginBottom: 0 }} > @@ -383,6 +413,21 @@ const MatchResultsBottomBar = observer( individualName={store.newIndividualName} themeColor={themeColor} /> + + {matchConfirmedData && ( + { + setShowMatchConfirmedModal(false); + setMatchConfirmedData(null); + }} + encounterId={matchConfirmedData.encounterId} + encounterCount={matchConfirmedData.encounterCount} + individualId={matchConfirmedData.individualId} + individualName={matchConfirmedData.individualName} + themeColor={themeColor} + /> + )} ); }, From 9f8e73abf2a7d8dee4303ed151f64c4d586c0de1 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 23 Jan 2026 22:10:15 +0000 Subject: [PATCH 086/192] UI polish and i18n --- frontend/src/locale/de.json | 20 +++++- frontend/src/locale/en.json | 23 ++++++- frontend/src/locale/es.json | 20 +++++- frontend/src/locale/fr.json | 20 +++++- frontend/src/locale/it.json | 20 +++++- .../components/InspectorModal.jsx | 18 +++--- .../components/MatchCriteriaDrawer.jsx | 28 +++++++-- .../components/MatchProspectTable.jsx | 61 +++++++++++++------ .../components/MatchResultsBottomBar.jsx | 13 ++-- 9 files changed, 180 insertions(+), 43 deletions(-) diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index cc99a24914..9ed48ea7b3 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -747,5 +747,23 @@ "AND": "und", "AND_N_ENCOUNTERS": "und {count, plural, one {# Begegnung} other {# Begegnungen}}", "INDIVIDUAL_A": "Individuum A", - "INDIVIDUAL_B": "Individuum B" + "INDIVIDUAL_B": "Individuum B", + "CREATE_NEW_INDIVIDUAL": "Neues Individuum Erstellen", + "CREATE_NEW_INDIVIDUAL_DESCRIPTION": "Erstellen Sie ein neues Individuum und weisen Sie einen neuen Namen für die Begegnung zu", + "SELECT_MATCH_METHOD": "Abgleichsmethode auswählen", + "NEW_INDIVIDUAL_ID": "Neue Individuum-ID", + "SUGGESTED_ID": "Vorgeschlagene ID", + "USE_THIS": "Dies Verwenden", + "TASK_ID": "Aufgaben-ID", + "NEW_INDIVIDUAL_CREATED": "Neues Individuum Erstellt", + "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL": "Begegnung Zugewiesen", + "AS_NEW_INDIVIDUAL": "als neues Individuum", + "MATCH_CONFIRMED": "Übereinstimmung Bestätigt!", + "YOU_MERGED_N_ENCOUNTERS": "Sie haben {count} Begegnung(en) mit dem Individuum zusammengeführt", + "YOU_MATCHED_ENCOUNTER": "Sie haben die Begegnung abgeglichen", + "WITH_INDIVIDUAL": "mit dem Individuum", + "NO_FILTER_SET_FOR_TASK": "Keine Filter wurden auf diese Aufgabe angewendet", + "FILTER_SET_FOR_TASK": "Unten sind die auf diese Aufgabe angewendeten Filter", + "LOCATION_IDS": "Standort-IDs", + "OR_CHOOSE_FROM_RESULTS_BELOW": "oder wählen Sie aus den Ergebnissen unten" } \ No newline at end of file diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index c21cd05f7e..a046bb60a4 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -736,8 +736,7 @@ "ZOOM_OUT": "Zoom Out", "WILDBOOK_DOCUMENTATION": "Wildbook Documentation", "SET_MATCH_FOR": "Set match for", - "NO_FURTHER_ACTION_NEEDED": "No further action needed", - "OR": "or", + "NO_FURTHER_ACTION_NEEDED": "No further action needed", "MARK_AS_NEW_INDIVIDUAL": "Mark As New Individual", "CONFIRM_MATCH": "Confrim Match", "MERGE_INDIVIDUALS": "Merge Individuals", @@ -747,5 +746,23 @@ "AND": "and", "AND_N_ENCOUNTERS": "and {count, plural, one {# encounter} other {# encounters}}", "INDIVIDUAL_A": "Individual A", - "INDIVIDUAL_B": "Individual B" + "INDIVIDUAL_B": "Individual B", + "CREATE_NEW_INDIVIDUAL": "Create New Individual", + "CREATE_NEW_INDIVIDUAL_DESCRIPTION": "Create a new individual and assign a new name for Encounter", + "SELECT_MATCH_METHOD": "Select match method", + "NEW_INDIVIDUAL_ID": "New Individual ID", + "SUGGESTED_ID": "Suggested ID", + "USE_THIS": "Use This", + "TASK_ID": "Task ID", + "NEW_INDIVIDUAL_CREATED": "New Individual Created", + "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL": "Assigned Encounter", + "AS_NEW_INDIVIDUAL": "as new individual", + "MATCH_CONFIRMED": "Match Confirmed!", + "YOU_MERGED_N_ENCOUNTERS": "You merged {count} encounter(s) to Individual", + "YOU_MATCHED_ENCOUNTER": "You matched Encounter", + "WITH_INDIVIDUAL": "with Individual", + "NO_FILTER_SET_FOR_TASK": "No filters were applied to this task", + "FILTER_SET_FOR_TASK": "Below are filters applied to this task", + "LOCATION_IDS": "Location IDs", + "OR_CHOOSE_FROM_RESULTS_BELOW": "or choose from results below" } \ No newline at end of file diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 3344533c0c..26aba17c97 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -746,5 +746,23 @@ "AND": "y", "AND_N_ENCOUNTERS": "y {count, plural, one {# encuentro} other {# encuentros}}", "INDIVIDUAL_A": "Individuo A", - "INDIVIDUAL_B": "Individuo B" + "INDIVIDUAL_B": "Individuo B", + "CREATE_NEW_INDIVIDUAL": "Crear Nuevo Individual", + "CREATE_NEW_INDIVIDUAL_DESCRIPTION": "Crear un nuevo individual y asignar un nuevo nombre para el Encuentro", + "SELECT_MATCH_METHOD": "Seleccionar método de coincidencia", + "NEW_INDIVIDUAL_ID": "ID de Nuevo Individual", + "SUGGESTED_ID": "ID Sugerido", + "USE_THIS": "Usar Este", + "TASK_ID": "ID de Tarea", + "NEW_INDIVIDUAL_CREATED": "Nuevo Individual Creado", + "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL": "Encuentro Asignado", + "AS_NEW_INDIVIDUAL": "como nuevo individual", + "MATCH_CONFIRMED": "¡Coincidencia Confirmada!", + "YOU_MERGED_N_ENCOUNTERS": "Fusionó {count} encuentro(s) con el Individual", + "YOU_MATCHED_ENCOUNTER": "Coincidió el Encuentro", + "WITH_INDIVIDUAL": "con el Individual", + "NO_FILTER_SET_FOR_TASK": "No se aplicaron filtros a esta tarea", + "FILTER_SET_FOR_TASK": "A continuación se muestran los filtros aplicados a esta tarea", + "LOCATION_IDS": "IDs de ubicación", + "OR_CHOOSE_FROM_RESULTS_BELOW": "o elija de los resultados a continuación" } \ No newline at end of file diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index c072ae59d6..b41f6f3287 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -746,5 +746,23 @@ "AND": "et", "AND_N_ENCOUNTERS": "et {count, plural, one {# rencontre} other {# rencontres}}", "INDIVIDUAL_A": "Individu A", - "INDIVIDUAL_B": "Individu B" + "INDIVIDUAL_B": "Individu B", + "CREATE_NEW_INDIVIDUAL": "Créer un Nouvel Individu", + "CREATE_NEW_INDIVIDUAL_DESCRIPTION": "Créer un nouvel individu et attribuer un nouveau nom pour l'Observation", + "SELECT_MATCH_METHOD": "Sélectionner la méthode de correspondance", + "NEW_INDIVIDUAL_ID": "ID du Nouvel Individu", + "SUGGESTED_ID": "ID Suggéré", + "USE_THIS": "Utiliser Ceci", + "TASK_ID": "ID de Tâche", + "NEW_INDIVIDUAL_CREATED": "Nouvel Individu Créé", + "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL": "Observation Attribuée", + "AS_NEW_INDIVIDUAL": "comme nouvel individu", + "MATCH_CONFIRMED": "Correspondance Confirmée !", + "YOU_MERGED_N_ENCOUNTERS": "Vous avez fusionné {count} observation(s) avec l'Individu", + "YOU_MATCHED_ENCOUNTER": "Vous avez apparié l'Observation", + "WITH_INDIVIDUAL": "avec l'Individu", + "NO_FILTER_SET_FOR_TASK": "Aucun filtre n'a été appliqué à cette tâche", + "FILTER_SET_FOR_TASK": "Ci-dessous les filtres appliqués à cette tâche", + "LOCATION_IDS": "IDs de localisation", + "OR_CHOOSE_FROM_RESULTS_BELOW": "ou choisissez parmi les résultats ci-dessous" } \ No newline at end of file diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index 2f2a707acd..f04e18ed71 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -746,5 +746,23 @@ "AND": "e", "AND_N_ENCOUNTERS": "e {count, plural, one {# incontro} other {# incontri}}", "INDIVIDUAL_A": "Individuo A", - "INDIVIDUAL_B": "Individuo B" + "INDIVIDUAL_B": "Individuo B", + "CREATE_NEW_INDIVIDUAL": "Crea Nuovo Individuo", + "CREATE_NEW_INDIVIDUAL_DESCRIPTION": "Crea un nuovo individuo e assegna un nuovo nome per l'Avvistamento", + "SELECT_MATCH_METHOD": "Seleziona metodo di corrispondenza", + "NEW_INDIVIDUAL_ID": "ID Nuovo Individuo", + "SUGGESTED_ID": "ID Suggerito", + "USE_THIS": "Usa Questo", + "TASK_ID": "ID Attività", + "NEW_INDIVIDUAL_CREATED": "Nuovo Individuo Creato", + "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL": "Avvistamento Assegnato", + "AS_NEW_INDIVIDUAL": "come nuovo individuo", + "MATCH_CONFIRMED": "Corrispondenza Confermata!", + "YOU_MERGED_N_ENCOUNTERS": "Hai unito {count} avvistamento/i all'Individuo", + "YOU_MATCHED_ENCOUNTER": "Hai abbinato l'Avvistamento", + "WITH_INDIVIDUAL": "con l'Individuo", + "NO_FILTER_SET_FOR_TASK": "Nessun filtro è stato applicato a questa attività", + "FILTER_SET_FOR_TASK": "Di seguito sono riportati i filtri applicati a questa attività", + "LOCATION_IDS": "ID località", + "OR_CHOOSE_FROM_RESULTS_BELOW": "o scegli dai risultati qui sotto" } \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx index 8e7684f98b..cdd98635cd 100644 --- a/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx +++ b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx @@ -120,14 +120,16 @@ export default function InspectorModal({
- +
+ +
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx index c48a2b5f81..0a1221c693 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx @@ -1,5 +1,6 @@ import React from "react"; import { Offcanvas } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; export default function MatchCriteriaDrawer({ show, onHide, filter }) { return ( @@ -10,20 +11,35 @@ export default function MatchCriteriaDrawer({ show, onHide, filter }) { style={{ borderTopLeftRadius: 14, borderBottomLeftRadius: 14, - overflow: "hidden", }} > - Match Criteria + + + - +
+ { +
+ +
+ } {filter?.locationIds && filter?.locationIds.length > 0 && ( -
Location IDs: {filter?.locationIds?.join(", ")}
+
+ :{" "} + {filter?.locationIds?.join(", ")} +
+ )} + {filter?.owner && ( +
+ : {filter?.owner} +
)} - {filter?.owner &&
Owner: filter?.owner
} {!filter?.owner && !filter?.locationIds && ( -
no filter set for this task
+
+ +
)}
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 5f2327b460..0442f754d9 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -87,6 +87,13 @@ const styles = { borderRadius: "8px", cursor: "pointer", }, + iconButtonDisabled: { + width: "32px", + height: "32px", + borderRadius: "8px", + cursor: "not-allowed", + opacity: 0.4, + }, matchListScrollContainer: { overflowX: "auto", overflowY: "hidden", @@ -455,15 +462,23 @@ const MatchProspectTable = ({ >
- {inspectorUrl && ( -
setInspectorOpen(true)} - > - -
- )} +
{ + if (inspectorUrl) { + setInspectorOpen(true); + } + }} + > + +
- {inspectorUrl && ( -
setInspectorOpen(true)} - > - -
- )} +
{ + if (inspectorUrl) { + setInspectorOpen(true); + } + }} + > + +
)}{" "} - +
); @@ -367,7 +368,11 @@ const MatchResultsBottomBar = observer( >
{left}
From 27c53b55414214202f860a89658cfda30771ec43 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Mon, 26 Jan 2026 15:34:34 +0000 Subject: [PATCH 087/192] add encounter button --- .../components/MatchProspectTable.jsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 0442f754d9..a55dcc5204 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -9,6 +9,7 @@ import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay" import { FormattedMessage, useIntl } from "react-intl"; import InspectorModal from "./InspectorModal"; import ExitFullScreenIcon from "../icons/ExitFullScreenIcon"; +import ExitIcon from "../../../components/icons/ExitIcon"; const styles = { matchRow: (selected, themeColor) => ({ @@ -36,6 +37,17 @@ const styles = { background: themeColor.wildMeColors.teal100, color: themeColor.wildMeColors.teal800, }), + encounterButton: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "0.8rem", + background: themeColor.primaryColors.primary500, + color: "white", + display: "flex", + alignItems: "center", + gap: "4px", + }), matchImageCard: { position: "relative", borderRadius: "8px", @@ -359,6 +371,20 @@ const MatchProspectTable = ({ {candidateIndividualDisplayName} + +
Date: Mon, 26 Jan 2026 15:35:03 +0000 Subject: [PATCH 088/192] add an icon --- frontend/src/components/icons/ExitIcon.jsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 frontend/src/components/icons/ExitIcon.jsx diff --git a/frontend/src/components/icons/ExitIcon.jsx b/frontend/src/components/icons/ExitIcon.jsx new file mode 100644 index 0000000000..816deffef8 --- /dev/null +++ b/frontend/src/components/icons/ExitIcon.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +export default function EditIcon({ width = 16, height = 16 }) { + return ( +
+ + + +
+ ); +} From 13eb15b551bb7d4100d8cbd4a7f3d1e8f0fc0be0 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 28 Jan 2026 12:24:42 -0700 Subject: [PATCH 089/192] apparently we dont really need a shepherd here --- src/main/java/org/ecocean/ia/MatchResult.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index 955db909d6..adb0de8df8 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -64,13 +64,13 @@ public MatchResult(Task task, JSONObject jsonResult, Shepherd myShepherd) this.createFromJsonResult(jsonResult, myShepherd); } - // FIXME will need scores and other stuff here? - public MatchResult(Task task, List annots, int numberCandidates, - Shepherd myShepherd) + // FIXME will need scores and other stuff here? currently scores from vectors all + // seem to be 0.0 but the ordering seems best-to-worst, so we skip scores for now + public MatchResult(Task task, List annots, int numberCandidates) throws IOException { this(); this.task = task; - this.createFromProspectAnnotations(annots, numberCandidates, myShepherd); + this.createFromProspectAnnotations(annots, numberCandidates); } public int getNumberCandidates() { @@ -93,9 +93,8 @@ public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd mySh } // this is for vector-based list of matches (annots) - // TODO FIXME also list of scores of prospects (vector hit?) - public void createFromProspectAnnotations(List annots, int numberCandidates, - Shepherd myShepherd) + // scores skipped for now: see note on MatchResult() constructor above + public void createFromProspectAnnotations(List annots, int numberCandidates) throws IOException { this.numberCandidates = numberCandidates; this.populateProspects(annots); @@ -173,7 +172,7 @@ private int populateProspects(List annots) if (this.prospects == null) this.prospects = new HashSet(); for (Annotation ann : annots) { - // FIXME what is score for vectors??? + // seems vector scoring is all 0.0 for now // inspect asset is null for vector matching i guess? this.prospects.add(new MatchResultProspect(ann, 0.0d, "annot", null)); } From cefa9037d6d218cd10357cb8d07073012f161cd0 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Wed, 28 Jan 2026 12:25:29 -0700 Subject: [PATCH 090/192] test for vector/annot based MatchResult() creation --- .../java/org/ecocean/MatchResultTest.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/ecocean/MatchResultTest.java b/src/test/java/org/ecocean/MatchResultTest.java index b172208f3c..66ea481b8d 100644 --- a/src/test/java/org/ecocean/MatchResultTest.java +++ b/src/test/java/org/ecocean/MatchResultTest.java @@ -30,7 +30,7 @@ import static org.mockito.Mockito.when; class MatchResultTest { - @Test void testMatchResult() + @Test void testMatchResultClassic() throws IOException { Task task = mock(Task.class); MatchResult mr = new MatchResult(task); @@ -75,6 +75,24 @@ class MatchResultTest { assertTrue(full.getInt("numberCandidates") == 3); } + // annotation-list style creation + @Test void testMatchResultVector() + throws IOException { + Task task = mock(Task.class); + int numCand = 99; + Annotation ann = mock(Annotation.class); + ArrayList annList = new ArrayList(); + + annList.add(ann); + + MatchResult mr = new MatchResult(task, annList, numCand); + assertTrue(mr.getNumberCandidates() == numCand); + assertTrue(mr.numberProspects() == 1); + // FIXME someday we need to figure out indiv-vector-search + // assertTrue(mr.prospectScoreTypes().contains("indiv")); + assertTrue(mr.prospectScoreTypes().contains("annot")); + } + @Test void basicMatchResultProspect() { MatchResultProspect mrp = new MatchResultProspect(null, 1.0, "test", null); From 125b909c7cc848239709a66e4a11a2215ced7081 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 28 Jan 2026 23:16:22 +0000 Subject: [PATCH 091/192] a few UI updates --- frontend/src/locale/en.json | 2 +- .../pages/MatchResultsPage/MatchResults.jsx | 57 ++++++++++++++----- .../components/MatchProspectTable.jsx | 34 +++++------ .../components/MatchResultsBottomBar.jsx | 33 ++++++++++- 4 files changed, 87 insertions(+), 39 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index a046bb60a4..bdda49d1b4 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -738,7 +738,7 @@ "SET_MATCH_FOR": "Set match for", "NO_FURTHER_ACTION_NEEDED": "No further action needed", "MARK_AS_NEW_INDIVIDUAL": "Mark As New Individual", - "CONFIRM_MATCH": "Confrim Match", + "CONFIRM_MATCH": "Confirm Match", "MERGE_INDIVIDUALS": "Merge Individuals", "CANNOT_MERGE_MORE_THAN_TWO": "Cannot merge more than two individuals", "MERGE": "Merge", diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 624fd2319b..be50a53cb1 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -134,21 +134,48 @@ const MatchResults = observer(() => { - { - store.setNumResults(Number(e.target.value)); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - store.fetchMatchResults(); - } - }} - style={{ width: "80px" }} - /> +
+ { + const val = e.target.value; + if (/^\d*$/.test(val)) { + store.setNumResults(val === "" ? 1 : Number(val)); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + store.fetchMatchResults(); + } + }} + style={{ + width: "100%", + paddingRight: "30px", + }} + /> + +
diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index a55dcc5204..0a39390a03 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -77,18 +77,16 @@ const styles = { }), toolsBarLeft: { position: "absolute", - top: "50%", + top: "0", left: "-40px", - transform: "translateY(-50%)", display: "flex", flexDirection: "column", gap: "6px", }, toolsBarRight: { position: "absolute", - top: "50%", + top: "0", right: "-40px", - transform: "translateY(-50%)", display: "flex", flexDirection: "column", gap: "6px", @@ -241,11 +239,7 @@ const MatchProspectTable = ({ ]; }, [previewedRow]); - const rightImageUrl = - previewedRow?.annotation?.asset?.url?.replace( - "http://frontend.scribble.com", - "https://zebra.wildme.org", - ) || ""; + const rightImageUrl = previewedRow?.annotation?.asset?.url; const leftOrigW = thisEncounterImageAsset?.attributes?.width ?? @@ -264,12 +258,7 @@ const MatchProspectTable = ({ previewedRow?.annotation?.asset?.height ?? previewedRow?.annotation?.asset?.attributes?.height; - // +++++++++ temporary workaround +++++++++ - const leftImageUrl = - (thisEncounterImageUrl || "").replace( - "http://frontend.scribble.com", - "https://zebra.wildme.org", - ) || ""; + const leftImageUrl = thisEncounterImageUrl; const openFullscreen = () => { setFullscreenOpen(true); @@ -352,7 +341,10 @@ const MatchProspectTable = ({ href={`/react/encounter?number=${candidateEncounterId}`} target="_blank" rel="noopener noreferrer" - style={{ textDecoration: "none" }} + style={{ + textDecoration: "none", + color: themeColor.primaryColors.primary500, + }} onClick={(e) => e.stopPropagation()} > {(Math.trunc(candidate.score * 10000) / 10000).toFixed(4)} @@ -381,7 +373,7 @@ const MatchProspectTable = ({ window.open(url, "_blank"); }} > - + @@ -430,7 +422,6 @@ const MatchProspectTable = ({ originalWidth={leftOrigW} originalHeight={leftOrigH} annotations={leftAnnotations} - showAnnotations rotationInfo={leftRotationInfo} />
@@ -509,7 +500,10 @@ const MatchProspectTable = ({
rightOverlayRef.current?.toggleAnnotations?.()} + onClick={() => { + rightOverlayRef.current?.toggleAnnotations?.(); + leftOverlayRef.current?.toggleAnnotations?.(); + }} >
@@ -567,7 +561,6 @@ const MatchProspectTable = ({ originalWidth={leftOrigW} originalHeight={leftOrigH} annotations={leftAnnotations} - showAnnotations rotationInfo={leftRotationInfo} />
@@ -618,6 +611,7 @@ const MatchProspectTable = ({ title="View Annotations" onClick={() => { fsRightRef.current?.toggleAnnotations?.(); + fsLeftRef.current?.toggleAnnotations?.(); }} > diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx index 1169f7e7fd..2a6d3cecd5 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -27,7 +27,7 @@ const styles = { }, warningText: { color: "#dc3545", - fontSize: "0.9rem", + fontSize: "1.2rem", fontWeight: "500", }, }; @@ -95,6 +95,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {store.individualDisplayName} @@ -104,6 +107,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {"encounter"} {shortEncId} @@ -166,6 +172,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {individualName} @@ -245,6 +254,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {nameA} {" "} @@ -256,6 +268,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {nameB} @@ -298,8 +313,14 @@ const MatchResultsBottomBar = observer( return { left: (
@@ -324,6 +345,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {store.individualDisplayName} @@ -333,6 +357,9 @@ const MatchResultsBottomBar = observer( target="_blank" rel="noopener noreferrer" className="text-decoration-none" + style={{ + color: themeColor.primaryColors.primary500, + }} > {"encounter"} {shortEncId} From 152ca8ccf53e65998f19fd8211a6948a39398bc3 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Wed, 28 Jan 2026 23:22:34 +0000 Subject: [PATCH 092/192] larger font size --- .../MatchResultsPage/components/MatchProspectTable.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx index 0a39390a03..9eb3025aaf 100644 --- a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -17,7 +17,7 @@ const styles = { alignItems: "center", gap: "8px", padding: "6px 10px", - fontSize: "0.9rem", + fontSize: "1rem", marginTop: "4px", borderRadius: "5px", backgroundColor: selected @@ -33,7 +33,7 @@ const styles = { borderRadius: "5px", border: "none", padding: "2px 10px", - fontSize: "0.8rem", + fontSize: "1rem", background: themeColor.wildMeColors.teal100, color: themeColor.wildMeColors.teal800, }), @@ -41,7 +41,7 @@ const styles = { borderRadius: "5px", border: "none", padding: "2px 10px", - fontSize: "0.8rem", + fontSize: "1rem", background: themeColor.primaryColors.primary500, color: "white", display: "flex", @@ -72,7 +72,7 @@ const styles = { color: themeColor.wildMeColors.teal800, padding: "2px 8px", borderRadius: "2px", - fontSize: "0.75rem", + fontSize: "1rem", zIndex: 10, }), toolsBarLeft: { From 57b92c65bfe094bddc026d64c724584337ff3311 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 29 Jan 2026 11:33:30 -0700 Subject: [PATCH 093/192] guess we need a queryAnnotation --- src/main/java/org/ecocean/ia/MatchResult.java | 15 +++++++++++++++ src/test/java/org/ecocean/MatchResultTest.java | 3 +++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java index adb0de8df8..94a757d857 100644 --- a/src/main/java/org/ecocean/ia/MatchResult.java +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -98,6 +98,21 @@ public void createFromProspectAnnotations(List annots, int numberCan throws IOException { this.numberCandidates = numberCandidates; this.populateProspects(annots); + this.setQueryAnnotationFromTask(); + } + + public Annotation setQueryAnnotationFromTask() + throws IOException { + if (this.task == null) + throw new IOException("setQueryAnnotationFromTask() failed as task is null"); + int numAnns = this.task.countObjectAnnotations(); + if (numAnns < 1) + throw new IOException("setQueryAnnotationFromTask() failed as task has no annotations"); + if (numAnns > 1) + System.out.println("WARNING: setQueryAnnotationFromTask() has " + numAnns + + " annotations; using first"); + this.queryAnnotation = this.task.getObjectAnnotations().get(0); + return this.queryAnnotation; } // json_result section should be passed here diff --git a/src/test/java/org/ecocean/MatchResultTest.java b/src/test/java/org/ecocean/MatchResultTest.java index 66ea481b8d..e0ea799940 100644 --- a/src/test/java/org/ecocean/MatchResultTest.java +++ b/src/test/java/org/ecocean/MatchResultTest.java @@ -79,11 +79,14 @@ class MatchResultTest { @Test void testMatchResultVector() throws IOException { Task task = mock(Task.class); + + when(task.countObjectAnnotations()).thenReturn(1); int numCand = 99; Annotation ann = mock(Annotation.class); ArrayList annList = new ArrayList(); annList.add(ann); + when(task.getObjectAnnotations()).thenReturn(annList); MatchResult mr = new MatchResult(task, annList, numCand); assertTrue(mr.getNumberCandidates() == numCand); From 1760b17a54ea75db0bfb81c034b7993c9aff7310 Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Thu, 29 Jan 2026 19:27:16 +0000 Subject: [PATCH 094/192] update /iaResults.jsp to /react/match-results --- frontend/src/__tests__/pages/Encounter/ImageCard.test.js | 4 ++-- frontend/src/__tests__/pages/Encounter/ImageModal.test.js | 2 +- .../src/__tests__/pages/Encounter/MatchCriteria.test.js | 2 +- .../__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js | 2 +- frontend/src/components/ImageModal.jsx | 2 +- frontend/src/components/home/PickUpWhereYouLeft.jsx | 2 +- frontend/src/pages/BulkImport/BulkImportTask.jsx | 2 +- frontend/src/pages/Encounter/ImageCard.jsx | 4 ++-- frontend/src/pages/Encounter/MatchCriteria.jsx | 2 +- frontend/src/pages/MatchResultsPage/MatchResults.jsx | 6 ++++++ src/main/java/org/ecocean/TwitterBot.java | 2 +- src/main/webapp/encounters/biologicalSamples.jsp | 2 +- src/main/webapp/encounters/encounter.jsp | 2 +- src/main/webapp/iaResults.jsp | 2 +- src/main/webapp/javascript/ia.IBEIS.js | 4 ++-- src/main/webapp/projects/project.jsp | 2 +- 16 files changed, 24 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/pages/Encounter/ImageCard.test.js b/frontend/src/__tests__/pages/Encounter/ImageCard.test.js index 03f4eb6da0..33c76bc14d 100644 --- a/frontend/src/__tests__/pages/Encounter/ImageCard.test.js +++ b/frontend/src/__tests__/pages/Encounter/ImageCard.test.js @@ -285,7 +285,7 @@ describe("ImageCard", () => { await user.click(screen.getByText("MATCH_RESULTS")); expect(window.open).toHaveBeenCalledTimes(1); const url = window.open.mock.calls[0][0]; - expect(url).toContain("/iaResults.jsp?taskId=TASK-99"); + expect(url).toContain("/react/match-results?taskId=TASK-99"); }); test("clicking MATCH_RESULTS on foreign encounter -> fetches encounter and may open", async () => { @@ -352,7 +352,7 @@ describe("ImageCard", () => { expect(window.open).toHaveBeenCalledTimes(1); }); const url = window.open.mock.calls[0][0]; - expect(url).toContain("/iaResults.jsp?taskId=TASK-FR-1"); + expect(url).toContain("/react/match-results?taskId=TASK-FR-1"); }); test("rects are cleared when encounter has no mediaAssets", () => { diff --git a/frontend/src/__tests__/pages/Encounter/ImageModal.test.js b/frontend/src/__tests__/pages/Encounter/ImageModal.test.js index b8568f8a98..c1c14a4ecb 100644 --- a/frontend/src/__tests__/pages/Encounter/ImageModal.test.js +++ b/frontend/src/__tests__/pages/Encounter/ImageModal.test.js @@ -251,7 +251,7 @@ describe("ImageModal", () => { fireEvent.click(matchBtn); expect(global.open).toHaveBeenCalledWith( - "/iaResults.jsp?taskId=task-123", + "/react/match-results?taskId=task-123", "_blank", ); }); diff --git a/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js b/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js index bbc79418c1..e23b1496a8 100644 --- a/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js +++ b/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js @@ -196,7 +196,7 @@ describe("MatchCriteriaModal", () => { await waitFor(() => { expect(store.newMatch.buildNewMatchPayload).toHaveBeenCalled(); expect(global.open).toHaveBeenCalledWith( - "/iaResults.jsp?taskId=t123", + "/react/match-results?taskId=t123", "_blank", ); expect(store.modals.setOpenMatchCriteriaModal).toHaveBeenCalledWith( diff --git a/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js b/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js index 13f3c0d26a..fbd02ca48b 100644 --- a/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js +++ b/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js @@ -76,7 +76,7 @@ describe("PickUp Component", () => { expect(latestMatchItem).toHaveTextContent( formatDate(mockData.latestMatchTask.dateTimeCreated, true), ); - expect(latestMatchItem).toHaveTextContent("/iaResults.jsp?taskId=123"); + expect(latestMatchItem).toHaveTextContent("/react/match-results?taskId=123"); }); test("generates the correct matchActionButtonUrl based on date", () => { diff --git a/frontend/src/components/ImageModal.jsx b/frontend/src/components/ImageModal.jsx index 38968f5623..818d742280 100644 --- a/frontend/src/components/ImageModal.jsx +++ b/frontend/src/components/ImageModal.jsx @@ -978,7 +978,7 @@ export const ImageModal = observer( const taskId = imageStore.encounterAnnotations.filter( (a) => a.id === imageStore.selectedAnnotationId, )?.[0]?.iaTaskId; - window.open(`/iaResults.jsp?taskId=${taskId}`, "_blank"); + window.open(`/react/match-results?taskId=${taskId}`, "_blank"); }} style={{ margin: "5px 0", diff --git a/frontend/src/components/home/PickUpWhereYouLeft.jsx b/frontend/src/components/home/PickUpWhereYouLeft.jsx index 516f614d5f..a936abeef1 100644 --- a/frontend/src/components/home/PickUpWhereYouLeft.jsx +++ b/frontend/src/components/home/PickUpWhereYouLeft.jsx @@ -13,7 +13,7 @@ const PickUp = ({ data }) => { const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); const matchActionButtonUrl = date > twoWeeksAgo - ? `/iaResults.jsp?taskId=${data?.latestMatchTask?.id}` + ? `/react/match-results?taskId=${data?.latestMatchTask?.id}` : `/encounters/encounter.jsp?number=${data?.latestMatchTask?.encounterId}`; return ( diff --git a/frontend/src/pages/BulkImport/BulkImportTask.jsx b/frontend/src/pages/BulkImport/BulkImportTask.jsx index 7d2aa2fcac..6a942c6996 100644 --- a/frontend/src/pages/BulkImport/BulkImportTask.jsx +++ b/frontend/src/pages/BulkImport/BulkImportTask.jsx @@ -207,7 +207,7 @@ const BulkImportTask = observer(() => { cell: (row) => { const arr = row.class; if (Array.isArray(arr) && arr.length === 3) { - const link = `/iaResults.jsp?taskId=${arr[0]}`; + const link = `/react/match-results?taskId=${arr[0]}`; return ( {arr[2]} {": "} diff --git a/frontend/src/pages/Encounter/ImageCard.jsx b/frontend/src/pages/Encounter/ImageCard.jsx index c56048d5fc..4e60280453 100644 --- a/frontend/src/pages/Encounter/ImageCard.jsx +++ b/frontend/src/pages/Encounter/ImageCard.jsx @@ -534,7 +534,7 @@ const ImageCard = observer(({ store = {} }) => { onClick={async () => { if (store.matchResultClickable) { const taskId = currentAnnotation?.iaTaskId; - const url = `/iaResults.jsp?taskId=${encodeURIComponent(taskId)}`; + const url = `/react/match-results?taskId=${encodeURIComponent(taskId)}`; window.open(url, "_blank", "noopener,noreferrer"); } else if ( clickedAnnotation && @@ -572,7 +572,7 @@ const ImageCard = observer(({ store = {} }) => { identActive && (detectionComplete || identificationStatus) ) { - const url = `/iaResults.jsp?taskId=${encodeURIComponent(selectedAnnotation.iaTaskId)}`; + const url = `/react/match-results?taskId=${encodeURIComponent(selectedAnnotation.iaTaskId)}`; window.open(url, "_blank", "noopener,noreferrer"); } else { alert("No match results available for this annotation."); diff --git a/frontend/src/pages/Encounter/MatchCriteria.jsx b/frontend/src/pages/Encounter/MatchCriteria.jsx index b4e29a51f1..cc83af0db3 100644 --- a/frontend/src/pages/Encounter/MatchCriteria.jsx +++ b/frontend/src/pages/Encounter/MatchCriteria.jsx @@ -102,7 +102,7 @@ export const MatchCriteriaModal = observer(function MatchCriteriaModal({ const result = await store.newMatch.buildNewMatchPayload(); console.log(JSON.stringify(result, null, 2)); if (result.status === 200) { - const url = `/iaResults.jsp?taskId=${result?.data?.taskId}`; + const url = `/react/match-results?taskId=${result?.data?.taskId}`; window.open(url, "_blank"); store.modals.setOpenMatchCriteriaModal(false); } else { diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index be50a53cb1..db86fc82ce 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -20,10 +20,16 @@ const MatchResults = observer(() => { const [instructionsVisible, setInstructionsVisible] = React.useState(false); const [params] = useSearchParams(); const taskId = params.get("taskId"); + const projectIdPrefix = params.get("projectIdPrefix"); const { projectsForUser = {}, identificationRemarks = [] } = useSiteSettings() || {}; const [filterVisible, setFilterVisible] = React.useState(false); + useEffect(()=> { + if(!projectIdPrefix) return; + store.setProjectName(projectIdPrefix); + }, [projectIdPrefix]) + useEffect(() => { if (taskId) { store.setTaskId(taskId); diff --git a/src/main/java/org/ecocean/TwitterBot.java b/src/main/java/org/ecocean/TwitterBot.java index 69b98be248..50eee1513f 100644 --- a/src/main/java/org/ecocean/TwitterBot.java +++ b/src/main/java/org/ecocean/TwitterBot.java @@ -543,7 +543,7 @@ public static String processIdentificationResults(Shepherd myShepherd, Map vars = new HashMap(); vars.put("SOURCE_SCREENNAME", originTweet.getUser().getScreenName()); vars.put("MATCH_URL", - CommonConfiguration.getServerURL(context) + "/iaResults.jsp?taskId=" + taskId); + CommonConfiguration.getServerURL(context) + "/react/match-results?taskId=" + taskId); taskTweeted.add(taskId); if ((annotPairDict == null) || (annotPairDict.optJSONArray("review_pair_list") == null) || (annotPairDict.getJSONArray("review_pair_list").length() < 1)) { diff --git a/src/main/webapp/encounters/biologicalSamples.jsp b/src/main/webapp/encounters/biologicalSamples.jsp index 9f1b3a8ca6..7a61a89849 100644 --- a/src/main/webapp/encounters/biologicalSamples.jsp +++ b/src/main/webapp/encounters/biologicalSamples.jsp @@ -1946,7 +1946,7 @@ $('.ia-match-filter-dialog input').each(function(i, el) { console.log('SENDING ===> %o', data); wildbook.IA.getPluginByType('IBEIS').restCall(data, function(xhr, textStatus) { console.log('RETURNED ========> %o %o', textStatus, xhr.responseJSON.taskId); - wildbook.openInTab('../iaResults.jsp?taskId=' + xhr.responseJSON.taskId); + wildbook.openInTab('../react/match-results?taskId=' + xhr.responseJSON.taskId); }); iaMatchFilterAnnotationIds = []; //clear it out in case user sends again from this page $('.ia-match-filter-dialog').hide(); diff --git a/src/main/webapp/encounters/encounter.jsp b/src/main/webapp/encounters/encounter.jsp index 4c400776c2..a2320dab24 100755 --- a/src/main/webapp/encounters/encounter.jsp +++ b/src/main/webapp/encounters/encounter.jsp @@ -6791,7 +6791,7 @@ $('.ia-match-filter-dialog input').each(function(i, el) { console.log('SENDING ===> %o', data); wildbook.IA.getPluginByType('IBEIS').restCall(data, function(xhr, textStatus) { console.log('RETURNED ========> %o %o', textStatus, xhr.responseJSON.taskId); - wildbook.openInTab('../iaResults.jsp?taskId=' + xhr.responseJSON.taskId); + wildbook.openInTab('../react/match-results?taskId=' + xhr.responseJSON.taskId); }); iaMatchFilterAnnotationIds = []; //clear it out in case user sends again from this page $('.ia-match-filter-dialog').hide(); diff --git a/src/main/webapp/iaResults.jsp b/src/main/webapp/iaResults.jsp index 42034865c4..1cc083c8f9 100755 --- a/src/main/webapp/iaResults.jsp +++ b/src/main/webapp/iaResults.jsp @@ -2009,7 +2009,7 @@ function isProjectSelected() { $('#projectDropdown').on('change', function() { let taskId = '<%=taskId%>'; - let reloadURL = "../iaResults.jsp?taskId="+taskId; + let reloadURL = "../react/match-results?taskId="+taskId; let selectedProject = $("#projectDropdown").val(); // replace reserved pound sign in incremental ID's selectedProject = selectedProject.replaceAll("#", "%23"); diff --git a/src/main/webapp/javascript/ia.IBEIS.js b/src/main/webapp/javascript/ia.IBEIS.js index 5da34479bf..df77afa612 100644 --- a/src/main/webapp/javascript/ia.IBEIS.js +++ b/src/main/webapp/javascript/ia.IBEIS.js @@ -101,7 +101,7 @@ console.log('_iaMenuHelper: mode=%o, mid=%o, aid=%o, ma=%o, iaStatus=%o, identAc return 'no matchable detection'; } else if (mode == 'funcStart') { //registerTaskId(iaStatus.taskId); - //wildbook.openInTab('../iaResults.jsp?taskId=' + iaStatus.taskId); + //wildbook.openInTab('../react/match-results?taskId=' + iaStatus.taskId); return; } // allow results page only if detection is complete or there is a verifiable identification status @@ -110,7 +110,7 @@ console.log('_iaMenuHelper: mode=%o, mid=%o, aid=%o, ma=%o, iaStatus=%o, identAc return 'match results'; } else if (mode == 'funcStart') { registerTaskId(iaStatus.taskId); - wildbook.openInTab('../iaResults.jsp?taskId=' + iaStatus.taskId); + wildbook.openInTab('../react/match-results?taskId=' + iaStatus.taskId); return; } } diff --git a/src/main/webapp/projects/project.jsp b/src/main/webapp/projects/project.jsp index d20f5a80aa..b9c27526d1 100644 --- a/src/main/webapp/projects/project.jsp +++ b/src/main/webapp/projects/project.jsp @@ -550,7 +550,7 @@ function removeEncounterFromProjectAjax(el) { function goToIAResults(taskId) { let projectIdPrefix = '<%= project.getProjectIdPrefix()%>'; - window.open('/iaResults.jsp?taskId='+taskId+'&projectIdPrefix='+encodeURIComponent(projIdPrefix), "_blank"); + window.open('/react/match-results?taskId='+taskId+'&projectIdPrefix='+encodeURIComponent(projIdPrefix), "_blank"); } function generateIALinkingMenu(json, encId) { From ccc3fba62afa488906d23b10749ced6058b1c110 Mon Sep 17 00:00:00 2001 From: Jon Van Oast Date: Thu, 29 Jan 2026 12:39:00 -0700 Subject: [PATCH 095/192] handle method info for vector stuff --- src/main/java/org/ecocean/ia/Task.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index 31ba00f2f6..a353c31349 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -609,6 +609,20 @@ public JSONObject getMatchingSetFilter() { public JSONObject getIdentificationMethodInfo() { if (getParameters() == null) return null; if (getParameters().optJSONObject("ibeis.identification") == null) return null; + JSONObject rtn = new JSONObject(); + // vector/embed flavor + if (getParameters().getJSONObject("ibeis.identification").optString("api_endpoint", + null) != null) { + String modelId = getParameters().getJSONObject("ibeis.identification").optString( + "model_id", null); + if (modelId == null) { + rtn.put("description", "Vector embedding match"); + } else { + rtn.put("description", "Vector embedding match (model: " + modelId + ")"); + rtn.put("modelId", modelId); + } + return rtn; + } // it seems both of these are in most logs (and are identical), but being safe in case there are // examples in the wild with only one JSONObject conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( @@ -616,12 +630,11 @@ public JSONObject getIdentificationMethodInfo() { if (conf == null) conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( "queryConfigDict"); - JSONObject rtn = new JSONObject(); // we set HotSpotter if pipeline_root is not set here if (conf != null) rtn.put("name", conf.optString("pipeline_root", "HotSpotter")); rtn.put("description", getParameters().getJSONObject("ibeis.identification").optString("description", - "unknown algorith/method")); + "unknown algorithm/method")); return rtn; } @@ -684,7 +697,7 @@ public JSONObject matchResultsJson(int cutoff, Set projectIds, Shepherd // we basically use this to determine if we are "identification-like" enough // to display extended details if (methodInfo != null) { - rtn.put("method", getIdentificationMethodInfo()); + rtn.put("method", methodInfo); rtn.put("matchingSetFilter", getMatchingSetFilter()); // unsure which of these two things is more accurate or useful; thus including both rtn.put("status", getStatus(myShepherd)); From 7f54dde4beb73792b10690eb0ad386b0ba68d0af Mon Sep 17 00:00:00 2001 From: erinz2020 Date: Fri, 30 Jan 2026 22:41:44 +0000 Subject: [PATCH 096/192] i18n, update project dropdown, null handling --- frontend/src/components/AnnotationOverlay.jsx | 4 +- frontend/src/locale/de.json | 3 +- frontend/src/locale/en.json | 3 +- frontend/src/locale/es.json | 3 +- frontend/src/locale/fr.json | 3 +- frontend/src/locale/it.json | 3 +- .../pages/MatchResultsPage/MatchResults.jsx | 108 ++++++++++++------ .../components/MatchProspectTable.jsx | 19 ++- .../pages/MatchResultsPage/helperFunctions.js | 23 ++-- .../stores/matchResultsStore.js | 14 ++- 10 files changed, 122 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx index fdd0a37676..5e8f4642f4 100644 --- a/frontend/src/components/AnnotationOverlay.jsx +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -12,8 +12,8 @@ const InteractiveAnnotationOverlay = forwardRef( { imageUrl, annotations = [], - originalWidth, - originalHeight, + originalWidth = 0, + originalHeight = 0, rotationInfo = null, initialZoom = 1, minZoom = 0.5, diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 9ed48ea7b3..d537c3d1bd 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -765,5 +765,6 @@ "NO_FILTER_SET_FOR_TASK": "Keine Filter wurden auf diese Aufgabe angewendet", "FILTER_SET_FOR_TASK": "Unten sind die auf diese Aufgabe angewendeten Filter", "LOCATION_IDS": "Standort-IDs", - "OR_CHOOSE_FROM_RESULTS_BELOW": "oder wählen Sie aus den Ergebnissen unten" + "OR_CHOOSE_FROM_RESULTS_BELOW": "oder wählen Sie aus den Ergebnissen unten", + "NO_MATCH_RESULT": "Keine Übereinstimmungsergebnisse" } \ No newline at end of file diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index bdda49d1b4..587b77424c 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -764,5 +764,6 @@ "NO_FILTER_SET_FOR_TASK": "No filters were applied to this task", "FILTER_SET_FOR_TASK": "Below are filters applied to this task", "LOCATION_IDS": "Location IDs", - "OR_CHOOSE_FROM_RESULTS_BELOW": "or choose from results below" + "OR_CHOOSE_FROM_RESULTS_BELOW": "or choose from results below", + "NO_MATCH_RESULT": "No match results" } \ No newline at end of file diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 26aba17c97..8bbe005f4f 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -764,5 +764,6 @@ "NO_FILTER_SET_FOR_TASK": "No se aplicaron filtros a esta tarea", "FILTER_SET_FOR_TASK": "A continuación se muestran los filtros aplicados a esta tarea", "LOCATION_IDS": "IDs de ubicación", - "OR_CHOOSE_FROM_RESULTS_BELOW": "o elija de los resultados a continuación" + "OR_CHOOSE_FROM_RESULTS_BELOW": "o elija de los resultados a continuación", + "NO_MATCH_RESULT": "No hay resultados de coincidencia" } \ No newline at end of file diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json index b41f6f3287..fad28d14cc 100644 --- a/frontend/src/locale/fr.json +++ b/frontend/src/locale/fr.json @@ -764,5 +764,6 @@ "NO_FILTER_SET_FOR_TASK": "Aucun filtre n'a été appliqué à cette tâche", "FILTER_SET_FOR_TASK": "Ci-dessous les filtres appliqués à cette tâche", "LOCATION_IDS": "IDs de localisation", - "OR_CHOOSE_FROM_RESULTS_BELOW": "ou choisissez parmi les résultats ci-dessous" + "OR_CHOOSE_FROM_RESULTS_BELOW": "ou choisissez parmi les résultats ci-dessous", + "NO_MATCH_RESULT": "Aucun résultat de correspondance" } \ No newline at end of file diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json index f04e18ed71..650420d118 100644 --- a/frontend/src/locale/it.json +++ b/frontend/src/locale/it.json @@ -764,5 +764,6 @@ "NO_FILTER_SET_FOR_TASK": "Nessun filtro è stato applicato a questa attività", "FILTER_SET_FOR_TASK": "Di seguito sono riportati i filtri applicati a questa attività", "LOCATION_IDS": "ID località", - "OR_CHOOSE_FROM_RESULTS_BELOW": "o scegli dai risultati qui sotto" + "OR_CHOOSE_FROM_RESULTS_BELOW": "o scegli dai risultati qui sotto", + "NO_MATCH_RESULT": "Nessun risultato di corrispondenza" } \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index db86fc82ce..10ec0cb8b5 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -13,22 +13,44 @@ import InstructionsModal from "./components/InstructionsModal"; import InfoIcon from "./icons/InfoIcon"; import FilterIcon from "./icons/FilterIcon"; import MatchCriteriaDrawer from "./components/MatchCriteriaDrawer"; +import Select from "react-select"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); const store = useMemo(() => new MatchResultsStore(), []); const [instructionsVisible, setInstructionsVisible] = React.useState(false); - const [params] = useSearchParams(); + const [params, setParams] = useSearchParams(); const taskId = params.get("taskId"); const projectIdPrefix = params.get("projectIdPrefix"); const { projectsForUser = {}, identificationRemarks = [] } = useSiteSettings() || {}; const [filterVisible, setFilterVisible] = React.useState(false); - useEffect(()=> { - if(!projectIdPrefix) return; - store.setProjectName(projectIdPrefix); - }, [projectIdPrefix]) + const projectOptions = useMemo(() => { + return [ + { value: "", label: "Select a project" }, + ...Object.entries(projectsForUser).map(([key, value]) => ({ + value: key, + label: value?.name || key, + })), + ]; + }, [projectsForUser]); + + const selectedProjectOption = useMemo(() => { + return projectOptions.find((o) => o.value === store.projectName) || null; + }, [projectOptions, store.projectName]); + + useEffect(() => { + if (!projectIdPrefix) return; + + const match = Object.entries(projectsForUser).find( + ([, p]) => p?.prefix === projectIdPrefix, + ); + if (!match) return; + + const [projectId] = match; + store.setProjectName(projectId); + }, [projectIdPrefix, projectsForUser, store]); useEffect(() => { if (taskId) { @@ -188,24 +210,44 @@ const MatchResults = observer(() => { - { - store.setProjectName(e.target.value); - }} - style={{ minWidth: "220px", maxWidth: "400px" }} - > - - {Object.entries(projectsForUser).map(([key, value]) => ( - - ))} - +
+ setDraft(next)} + maxTagCount={2} + options={options} + filterOption={(input, option) => + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + dropdownRender={(menu) => ( + <> + {menu} + +
+ +
+ + )} + optionRender={(option) => { + const checked = draftSet.has(option.value); + return ( +
+ + {option.label} +
+ ); + }} + /> + ); +} diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx index 10ec0cb8b5..2c094dbc84 100644 --- a/frontend/src/pages/MatchResultsPage/MatchResults.jsx +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -13,7 +13,8 @@ import InstructionsModal from "./components/InstructionsModal"; import InfoIcon from "./icons/InfoIcon"; import FilterIcon from "./icons/FilterIcon"; import MatchCriteriaDrawer from "./components/MatchCriteriaDrawer"; -import Select from "react-select"; + +import MultiSelectWithCheckbox from "../../components/MultiSelectWithCheckbox"; const MatchResults = observer(() => { const themeColor = React.useContext(ThemeColorContext); @@ -27,19 +28,12 @@ const MatchResults = observer(() => { const [filterVisible, setFilterVisible] = React.useState(false); const projectOptions = useMemo(() => { - return [ - { value: "", label: "Select a project" }, - ...Object.entries(projectsForUser).map(([key, value]) => ({ - value: key, - label: value?.name || key, - })), - ]; + return Object.entries(projectsForUser).map(([key, value]) => ({ + value: key, + label: value?.name || key, + })); }, [projectsForUser]); - const selectedProjectOption = useMemo(() => { - return projectOptions.find((o) => o.value === store.projectName) || null; - }, [projectOptions, store.projectName]); - useEffect(() => { if (!projectIdPrefix) return; @@ -49,7 +43,8 @@ const MatchResults = observer(() => { if (!match) return; const [projectId] = match; - store.setProjectName(projectId); + + store.setProjectNames([projectId]); }, [projectIdPrefix, projectsForUser, store]); useEffect(() => { @@ -210,40 +205,27 @@ const MatchResults = observer(() => { +
- ({ ...base, zIndex: 9999 }) }} - isLoading={siteSettingsLoading} - isDisabled={siteSettingsLoading} - placeholder={ - siteSettingsLoading - ? "Loading algorithms..." - : "Select algorithms" - } - loadingMessage={() => "Loading algorithms..."} - noOptionsMessage={() => - siteSettingsLoading - ? "Loading algorithms..." - : "No algorithms available" - } - value={(store?.newMatch?.algorithmOptions ?? []).filter((o) => - (store?.newMatch?.algorithms ?? []).includes(o.value), - )} - onChange={(newValue) => { - if (siteSettingsLoading) return; - store?.newMatch?.setAlgorithm?.( - (newValue ?? []).map((o) => o.value), - ); - }} - closeMenuOnSelect={false} - /> - +
+ {store?.siteSettingsLoading ? ( + +
Loading algorithms...
+
+ ) : ( +