Skip to content

Commit 68b04cf

Browse files
authored
fix(geo-editor): keep edit overlay at the edited layer's z-position (#1015) (#1017)
* fix(geo-editor): keep edit overlay at the edited layer's z-position When geometry editing is active, the Geoman edit layers (and the GeoEditor selection layers) could drift below other layers loaded above the edited layer, so the features being edited disappeared behind them. MapController.syncLayers reorders the map's style layers on every layers change and has no knowledge of the editor overlay, so the overlay drifted out of place after any re-sync (e.g. toggling another layer's visibility). The editor now repositions the overlay to sit directly above the edited layer's own (hidden) map layers whenever it (re)shows the overlay or the layers array changes during a session, restoring the layer-tree order. The ordering decision is a pure helper (planGeoEditorOverlayOrder) with unit tests. Fixes #1015 * refactor(geo-editor): harden overlay-ordering helpers per review - Fall back to the conventional layer ids when nativeLayerIds holds only non-string entries. - Document where the geo-editor selection-layer prefix comes from and that it degrades safely if the library renames those layers. - Coalesce the scheduled display re-applies so a burst of unrelated layer updates during an edit session collapses to one apply per phase.
1 parent 6ead07d commit 68b04cf

3 files changed

Lines changed: 289 additions & 13 deletions

File tree

packages/plugins/src/plugins/geo-editor-geometry.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,91 @@ export function reconcileEditedFeatures(
160160
}),
161161
};
162162
}
163+
164+
/** A map style layer described by what role it plays for overlay ordering. */
165+
export interface OverlayOrderLayer {
166+
/** The MapLibre style layer id. */
167+
id: string;
168+
/** True for the GeoEditor overlay (Geoman `gm_*` and `geo-editor-*`) layers. */
169+
isOverlay: boolean;
170+
/** True for the edited layer's own (anchor) map layers. */
171+
isAnchor: boolean;
172+
}
173+
174+
/** The move the caller should apply to keep the overlay above the edited layer. */
175+
export interface OverlayOrderPlan {
176+
/** The ids of the overlay layers, in their current bottom-to-top order. */
177+
overlayIds: string[];
178+
/**
179+
* The MapLibre `beforeId` to pass to `moveLayer` for each overlay layer: the
180+
* first non-overlay layer above the edited layer, or `undefined` to move the
181+
* overlay to the very top (nothing but overlay sits above the edited layer).
182+
*/
183+
beforeId: string | undefined;
184+
}
185+
186+
/**
187+
* Decide how to reposition the GeoEditor overlay so it renders at the edited
188+
* layer's slot in the stack. `MapController.syncLayers` reorders the map's
189+
* layers on every layers change and has no knowledge of the overlay, so without
190+
* this the overlay drifts below any layer stacked above the edited one and the
191+
* edit features disappear behind it (issue #1015).
192+
*
193+
* Returns `null` when nothing should move: the edited layer is not on the map
194+
* (no anchor), there are no overlay layers, or the overlay already sits in one
195+
* contiguous run directly above the anchor (so re-applying would needlessly
196+
* churn the style and re-fire `styledata`).
197+
*
198+
* @param layers The map's style layers, bottom-to-top, tagged with their roles.
199+
* @returns The reposition plan, or `null` when no move is needed.
200+
*/
201+
export function planGeoEditorOverlayOrder(
202+
layers: OverlayOrderLayer[],
203+
): OverlayOrderPlan | null {
204+
let lastAnchorIndex = -1;
205+
for (let i = 0; i < layers.length; i += 1) {
206+
if (layers[i].isAnchor) lastAnchorIndex = i;
207+
}
208+
// Without the edited layer on the map there is no anchor; leave the overlay
209+
// where Geoman placed it (on top) rather than guessing a position.
210+
if (lastAnchorIndex < 0) return null;
211+
212+
const overlayIds = layers
213+
.filter((layer) => layer.isOverlay)
214+
.map((layer) => layer.id);
215+
if (overlayIds.length === 0) return null;
216+
217+
if (overlayLayersAlreadyPositioned(layers, overlayIds.length, lastAnchorIndex)) {
218+
return null;
219+
}
220+
221+
// Anchor the overlay just below the first non-overlay layer above the edited
222+
// layer (or on top of the map when nothing sits above it).
223+
let beforeId: string | undefined;
224+
for (let i = lastAnchorIndex + 1; i < layers.length; i += 1) {
225+
if (!layers[i].isOverlay) {
226+
beforeId = layers[i].id;
227+
break;
228+
}
229+
}
230+
231+
return { overlayIds, beforeId };
232+
}
233+
234+
/**
235+
* Whether the overlay layers already sit in one contiguous run directly above
236+
* the edited layer's anchor layers, so no reposition is needed.
237+
*/
238+
function overlayLayersAlreadyPositioned(
239+
layers: OverlayOrderLayer[],
240+
overlayCount: number,
241+
lastAnchorIndex: number,
242+
): boolean {
243+
const start = lastAnchorIndex + 1;
244+
if (start + overlayCount > layers.length) return false;
245+
for (let i = 0; i < overlayCount; i += 1) {
246+
if (!layers[start + i].isOverlay) return false;
247+
}
248+
const after = layers[start + overlayCount];
249+
return !(after && after.isOverlay);
250+
}

packages/plugins/src/plugins/maplibre-geo-editor.ts

Lines changed: 127 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { GeoEditor, type GeoEditorOptions } from "maplibre-gl-geo-editor";
1111
import {
1212
SKETCHES_SOURCE_KIND,
1313
canEditLayerGeometry,
14+
planGeoEditorOverlayOrder,
1415
reconcileEditedFeatures,
1516
tagFeatureKeys,
1617
} from "./geo-editor-geometry";
@@ -791,17 +792,18 @@ function bindSketchesStoreSync(): void {
791792
const previousTarget = previous.layers.find(
792793
(layer) => layer.id === editTargetLayerId,
793794
);
794-
if (
795-
previousTarget &&
796-
(target.visible !== previousTarget.visible ||
797-
target.opacity !== previousTarget.opacity ||
798-
target.style !== previousTarget.style)
799-
) {
800-
// If the user toggled the target layer back on while editing, re-hide it
801-
// so its stale normal rendering does not double-draw over Geoman.
802-
if (target.visible && !previousTarget.visible) {
803-
setEditTargetStoreVisible(editTargetLayerId, false);
804-
}
795+
// If the user toggled the target layer back on while editing, re-hide it
796+
// so its stale normal rendering does not double-draw over Geoman.
797+
if (target.visible && previousTarget && !previousTarget.visible) {
798+
setEditTargetStoreVisible(editTargetLayerId, false);
799+
}
800+
// Any change to the layers array (this layer's visibility/opacity/style, or
801+
// another layer being added, removed, reordered, or toggled) re-runs
802+
// MapController.syncLayers, which reorders the map's style layers and can
803+
// push the editor's overlay below a layer stacked above the edited one
804+
// (issue #1015). Re-apply the edit display so the overlay returns to the
805+
// edited layer's slot in the stack.
806+
if (state.layers !== previous.layers) {
805807
scheduleApplySketchesMapDisplay();
806808
}
807809
return;
@@ -884,12 +886,14 @@ function applySketchesMapDisplay(): void {
884886
// visibility for the target is unnecessary and would race the layer sync.
885887
if (editTargetLayerId) {
886888
showGeomanDisplayLayers();
889+
positionGeoEditorOverlayLayers();
887890
scheduleShowGeomanDisplayLayersOnStyleData();
888891
return;
889892
}
890893

891894
if (isGeoEditorInteractionMode()) {
892895
showGeomanDisplayLayers();
896+
positionGeoEditorOverlayLayers();
893897
scheduleShowGeomanDisplayLayersOnStyleData();
894898
setSketchesMapLayerSuppressed(true);
895899
return;
@@ -899,9 +903,30 @@ function applySketchesMapDisplay(): void {
899903
setSketchesMapLayerSuppressed(false);
900904
}
901905

906+
// Coalesce flags so a burst of store updates (e.g. an opacity slider dragged on
907+
// any layer while a geometry-edit session is active, each of which re-runs
908+
// MapController.syncLayers and can disturb the overlay ordering) collapses to a
909+
// single apply per phase instead of one apply per update. The two phases are
910+
// kept: a microtask pass and a macrotask pass, so the display is re-applied both
911+
// before and after the layer sync that the same update triggers.
912+
let applyMicrotaskPending = false;
913+
let applyMacrotaskPending = false;
914+
902915
function scheduleApplySketchesMapDisplay(): void {
903-
queueMicrotask(() => applySketchesMapDisplay());
904-
window.setTimeout(() => applySketchesMapDisplay(), 0);
916+
if (!applyMicrotaskPending) {
917+
applyMicrotaskPending = true;
918+
queueMicrotask(() => {
919+
applyMicrotaskPending = false;
920+
applySketchesMapDisplay();
921+
});
922+
}
923+
if (!applyMacrotaskPending) {
924+
applyMacrotaskPending = true;
925+
window.setTimeout(() => {
926+
applyMacrotaskPending = false;
927+
applySketchesMapDisplay();
928+
}, 0);
929+
}
905930
}
906931

907932
function scheduleShowGeomanDisplayLayersOnStyleData(): void {
@@ -912,6 +937,7 @@ function scheduleShowGeomanDisplayLayersOnStyleData(): void {
912937
pendingStyleDataListener = null;
913938
if (editTargetLayerId || isGeoEditorInteractionMode()) {
914939
showGeomanDisplayLayers();
940+
positionGeoEditorOverlayLayers();
915941
}
916942
};
917943
map.once("styledata", pendingStyleDataListener);
@@ -985,6 +1011,72 @@ function showGeomanDisplayLayers(): void {
9851011
setGeomanDisplayLayersVisibility("visible");
9861012
}
9871013

1014+
/**
1015+
* The edited layer's own map layers, used as the z-anchor for the editor
1016+
* overlay. Add-Vector-Layer layers carry their layer ids in
1017+
* `metadata.nativeLayerIds`; plain geojson layers use the conventional
1018+
* `layer-<id>-*` ids. Only ids that actually exist on the map are returned.
1019+
*/
1020+
function geoEditorTargetAnchorLayerIds(
1021+
map: maplibregl.Map,
1022+
layer: GeoLibreLayer,
1023+
): string[] {
1024+
const nativeLayerIds = layer.metadata.nativeLayerIds;
1025+
// Filter to strings first, then fall back: a non-empty `nativeLayerIds` that
1026+
// holds only non-string entries must still fall back to the conventional ids.
1027+
const stringIds = Array.isArray(nativeLayerIds)
1028+
? nativeLayerIds.filter((id): id is string => typeof id === "string")
1029+
: [];
1030+
const candidates =
1031+
stringIds.length > 0 ? stringIds : sketchesMapLayerIds(layer.id);
1032+
return candidates.filter((id) => map.getLayer(id));
1033+
}
1034+
1035+
/**
1036+
* Move the editor's overlay layers (Geoman's `gm_*` display layers and the
1037+
* GeoEditor selection layers) to sit immediately above the edited layer's own
1038+
* (hidden) map layers, so the features being edited render at that layer's
1039+
* position in the tree.
1040+
*
1041+
* `MapController.syncLayers` reorders the store's layers on every layers change
1042+
* and has no knowledge of the overlay, so without this the overlay drifts below
1043+
* any layer stacked above the edited one and the edit features disappear behind
1044+
* it (issue #1015). Idempotent: `planGeoEditorOverlayOrder` returns `null` when
1045+
* the overlay is already in place, so the `styledata` that `moveLayer` triggers
1046+
* does not loop.
1047+
*/
1048+
function positionGeoEditorOverlayLayers(): void {
1049+
const map = appApi?.getMap?.();
1050+
if (!map) return;
1051+
const styleLayers = map.getStyle()?.layers;
1052+
if (!styleLayers || styleLayers.length === 0) return;
1053+
1054+
const target = activeEditableLayer(useAppStore.getState().layers);
1055+
if (!target) return;
1056+
const anchorIds = new Set(geoEditorTargetAnchorLayerIds(map, target));
1057+
// Without the edited layer on the map there is no anchor; leave the overlay
1058+
// where Geoman placed it (on top) rather than guessing a position.
1059+
if (anchorIds.size === 0) return;
1060+
1061+
const plan = planGeoEditorOverlayOrder(
1062+
styleLayers.map((layer) => ({
1063+
id: layer.id,
1064+
isOverlay: isGeoEditorOverlayLayer(layer),
1065+
isAnchor: anchorIds.has(layer.id),
1066+
})),
1067+
);
1068+
if (!plan) return;
1069+
1070+
// Moving each id before the same anchor preserves the overlay's draw order.
1071+
for (const id of plan.overlayIds) {
1072+
try {
1073+
map.moveLayer(id, plan.beforeId);
1074+
} catch {
1075+
// A layer may have been removed by a concurrent style change.
1076+
}
1077+
}
1078+
}
1079+
9881080
function applyGeomanSketchesStyle(
9891081
map: maplibregl.Map,
9901082
sketchesLayer: GeoLibreLayer,
@@ -1078,6 +1170,28 @@ function setGeomanPaintProperty(
10781170
}
10791171
}
10801172

1173+
/**
1174+
* Every map layer that belongs to the editor overlay and must be kept above the
1175+
* edited layer: Geoman's `gm_*` display layers plus the GeoEditor's own
1176+
* selection layers. Used only for z-ordering, so it is broader than
1177+
* `isGeomanDisplayLayer` (which drives show/hide of the Geoman layers).
1178+
*
1179+
* The `geo-editor` prefix matches the layers `maplibre-gl-geo-editor` adds for
1180+
* its selection highlight (observed ids `geo-editor-selection-{fill,line,
1181+
* circle}-layer` on the `geo-editor-selection-source` source). The library does
1182+
* not export those ids, so this stays a prefix heuristic; if a future version
1183+
* renames them the overlay would simply not be re-stacked (a safe degradation,
1184+
* no error). The Geoman `gm_*` layers are matched by `isGeomanDisplayLayer`.
1185+
*/
1186+
function isGeoEditorOverlayLayer(layer: maplibregl.LayerSpecification): boolean {
1187+
if (isGeomanDisplayLayer(layer)) return true;
1188+
const id = layer.id.toLowerCase();
1189+
if (id.startsWith("geo-editor")) return true;
1190+
if (!("source" in layer)) return false;
1191+
const source = layer.source;
1192+
return typeof source === "string" && source.startsWith("geo-editor");
1193+
}
1194+
10811195
function isGeomanDisplayLayer(layer: maplibregl.LayerSpecification): boolean {
10821196
const id = layer.id.toLowerCase();
10831197
if (id.startsWith("gm_") || id.startsWith("gm-")) {

tests/geo-editor-geometry.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import type { FeatureCollection } from "geojson";
44
import type { GeoLibreLayer } from "../packages/core/src/types";
55
import {
66
GEOMETRY_EDIT_FID_PROPERTY,
7+
type OverlayOrderLayer,
78
canEditLayerGeometry,
9+
planGeoEditorOverlayOrder,
810
reconcileEditedFeatures,
911
tagFeatureKeys,
1012
} from "../packages/plugins/src/plugins/geo-editor-geometry";
@@ -256,3 +258,75 @@ describe("reconcileEditedFeatures", () => {
256258
assert.equal(new Set(ids).size, ids.length);
257259
});
258260
});
261+
262+
describe("planGeoEditorOverlayOrder", () => {
263+
function row(
264+
id: string,
265+
flags: Partial<OverlayOrderLayer> = {},
266+
): OverlayOrderLayer {
267+
return { id, isOverlay: false, isAnchor: false, ...flags };
268+
}
269+
270+
it("raises overlay above a layer stacked over the edited layer (issue #1015)", () => {
271+
// Bottom-to-top: the overlay has sunk below the raster, which is stacked
272+
// above the (hidden) edited layer; it must move back up to the edited slot.
273+
const plan = planGeoEditorOverlayOrder([
274+
row("basemap"),
275+
row("gm_main-fill", { isOverlay: true }),
276+
row("geo-editor-selection-fill", { isOverlay: true }),
277+
row("xyz-raster"),
278+
row("edited-fill", { isAnchor: true }),
279+
row("edited-line", { isAnchor: true }),
280+
]);
281+
assert.deepEqual(plan, {
282+
overlayIds: ["gm_main-fill", "geo-editor-selection-fill"],
283+
// The edited layer is the topmost data layer here, so nothing real sits
284+
// above it: the overlay goes to the very top.
285+
beforeId: undefined,
286+
});
287+
});
288+
289+
it("anchors the overlay just below the first layer above the edited layer", () => {
290+
// The overlay has sunk to the bottom; the edited layer is genuinely below a
291+
// raster, so the overlay must return to the edited layer's slot (below the
292+
// raster), not jump to the very top.
293+
const plan = planGeoEditorOverlayOrder([
294+
row("basemap"),
295+
row("gm_main-fill", { isOverlay: true }),
296+
row("edited-fill", { isAnchor: true }),
297+
row("raster-on-top"),
298+
]);
299+
assert.deepEqual(plan, {
300+
overlayIds: ["gm_main-fill"],
301+
beforeId: "raster-on-top",
302+
});
303+
});
304+
305+
it("returns null when the overlay already sits directly above the anchor", () => {
306+
const plan = planGeoEditorOverlayOrder([
307+
row("basemap"),
308+
row("edited-fill", { isAnchor: true }),
309+
row("edited-line", { isAnchor: true }),
310+
row("gm_main-fill", { isOverlay: true }),
311+
row("geo-editor-selection-fill", { isOverlay: true }),
312+
row("raster-on-top"),
313+
]);
314+
assert.equal(plan, null);
315+
});
316+
317+
it("returns null when the edited layer is not on the map (no anchor)", () => {
318+
const plan = planGeoEditorOverlayOrder([
319+
row("basemap"),
320+
row("gm_main-fill", { isOverlay: true }),
321+
]);
322+
assert.equal(plan, null);
323+
});
324+
325+
it("returns null when there are no overlay layers", () => {
326+
const plan = planGeoEditorOverlayOrder([
327+
row("basemap"),
328+
row("edited-fill", { isAnchor: true }),
329+
]);
330+
assert.equal(plan, null);
331+
});
332+
});

0 commit comments

Comments
 (0)