Skip to content

Commit 917dc58

Browse files
authored
fix(processing): preserve dissolve attribute groups (#1978)
* fix(processing): preserve dissolve attribute groups * Address Claude review feedback - Preserve each dissolve field value's original type across connected and disconnected groups. - Cover numeric group values and no-field disconnected dissolves. * Address Claude review feedback - Carry the first source feature's whole attribute set through Dissolve instead of rebuilding properties from just the dissolve field, so attributes no longer disappear when a group is merged (matching the sidecar's GeoPandas `dissolve`, aggfunc="first"). The field's original value/type still wins because it comes from that same source feature. - Copy the properties object so the result layer never aliases the input layer's. - Extend the dissolve tests to assert a non-dissolve attribute survives, both with and without a dissolve field.
1 parent 0473263 commit 917dc58

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

packages/processing/src/vector-tools.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,42 @@ function explodeToPolygons(features: Feature[]): Feature<Polygon>[] {
9494
return result;
9595
}
9696

97+
/** Reassemble Turf dissolve parts into one Polygon/MultiPolygon per group. */
98+
function collectDissolveParts(
99+
dissolved: FeatureCollection<Polygon>,
100+
field?: string,
101+
originalProperties?: ReadonlyMap<string, GeoJsonProperties>,
102+
): FeatureCollection<Polygon | MultiPolygon> {
103+
const groups = new Map<string, Feature<Polygon>[]>();
104+
for (const feature of dissolved.features) {
105+
const key = field ? String(feature.properties?.[field]) : "";
106+
const group = groups.get(key);
107+
if (group) group.push(feature);
108+
else groups.set(key, [feature]);
109+
}
110+
111+
const features: Feature<Polygon | MultiPolygon>[] = [...groups.entries()].map(([key, parts]) => {
112+
// Mirror GeoPandas' `dissolve` (aggfunc="first"): every merged feature keeps
113+
// the first source feature's whole attribute set, so the dissolve field keeps
114+
// its original type and the other attributes survive the merge — whether or
115+
// not the group happened to stay connected.
116+
const source = originalProperties?.get(key) ?? parts[0].properties;
117+
const properties: GeoJsonProperties = source ? { ...source } : {};
118+
if (parts.length === 1) {
119+
return { ...parts[0], properties };
120+
}
121+
return {
122+
type: "Feature" as const,
123+
properties,
124+
geometry: {
125+
type: "MultiPolygon" as const,
126+
coordinates: parts.map((part) => part.geometry.coordinates),
127+
},
128+
};
129+
});
130+
return featureCollection(features);
131+
}
132+
97133
/** Merge all polygons of a collection into a single (multi)polygon feature. */
98134
function mergePolygons(fc: FeatureCollection): Feature<Polygon | MultiPolygon> | null {
99135
const polys = polygonFeatures(fc);
@@ -266,9 +302,17 @@ export const dissolveTool: ProcessingAlgorithm = {
266302
return;
267303
}
268304
const field = (ctx.parameters.field as string)?.trim();
269-
const dissolved = dissolve(featureCollection(polys), {
305+
// Remember the first source feature of each group so the merged output can
306+
// carry its attributes through, the way the sidecar's GeoPandas dissolve does.
307+
const originalProperties = new Map<string, GeoJsonProperties>();
308+
for (const polygon of polys) {
309+
const key = field ? String(polygon.properties?.[field]) : "";
310+
if (!originalProperties.has(key)) originalProperties.set(key, polygon.properties);
311+
}
312+
const dissolvedParts = dissolve(featureCollection(polys), {
270313
propertyName: field || undefined,
271314
});
315+
const dissolved = collectDissolveParts(dissolvedParts, field || undefined, originalProperties);
272316
ctx.log(`Dissolved ${polys.length} polygon(s) into ${dissolved.features.length} feature(s)`);
273317
ctx.addResultLayer?.("Dissolve", dissolved);
274318
},

tests/processing.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,113 @@ describe("processing registry", () => {
5252
assert.deepEqual(messages, ["Feature count: 2"]);
5353
});
5454

55+
it("dissolves disconnected polygons into one feature per attribute value", () => {
56+
const square = (x: number, group: string | number, name = `square-${x}`) => ({
57+
type: "Feature" as const,
58+
properties: { group, name },
59+
geometry: {
60+
type: "Polygon" as const,
61+
coordinates: [
62+
[
63+
[x, 0],
64+
[x + 1, 0],
65+
[x + 1, 1],
66+
[x, 1],
67+
[x, 0],
68+
],
69+
],
70+
},
71+
});
72+
const polygons: GeoLibreLayer = {
73+
...layer,
74+
id: "polygons",
75+
geojson: {
76+
type: "FeatureCollection",
77+
features: [square(0, 5), square(3, 5), square(6, "B")],
78+
},
79+
};
80+
const messages: string[] = [];
81+
let result: FeatureCollection | null = null;
82+
83+
getVectorTool("dissolve")!.run({
84+
layers: [polygons],
85+
parameters: { layer: "polygons", field: "group" },
86+
log: (message) => messages.push(message),
87+
addResultLayer: (_name, geojson) => {
88+
result = geojson;
89+
},
90+
});
91+
92+
assert.equal(result!.features.length, 2);
93+
const numericGroup = result!.features.find((feature) => feature.properties?.group === 5);
94+
assert.equal(numericGroup?.properties?.group, 5);
95+
assert.equal(numericGroup?.geometry.type, "MultiPolygon");
96+
// Merged groups keep the first source feature's other attributes, matching
97+
// the sidecar's GeoPandas dissolve.
98+
assert.equal(numericGroup?.properties?.name, "square-0");
99+
const connectedGroup = result!.features.find((feature) => feature.properties?.group === "B");
100+
assert.equal(connectedGroup?.properties?.name, "square-6");
101+
assert.deepEqual(messages, ["Dissolved 3 polygon(s) into 2 feature(s)"]);
102+
});
103+
104+
it("dissolves all disconnected polygons into one feature without a field", () => {
105+
const polygons: GeoLibreLayer = {
106+
...layer,
107+
id: "polygons",
108+
geojson: {
109+
type: "FeatureCollection",
110+
features: [
111+
{
112+
type: "Feature",
113+
properties: { name: "first" },
114+
geometry: {
115+
type: "Polygon",
116+
coordinates: [
117+
[
118+
[0, 0],
119+
[1, 0],
120+
[1, 1],
121+
[0, 1],
122+
[0, 0],
123+
],
124+
],
125+
},
126+
},
127+
{
128+
type: "Feature",
129+
properties: { name: "second" },
130+
geometry: {
131+
type: "Polygon",
132+
coordinates: [
133+
[
134+
[3, 0],
135+
[4, 0],
136+
[4, 1],
137+
[3, 1],
138+
[3, 0],
139+
],
140+
],
141+
},
142+
},
143+
],
144+
},
145+
};
146+
let result: FeatureCollection | null = null;
147+
148+
getVectorTool("dissolve")!.run({
149+
layers: [polygons],
150+
parameters: { layer: "polygons" },
151+
log: () => {},
152+
addResultLayer: (_name, geojson) => {
153+
result = geojson;
154+
},
155+
});
156+
157+
assert.equal(result!.features.length, 1);
158+
assert.equal(result!.features[0].geometry.type, "MultiPolygon");
159+
assert.equal(result!.features[0].properties?.name, "first");
160+
});
161+
55162
it("spatially joins zone attributes onto points", () => {
56163
const zone: GeoLibreLayer = {
57164
...layer,

0 commit comments

Comments
 (0)