Description
OverlayManager.applyLoadedOrder() re-applies the loaded array's order to the real MapLibre map by walking from the top of the stack down and anchoring each overlay directly before its immediate array neighbour:
static applyLoadedOrder(): void {
for (let i = this.loaded.length - 1; i >= 0; i--) {
this.loaded[i].moveBefore(this.loaded[i + 1]);
}
}
Overlay.moveBefore(overlay) resolves the anchor to move before as the first non-background style layer belonging to overlay. If that neighbour overlay has zero style layers of its own, there is nothing to anchor to, and moveBefore() falls back to mapStore.map.moveLayer(layerId) with no before argument — which MapLibre defines as moving the layer to the literal top of the entire map, above everything, not just above its intended neighbour.
An overlay with zero style layers is not a hypothetical edge case — it's a permanent fixture of every CloudTAK profile: ensureDefaultTerrain() (api/stateless/lib/control/user.ts) auto-provisions a hidden raster-dem overlay for the server's configured default terrain on every login, and Overlay.init() (overlay-class.ts) leaves a raster-dem overlay's styles as [] (raster-dem terrain is applied via setTerrain(), not style layers). This overlay is deliberately hidden from the Overlays menu (MenuOverlays.vue filters overlay.type === 'raster-dem' out of the card list) but it is not filtered out of OverlayManager.loaded, so it still occupies a slot in the array and still gets anchored against during applyLoadedOrder().
Whenever this terrain overlay ends up sorted directly above some other overlay X in loaded, X.moveBefore(terrainOverlay) has no anchor layer to use and pushes X's layers to the literal top of the map — above every overlay that should be above X, including the "Map Features" internal overlay that is supposed to always render on top. Every overlay below X in loaded that anchors to X afterward inherits the same corruption, since X's layers are no longer where the stack order says they should be.
Steps to Reproduce
- Ensure the server has a default terrain configured (
map::terrain setting) so ensureDefaultTerrain() provisions the hidden raster-dem overlay on login — this is the default in most deployments.
- Open
/menu/overlays with at least two other (non-basemap) overlays loaded, so the hidden terrain overlay sits between one of them and the basemap/Map Features in the real pos ordering.
- Enable reorder mode and drag any two of the visible overlays to swap their order. Save.
Expected: Only the two dragged overlays change position in the real map stack; the basemap stays at the bottom and Map Features stays at the top.
Actual: The basemap (or another overlay) can jump above "Map Features," visually breaking the layer stack, because whichever overlay ended up anchored against the layerless terrain overlay got moved to the literal top of the map instead of to its intended position.
Root Cause
Confirmed via a unit-level reproduction against a fake MapLibre map that implements real moveLayer/addLayer z-order semantics: with loaded = [basemap, m1, m2, terrainOverlay, mapFeatures] and a drag that swaps m1/m2, applyLoadedOrder()'s per-overlay anchoring degenerates because terrainOverlay.anchorLayerId() returns undefined (it has no styles), so whichever overlay is anchored to it falls through to moveLayer(id) with no before — moving to the literal top. The bug is in applyLoadedOrder()'s anchor selection, not in moveBefore() itself: moveBefore() correctly falls back to "move to top" when it's given undefined or an overlay with no layers, but applyLoadedOrder() should never have handed it an anchor that resolves to nothing when a better one existed.
Two existing helpers already solve exactly this by searching upward for the nearest overlay that actually resolves an anchor — OverlayManager.loadedAnchorFrom() and OverlayManager.loadedBeforeOverlay() — but applyLoadedOrder() does not use either of them; it anchors to the raw array neighbour instead.
Impact
- Any client with the (very common) default-terrain feature enabled is at risk of this on every overlay reorder, not just an edge case.
- The corruption is visual/runtime only (no
pos is written incorrectly by this particular bug), but it makes the Overlays menu and the real map disagree until the next full page reload triggers reconcileOverlaysOnce() — and even then, the same anchor bug re-corrupts the stack the next time applyLoadedOrder() runs (e.g. on any subsequent reorder, or overlay add/remove).
- Compounds with the separate
pos-reassignment bug (see overlay-reorder-corrupts-basemap-and-map-features-position.md) to make basemap/Map Features positioning unreliable in more than one way.
Recommended Fix
In OverlayManager.applyLoadedOrder(), search upward from i + 1 for the nearest overlay whose anchorLayerId() resolves to a real layer id, instead of anchoring blindly to this.loaded[i + 1]:
static applyLoadedOrder(): void {
for (let i = this.loaded.length - 1; i >= 0; i--) {
let anchor: Overlay | undefined;
for (let j = i + 1; j < this.loaded.length; j++) {
if (this.loaded[j].anchorLayerId()) {
anchor = this.loaded[j];
break;
}
}
this.loaded[i].moveBefore(anchor);
}
}
This mirrors the pattern already used by loadedAnchorFrom()/loadedBeforeOverlay() and only changes behavior when an intervening overlay has no layers on the map — the common case (every overlay has layers) is unaffected.
Description
OverlayManager.applyLoadedOrder()re-applies theloadedarray's order to the real MapLibre map by walking from the top of the stack down and anchoring each overlay directly before its immediate array neighbour:Overlay.moveBefore(overlay)resolves the anchor to move before as the first non-background style layer belonging tooverlay. If that neighbour overlay has zero style layers of its own, there is nothing to anchor to, andmoveBefore()falls back tomapStore.map.moveLayer(layerId)with nobeforeargument — which MapLibre defines as moving the layer to the literal top of the entire map, above everything, not just above its intended neighbour.An overlay with zero style layers is not a hypothetical edge case — it's a permanent fixture of every CloudTAK profile:
ensureDefaultTerrain()(api/stateless/lib/control/user.ts) auto-provisions a hiddenraster-demoverlay for the server's configured default terrain on every login, andOverlay.init()(overlay-class.ts) leaves araster-demoverlay'sstylesas[](raster-dem terrain is applied viasetTerrain(), not style layers). This overlay is deliberately hidden from the Overlays menu (MenuOverlays.vuefiltersoverlay.type === 'raster-dem'out of the card list) but it is not filtered out ofOverlayManager.loaded, so it still occupies a slot in the array and still gets anchored against duringapplyLoadedOrder().Whenever this terrain overlay ends up sorted directly above some other overlay X in
loaded,X.moveBefore(terrainOverlay)has no anchor layer to use and pushes X's layers to the literal top of the map — above every overlay that should be above X, including the "Map Features" internal overlay that is supposed to always render on top. Every overlay below X inloadedthat anchors to X afterward inherits the same corruption, since X's layers are no longer where the stack order says they should be.Steps to Reproduce
map::terrainsetting) soensureDefaultTerrain()provisions the hidden raster-dem overlay on login — this is the default in most deployments./menu/overlayswith at least two other (non-basemap) overlays loaded, so the hidden terrain overlay sits between one of them and the basemap/Map Features in the realposordering.Expected: Only the two dragged overlays change position in the real map stack; the basemap stays at the bottom and Map Features stays at the top.
Actual: The basemap (or another overlay) can jump above "Map Features," visually breaking the layer stack, because whichever overlay ended up anchored against the layerless terrain overlay got moved to the literal top of the map instead of to its intended position.
Root Cause
Confirmed via a unit-level reproduction against a fake MapLibre map that implements real
moveLayer/addLayerz-order semantics: withloaded=[basemap, m1, m2, terrainOverlay, mapFeatures]and a drag that swapsm1/m2,applyLoadedOrder()'s per-overlay anchoring degenerates becauseterrainOverlay.anchorLayerId()returnsundefined(it has no styles), so whichever overlay is anchored to it falls through tomoveLayer(id)with nobefore— moving to the literal top. The bug is inapplyLoadedOrder()'s anchor selection, not inmoveBefore()itself:moveBefore()correctly falls back to "move to top" when it's givenundefinedor an overlay with no layers, butapplyLoadedOrder()should never have handed it an anchor that resolves to nothing when a better one existed.Two existing helpers already solve exactly this by searching upward for the nearest overlay that actually resolves an anchor —
OverlayManager.loadedAnchorFrom()andOverlayManager.loadedBeforeOverlay()— butapplyLoadedOrder()does not use either of them; it anchors to the raw array neighbour instead.Impact
posis written incorrectly by this particular bug), but it makes the Overlays menu and the real map disagree until the next full page reload triggersreconcileOverlaysOnce()— and even then, the same anchor bug re-corrupts the stack the next timeapplyLoadedOrder()runs (e.g. on any subsequent reorder, or overlay add/remove).pos-reassignment bug (seeoverlay-reorder-corrupts-basemap-and-map-features-position.md) to make basemap/Map Features positioning unreliable in more than one way.Recommended Fix
In
OverlayManager.applyLoadedOrder(), search upward fromi + 1for the nearest overlay whoseanchorLayerId()resolves to a real layer id, instead of anchoring blindly tothis.loaded[i + 1]:This mirrors the pattern already used by
loadedAnchorFrom()/loadedBeforeOverlay()and only changes behavior when an intervening overlay has no layers on the map — the common case (every overlay has layers) is unaffected.