Skip to content

Commit 0c816d0

Browse files
authored
feat(fusion) (#346)
* feat(fusion): v0 of feature * fix(polygon): consider overlapping polygon and reconstruct new geom * feat(attibutes modal): let user choose which values to keep7 * fix(copilot review): logic, guard and copy * feat(edge tolerence): add a small margin to handle overlapping polygons * feat(line fusion): add human margin to not need snap and keep feature selected style after merge
1 parent 2ae9eef commit 0c816d0

16 files changed

Lines changed: 512 additions & 6 deletions

File tree

src/constants/communities/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export enum CommunityLayerFunctionalityType {
264264
COPY_REF = "copyRef",
265265
TOOLTIP = "tooltip",
266266
OVERVIEW = "overview_map_control",
267+
MERGE = "merge",
267268
}
268269

269270
export enum CommunityLayerRoleType {
@@ -285,6 +286,7 @@ export enum InteractionType {
285286
SPLIT_LINE = "split_line",
286287
SHORTEST_PATH = "shortest_path",
287288
EXPORT_IMAGE = "export_image",
289+
MERGE_OBJECTS = "merge_objects",
288290
}
289291

290292
export type CustomControlItem = {

src/constants/contributions/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface InteractionsFuncsProps {
5252
splitLineInteractionFuncPointer: (e: MapBrowserEvent) => void;
5353
getInteractionByType: (type: string | null, target: string) => CustomInteraction;
5454
handleClick: (control: CustomControlItem) => void;
55+
mergeInteractionFunc: (customData: Record<string, unknown>) => void;
5556
}
5657

5758
export interface InteractionsProps {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Feature } from "ol";
2+
import { Coordinate } from "ol/coordinate";
3+
import { LineString, Polygon, Geometry } from "ol/geom";
4+
import { GeoserviceFeatureTypeProp } from "@/constants/communities/types";
5+
import { COORD_EPSILON } from "@/constants";
6+
7+
const NODE_MATCH_MARGIN = COORD_EPSILON * 1000;
8+
9+
function coordDist(a: Coordinate, b: Coordinate): number {
10+
return Math.hypot(a[0] - b[0], a[1] - b[1]);
11+
}
12+
13+
function findCoordIndex(coords: Coordinate[], target: Coordinate): number {
14+
let bestIdx = -1;
15+
let bestDist = NODE_MATCH_MARGIN;
16+
for (let i = 0; i < coords.length; i++) {
17+
const dist = coordDist(coords[i], target);
18+
if (dist < bestDist) {
19+
bestDist = dist;
20+
bestIdx = i;
21+
}
22+
}
23+
return bestIdx;
24+
}
25+
26+
// Find shared edge: returns indices of shared vertices in both rings
27+
function findSharedEdge(ring1: Coordinate[], ring2: Coordinate[]): { sharedR1: number[]; sharedR2: number[] } | null {
28+
const r1 = ring1.slice(0, -1);
29+
const r2 = ring2.slice(0, -1);
30+
const n1 = r1.length;
31+
32+
const sharedR1: number[] = [];
33+
const sharedR2: number[] = [];
34+
35+
for (let i = 0; i < n1; i++) {
36+
const idx2 = findCoordIndex(r2, r1[i]);
37+
if (idx2 !== -1) {
38+
sharedR1.push(i);
39+
sharedR2.push(idx2);
40+
}
41+
}
42+
43+
if (sharedR1.length < 2) return null;
44+
45+
return { sharedR1, sharedR2 };
46+
}
47+
48+
export function mergeAdjacentPolygonRings(ring1: Coordinate[], ring2: Coordinate[]): Coordinate[] | null {
49+
const shared = findSharedEdge(ring1, ring2);
50+
if (!shared) return null;
51+
52+
const r1 = ring1.slice(0, -1);
53+
const r2 = ring2.slice(0, -1);
54+
const n1 = r1.length;
55+
const n2 = r2.length;
56+
57+
const r1SharedSet = new Set(shared.sharedR1);
58+
const r2SharedSet = new Set(shared.sharedR2);
59+
60+
// Find transition point
61+
let transitionIdx1 = -1;
62+
for (let i = 0; i < n1; i++) {
63+
if (r1SharedSet.has(i) && !r1SharedSet.has((i + 1) % n1)) {
64+
transitionIdx1 = i;
65+
break;
66+
}
67+
}
68+
if (transitionIdx1 === -1) return null;
69+
70+
// Find where we re-enter shared
71+
let reentryIdx1 = (transitionIdx1 + 1) % n1;
72+
while (!r1SharedSet.has(reentryIdx1)) {
73+
reentryIdx1 = (reentryIdx1 + 1) % n1;
74+
if (reentryIdx1 === transitionIdx1) return null;
75+
}
76+
77+
const r1Exclusive: Coordinate[] = [];
78+
let i = (transitionIdx1 + 1) % n1;
79+
while (i !== reentryIdx1) {
80+
r1Exclusive.push(r1[i]);
81+
i = (i + 1) % n1;
82+
}
83+
84+
// Find corresponding indices
85+
const transitionCoord = r1[transitionIdx1];
86+
const reentryCoord = r1[reentryIdx1];
87+
const transitionIdx2 = findCoordIndex(r2, transitionCoord);
88+
const reentryIdx2 = findCoordIndex(r2, reentryCoord);
89+
if (transitionIdx2 === -1 || reentryIdx2 === -1) return null;
90+
91+
let dir: 1 | -1 = r2SharedSet.has((reentryIdx2 + 1 + n2) % n2) ? -1 : 1;
92+
93+
const collectR2Exclusive = (d: 1 | -1): Coordinate[] => {
94+
const out: Coordinate[] = [];
95+
let j = (reentryIdx2 + d + n2) % n2;
96+
let safety = 0;
97+
while (j !== transitionIdx2 && safety < n2) {
98+
if (!r2SharedSet.has(j)) out.push(r2[j]);
99+
j = (j + d + n2) % n2;
100+
safety++;
101+
}
102+
return out;
103+
};
104+
105+
let r2Exclusive = collectR2Exclusive(dir);
106+
if (r2Exclusive.length === 0) {
107+
dir = (dir * -1) as 1 | -1;
108+
r2Exclusive = collectR2Exclusive(dir);
109+
}
110+
111+
// Build merged ring: transition vertex -> r1 exclusive -> reentry vertex -> r2 exclusive -> close
112+
const merged: Coordinate[] = [transitionCoord, ...r1Exclusive, reentryCoord, ...r2Exclusive];
113+
merged.push(merged[0]);
114+
return merged.length >= 4 ? merged : null;
115+
}
116+
117+
type EndpointPairType = "end1-start2" | "end1-end2" | "start1-start2" | "start1-end2";
118+
119+
interface EndpointPair {
120+
type: EndpointPairType;
121+
dist: number;
122+
}
123+
124+
function findClosestEndpointPair(coords1: Coordinate[], coords2: Coordinate[]): EndpointPair | null {
125+
const end1 = coords1[coords1.length - 1];
126+
const start1 = coords1[0];
127+
const end2 = coords2[coords2.length - 1];
128+
const start2 = coords2[0];
129+
130+
const candidates: EndpointPair[] = [
131+
{ type: "end1-start2", dist: coordDist(end1, start2) },
132+
{ type: "end1-end2", dist: coordDist(end1, end2) },
133+
{ type: "start1-start2", dist: coordDist(start1, start2) },
134+
{ type: "start1-end2", dist: coordDist(start1, end2) },
135+
];
136+
137+
const best = candidates.reduce((min, candidate) => (candidate.dist < min.dist ? candidate : min));
138+
if (best.dist >= NODE_MATCH_MARGIN) return null;
139+
return best;
140+
}
141+
142+
export function mergeLineCoordinates(coords1: Coordinate[], coords2: Coordinate[]): Coordinate[] | null {
143+
if (coords1.length < 2 || coords2.length < 2) return null;
144+
145+
const pair = findClosestEndpointPair(coords1, coords2);
146+
if (!pair) return null;
147+
148+
switch (pair.type) {
149+
case "end1-start2":
150+
return [...coords1, ...coords2.slice(1)];
151+
case "end1-end2":
152+
return [...coords1, ...[...coords2].reverse().slice(1)];
153+
case "start1-start2":
154+
return [...[...coords1].reverse(), ...coords2.slice(1)];
155+
case "start1-end2":
156+
return [...coords2, ...coords1.slice(1)];
157+
}
158+
}
159+
160+
export function mergeFeatureGeometries(feat1: Feature, feat2: Feature, featureType: GeoserviceFeatureTypeProp): Geometry | null {
161+
if (featureType === GeoserviceFeatureTypeProp.LINE) {
162+
const geom1 = feat1.getGeometry() as LineString;
163+
const geom2 = feat2.getGeometry() as LineString;
164+
if (!geom1 || !geom2) return null;
165+
166+
const merged = mergeLineCoordinates(geom1.getCoordinates(), geom2.getCoordinates());
167+
return merged ? new LineString(merged) : null;
168+
}
169+
170+
if (featureType === GeoserviceFeatureTypeProp.POLYGON) {
171+
const geom1 = feat1.getGeometry() as Polygon;
172+
const geom2 = feat2.getGeometry() as Polygon;
173+
if (!geom1 || !geom2) return null;
174+
175+
const ring1 = geom1.getLinearRing(0)?.getCoordinates();
176+
const ring2 = geom2.getLinearRing(0)?.getCoordinates();
177+
if (!ring1 || !ring2) return null;
178+
179+
const merged = mergeAdjacentPolygonRings(ring1, ring2);
180+
return merged ? new Polygon([merged]) : null;
181+
}
182+
183+
return null;
184+
}

src/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const MEMBER_ROLE = "member";
1515

1616
export const HIT_DETECTION_TOLERENCE = 1;
1717
export const POINTER_HIT_DETECTION_TOLERENCE = 10;
18+
export const COORD_EPSILON = 0.01;
1819

1920
export const TILE_SIZE = 2048;
2021
export const TILE_MAX_FEATURES = 5000;

src/features/navigation/controls/custom-controls/index.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { FeatureTypeFormActionMode } from "@/constants/contributions/types";
1515
import { FEATURE_TYPE_SELECTED_PROPERTY } from "@/constants";
1616
import ConfirmMultipleDeselection from "./ConfirmMultipleDeselection";
1717
import ConfirmMultipleObjectsActionModal from "@/features/working-layer/forms/ConfirmMultipleObjectsActionModal";
18+
import MergeFeatureAttributesModal from "@/features/working-layer/forms/MergeFeatureAttributesModal";
1819
import SearchObjectsModal from "@/features/working-layer/modal/searchObjects/SearchObjectsModal";
1920
import ExportMapModal from "./ExportMapModal";
2021
import NamedPositionModal from "../NamedPositionModal";
@@ -66,7 +67,8 @@ const CustomControls = () => {
6667
if (
6768
control.interaction !== InteractionType.MODIFY &&
6869
control.interaction !== InteractionType.TRANSLATE_OBJECT &&
69-
control.interaction !== InteractionType.COPY_OBJECT
70+
control.interaction !== InteractionType.COPY_OBJECT &&
71+
control.interaction !== InteractionType.MERGE_OBJECTS
7072
) {
7173
interactions.selectInteraction.clearSelection();
7274
selectedObjects.forEach((feat) => {
@@ -96,7 +98,7 @@ const CustomControls = () => {
9698
(control: CustomControlItem) => {
9799
if (control.disabled) return;
98100

99-
if (clickedControl?.interaction === InteractionType.SELECT && selectedObjects.length > 1) {
101+
if (clickedControl?.interaction === InteractionType.SELECT && selectedObjects.length > 1 && control.interaction !== InteractionType.MERGE_OBJECTS) {
100102
pendingControlChange.current = control;
101103
confirmMultipleDeselectionModal.open();
102104
return;
@@ -136,6 +138,13 @@ const CustomControls = () => {
136138
}
137139
};
138140

141+
const handleConfirmMerge = useCallback(
142+
(customData: Record<string, unknown>) => {
143+
interactionsFuncs.mergeInteractionFunc(customData);
144+
},
145+
[interactionsFuncs]
146+
);
147+
139148
const isEditableTarget = useCallback((target: EventTarget | null) => {
140149
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable);
141150
}, []);
@@ -224,6 +233,7 @@ const CustomControls = () => {
224233
<ConfirmMultipleDeselection onConfirm={handleConfirmUnSelectMultiple} />
225234
<SearchObjectsModal />
226235
<ConfirmMultipleObjectsActionModal action={FeatureTypeFormActionMode.DELETE} onConfirm={handleConfirmDeleteMultiple} />
236+
<MergeFeatureAttributesModal onConfirm={handleConfirmMerge} />
227237
<ExportMapModal />
228238
<NamedPositionModal />
229239
</>

src/features/navigation/controls/custom-controls/locale/index.locale.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const CustomControlsFrTranslations: Translations<"fr">["CustomControls"]
1616
copy_object: "Copier un objet",
1717
paste_object: "Coller un objet",
1818
shortest_path: "Créer un plus court chemin",
19+
merge_objects: "Fusionner 2 objets en contact",
1920
};
2021

2122
export const CustomControlsEnTranslations: Translations<"en">["CustomControls"] = {
@@ -33,6 +34,7 @@ export const CustomControlsEnTranslations: Translations<"en">["CustomControls"]
3334
copy_object: "Copy an object",
3435
paste_object: "Paste an object",
3536
shortest_path: "Create a shortest path",
37+
merge_objects: "Merge 2 touching objects",
3638
};
3739

3840
const { i18n } = declareComponentKeys<
@@ -50,5 +52,6 @@ const { i18n } = declareComponentKeys<
5052
| "copy_object"
5153
| "paste_object"
5254
| "shortest_path"
55+
| "merge_objects"
5356
>()("CustomControls");
5457
export type I18n = typeof i18n;

0 commit comments

Comments
 (0)