/**
* 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 型別依原序逐一搬移整組, 維持組內相對順序
})
})
}