@@ -11,6 +11,7 @@ import { GeoEditor, type GeoEditorOptions } from "maplibre-gl-geo-editor";
1111import {
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+
902915function 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
907932function 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+
9881080function 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+
10811195function isGeomanDisplayLayer ( layer : maplibregl . LayerSpecification ) : boolean {
10821196 const id = layer . id . toLowerCase ( ) ;
10831197 if ( id . startsWith ( "gm_" ) || id . startsWith ( "gm-" ) ) {
0 commit comments