diff --git a/frontend/src/features/start-mapping/components/model-action.tsx b/frontend/src/features/start-mapping/components/model-action.tsx
index 4edf3cb0..807ab78d 100644
--- a/frontend/src/features/start-mapping/components/model-action.tsx
+++ b/frontend/src/features/start-mapping/components/model-action.tsx
@@ -1,24 +1,64 @@
import { handleConflation, showErrorToast, showSuccessToast } from "@/utils";
import { Map } from "maplibre-gl";
import { START_MAPPING_PAGE_CONTENT, TOAST_NOTIFICATIONS } from "@/constants";
-import { TModelPredictions, TModelPredictionsConfig } from "@/types";
+import {
+ BBOX,
+ TModel,
+ TModelPredictions,
+ TModelPredictionsConfig,
+ TQueryParams,
+ TTrainingDataset,
+} from "@/types";
import { ToolTip } from "@/components/ui/tooltip";
import { useCallback } from "react";
import { useGetModelPredictions } from "@/features/start-mapping/hooks/use-model-predictions";
+import { SEARCH_PARAMS } from "@/app/routes/start-mapping";
+import { PREDICTION_API_FILE_EXTENSIONS } from "@/config";
+import { BASE_MODELS } from "@/enums";
+import { useParams } from "react-router-dom";
const ModelAction = ({
setModelPredictions,
modelPredictions,
- trainingConfig,
map,
disablePrediction,
+ query,
+ trainingDataset,
+ currentZoom,
+ modelInfo,
}: {
- trainingConfig: TModelPredictionsConfig;
modelPredictions: TModelPredictions;
setModelPredictions: React.Dispatch
>;
map: Map | null;
disablePrediction: boolean;
+ query: TQueryParams;
+ trainingDataset: TTrainingDataset;
+ currentZoom: number;
+ modelInfo: TModel;
}) => {
+ const { modelId } = useParams();
+
+ const getTrainingConfig = useCallback((): TModelPredictionsConfig => {
+ return {
+ tolerance: query[SEARCH_PARAMS.tolerance] as number,
+ area_threshold: query[SEARCH_PARAMS.area] as number,
+ use_josm_q: query[SEARCH_PARAMS.useJOSMQ] as boolean,
+ confidence: query[SEARCH_PARAMS.confidenceLevel] as number,
+ checkpoint: `/mnt/efsmount/data/trainings/dataset_${modelInfo?.dataset}/output/training_${modelInfo?.published_training}/checkpoint${PREDICTION_API_FILE_EXTENSIONS[modelInfo?.base_model as BASE_MODELS]}`,
+ max_angle_change: 15,
+ model_id: modelId as string,
+ skew_tolerance: 15,
+ source: trainingDataset?.source_imagery as string,
+ zoom_level: currentZoom,
+ bbox: [
+ map?.getBounds().getWest(),
+ map?.getBounds().getSouth(),
+ map?.getBounds().getEast(),
+ map?.getBounds().getNorth(),
+ ] as BBOX,
+ };
+ }, [map, query, currentZoom, trainingDataset, modelInfo]);
+
const modelPredictionMutation = useGetModelPredictions({
mutationConfig: {
onSuccess: (data) => {
@@ -28,7 +68,7 @@ const ModelAction = ({
const conflatedResults = handleConflation(
modelPredictions,
data.features,
- trainingConfig,
+ getTrainingConfig(),
);
setModelPredictions(conflatedResults);
},
@@ -38,8 +78,8 @@ const ModelAction = ({
const handlePrediction = useCallback(async () => {
if (!map) return;
- await modelPredictionMutation.mutateAsync(trainingConfig);
- }, [trainingConfig]);
+ await modelPredictionMutation.mutateAsync(getTrainingConfig());
+ }, [getTrainingConfig, modelPredictionMutation, map]);
return (
diff --git a/frontend/src/utils/__tests__/geo/geometry-utils.test.ts b/frontend/src/utils/__tests__/geo/geometry-utils.test.ts
new file mode 100644
index 00000000..4f56f286
--- /dev/null
+++ b/frontend/src/utils/__tests__/geo/geometry-utils.test.ts
@@ -0,0 +1,323 @@
+import { Feature } from "geojson";
+import { describe, expect, it } from "vitest";
+
+import { TModelPredictions, TModelPredictionsConfig } from "@/types";
+import {
+ calculateGeoJSONArea,
+ formatAreaInAppropriateUnit,
+ getGeoJSONFeatureBounds,
+ handleConflation,
+} from "@/utils";
+
+const predictionConfig: TModelPredictionsConfig = {
+ area_threshold: 6,
+ bbox: [0, 0, 0, 0],
+ checkpoint: "",
+ confidence: 95,
+ max_angle_change: 0,
+ model_id: "",
+ use_josm_q: true,
+ skew_tolerance: 0,
+ zoom_level: 21,
+ source: "",
+ tolerance: 0,
+};
+
+describe("geometry-utils", () => {
+ it("should calculate the area of a GeoJSON Feature", () => {
+ const feature: Feature = {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-10, -10],
+ [10, -10],
+ [10, 10],
+ [-10, 10],
+ [-10, -10],
+ ],
+ ],
+ },
+ properties: {},
+ };
+ const result = calculateGeoJSONArea(feature);
+ expect(result).toBeGreaterThan(0);
+ });
+
+ it("should format area into human readable string", () => {
+ const result = formatAreaInAppropriateUnit(12222000);
+ expect(result).toBe("12.2kmĀ²");
+ });
+
+ it("should compute the bounding box of a GeoJSON Feature", () => {
+ const feature: Feature = {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-10, -10],
+ [10, -10],
+ [10, 10],
+ [-10, 10],
+ [-10, -10],
+ ],
+ ],
+ },
+ properties: {},
+ };
+ const result = getGeoJSONFeatureBounds(feature);
+ expect(result).toEqual([-10, -10, 10, 10]);
+ });
+
+ it("should handle conflation of new features with existing predictions", () => {
+ const existingPredictions = {
+ all: [],
+ accepted: [],
+ rejected: [],
+ };
+ const newFeatures: Feature[] = [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [10, 10],
+ [20, 20],
+ [30, 30],
+ [10, 10],
+ ],
+ ],
+ },
+ properties: {},
+ },
+ ];
+
+ const result = handleConflation(
+ existingPredictions,
+ newFeatures,
+ predictionConfig,
+ );
+ expect(result.all.length).toBe(1);
+ });
+
+ it("should replace existing feature if it intersects with new feature", () => {
+ const existingPredictions = {
+ all: [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [15, 15],
+ [25, 25],
+ [35, 35],
+ [15, 15],
+ ],
+ ],
+ },
+ properties: { config: predictionConfig, _id: "existing" },
+ },
+ ],
+ accepted: [],
+ rejected: [],
+ } as TModelPredictions;
+ const newFeatures: Feature[] = [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [20, 30],
+ [25, 25],
+ [35, 35],
+ [20, 30],
+ ],
+ ],
+ },
+ properties: {},
+ },
+ ];
+
+ const result = handleConflation(
+ existingPredictions,
+ newFeatures,
+ predictionConfig,
+ );
+ expect(result.all.length).toBe(1);
+ expect(result.all[0].properties?._id).not.toBe("existing");
+ });
+
+ it("should replace the correct feature if multiple features intersect", () => {
+ const existingPredictions = {
+ all: [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [5, 5],
+ [15, 5],
+ [15, 15],
+ [5, 15],
+ [5, 5],
+ ],
+ ],
+ },
+ properties: { _id: "existing1", config: predictionConfig },
+ },
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [20, 20],
+ [30, 20],
+ [30, 30],
+ [20, 30],
+ [20, 20],
+ ],
+ ],
+ },
+ properties: { _id: "existing2", config: predictionConfig },
+ },
+ ],
+ accepted: [],
+ rejected: [],
+ } as TModelPredictions;
+ const newFeatures: Feature[] = [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [20, 20],
+ [30, 20],
+ [30, 30],
+ [20, 30],
+ [20, 20],
+ ],
+ ],
+ },
+ properties: {},
+ },
+ ];
+
+ const result = handleConflation(
+ existingPredictions,
+ newFeatures,
+ predictionConfig,
+ );
+ expect(result.all.length).toBe(2);
+ expect(result.all[1].properties?._id).not.toBe("existing2");
+ expect(result.all[0].properties?._id).toBe("existing1");
+ });
+
+ it("should not add new feature if it intersects with accepted feature", () => {
+ const existingPredictions = {
+ all: [],
+ accepted: [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [10, 10],
+ [20, 20],
+ [30, 30],
+ [10, 10],
+ ],
+ ],
+ },
+ properties: { config: predictionConfig },
+ },
+ ],
+ rejected: [],
+ } as TModelPredictions;
+ const newFeatures: Feature[] = [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [15, 15],
+ [25, 25],
+ [35, 35],
+ [15, 15],
+ ],
+ ],
+ },
+ properties: {},
+ },
+ ];
+
+ const result = handleConflation(
+ existingPredictions,
+ newFeatures,
+ predictionConfig,
+ );
+ expect(result.all.length).toBe(0);
+ expect(result.accepted.length).toBe(1);
+ expect(result.rejected.length).toBe(0);
+ });
+
+ it("should not add new feature if it intersects with rejected feature", () => {
+ const existingPredictions = {
+ all: [],
+ accepted: [],
+ rejected: [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [10, 10],
+ [20, 20],
+ [30, 30],
+ [10, 10],
+ ],
+ ],
+ },
+ properties: { config: predictionConfig },
+ },
+ ],
+ } as TModelPredictions;
+
+ const newFeatures: Feature[] = [
+ {
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [15, 15],
+ [25, 25],
+ [35, 35],
+ [15, 15],
+ ],
+ ],
+ },
+ properties: {},
+ },
+ ];
+
+ const result = handleConflation(
+ existingPredictions,
+ newFeatures,
+ predictionConfig,
+ );
+ expect(result.all.length).toBe(0);
+ expect(result.rejected.length).toBe(1);
+ expect(result.accepted.length).toBe(0);
+ });
+});
diff --git a/frontend/src/utils/geo/geometry-utils.ts b/frontend/src/utils/geo/geometry-utils.ts
index 03134cd4..44d2da51 100644
--- a/frontend/src/utils/geo/geometry-utils.ts
+++ b/frontend/src/utils/geo/geometry-utils.ts
@@ -312,12 +312,32 @@ export const snapGeoJSONPolygonToClosestTile = (geometry: Polygon) => {
return geometry;
};
-/*
- Logic.
- | 1 - Purple: Will be replaced with new feature if it intersects with new features, otherwise, it'll be appended.
- | 2 - Red: No touch
- | 3 - Green: No touch
-*/
+/**
+ * Conflates new features with existing predictions.
+ *
+ * Existing Predictions:
+ * accepted: [A1, A2, A3]
+ * rejected: [R1, R2]
+ * all: [E1, E2, E3, E4]
+ *
+ * New Features:
+ * newFeatures: [N1, N2, N3]
+ *
+ * Logic:
+ * 1. If N1 intersects with any feature in 'all', replace the intersecting feature in 'all' with N1.
+ * 2. If N2 does not intersect with any feature in 'accepted' or 'rejected', append N2 to 'all'.
+ * 3. If N3 intersects with any feature in 'accepted' or 'rejected', do not add N3 to 'all'.
+ *
+ * Example:
+ * - N1 intersects with E2 -> Replace E2 with N1 in 'all'.
+ * - N2 does not intersect with any in 'accepted' or 'rejected' -> Append N2 to 'all'.
+ * - N3 intersects with A2 -> Do not add N3 to 'all'.
+ *
+ * Result:
+ * all: [E1, N1, E3, E4, N2]
+ * accepted: [A1, A2, A3]
+ * rejected: [R1, R2]
+ */
export const handleConflation = (
existingPredictions: TModelPredictions,
newFeatures: Feature[],
@@ -325,14 +345,27 @@ export const handleConflation = (
): TModelPredictions => {
let updatedAll = [...existingPredictions.all];
- newFeatures.forEach((newFeature) => {
- const intersectsWithAccepted = existingPredictions.accepted.some(
- (acceptedFeature) => booleanIntersects(newFeature, acceptedFeature),
- );
- const intersectsWithRejected = existingPredictions.rejected.some(
- (rejectedFeature) => booleanIntersects(newFeature, rejectedFeature),
- );
+ for (const newFeature of newFeatures) {
+ let intersectsAccepted = false;
+ let intersectsRejected = false;
+ // Check for intersections in accepted features with early exit.
+ for (const acceptedFeature of existingPredictions.accepted) {
+ if (booleanIntersects(newFeature, acceptedFeature)) {
+ intersectsAccepted = true;
+ break;
+ }
+ }
+
+ // Check for intersections in rejected features with early exit.
+ for (const rejectedFeature of existingPredictions.rejected) {
+ if (booleanIntersects(newFeature, rejectedFeature)) {
+ intersectsRejected = true;
+ break;
+ }
+ }
+
+ // Check if the new feature intersects with any feature in updatedAll.
const intersectingIndex = updatedAll.findIndex((existingFeature) =>
booleanIntersects(newFeature, existingFeature),
);
@@ -342,11 +375,12 @@ export const handleConflation = (
...newFeature,
properties: {
...newFeature.properties,
+ // Reuse existing id if available, or generate a new one.
id: updatedAll[intersectingIndex].properties?.id || uuid4(),
config: predictionConfig,
},
};
- } else if (!intersectsWithAccepted && !intersectsWithRejected) {
+ } else if (!intersectsAccepted && !intersectsRejected) {
updatedAll.push({
...newFeature,
properties: {
@@ -356,7 +390,7 @@ export const handleConflation = (
},
});
}
- });
+ }
return {
all: updatedAll,