From 566e0e7c4079d791ce3933ddc58255f35b46e560 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 8 Nov 2026 00:17:19 -0800 Subject: [PATCH] Stack overlay layers in one fixed order Every layer this SDK adds now goes in with the beforeId from overlayLayerBeforeId() (js-sdk-core overlayStackBeforeId), so the stack is raster < ground image < polygon < circle < polyline < marker whatever order overlays were added in, re-sent in after clearOverlays(), or put back in after a style swap. Before: - circles went in before the polygon fill, so under the polygons; - ground images and polylines were appended at the top, so a ground image added (or re-sent after clearOverlays()) after the polygons covered them and the polyline; - a raster went under the first basemap label only, not under our layers when those sat below the labels. Rasters keep going under the basemap labels as well (unchanged), and are ordered by zIndex among themselves; a zIndex change moves the layer. Marker tiles, mounted as a raster, sit with the markers. Labels stay under the ground images, shapes and markers (unchanged). OverlayStackOrder.test.mjs drives the real layer classes on a fake GL map: all 120 insertion orders, a ground image and raster re-sent after the others, a style swap, raster zIndex. It fails on the old code (circles under polygons, the re-sent ground image on top) and passes now. Needs the js-sdk-core release with overlayStackBeforeId (fix/overlay-stacking-order). --- api/react-for-mapbox.api.d.ts | 13 ++ src/circle/MapboxCircleLayer.ts | 13 +- .../MapboxGroundImageOverlayRenderer.ts | 24 ++- src/helpers.ts | 84 +++++++- src/polygon/MapboxPolygonLayer.ts | 14 +- src/polyline/MapboxPolylineLayer.ts | 4 +- .../MapboxRasterLayerOverlayRenderer.ts | 45 ++++- test/OverlayStackOrder.test.mjs | 187 ++++++++++++++++++ test/PolygonController.test.mjs | 2 + 9 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 test/OverlayStackOrder.test.mjs diff --git a/api/react-for-mapbox.api.d.ts b/api/react-for-mapbox.api.d.ts index 79d9a97..76afbe3 100644 --- a/api/react-for-mapbox.api.d.ts +++ b/api/react-for-mapbox.api.d.ts @@ -403,6 +403,12 @@ declare class MapboxGroundImageOverlayRenderer extends AbstractGroundImageOverla /** Sync an already-created image source+layer to the current state (diffed). */ private applyToExisting; removeGroundImage(entity: GroundImageEntity): Promise; + /** + * Under the polygons, circles, polylines and markers, above the rasters, whatever order the + * overlays were added in. Ground images have no zIndex; among themselves they keep the order + * they were handed over in. + */ + private groundImageBeforeId; private removeFromStyle; } @@ -458,6 +464,13 @@ declare class MapboxRasterLayerOverlayRenderer implements RasterLayerOverlayRend onCameraChanged(_mapCameraPosition: MapCameraPosition): Promise; onPostProcess(): Promise; private addLayer; + /** This raster's place in the overlay stack: the lowest band, ordered by zIndex. */ + private stackKey; + /** + * Under every other overlay (the raster band is the lowest), and under the basemap's labels, + * whichever comes first. Marker tiles are drawn as a raster but belong with the markers. + */ + private beforeId; /** * The style's first label layer, so a raster overlay can go under it. * diff --git a/src/circle/MapboxCircleLayer.ts b/src/circle/MapboxCircleLayer.ts index 7f5464f..182ed61 100644 --- a/src/circle/MapboxCircleLayer.ts +++ b/src/circle/MapboxCircleLayer.ts @@ -3,7 +3,7 @@ import type { LayerSpecification, } from 'mapbox-gl'; import { type CircleEntity } from '@mapconductor/js-sdk-core'; -import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type PolygonFeature } from '../helpers'; +import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type PolygonFeature, overlayLayerBeforeId, overlayStackKeyOf } from '../helpers'; import { MapboxMapViewHolder } from '../MapboxMapViewHolder'; export type MapboxActualCircle = PolygonFeature & { id?: string | number }; @@ -73,14 +73,8 @@ export class MapboxCircleLayer { data: { type: 'FeatureCollection', features: [] }, }); } - const beforeId = map.getLayer('polygon-fill-layer') - ? 'polygon-fill-layer' - : map.getLayer('polyline-layer') - ? 'polyline-layer' - : map.getLayer('mc-marker-layer') - ? 'mc-marker-layer' - : undefined; if (!map.getLayer(this.layerId)) { + const beforeId = overlayLayerBeforeId(map, overlayStackKeyOf(map, this.layerId) ?? { kind: 'circle', sub: 0 }); map.addLayer( { id: this.layerId, @@ -97,8 +91,7 @@ export class MapboxCircleLayer { ); } if (!map.getLayer(this.strokeLayerId)) { - // Added with the same beforeId after the fill layer, so it renders - // directly above the fill layer. + const beforeId = overlayLayerBeforeId(map, overlayStackKeyOf(map, this.strokeLayerId) ?? { kind: 'circle', sub: 1 }); map.addLayer( { id: this.strokeLayerId, diff --git a/src/groundimage/MapboxGroundImageOverlayRenderer.ts b/src/groundimage/MapboxGroundImageOverlayRenderer.ts index 3a35280..a0045a7 100644 --- a/src/groundimage/MapboxGroundImageOverlayRenderer.ts +++ b/src/groundimage/MapboxGroundImageOverlayRenderer.ts @@ -4,7 +4,15 @@ import { type GroundImageEntity, type GroundImageState, } from '@mapconductor/js-sdk-core'; -import { bringMarkerLayersToFront, groundImageCoordinates, removeLayerIfExists, removeSourceIfExists } from '../helpers'; +import { + bringMarkerLayersToFront, + forgetOverlayStackKey, + groundImageCoordinates, + overlayLayerBeforeId, + rememberOverlayStackKey, + removeLayerIfExists, + removeSourceIfExists, +} from '../helpers'; import { MapboxMapViewHolder } from '../MapboxMapViewHolder'; type Coordinates = ReturnType; @@ -75,7 +83,7 @@ export class MapboxGroundImageOverlayRenderer extends AbstractGroundImageOverlay // but the two renderers are kept identical, so this is set on both. 'raster-fade-duration': 0, }, - } as LayerSpecification); + } as LayerSpecification, this.groundImageBeforeId(layerId)); } bringMarkerLayersToFront(this.holder.map); this.applied.set(state.id, { @@ -97,7 +105,7 @@ export class MapboxGroundImageOverlayRenderer extends AbstractGroundImageOverlay type: 'raster', source: sourceId, paint: { 'raster-opacity': state.opacity }, - } as LayerSpecification); + } as LayerSpecification, this.groundImageBeforeId(layerId)); } this.applyToExisting(state, source, layerId, coordinates); bringMarkerLayersToFront(this.holder.map); @@ -176,7 +184,17 @@ export class MapboxGroundImageOverlayRenderer extends AbstractGroundImageOverlay this.pendingRemovals.clear(); } + /** + * Under the polygons, circles, polylines and markers, above the rasters, whatever order the + * overlays were added in. Ground images have no zIndex; among themselves they keep the order + * they were handed over in. + */ + private groundImageBeforeId(layerId: string): string | undefined { + return overlayLayerBeforeId(this.holder.map, rememberOverlayStackKey(this.holder.map, layerId, 'groundImage')); + } + private removeFromStyle(id: string): void { + forgetOverlayStackKey(this.holder.map, this.layerId(id)); removeLayerIfExists(this.holder.map, this.layerId(id)); removeSourceIfExists(this.holder.map, this.sourceId(id)); } diff --git a/src/helpers.ts b/src/helpers.ts index f4db4a6..529614a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,5 @@ import type { GeoJSONSource, Map as MapboxMap, SourceSpecification } from 'mapbox-gl'; -import { createGeoPoint, TileScheme, type GeoPoint, type GroundImageState, type PolygonState, RasterLayerSource } from '@mapconductor/js-sdk-core'; +import { createGeoPoint, TileScheme, type GeoPoint, type GroundImageState, type PolygonState, RasterLayerSource, overlayStackBeforeId, type OverlayKind, type OverlayStackKey } from '@mapconductor/js-sdk-core'; export type Coordinate = [number, number]; export type GeoJSONSourceData = Parameters[0]; @@ -92,6 +92,88 @@ export function bringMarkerLayersToFront(map: MapboxMap): void { if (arranged) arrangedOrders.set(map, arranged.join('\n')); } +// ── Overlay stacking ─────────────────────────────────────────────────────── +// +// Bottom to top, on every provider: raster < ground image < polygon < circle < polyline < +// marker (js-sdk-core OVERLAY_STACK_ORDER). Every layer this SDK adds goes in with the +// beforeId from overlayLayerBeforeId(), so the order does not depend on the order things were +// added, re-sent after clearOverlays(), or put back after a style swap. It used to: polygons +// went in "before the polyline layer", circles "before the polygon fill" (under polygons), +// ground images and polylines at the top, so a ground image re-sent after the polygons covered +// them, and a raster added after clearOverlays() could cover everything. + +export const MARKER_TILES_LAYER_ID = 'mc-raster-lyr-mc-marker-tiles'; + +/** Layers with a fixed place: one per kind, holding every overlay of that kind. */ +const FIXED_STACK_KEYS: Record = { + 'polygon-fill-layer': { kind: 'polygon', sub: 0 }, + 'polygon-outline-layer': { kind: 'polygon', sub: 1 }, + 'circle-layer': { kind: 'circle', sub: 0 }, + 'circle-layer-stroke': { kind: 'circle', sub: 1 }, + 'polyline-layer': { kind: 'polyline' }, + [MARKER_TILES_LAYER_ID]: { kind: 'marker', sub: 0 }, + 'mc-marker-layer': { kind: 'marker', sub: 1 }, + 'mc-marker-drag-layer': { kind: 'marker', sub: 2 }, +}; + +// Layers holding one overlay each (rasters, ground images), per map: their zIndex and the +// order they were handed over in. +const itemStackKeys = new WeakMap>(); +const itemSequences = new WeakMap(); + +/** Where a layer of ours belongs in the stack; null for the style's own layers. */ +export function overlayStackKeyOf(map: object, layerId: string): OverlayStackKey | null { + const fixed = FIXED_STACK_KEYS[layerId]; + if (fixed) return fixed; + const item = itemStackKeys.get(map)?.get(layerId); + if (item) return item; + if (layerId.startsWith('mc-raster-lyr-')) return { kind: 'rasterLayer' }; + if (layerId.startsWith('mc-gimg-lyr-')) return { kind: 'groundImage' }; + return null; +} + +/** + * Records the key of a layer that holds one overlay. A layer re-created for the same overlay + * (a raster whose source changed, a style swap) keeps the place it had among equal zIndexes. + */ +export function rememberOverlayStackKey(map: object, layerId: string, kind: OverlayKind, zIndex = 0): OverlayStackKey { + let keys = itemStackKeys.get(map); + if (!keys) { + keys = new Map(); + itemStackKeys.set(map, keys); + } + let sequence = keys.get(layerId)?.sequence; + if (sequence == null) { + sequence = (itemSequences.get(map) ?? 0) + 1; + itemSequences.set(map, sequence); + } + const key: OverlayStackKey = { kind, zIndex, sequence }; + keys.set(layerId, key); + return key; +} + +/** The overlay is gone (not just its layer): a later one with the same id starts over. */ +export function forgetOverlayStackKey(map: object, layerId: string): void { + itemStackKeys.get(map)?.delete(layerId); +} + +/** The beforeId that puts a new layer with this key in its place in the stack. */ +export function overlayLayerBeforeId(map: MapboxMap, key: OverlayStackKey): string | undefined { + return overlayStackBeforeId(currentLayerOrder(map) ?? [], (id) => overlayStackKeyOf(map, id), key); +} + +/** Moves an existing layer of ours to its place (after its zIndex changed). */ +export function moveLayerToStackPlace(map: MapboxMap, layerId: string, key: OverlayStackKey): void { + const order = currentLayerOrder(map); + if (!order || !order.includes(layerId)) return; + const rest = order.filter((id) => id !== layerId); + const beforeId = overlayStackBeforeId(rest, (id) => overlayStackKeyOf(map, id), key); + const at = order.indexOf(layerId); + const next = order[at + 1]; + if (next === beforeId) return; // already there: moveLayer would fire styledata for nothing + map.moveLayer(layerId, beforeId); +} + // What each GeoJSON source was last given, keyed by the source object itself. A style swap // replaces the source objects, so a lost source never matches and is written again. const writtenFeatures = new WeakMap(); diff --git a/src/polygon/MapboxPolygonLayer.ts b/src/polygon/MapboxPolygonLayer.ts index c5aba25..91f8837 100644 --- a/src/polygon/MapboxPolygonLayer.ts +++ b/src/polygon/MapboxPolygonLayer.ts @@ -5,7 +5,7 @@ import type { LineFeature, PolygonFeature, } from '../helpers'; -import { bringMarkerLayersToFront, setSourceFeatures } from '../helpers'; +import { bringMarkerLayersToFront, overlayLayerBeforeId, overlayStackKeyOf, setSourceFeatures } from '../helpers'; import { MapboxMapViewHolder } from '../MapboxMapViewHolder'; export interface MapboxActualPolygon { @@ -101,11 +101,7 @@ export class MapboxPolygonLayer { }); } if (!map.getLayer(this.layerId)) { - const beforeId = map.getLayer('polyline-layer') - ? 'polyline-layer' - : map.getLayer('mc-marker-layer') - ? 'mc-marker-layer' - : undefined; + const beforeId = overlayLayerBeforeId(map, overlayStackKeyOf(map, this.layerId) ?? { kind: 'polygon', sub: 0 }); map.addLayer( { id: this.layerId, @@ -122,11 +118,7 @@ export class MapboxPolygonLayer { ); } if (!map.getLayer(this.outlineLayerId)) { - const beforeId = map.getLayer('polyline-layer') - ? 'polyline-layer' - : map.getLayer('mc-marker-layer') - ? 'mc-marker-layer' - : undefined; + const beforeId = overlayLayerBeforeId(map, overlayStackKeyOf(map, this.outlineLayerId) ?? { kind: 'polygon', sub: 1 }); map.addLayer( { id: this.outlineLayerId, diff --git a/src/polyline/MapboxPolylineLayer.ts b/src/polyline/MapboxPolylineLayer.ts index ef88633..9ab7802 100644 --- a/src/polyline/MapboxPolylineLayer.ts +++ b/src/polyline/MapboxPolylineLayer.ts @@ -1,6 +1,6 @@ import type { GeoJSONSource, LayerSpecification } from 'mapbox-gl'; import type { PolylineEntity } from '@mapconductor/js-sdk-core'; -import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type LineFeature } from '../helpers'; +import { bringMarkerLayersToFront, setSourceFeatures, type FeatureCollection, type LineFeature, overlayLayerBeforeId, overlayStackKeyOf } from '../helpers'; import { MapboxMapViewHolder } from '../MapboxMapViewHolder'; export type MapboxActualPolyline = LineFeature[]; @@ -79,7 +79,7 @@ export class MapboxPolylineLayer { 'line-width': ['get', MapboxPolylineLayer.Prop.STROKE_WIDTH], }, } as LayerSpecification, - map.getLayer('mc-marker-layer') ? 'mc-marker-layer' : undefined, + overlayLayerBeforeId(map, overlayStackKeyOf(map, this.layerId) ?? { kind: 'polyline' }), ); } bringMarkerLayersToFront(map); diff --git a/src/raster/MapboxRasterLayerOverlayRenderer.ts b/src/raster/MapboxRasterLayerOverlayRenderer.ts index a6d82bc..154b7ad 100644 --- a/src/raster/MapboxRasterLayerOverlayRenderer.ts +++ b/src/raster/MapboxRasterLayerOverlayRenderer.ts @@ -7,7 +7,18 @@ import { type RasterLayerOverlayRenderer, type RasterLayerState, } from '@mapconductor/js-sdk-core'; -import { bringMarkerLayersToFront, createRasterSource, removeLayerIfExists, removeSourceIfExists } from '../helpers'; +import { + bringMarkerLayersToFront, + createRasterSource, + forgetOverlayStackKey, + MARKER_TILES_LAYER_ID, + moveLayerToStackPlace, + overlayLayerBeforeId, + overlayStackKeyOf, + rememberOverlayStackKey, + removeLayerIfExists, + removeSourceIfExists, +} from '../helpers'; import { MapboxMapViewHolder } from '../MapboxMapViewHolder'; /** GL のソース/レイヤー ID の対。android-sdk の MapboxRasterLayerHandle と同一。 */ @@ -53,10 +64,14 @@ export class MapboxRasterLayerOverlayRenderer const { prev } = params; const next = params.current.state; if (prev.state.source !== next.source) { - this.removeLayer(prev.layer); + // Replaced, not removed: the new layer keeps the old one's place among equal zIndexes. + this.removeLayer(prev.layer, { keepStackPlace: true }); return this.addLayer(next); } this.updateLayer(prev.layer, next); + if (prev.state.zIndex !== next.zIndex && prev.layer.layerId !== MARKER_TILES_LAYER_ID && this.canEditStyle()) { + moveLayerToStackPlace(this.holder.map, prev.layer.layerId, this.stackKey(prev.layer.layerId, next)); + } return prev.layer; }); bringMarkerLayersToFront(this.holder.map); @@ -98,7 +113,7 @@ export class MapboxRasterLayerOverlayRenderer source: handle.sourceId, paint: { 'raster-opacity': opacity }, } as LayerSpecification, - this.firstBasemapLabelLayerId(), + this.beforeId(handle.layerId, state), ); } else { this.holder.map.setPaintProperty(handle.layerId, 'raster-opacity', opacity); @@ -106,6 +121,27 @@ export class MapboxRasterLayerOverlayRenderer return handle; } + /** This raster's place in the overlay stack: the lowest band, ordered by zIndex. */ + private stackKey(layerId: string, state: RasterLayerState) { + return rememberOverlayStackKey(this.holder.map, layerId, 'rasterLayer', state.zIndex); + } + + /** + * Under every other overlay (the raster band is the lowest), and under the basemap's labels, + * whichever comes first. Marker tiles are drawn as a raster but belong with the markers. + */ + private beforeId(layerId: string, state: RasterLayerState): string | undefined { + if (layerId === MARKER_TILES_LAYER_ID) { + return overlayLayerBeforeId(this.holder.map, overlayStackKeyOf(this.holder.map, layerId)!); + } + const stack = overlayLayerBeforeId(this.holder.map, this.stackKey(layerId, state)); + const label = this.firstBasemapLabelLayerId(); + if (label == null) return stack; + if (stack == null) return label; + const layers = this.holder.map.getStyle?.()?.layers?.map((layer) => layer.id) ?? []; + return layers.indexOf(label) < layers.indexOf(stack) ? label : stack; + } + /** * The style's first label layer, so a raster overlay can go under it. * @@ -150,7 +186,8 @@ export class MapboxRasterLayerOverlayRenderer } } - private removeLayer(handle: MapboxRasterLayerHandle): void { + private removeLayer(handle: MapboxRasterLayerHandle, { keepStackPlace = false } = {}): void { + if (!keepStackPlace) forgetOverlayStackKey(this.holder.map, handle.layerId); if (!this.canEditStyle()) { this.pendingRemovals.push(handle); return; diff --git a/test/OverlayStackOrder.test.mjs b/test/OverlayStackOrder.test.mjs new file mode 100644 index 0000000..76b5945 --- /dev/null +++ b/test/OverlayStackOrder.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { after, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +import { build } from 'tsup'; +import { + createGeoPoint, + createGeoRectBounds, + createGroundImageState, + createRasterLayerState, + RasterLayerSource, +} from '@mapconductor/js-sdk-core'; + +// The overlay stack, bottom to top, on every provider: raster < ground image < polygon < +// circle < polyline < marker (js-sdk-core OVERLAY_STACK_ORDER), whatever order the overlays +// were added in, re-sent in after clearOverlays(), or put back in after a style swap. +// +// It used to depend on all three: polygons went in "before the polyline layer", circles +// "before the polygon fill" (so under the polygons), ground images and polylines at the top. +// A ground image re-sent after clearOverlays() covered the polygons. +mkdirSync(new URL('../build/', import.meta.url), { recursive: true }); +const outDir = mkdtempSync(new URL('../build/stack-order-tests-', import.meta.url)); +after(() => rmSync(outDir, { recursive: true, force: true })); +await build({ + entry: { + polygon: 'src/polygon/MapboxPolygonLayer.ts', + circle: 'src/circle/MapboxCircleLayer.ts', + polyline: 'src/polyline/MapboxPolylineLayer.ts', + ground: 'src/groundimage/MapboxGroundImageOverlayRenderer.ts', + raster: 'src/raster/MapboxRasterLayerOverlayRenderer.ts', + }, + outDir, format: ['esm'], platform: 'node', splitting: false, + dts: false, sourcemap: false, config: false, silent: true, +}); +const load = async (name) => import(pathToFileURL(resolve(outDir, `${name}.mjs`))); +const { MapboxPolygonLayer } = await load('polygon'); +const { MapboxCircleLayer } = await load('circle'); +const { MapboxPolylineLayer } = await load('polyline'); +const { MapboxGroundImageOverlayRenderer } = await load('ground'); +const { MapboxRasterLayerOverlayRenderer } = await load('raster'); + +const BASEMAP = [ + { id: 'background', type: 'background' }, + { id: 'water', type: 'fill' }, + { id: 'place-label', type: 'symbol' }, +]; + +/** Enough of a GL map: an ordered layer list with addLayer(layer, beforeId). */ +function glMap() { + const sources = new Map(); + const layers = new Map(); + let order = []; + const add = (layer, before) => { + assert.ok(!layers.has(layer.id), `${layer.id} added twice`); + if (before != null) assert.ok(layers.has(before), `beforeId ${before} is not on the map`); + layers.set(layer.id, { paint: {}, ...layer }); + const at = before ? order.indexOf(before) : -1; + if (at >= 0) order.splice(at, 0, layer.id); else order.push(layer.id); + }; + const map = { + order: () => [...order], + swapStyle() { + sources.clear(); layers.clear(); order = []; + for (const layer of BASEMAP) add(layer); + }, + getStyle: () => ({ layers: order.map((id) => layers.get(id)) }), + getLayersOrder: () => [...order], + getSource: (id) => sources.get(id), + getLayer: (id) => layers.get(id), + addSource: (id, source) => { sources.set(id, { ...source, setData() {}, updateImage() {}, setCoordinates() {} }); }, + addLayer: (layer, before) => add(layer, before), + removeLayer: (id) => { layers.delete(id); order = order.filter((x) => x !== id); }, + removeSource: (id) => { sources.delete(id); }, + setPaintProperty: (id, property, value) => { layers.get(id).paint[property] = value; }, + setLayoutProperty() {}, + moveLayer: (id, before) => { + order = order.filter((x) => x !== id); + const at = before ? order.indexOf(before) : -1; + if (at >= 0) order.splice(at, 0, id); else order.push(id); + }, + }; + map.swapStyle(); + return map; +} + +function overlays(map) { + const holder = { map }; + const canEditStyle = () => true; + const polygon = new MapboxPolygonLayer({ holder, canEditStyle }); + const circle = new MapboxCircleLayer({ holder, canEditStyle }); + const polyline = new MapboxPolylineLayer({ holder, canEditStyle }); + const ground = new MapboxGroundImageOverlayRenderer({ holder, canEditStyle }); + const raster = new MapboxRasterLayerOverlayRenderer(holder, canEditStyle); + const gi = createGroundImageState({ + id: 'GI', + bounds: createGeoRectBounds({ + southWest: createGeoPoint({ latitude: 35.66, longitude: 139.73 }), + northEast: createGeoPoint({ latitude: 35.69, longitude: 139.77 }), + }), + imageUrl: '/image.png', + }); + const rl = createRasterLayerState({ + id: 'RL', + source: RasterLayerSource.UrlTemplate({ template: 'https://example.test/{z}/{x}/{y}.png', tileSize: 256 }), + }); + return { + raster: () => raster.onAdd([{ state: rl }]), + ground: () => ground.createGroundImage(gi), + polygon: () => polygon.draw([]), + circle: () => circle.draw([]), + polyline: () => polyline.draw([]), + rasterRenderer: raster, + groundRenderer: ground, + gi, rl, + }; +} + +// Ours, bottom to top. The raster also stays under the basemap's labels (unchanged). +const WANT = [ + 'mc-raster-lyr-RL', + 'place-label', + 'mc-gimg-lyr-GI', + 'polygon-fill-layer', + 'polygon-outline-layer', + 'circle-layer', + 'circle-layer-stroke', + 'polyline-layer', +]; + +const ours = (order) => order.filter((id) => WANT.includes(id)); + +function* permutations(items) { + if (items.length <= 1) { yield items; return; } + for (let i = 0; i < items.length; i++) { + for (const rest of permutations([...items.slice(0, i), ...items.slice(i + 1)])) yield [items[i], ...rest]; + } +} + +const KINDS = ['raster', 'ground', 'polygon', 'circle', 'polyline']; + +test('the stack is the same whatever order the overlays are added in', async () => { + for (const perm of permutations(KINDS)) { + const map = glMap(); + const o = overlays(map); + for (const kind of perm) await o[kind](); + assert.deepEqual(ours(map.order()), WANT, perm.join(' → ')); + } +}); + +test('a ground image and a raster re-sent after the others go back to their bands', async () => { + // What clearOverlays() + sending everything again does: the vector layers stay (their + // sources are only emptied), the ground image and the raster are removed and added again. + const map = glMap(); + const o = overlays(map); + for (const kind of KINDS) await o[kind](); + await o.groundRenderer.removeGroundImage({ groundImage: 'GI', state: o.gi }); + await o.rasterRenderer.onRemove([{ layer: { sourceId: 'mc-raster-src-RL', layerId: 'mc-raster-lyr-RL' }, state: o.rl }]); + await o.ground(); + await o.raster(); + assert.deepEqual(ours(map.order()), WANT); +}); + +test('after a style swap the overlays come back in their bands, in any order', async () => { + for (const perm of [[...KINDS].reverse(), ['polyline', 'raster', 'circle', 'ground', 'polygon']]) { + const map = glMap(); + const o = overlays(map); + for (const kind of KINDS) await o[kind](); + map.swapStyle(); + for (const kind of perm) await o[kind](); + assert.deepEqual(ours(map.order()), WANT, perm.join(' → ')); + } +}); + +test('rasters keep their zIndex order among themselves, under everything else', async () => { + const map = glMap(); + const o = overlays(map); + await o.polygon(); + const state = (id, zIndex) => createRasterLayerState({ + id, zIndex, + source: RasterLayerSource.UrlTemplate({ template: `https://example.test/${id}/{z}/{x}/{y}.png`, tileSize: 256 }), + }); + await o.rasterRenderer.onAdd([{ state: state('high', 2) }, { state: state('low', -1) }, { state: state('mid', 0) }]); + const order = map.order(); + const ids = ['mc-raster-lyr-low', 'mc-raster-lyr-mid', 'mc-raster-lyr-high', 'polygon-fill-layer']; + assert.deepEqual(order.filter((id) => ids.includes(id)), ids); +}); diff --git a/test/PolygonController.test.mjs b/test/PolygonController.test.mjs index 466b039..6002a40 100644 --- a/test/PolygonController.test.mjs +++ b/test/PolygonController.test.mjs @@ -38,6 +38,8 @@ function fixture({ existingLayers = [] } = {}) { const map = { getSource: (id) => sources.get(id), getLayer: (id) => order.find((layer) => layer.id === id), + // The real map's layer order: the stacking (overlayStackBeforeId) reads it to place new layers. + getLayersOrder: () => order.map((layer) => layer.id), addSource(id, spec) { assert.ok(ready, 'cannot add a source while the style is unavailable'); const source = { spec, data: spec.data, setData(data) { this.data = data; } };