js/basemapManager.mjs

/**
 * basemapManager.mjs
 * 底圖與 3D 地形的新增、切換、透明度控制
 */
import each from 'lodash-es/each.js'
import get from 'lodash-es/get.js'
import omit from 'lodash-es/omit.js'
import cloneDeep from 'lodash-es/cloneDeep.js'
import isEqual from 'lodash-es/isEqual.js'
import isobj from 'wsemi/src/isobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'


// 圖磚層級欄位正規化: 數字或數字字串; 非有限數、負數、非數值回 null(視為未給)。
// maxzoom 無條件捨去、minzoom 無條件進位: maplibre 對小數層級之實際效果等同進位, 故捨去 maxzoom、進位 minzoom 方不請求服務沒有之層級
function toTileZoom(v, kind) {
    if (!isnum(v) || !Number.isFinite(Number(v))) return null //須先判有限: cdbl 經 lodash toFinite 會把 Infinity 轉成 Number.MAX_VALUE
    let n = cdbl(v)
    if (n < 0) return null
    return kind === 'min' ? Math.ceil(n) : Math.floor(n)
}


// 將設定之 minzoom/maxzoom 有效值寫入 source spec(未給或無效者不寫鍵, spec 與未給時完全相同);
// 兩者皆有效但 minzoom > maxzoom 時只捨 minzoom 並警告(maxzoom 為保護性欄位, 不可因 minzoom 誤設而失去)。
// useMin=false 供地形/陰影: DEM 低於 minzoom 時地形變平而非不顯示, 語義與底圖不同故不開放
function assignSourceZoomRange(spec, cfg, label, useMin = true) {
    let maxz = toTileZoom(get(cfg, 'maxzoom'), 'max')
    let minz = useMin ? toTileZoom(get(cfg, 'minzoom'), 'min') : null
    if (minz !== null && maxz !== null && minz > maxz) {
        console.warn(`[basemapManager] ${label}: minzoom(${minz}) > maxzoom(${maxz}), 忽略 minzoom`)
        minz = null
    }
    if (minz !== null) spec.minzoom = minz
    if (maxz !== null) spec.maxzoom = maxz
    return spec
}


/**
 * 疊加層(colorShade 空字串)透明度之單一擁有者(R14): 預設值與值域只在此決定, 渲染(vector/raster 建立與 paint 更新)與面板一律呼叫之。
 * bm.opacity 為數字或數字字串 → 夾在 0~1 之有限數(0 有效); 非數值/非有限(NaN、±Infinity、'abc'、布林、陣列…)視為未給 → vector 預設 0.8、其餘(raster/wms)預設 1;
 * type 為 'style' 不支援整組透明度(各層自帶 opacity, 常為 zoom 表達式) → 回 null
 * @param {Object} bm - 底圖條目
 * @returns {Number|null}
 */
export function getBaseMapOpacity(bm) {
    let type = get(bm, 'type', '')
    if (type === 'style') return null
    let def = type === 'vector' ? 0.8 : 1
    let v = get(bm, 'opacity', null)
    if (!isnum(v)) return def
    v = Number(v)
    if (!Number.isFinite(v)) return def
    return Math.min(Math.max(v, 0), 1)
}


// fill-extrusion 建物色: 帶 colorFillExtrusion 則統一上色, 否則回退資料 colour 屬性(向後相容)
function fxColorExpr(bm) {
    return bm.colorFillExtrusion || ['coalesce', ['get', 'colour'], '#aaaaaa']
}


// 底圖/疊加層的穩定身分 key: 由內容(type/url/layer/layerType/colorShade/layers)衍生, 不綁陣列索引,
// 使重排時同一份內容的圖層 ID 不變 → 可用 moveLayer 增量重排, 免整批重抓圖磚.
function baseMapKey(bm) {
    let raw = [bm.type, bm.url, bm.layer, bm.layerType, bm.colorShade, bm.layers].map((v) => v == null ? '' : String(v)).join('|')
    return raw.replace(/[^a-zA-Z0-9]/g, '_')
}


// 全陣列的 key 串列(與陣列同序); 內容完全相同者(罕見)以出現序加尾碼去重
function baseMapKeys(baseMaps) {
    let seen = {}
    let arr = isarr(baseMaps) ? baseMaps : []
    return arr.map((bm) => {
        let k = baseMapKey(bm)
        seen[k] = (seen[k] || 0) + 1
        return seen[k] > 1 ? `${k}__${seen[k]}` : k
    })
}


// style 型別底圖的 style JSON 快取: key = style url。applyBaseMaps 為同步流程,
// 故 fetch 由 preloadBaseMapStyles 預先完成, addBaseMapLayer 只讀快取
let kpStyleCache = {}


/**
 * 預先抓取 style 型別底圖的 style JSON 並快取(applyBaseMaps 為同步流程, 只讀快取)
 * @param {Array} baseMaps - 底圖配置陣列
 * @returns {Promise<Number>} 本次新載入成功的 style 數量, 0 代表全部已快取或無 style 型別(呼叫端無須補套, 避免無謂重建)
 */
export async function preloadBaseMapStyles(baseMaps) {
    let bms = (isarr(baseMaps) ? baseMaps : []).filter((bm) => isobj(bm) && bm.type === 'style' && isestr(bm.url))
    let n = 0
    await Promise.all(bms.map(async (bm) => {
        let u = bm.url
        if (kpStyleCache[u]) return
        try {
            let r = await fetch(u)
            if (!r.ok) throw new Error(`HTTP ${r.status}`)
            kpStyleCache[u] = await r.json()
            n++
        }
        catch (e) {
            console.error('[basemapManager] preloadBaseMapStyles fetch error:', u, e)
            kpStyleCache[u] = null //失敗記 null 跳過該底圖不阻斷其他圖層; null 為 falsy, 下次呼叫會重試
        }
    }))
    return n
}


// 取得某 baseMap 條目實際對應的所有 layer id: style 型別展開為一組 layer(id 帶 `-<序號>-<原id>` 尾碼),
// 其餘型別為單一 layer。key 經 baseMapKey 淨化後不含 '-', 故 `${id}-` 前綴過濾不會誤命中其他條目
function baseMapLayerIds(map, baseMaps, idx) {
    if (!map) return []
    let id = `basemap-layer-${baseMapKeys(baseMaps)[idx]}`
    let bm = get(baseMaps, idx, null)
    if (isobj(bm) && bm.type === 'style') {
        let style = map.getStyle ? (map.getStyle() || {}) : {}
        return (style.layers || []).map((l) => get(l, 'id', '')).filter((v) => isestr(v) && v.indexOf(`${id}-`) === 0)
    }
    return map.getLayer(id) ? [id] : []
}


/**
 * 將底圖配置套用到 map(建立 raster/vector/wms/style source 與 layer)
 * style 型別讀取 preloadBaseMapStyles 之快取, 未命中即跳過該條目(由呼叫端於預載完成後補套)
 * @param {Object} map - MapLibre 地圖實例
 * @param {Array} baseMaps - 底圖配置陣列
 */
export function applyBaseMaps(map, baseMaps) {
    if (!map) return
    //先掃除既有全部 basemap 圖層/來源: 結構性重套時, 被刪除或 key 變更(如換 url)的舊條目不得殘留——
    //否則舊圖層持續顯示且面板已無項目可控制, 反覆變更會累積 source/layer
    let style = map.getStyle ? (map.getStyle() || {}) : {}
    each((style.layers || []), (l) => {
        if (l && isestr(l.id) && l.id.indexOf('basemap-layer-') === 0 && map.getLayer(l.id)) map.removeLayer(l.id)
    })
    each(Object.keys(style.sources || {}), (id) => {
        if (id.indexOf('basemap-src-') === 0 && map.getSource(id)) map.removeSource(id)
    })
    let keys = baseMapKeys(baseMaps)
    let addBaseMapLayer = (bm, k) => {
        //url 無效之條目略過(與地形/陰影之 isestr 檢查對稱): 否則各分支之 url 字串操作拋例外, 中斷整個套用(舊條目此時已全移除)
        if (!isestr(bm.url)) {
            console.warn(`[basemapManager] baseMaps[${k}](${bm.name}) url 無效, 略過`)
            return
        }
        let srcId = `basemap-src-${keys[k]}`; let layerId = `basemap-layer-${keys[k]}`
        if (map.getLayer(layerId)) map.removeLayer(layerId)
        if (map.getSource(srcId)) map.removeSource(srcId)
        if (bm.type === 'style') {
            //完整 MapLibre style JSON 底圖: 整組 sources/layers 注入現有 map(不可用 setStyle, 會清除資料圖層與地形)
            let st = kpStyleCache[bm.url]
            if (!isobj(st)) return //尚未載入或載入失敗 → 跳過, 待呼叫端於 preloadBaseMapStyles resolve 後補套
            //glyphs/sprite 為整份 style 的全域單例: 與現值不同才設定, 避免每次重套都重載字型/sprite;
            //sprite 僅支援字串形式(常見), 陣列形式不處理
            if (isestr(st.glyphs) && map.getGlyphs() !== st.glyphs) map.setGlyphs(st.glyphs)
            if (isestr(st.sprite)) {
                let cur = get((map.getSprite() || []).find((sp) => get(sp, 'id') === 'default'), 'url', null)
                if (cur !== st.sprite) map.setSprite(st.sprite)
            }
            let vis = bm.visible ? 'visible' : 'none'
            each(Object.keys(st.sources || {}), (sn) => {
                let sid = `${srcId}-${sn}`
                if (!map.getSource(sid)) map.addSource(sid, st.sources[sn])
            })
            each((st.layers || []), (l, i) => {
                let spec = cloneDeep(l)
                spec.id = `${layerId}-${i}-${l.id}` //共用條目前綴: 供 baseMapLayerIds 聚合與 applyBaseMaps 前綴清除回收
                if (spec.source) spec.source = `${srcId}-${spec.source}`
                spec.layout = { ...(spec.layout || {}), visibility: vis }
                map.addLayer(spec)
            })
            return
        }
        if (bm.type === 'vector') {
            let u = bm.url; if (u.startsWith('//')) u = 'https:' + u
            map.addSource(srcId, assignSourceZoomRange({ type: 'vector', url: u }, bm, `baseMaps[${k}]`)) //明給之 minzoom/maxzoom 優先於 TileJSON
            let layerType = bm.layerType || 'fill'
            let sourceLayer = bm.layer || ''
            let op = getBaseMapOpacity(bm)
            let paint = {}
            if (layerType === 'fill-extrusion') {
                paint = {
                    'fill-extrusion-color': fxColorExpr(bm),
                    'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['coalesce', ['get', 'render_height'], ['get', 'height'], 0]],
                    'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.05, ['coalesce', ['get', 'render_min_height'], ['get', 'min_height'], 0]],
                    'fill-extrusion-opacity': op,
                    'fill-extrusion-opacity-transition': { duration: 0, delay: 0 },
                }
            }
            else if (layerType === 'fill') {
                paint = { 'fill-color': '#aaaaaa', 'fill-opacity': op, 'fill-opacity-transition': { duration: 0, delay: 0 } }
            }
            else if (layerType === 'line') {
                //初始即套用 opacity(與 setOverlayOpacity/updateBaseMapPaint 之更新路徑一致, 不可漏掉初建)
                paint = { 'line-opacity': op, 'line-opacity-transition': { duration: 0, delay: 0 } }
            }
            map.addLayer({ 'id': layerId, 'type': layerType, 'source': srcId, 'source-layer': sourceLayer, paint, 'layout': { visibility: bm.visible ? 'visible' : 'none' } })
            return
        }
        let tiles = []
        if (bm.type === 'wms') {
            let u = bm.url
            if (!u.includes('?')) u += '?'
            else if (!u.endsWith('?') && !u.endsWith('&')) u += '&' //url 已帶 query 參數時須以 & 續接, 避免直接黏接壞掉
            u += `SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=${bm.layers || ''}&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}`
            tiles = [u]
        }
        else {
            let u = bm.url
            tiles = u.includes('{s}') ? ['a', 'b', 'c'].map((s) => u.replace('{s}', s)) : [u]
            tiles = tiles.map((t) => t.startsWith('//') ? 'https:' + t : t)
        }
        let op = getBaseMapOpacity(bm)
        //maxzoom(選填): 服務之最大圖磚層級, 超出時以該級圖磚放大顯示; 未給時 256px 圖磚於地圖層級 z 請求 round(z+1) 級, 服務沒有即空白並報錯
        map.addSource(srcId, assignSourceZoomRange({ type: 'raster', tiles, tileSize: 256 }, bm, `baseMaps[${k}]`))
        map.addLayer({ id: layerId, type: 'raster', source: srcId, paint: { 'raster-opacity': op, 'raster-opacity-transition': { duration: 0, delay: 0 } }, layout: { visibility: bm.visible ? 'visible' : 'none' } })
    }
    // 第一輪:底圖(colorShade 非空),確保先渲染在下方
    each(baseMaps, (bm, k) => {
        if (isestr(bm.colorShade)) addBaseMapLayer(bm, k)
    })
    // 第二輪:疊加圖層(colorShade 空字串),後渲染在底圖上方
    each(baseMaps, (bm, k) => {
        if (!isestr(bm.colorShade)) addBaseMapLayer(bm, k)
    })
}


/**
 * 套用 3D 地形(清除舊地形後重新設定)
 * @param {Object} map - MapLibre 地圖實例
 * @param {Object} terrainMap - 地形配置物件
 * @param {Array} trackedLayerIds - 追蹤的 layer ID(用於 hillshade 插入位置)
 */
export function applyTerrain(map, terrainMap, trackedLayerIds) {
    if (!map) return
    try {
        map.setTerrain(null)
    }
    catch (e) {}
    if (map.getLayer('terrain-hillshade')) map.removeLayer('terrain-hillshade')
    if (map.getSource('terrain-hillshade-src')) map.removeSource('terrain-hillshade-src')
    if (map.getSource('terrain-src')) map.removeSource('terrain-src')

    let tm = terrainMap
    if (!isobj(tm)) return
    let tsrc = get(tm, 'terrainSource', null)
    if (!isobj(tsrc) || !isestr(tsrc.url)) return

    let tu = tsrc.url; if (tu.startsWith('//')) tu = 'https:' + tu
    let terrainSrcSpec = {
        type: 'raster-dem',
        tiles: [tu],
        tileSize: tsrc.tileSize || 256,
        encoding: tsrc.encoding || 'terrarium',
    }
    assignSourceZoomRange(terrainSrcSpec, tsrc, 'terrainSource', false)
    try {
        map.addSource('terrain-src', terrainSrcSpec)
    }
    catch (e) {
        console.error('[basemapManager] applyTerrain addSource error:', e); return
    }
    try {
        map.setTerrain({ source: 'terrain-src', exaggeration: isnum(tm.exaggeration) ? cdbl(tm.exaggeration) : 1.5 })
    }
    catch (e) {
        console.error('[basemapManager] applyTerrain setTerrain error:', e)
    }

    let hsrc = get(tm, 'hillshadeSource', null)
    if (isobj(hsrc) && isestr(hsrc.url)) {
        let hu = hsrc.url; if (hu.startsWith('//')) hu = 'https:' + hu
        let hillshadeSourceId = 'terrain-src'
        if (hu !== tu) {
            let hillshadeSrcSpec = {
                type: 'raster-dem',
                tiles: [hu],
                tileSize: hsrc.tileSize || 256,
                encoding: hsrc.encoding || 'terrarium',
            }
            assignSourceZoomRange(hillshadeSrcSpec, hsrc, 'hillshadeSource', false)
            map.addSource('terrain-hillshade-src', hillshadeSrcSpec)
            hillshadeSourceId = 'terrain-hillshade-src'
        }
        //以「實際 style 疊序」找最底層的資料圖層作為插入點: tracked 為插入序, 與 displayOrderByType
        //重排後之實際疊序不同, 依 tracked 首項會把 hillshade 插到部分資料圖層之上
        let insertBefore = null
        let tset = {}
        each((trackedLayerIds || []), (lid) => {
            tset[lid] = true
        })
        let sty = map.getStyle ? (map.getStyle() || {}) : {}
        for (let l of (sty.layers || [])) {
            if (l && tset[l.id] && map.getLayer(l.id)) {
                insertBefore = l.id; break
            }
        }
        let hillshadeSpec = {
            id: 'terrain-hillshade',
            type: 'hillshade',
            source: hillshadeSourceId,
            paint: {
                'hillshade-shadow-color': '#473B24',
                'hillshade-exaggeration': isnum(tm.hillshadeExaggeration) ? cdbl(tm.hillshadeExaggeration) : 0.5,
            },
        }
        if (insertBefore && map.getLayer(insertBefore)) map.addLayer(hillshadeSpec, insertBefore)
        else map.addLayer(hillshadeSpec)
    }
}


/**
 * 切換底圖(radio 單選)
 * @param {Object} map
 * @param {Array} baseMaps - panelBaseMaps.baseMaps(直接修改 visible 屬性)
 * @param {Number} idx - 選中的底圖索引
 */
export function switchBaseMap(map, baseMaps, idx) {
    each(baseMaps, (bm, k) => {
        if (!isestr(bm.colorShade)) return
        bm.visible = (k === idx)
        each(baseMapLayerIds(map, baseMaps, k), (id) => {
            map.setLayoutProperty(id, 'visibility', bm.visible ? 'visible' : 'none')
        })
    })
}


/**
 * 切換疊加圖層可見性(checkbox)
 * @param {Object} map
 * @param {Array} baseMaps
 * @param {Number} idx
 */
export function toggleOverlayVisible(map, baseMaps, idx) {
    let bm = get(baseMaps, idx, null); if (!bm) return
    bm.visible = !bm.visible
    each(baseMapLayerIds(map, baseMaps, idx), (id) => {
        map.setLayoutProperty(id, 'visibility', bm.visible ? 'visible' : 'none')
    })
}


/**
 * 調整疊加圖層透明度
 * @param {Object} map
 * @param {Array} baseMaps
 * @param {Number} idx
 * @param {Number|String} val
 */
export function setOverlayOpacity(map, baseMaps, idx, val) {
    let bm = get(baseMaps, idx, null); if (!bm) return
    if (bm.type === 'style') return //style 型別為整組 style 圖層, 各層自帶 opacity(常為 zoom 表達式, 且含 raster/fill-extrusion 等多種 paint), 不支援整組透明度調整
    bm.opacity = parseFloat(val)
    let id = `basemap-layer-${baseMapKeys(baseMaps)[idx]}`
    if (!map || !map.getLayer(id)) return
    if (bm.type === 'vector') {
        let lt = bm.layerType || 'fill'
        if (lt === 'fill-extrusion') map.setPaintProperty(id, 'fill-extrusion-opacity', bm.opacity)
        else if (lt === 'line') map.setPaintProperty(id, 'line-opacity', bm.opacity)
        else map.setPaintProperty(id, 'fill-opacity', bm.opacity)
    }
    else {
        map.setPaintProperty(id, 'raster-opacity', bm.opacity)
    }
}


/**
 * 就地更新單一底圖 entry 的 paint(顏色/透明度), 不重建 layer/source —
 * 避免重抓圖磚與其他未變更圖層閃爍。供「僅 paint 變更」的快路徑使用。
 * @param {Object} map
 * @param {Array} baseMaps - 底圖配置陣列
 * @param {Number} idx - 索引
 */
export function updateBaseMapPaint(map, baseMaps, idx) {
    if (!map) return
    let bm = get(baseMaps, idx, null); if (!bm) return
    if (bm.type === 'style') return //style 型別不支援 paint 更新(理由見 setOverlayOpacity)
    let layerId = `basemap-layer-${baseMapKeys(baseMaps)[idx]}`
    if (!map.getLayer(layerId)) return
    if (bm.type === 'vector') {
        let lt = bm.layerType || 'fill'
        let op = getBaseMapOpacity(bm)
        if (lt === 'fill-extrusion') {
            map.setPaintProperty(layerId, 'fill-extrusion-color', fxColorExpr(bm))
            map.setPaintProperty(layerId, 'fill-extrusion-opacity', op)
        }
        else if (lt === 'line') {
            map.setPaintProperty(layerId, 'line-opacity', op)
        }
        else {
            map.setPaintProperty(layerId, 'fill-opacity', op)
        }
    }
    else {
        let op = getBaseMapOpacity(bm)
        map.setPaintProperty(layerId, 'raster-opacity', op)
    }
}


/**
 * 判斷新舊 baseMaps 是否「僅 paint 欄位(colorFillExtrusion/opacity)變更」。
 * true → 可只就地 setPaintProperty(updateBaseMapPaint), 無需重建底圖;
 * false → 非僅 paint 變更, 由呼叫端再以 isBaseMapsStructuralDiff 判斷走增量或完整重套。
 * @param {Array} prev - 上次套用的 baseMaps
 * @param {Array} next - 本次 baseMaps
 * @returns {Boolean}
 */
export function isBaseMapsPaintOnlyDiff(prev, next) {
    if (!isarr(prev) || !isarr(next) || prev.length !== next.length) return false
    let stripPaint = (e) => omit(e, ['colorFillExtrusion', 'opacity'])
    let anyChanged = false
    for (let i = 0; i < next.length; i++) {
        if (!isEqual(stripPaint(prev[i]), stripPaint(next[i]))) return false //有非 paint 變更
        if (!isEqual(prev[i], next[i])) anyChanged = true
    }
    return anyChanged
}


// 不需重建即可就地處理的欄位: paint(colorFillExtrusion/opacity) 與顯隱(visible)
const BASEMAP_PAINT_STATE = ['colorFillExtrusion', 'opacity', 'visible']


/**
 * 判斷新舊 baseMaps 是否有「結構性變更」(新增/刪除/url/type/layer/layerType/colorShade 等)。
 * false → 僅順序/可見性/paint 差異, 可走增量(updateBaseMapsIncremental), 不重建、不重抓圖磚;
 * true → 有結構性變更, 需完整重套 applyBaseMaps。
 * @param {Array} prev - 上次套用的 baseMaps
 * @param {Array} next - 本次 baseMaps
 * @returns {Boolean}
 */
export function isBaseMapsStructuralDiff(prev, next) {
    if (!isarr(prev) || !isarr(next) || prev.length !== next.length) return true
    let kp = baseMapKeys(prev); let kn = baseMapKeys(next)
    if (!isEqual(kp.slice().sort(), kn.slice().sort())) return true //key 集合不同 = 新增/刪除/url/type 變更 → 需重建
    let mp = new Map()
    each(prev, (e, i) => mp.set(kp[i], e))
    for (let i = 0; i < next.length; i++) {
        let p = mp.get(kn[i]); if (!p) return true
        if (!isEqual(omit(p, BASEMAP_PAINT_STATE), omit(next[i], BASEMAP_PAINT_STATE))) return true //同 key 但其餘結構欄位有變 → 需重建
    }
    return false //只剩順序/可見性/paint 差異 → 可走增量
}


/**
 * 「僅順序/可見性/paint 變更」時的增量套用: 就地更新 paint 與 visibility,
 * 並用 moveLayer 將疊加層依新陣列序重排至「資料圖層之下、底圖之上」。
 * 完全不 remove/add source(不重抓圖磚), 不擾動資料圖層 z-order(無閃爍)。
 * @param {Object} map
 * @param {Array} baseMaps - 本次 baseMaps(新順序)
 */
export function updateBaseMapsIncremental(map, baseMaps) {
    if (!map || !isarr(baseMaps)) return
    // 1) paint 與 visibility 全部就地更新(style 型別為一組 layer id, 逐一設定)
    each(baseMaps, (bm, k) => {
        let ids = baseMapLayerIds(map, baseMaps, k)
        if (ids.length === 0) return
        updateBaseMapPaint(map, baseMaps, k)
        each(ids, (id) => {
            map.setLayoutProperty(id, 'visibility', bm.visible ? 'visible' : 'none')
        })
    })
    // 2) 疊加層(colorShade 空字串)依陣列序 moveLayer 至「hillshade(若有)之下, 否則首個資料圖層之下」;
    //    維持「底圖 < 疊加層 < hillshade < 資料」疊序與完整重建路徑一致, 底圖恆在最底, 不動
    let dataPrefixes = ['image-', 'contour-', 'polygon-', 'geojson-', 'polyline-', 'point-']
    let beforeId
    let style = map.getStyle()
    if (style && isarr(style.layers)) {
        let hit = style.layers.find((l) => l && isestr(l.id) && (l.id === 'terrain-hillshade' || dataPrefixes.some((p) => l.id.indexOf(p) === 0)))
        if (hit) beforeId = hit.id
    }
    each(baseMaps, (bm, k) => {
        if (isestr(bm.colorShade)) return //僅疊加層需重排, 底圖不動
        each(baseMapLayerIds(map, baseMaps, k), (id) => {
            map.moveLayer(id, beforeId) //style 型別依原序逐一搬移整組, 維持組內相對順序
        })
    })
}